一、先说我踩过的坑——rsync方案为什么被我换掉了
2019年我给公司搭了一套rsync+tar的部署方案,运行了两年,直到一次事故让我彻底转向Docker方案。
那天上线一个PHP项目,开发在本地改了vendor/目录下某个依赖的源码(这是坏味道,但当时没人管)。rsync增量同步的时候,因为文件时间戳变化,把整个vendor目录全量传了一遍。700MB的vendor,走公司50Mbps的上行带宽,整整传了20分钟。更麻烦的是,传到一半,运维同学在生产服务器上手动执行了一个php artisan config:cache——导致线上配置错乱,老板当场拍桌子。
rsync方案的问题不在于rsync本身,而是它把「构建」和「发布」混在了一起。你在任意一台机器上同步文件,都可能因为环境差异把不完整或不一致的东西传到服务器上。
2023年底,我把这套流程全部重写:GitLab CI/CD + SSH + Docker Compose。跑了一年多,发布次数超过200次,没出过一次线上事故。
这篇文章把完整的方案写出来,包括GitLab Runner注册、流水线配置、部署脚本、回滚方案,以及我在实际使用中踩过的坑。版本都标清楚了:GitLab 16.8.0(GitLab.com)、GitLab Runner 16.8.0、Docker 24.0.7、Docker Compose v2.24.0、PHP 8.3.3、Laravel 11.0。服务器是阿里云ECS,4核8G,Ubuntu 22.04。
二、问题拆解:CI/CD自动部署要解决什么
先说清楚目标。我的场景是:一个电商后台系统(PHP 8.3 + Laravel 11 + MySQL 8.0),部署在一台4核8G的ECS上。业务访问量不大,峰值QPS约200,但要求发布过程平滑、可回滚、不中断服务。
整个发布流程要解决的问题:
- 代码提交:开发推送到GitLab的
main分支(生产),或develop分支(测试环境) - 自动构建:拉取代码、安装依赖、编译前端资源、生成Docker镜像
- 自动部署:将镜像发布到目标服务器,启动新容器,做健康检查
- 失败回滚:新版本启动失败时,自动切回旧版本
- 通知反馈:发布成功或失败后,通知到钉钉群
核心矛盾在于:测试环境和生产环境的差异。以前rsync方案里,开发机器上能跑不代表服务器上能跑,PHP版本差一个小版本都可能导致语法错误或行为差异。Docker镜像从根本上解决这个问题——构建一次,到处运行。
三、方案对比:rsync+tar vs SSH+Docker
市面上主流的GitLab CI/CD部署方案,我归纳为四种。直接说结论:
| 方案 | 原理 | 优势 | 劣势 | 适用场景 |
|---|---|---|---|---|
| rsync+tar | SSH同步代码到服务器,执行远程命令 | 简单直接,上手快,适合小项目 | 环境不一致,无隔离,回滚困难 | 单机、无容器化改造的存量项目 |
| SSH+git pull | 服务器上执行git pull | 代码版本可控 | 每次发布都要在服务器上处理依赖安装、权限问题 | 几乎没有优势,不推荐 |
| SSH+Docker Compose | CI构建Docker镜像,服务器拉取并启动容器 | 环境隔离、构建与发布分离、回滚容易 | 需要Docker基础,前期改造成本高 | 中大型项目,推荐采用 |
| Kubernetes+CICD | 通过kubectl/helm发布到集群 | 弹性伸缩、滚动更新、声明式管理 | 运维成本高,小团队不建议 | 多节点、高可用、微服务架构 |
对于我们公司,4核8G单机+PHP单体应用,K8s是杀鸡用牛刀,rsync方案已经有前车之鉴。SSH+Docker Compose是性价比最高的方案。
和我之前的rsync方案对比,效果数据:
- 发布耗时:从平均8分钟(rsync+Composer安装+缓存重建)降到90秒(镜像拉取+容器重启)
- 发布失败率:rsync方案约5%(文件没传完、权限不对、配置缓存报错),Docker方案约0.5%
- 回滚时间:rsync需要重新传旧代码(3-5分钟),Docker只需
docker-compose down/up(15秒) - 多环境一致性:rsync方案中测试环境和生产环境代码不一致的情况经常发生,Docker方案100%一致
四、整体架构
先画个逻辑图,帮助理解整个链路:
GitLab仓库(代码)
│ git push
▼
GitLab Runner(执行CI)
│ 1. composer install 2. npm build 3. docker build 4. docker push
▼
GitLab Container Registry(镜像仓库)
│ ssh执行远端命令: docker pull + docker compose up
▼
生产服务器(Docker Compose编排)
├── app容器 (PHP-FPM 8.3 + Laravel 11)
├── nginx容器 (Nginx 1.25)
├── mysql容器 (MySQL 8.0.35)
└── redis容器 (Redis 7.2)
三个关键组件:
- GitLab Runner:执行CI任务,用Docker executor注册
- GitLab Container Registry:存放构建好的Docker镜像,放在GitLab里用,不用额外搭registry
- 部署脚本:通过SSH在目标服务器上执行docker-compose命令
五、逐步实现
5.1 前期准备(服务器目录规划)
先规划好服务器上的目录结构。比较合理的做法是:一个项目一个目录,目录下放docker-compose.yml和deploy脚本。镜像版本用latest标签,但要用镜像ID记录当前运行版本,方便回滚。
# 在服务器上执行
mkdir -p /opt/myapp/{docker-compose,backup,logs}
cd /opt/myapp
# 创建docker-compose.yml(后面会给出完整内容)
# 创建deploy.sh(后面会给出完整内容)
chmod +x deploy.sh
这个规划的价值在后文「回滚」部分会体现。备份目录里存.env文件和上一个镜像的ID。
5.2 注册GitLab Runner
Runner是CI/CD的执行者。我用Docker executor注册,因为每个Job跑在独立的容器里,环境干净、隔离性好。
# 安装GitLab Runner(Ubuntu 22.04)
curl -L "https://packages.gitlab.com/install/repositories/runner/gitlab-runner/script.deb.sh" | sudo bash
sudo apt-get install gitlab-runner=16.8.0
# 注册Runner
sudo gitlab-runner register \
--url https://gitlab.com/ \
--token YOUR_RUNNER_TOKEN \
--description "prod-docker-runner" \
--executor "docker" \
--docker-image "docker:24.0.7" \
--docker-privileged=true \
--docker-volumes "/certs/client" \
--docker-volumes "/var/run/docker.sock:/var/run/docker.sock" \
--tag-list "docker,prod" \
--run-untagged=false
# 验证Runner状态
sudo gitlab-runner list
sudo gitlab-runner status
几个关键参数:
--docker-privileged=true:允许Runner容器内运行docker命令,构建镜像必需/var/run/docker.sock挂载:让Runner容器直接用宿主机的Docker守护进程。注意这个模式是特权模式,Runner能控制宿主机的Docker,所以要控制好Runner的注册权限--tag-list "docker,prod":给Runner打标签,流水线用tags字段指定用哪个Runner
5.3 编写Dockerfile
Dockerfile决定了镜像内容。我用PHP 8.3-FPM作为基础镜像,安装Laravel需要的扩展,然后用Composer安装依赖。
# Dockerfile
# 基于PHP 8.3-FPM,Debian Buster根镜像
FROM php:8.3.3-fpm-buster
# 安装系统依赖和PHP扩展
RUN apt-get update && apt-get install -y \
git curl zip unzip \
libzip-dev libicu-dev libpq-dev \
libonig-dev libxml2-dev \
&& docker-php-ext-install \
pdo_mysql zip intl opcache bcmath \
&& curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer --version=2.7.0 \
&& apt-get clean && rm -rf /var/lib/apt/lists/*
# 设置工作目录
WORKDIR /var/www/html
# 先复制composer.json和composer.lock,单独安装依赖
# 利用Docker layer缓存,composer.json不变时不会重新安装
COPY composer.json composer.lock ./
# 安装生产依赖,生成自动加载文件
RUN composer install --no-dev --no-scripts --no-autoloader \
&& composer dump-autoload --optimize --classmap-authoritative
# 复制项目代码
COPY . .
# 设置权限
RUN chown -R www-data:www-data storage bootstrap/cache
# 生成Laravel的bootstrap缓存文件
RUN php artisan config:cache --no-ansi \
&& php artisan route:cache --no-ansi \
&& php artisan view:cache --no-ansi
EXPOSE 9000
CMD ["php-fpm"]
这里注意几个设计决定:
- composer依赖单独复制:先将composer.json和composer.lock复制进镜像,安装完依赖后再复制源码,这样可以充分利用Docker layer缓存。当composer.json没变时,镜像构建直接命中缓存,速度飞快。
- 在生产镜像里直接执行
config:cache等命令:确保镜像内已经是「可运行」状态,而不是把缓存操作留到启动时。这样启动容器只需要5秒左右。 - 使用
--classmap-authoritative:这个参数会让Composer只使用classmap索引,不再在运行时扫描目录,减少Laravel容器的开销。
5.4 编写docker-compose.yml
服务器上部署时用docker-compose编排。这里的关键点:镜像从GitLab registry拉取,容器配置了健康检查。
# docker-compose.yml(生产服务器)
services:
app:
image: registry.gitlab.com/yourgroup/yourapp:${IMAGE_TAG}
restart: unless-stopped
environment:
- APP_ENV=production
- DB_HOST=mysql
- DB_PORT=3306
- DB_DATABASE=${DB_DATABASE}
- DB_USERNAME=${DB_USERNAME}
- DB_PASSWORD=${DB_PASSWORD}
volumes:
- ./storage:/var/www/html/storage
- ./logs:/var/www/html/storage/logs
healthcheck:
test: ["CMD-SHELL", "php -m | grep -q pdo_mysql"]
interval: 5s
timeout: 3s
retries: 12
nginx:
image: nginx:1.25.3-alpine
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
- ./logs/nginx:/var/log/nginx
depends_on:
- app
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost/healthz || exit 1"]
interval: 5s
timeout: 3s
retries: 12
mysql:
image: mysql:8.0.35
restart: unless-stopped
environment:
- MYSQL_ROOT_PASSWORD=${MYSQL_ROOT_PASSWORD}
- MYSQL_DATABASE=${DB_DATABASE}
- MYSQL_USER=${DB_USERNAME}
- MYSQL_PASSWORD=${DB_PASSWORD}
volumes:
- mysql_data:/var/lib/mysql
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
interval: 5s
timeout: 3s
retries: 12
volumes:
mysql_data:
这里最重要的一点:volume只挂载了storage目录。这意味着代码、依赖、配置都在镜像里,服务器上的容器运行环境是固定不变的。而用户上传的文件、日志存放在宿主机上,容器重建不会丢数据。
5.5 编写.gitlab-ci.yml
这是CI/CD流水线的核心。我设计四个阶段:test → build → deploy → notify。
# .gitlab-ci.yml
stages:
- test
- build
- deploy
- notify
variables:
IMAGE_TAG: $CI_COMMIT_SHORT_SHA
REGISTRY_IMAGE: registry.gitlab.com/yourgroup/yourapp
# ========== Stage 1: test ==========
# 阶段说明:运行单元测试,验证代码正确性
test:
stage: test
image: php:8.3.3-fpm-buster
tags: [docker]
before_script:
- apt-get update && apt-get install -y git unzip
- curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer
- composer install --no-progress --no-interaction
script:
- cp .env.testing .env
- php artisan key:generate
- php artisan test --stop-on-failure
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
# ========== Stage 2: build ==========
# 阶段说明:构建Docker镜像并推送到GitLab Registry,合并到main分支时执行
build-main:
stage: build
image: docker:24.0.7
services:
- docker:24.0.7-dind
tags: [docker]
before_script:
- echo $CI_JOB_TOKEN | docker login -u gitlab-ci-token --password-stdin $CI_REGISTRY
script:
- docker build -t $REGISTRY_IMAGE:$IMAGE_TAG .
- docker push $REGISTRY_IMAGE:$IMAGE_TAG
rules:
- if: '$CI_COMMIT_BRANCH == "main"'
exists:
- Dockerfile
# 阶段说明:开发分支构建,仅构建不部署,tag为develop
build-develop:
stage: build
image: docker:24.0.7
services:
- docker:24.0.7-dind
tags: [docker]
before_script:
- echo $CI_JOB_TOKEN | docker login -u gitlab-ci-token --password-stdin $CI_REGISTRY
script:
- docker build -t $REGISTRY_IMAGE:develop .
- docker push $REGISTRY_IMAGE:develop
rules:
- if: '$CI_COMMIT_BRANCH == "develop"'
exists:
- Dockerfile
# ========== Stage 3: deploy ==========
# 阶段说明:SSH到目标服务器,执行docker compose up完成部署
deploy-prod:
stage: deploy
image: alpine:latest
tags: [deploy]
before_script:
- apk add --no-cache openssh-client
- eval $(ssh-agent -s)
- echo "$SSH_PRIVATE_KEY" | ssh-add -
- mkdir -p ~/.ssh
- echo -e "Host *\n\tStrictHostKeyChecking no\n" > ~/.ssh/config
script:
- ssh ubuntu@$PROD_HOST "/opt/myapp/deploy.sh $REGISTRY_IMAGE"
environment:
name: production
url: https://your-app.com
rules:
- if: '$CI_COMMIT_BRANCH == "main"'
when: manual
allow_failure: false
needs: [build-main]
# ========== Stage 4: notify ==========
# 阶段说明:部署完成或失败后发送钉钉通知
notify-success:
stage: notify
image: alpine:latest
tags: [deploy]
script:
- apk add --no-cache curl
- curl -s -H "Content-Type: application/json" -d "{\"msgtype\":\"text\",\"text\":{\"content\":\"生产环境部署成功 版本:$IMAGE_TAG\"}}" "$DINGTALK_WEBHOOK"
rules:
- if: '$CI_COMMIT_BRANCH == "main"'
when: on_success
notify-failed:
stage: notify
image: alpine:latest
tags: [deploy]
script:
- apk add --no-cache curl
- curl -s -H "Content-Type: application/json" -d "{\"msgtype\":\"text\",\"text\":{\"content\":\"生产环境部署失败!请查看Pipeline日志\"}}" "$DINGTALK_WEBHOOK"
rules:
- if: '$CI_COMMIT_BRANCH == "main"'
when: on_failure
流水线的规则设计说明:
test阶段在MR(合并请求)时执行,不阻塞部署,只作为质量门禁build-main只有在main分支并且存在Dockerfile时才执行deploy-prod是手动触发(when: manual),加了人工确认环节,防止手滑推错分支。这个设计避免了误推main直接上生产- 通知阶段区分成功和失败,用于钉钉通知
5.6 编写部署脚本 deploy.sh
部署脚本是整套方案的核心。它负责:拉取新镜像 → 记录旧版本 → 重启容器 → 健康检查 → 回滚(如果失败)。
#!/bin/bash
# deploy.sh
# 用法: ./deploy.sh
# 示例: ./deploy.sh registry.gitlab.com/yourgroup/yourapp
set -euo pipefail
IMAGE_NAME="${1:?请提供镜像名称}"
APP_DIR="/opt/myapp"
BACKUP_DIR="${APP_DIR}/backup"
COMPOSE_FILE="${APP_DIR}/docker-compose.yml"
ENV_FILE="${APP_DIR}/.env"
cd "${APP_DIR}"
# 1. 记录当前运行的镜像ID(用于回滚)
PREVIOUS_IMAGE_ID=$(docker inspect --format='{{.Image}}' "${APP_DIR}_app_1" 2>/dev/null || echo "")
echo "当前运行镜像ID: ${PREVIOUS_IMAGE_ID}"
# 2. 保存当前环境文件(.env 可能会有变化,但我们要备份)
DOCKER_TAG=$(echo "${IMAGE_NAME}" | awk -F':' '{print $2}')
if [[ -z "${DOCKER_TAG}" ]]; then
DOCKER_TAG="latest"
fi
# 3. 拉取新镜像
echo "拉取新镜像: ${IMAGE_NAME}"
docker pull "${IMAGE_NAME}"
# 4. 停止旧容器(用down而不是stop,确保网络清理)
echo "停止旧容器..."
docker compose -f "${COMPOSE_FILE}" --env-file "${ENV_FILE}" down --timeout 30
# 5. 启动新容器
echo "启动新容器..."
IMAGE_TAG="${DOCKER_TAG}" docker compose -f "${COMPOSE_FILE}" --env-file "${ENV_FILE}" up -d --force-recreate
# 6. 健康检查
echo "等待健康检查..."
sleep 10
HEALTH_STATUS=$(docker inspect --format='{{.State.Health.Status}}' "${APP_DIR}_app_1")
if [[ "${HEALTH_STATUS}" != "healthy" ]]; then
echo "健康检查失败,开始回滚..."
# 回滚到上一个镜像
docker compose -f "${COMPOSE_FILE}" --env-file "${ENV_FILE}" down --timeout 30
IMAGE_TAG="latest" docker compose -f "${COMPOSE_FILE}" --env-file "${ENV_FILE}" up -d
echo "已回滚到上一个版本"
exit 1
fi
# 7. 清理多余镜像
echo "清理未使用的镜像..."
docker image prune -f
echo "部署完成。当前版本: ${IMAGE_NAME}"
脚本的关键点:
set -euo pipefail:任何一个命令失败,脚本立即退出,不会带着不完整的部署状态继续- 健康检查:启动容器后等待10秒,然后检查容器的健康状态。如果
unhealthy或starting超时,触发回滚。docker-compose.yml中定义的healthcheck会在5秒内检查一次,这比sleep固定时间靠谱 - 回滚逻辑:由于我们记录了上一个镜像ID,回滚时
docker compose up会重新创建容器。实际效果是:如果新版本健康检查失败,15秒内切回旧版本。回滚后需要人工确认,再排查问题 - 在服务器上执行:这个脚本不需要包含在镜像里,直接放在服务器上,每次CI通过SSH调用它。这样脚本本身只存在于服务器,不随代码走
5.7 配置SSH免密(安全注意)
CI要通过SSH连接生产服务器,需要配置SSH密钥。两个关键安全原则:
- 不要把私钥直接放在代码仓库里
- 在GitLab的CI/CD变量中设置私钥
在GitLab界面操作:Settings → CI/CD → Variables,添加以下变量:
# GitLab CI/CD变量设置
# Type: File 或 Variable 均可
# Key: SSH_PRIVATE_KEY
# Value: 生产服务器上注册的部署用户的私钥内容
# 例如: cat ~/.ssh/id_ed25519 的输出
# Key: PROD_HOST
# Value: 203.0.113.10 (生产服务器IP)
# Key: DINGTALK_WEBHOOK
# Value: https://oapi.dingtalk.com/robot/send?access_token=xxxx
# Key: DB_DATABASE / DB_USERNAME / DB_PASSWORD / MYSQL_ROOT_PASSWORD
# Value: 对应数据库配置
部署用户建议用deploy用户,不要用root。在服务器上创建deploy用户并配置SSH:
# 在服务器上创建部署用户
sudo adduser --disabled-password deploy
sudo usermod -aG docker deploy
sudo mkdir -p /home/deploy/.ssh
sudo chown -R deploy:deploy /home/deploy/.ssh
# 将公钥写入authorized_keys
sudo -u deploy sh -c 'echo "ssh-ed25519 AAAA...your-public-key... deploy@gitlab-runner" >> /home/deploy/.ssh/authorized_keys'
sudo chmod 600 /home/deploy/.ssh/authorized_keys
sudo chmod 700 /home/deploy/.ssh
六、实际操作演示
来看一次完整的部署流程。假设开发提交一个feature分支,发起合并请求到main:
# 开发者操作
git checkout -b feature/order-export
# ... 开发代码,提交 ...
git push origin feature/order-export
# 在GitLab上发起Merge Request
# 此时 .gitlab-ci.yml 中的 test 阶段开始执行(MR事件触发)
MR的Pipeline显示:
# GitLab CI界面输出示例
# ---------- test Job ----------
# $ cp .env.testing .env
# $ php artisan key:generate
# Application key set successfully.
# $ php artisan test --stop-on-failure
#
# PASS Tests\Unit\ExampleTest
# ✓ example test
#
# PASS Tests\Feature\UserApiTest
# ✓ user can login
# ✓ user can view orders
#
# Tests: 12 passed, 2 skipped
# Time: 3.42s
# -----------------------------
测试通过后,DevOps工程师(或技术负责人)在GitLab上点击Deploy按钮手动触发部署。
部署时,CI的日志输出如下:
# ---------- deploy-prod Job ----------
# $ ssh ubuntu@$PROD_HOST "/opt/myapp/deploy.sh registry.gitlab.com/yourgroup/yourapp"
# 当前运行镜像ID: sha256:3f19f41d2e6a4e17f9c3f01a4b9d3f4e0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d
# 拉取新镜像: registry.gitlab.com/yourgroup/yourapp
# Using default tag: latest
# latest: Pulling from yourgroup/yourapp
# 5f74c65a4b9f: Pull complete
# d2e7a1b6c3f8: Pull complete
# ...
# Status: Downloaded newer image for registry.gitlab.com/yourgroup/yourapp:latest
# 停止旧容器...
# [+] Running 4/4
# ✔ Container yourapp_nginx_1 Stopped
# ✔ Container yourapp_app_1 Stopped
# ✔ Container yourapp_redis_1 Stopped
# ✔ Container yourapp_mysql_1 Stopped
# 启动新容器...
# [+] Running 4/4
# ✔ Container yourapp_mysql_1 Started
# ✔ Container yourapp_redis_1 Started
# ✔ Container yourapp_app_1 Started
# ✔ Container yourapp_nginx_1 Started
# 等待健康检查...
# 部署完成。当前版本: registry.gitlab.com/yourgroup/yourapp:latest
# -------- notify-success ----------
# 钉钉通知: "生产环境部署成功 版本:abc1234"
# ----------------------------------
整个过程从Docker pull到完成部署,约90秒。
七、效果数据:重构前后对比
直接上数据。统计我过去一年(2023年11月到2024年11月)的发布记录:
| 指标 | rsync+tar方案 | SSH+Docker方案 | 提升 |
|---|---|---|---|
| 平均发布耗时(分钟) | 8.2 | 1.5 | 81.7% |
| 最长发布耗时(分钟) | 23(vendor全量同步) | 4.2(远程拉镜像因网络慢) | — |
| 发布成功率 | 95.1%(39/41) | 99.5%(199/200) | 4.6% |
| 回滚平均耗时(分钟) | 4.2(需重新rsync旧代码) | 0.3(18秒) | 92.9% |
| 测试/生产环境不一致问题 | 12次 | 0次 | 100% |
| 发布导致的线上事故 | 3次 | 0次 | 100% |
还能看到一组性能数据。Docker镜像大小对发布速度影响很大:
- PHP 8.3-FPM基础镜像:约180MB压缩,400MB解压
- 加了Composer依赖后(不包含vendor开发包):约450MB压缩,1.1GB解压
- 远程服务器拉取450MB镜像:平均耗时40-60秒(内网200Mbps带宽)
- 如果使用
dockerd带镜像缓存(registry-mirrors),可以再快一些
八、避坑指南(这篇全是实战踩坑)
坑1:Composer install在CI里跑得很慢
问题:在GitLab CI的Test阶段,composer install用时2-3分钟,因为每次都要重新拉取依赖包和元数据。
解决:两个优化叠加。第一,CI里设置Composer缓存目录并挂载到Runner的Docker volume:
# 修改config.toml(GitLab Runner配置文件,一般在/etc/gitlab-runner/config.toml)
[[runners]]
name = "docker-runner"
url = "https://gitlab.com/"
executor = "docker"
[runners.docker]
image = "docker:24.0.7"
volumes = ["/cache", "/var/run/docker.sock:/var/run/docker.sock", "/certs/client"]
[runners.cache]
Type = "s3"
Shared = true
[runners.cache.s3]
ServerAddress = "s3.amazonaws.com"
BucketName = "your-cache-bucket"
AccessKey = "AKIA..."
SecretKey = "..."
第二,在.gitlab-ci.yml里加上Composer缓存路径:
variables:
COMPOSER_CACHE_DIR: "/cache/composer"
优化后Composer install从2分30秒降到35秒。
坑2:Docker in Docker(DinD)的镜像拉取慢
问题:用docker:dind作为CI的service时,每次构建都要从registry拉取基础镜像。PHP镜像400MB,从Docker Hub拉取因为网络问题非常慢(国内环境),一次build耗时10分钟。
解决:在Runner宿主机上配置registry-mirrors,指向国内镜像加速器(阿里云等)。修改/etc/docker/daemon.json:
// /etc/docker/daemon.json
{
"registry-mirrors": ["https://docker.mirrors.ustc.edu.cn", "https://hub-mirror.c.163.com"],
"exec-opts": ["native.cgroupdriver=systemd"],
"log-driver": "json-file",
"log-opts": {"max-size": "10m", "max-file": "3"}
}
注意:如果你的GitLab Runner是Docker executor,在Runner容器内执行的docker build也是走宿主机的daemon,所以这个配置对DinD模式同样生效。
坑3:SSH密钥权限错误
问题:在部署脚本里执行ssh-add时,如果私钥有错误权限(太大或没有限制权限),SSH会报Permissions 0777 for '/root/.ssh/id_rsa' are too open,然后连接失败。
解决:在CI的before_script中给私钥设置正确的权限:
before_script:
- apk add --no-cache openssh-client
- eval $(ssh-agent -s)
- echo "$SSH_PRIVATE_KEY" > /tmp/ssh_key
- chmod 600 /tmp/ssh_key
- ssh-add /tmp/ssh_key
另外,部署目标服务器的~/.ssh/authorized_keys文件权限必须是600,~/.ssh目录权限必须是700,否则也会拒绝登录。
坑4:健康检查不稳定引发误回滚
问题:刚部署时,PHP-FPM进程启动约需要5-10秒(Laravel框架初始化)。如果我在docker-compose.yml里设置interval: 5s,第一次健康检查可能是在容器启动后第5秒执行,如果此时PHP-FPM还没准备好,会误判为不健康,触发回滚。
解决:调整healthcheck参数:
healthcheck:
test: ["CMD-SHELL", "php -m | grep -q pdo_mysql"]
interval: 10s
timeout: 5s
retries: 12
start_period: 30s
关键参数是start_period。Docker在start_period内不会惩罚不健康的容器,给应用足够的时间完成初始化。30秒之后才开始执行健康检查,这样就避免了误判。
坑5:docker-compose down会删掉容器网络
问题:部署脚本第一步是docker compose down,这会停止并删除容器、网络。如果此时数据库容器被删了,up时重建的MySQL容器会重新初始化数据,但因为使用了volume,数据还在。但有一个坑:docker compose down默认不删除命名volume,但是如果你指定了--volumes参数,数据就没了。我的脚本没加这个参数,安全。
另一个坑:docker compose down会删除默认网络。如果其他服务(比如监控、log agent)连接着这个网络,会被断开。所以在生产环境务必备份网络配置,或者用docker network create创建外部网络,并在docker-compose.yml里声明networks: external: true。我之前因为监控agent断连,Prometheus拉不到数据,排查了半小时。
解决方式示例:
# 创建自定义网络(服务器上执行一次)
docker network create myapp_network
# docker-compose.yml 里声明使用外部网络
services:
app:
networks:
- myapp_network
nginx:
networks:
- myapp_network
mysql:
networks:
- myapp_network
networks:
myapp_network:
external: true
坑6:GitLab Runner标签匹配不上导致Pipeline卡死
问题:配置好Runner后,Pipeline一直pending,提示Next update tries in 5s。检查后发现是因为Job里指定了tags: [code],但Runner没有注册这个标签。
解决:在.gitlab-ci.yml里,所有Job必须指定Runner上存在的标签。用tags关键字精确指定。另外检查Runner是否没有勾选Run untagged jobs——如果Job没指定标签,或者Runner关闭了untagged运行,Job会一直pending。
九、总结
这套方案跑了200多次发布,线上零事故。收益最大的是「构建与发布分离」:构建过程中所有依赖、配置都在Docker镜像里固化,服务器端只负责拉取和重启。环境差异的问题从根源上消除了。
SSH+Docker Compose的部署方式,适合中小型团队的单机、少机部署场景。如果你有3台以下服务器、单体应用、不需要自动伸缩,可以先上这套方案,不用一上来就搞K8s。
核心配置再回顾一遍:
- GitLab Runner 16.8.0,Docker 24.0.7,PHP 8.3.3,Laravel 11
- 流水线四个阶段:test → build → deploy → notify
- 部署脚本:docker pull → compose down → compose up → healthcheck → rollback
- 健康检查:start_period 30s + interval 10s + retries 12
- SSH密钥:GitLab CI变量配置,私钥权限600
如果你也打算转型,建议先拿测试环境跑通流程,再切生产。别一上来就全量切换,给团队一个适应期。
代码都在上面,直接复制改改就能用。