GitLab CI/CD部署:ssh+Docker多环境自动发布
发布日期: 2026/08/05 阅读总量: 0

一、先说我踩过的坑——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+tarSSH同步代码到服务器,执行远程命令简单直接,上手快,适合小项目环境不一致,无隔离,回滚困难单机、无容器化改造的存量项目
SSH+git pull服务器上执行git pull代码版本可控每次发布都要在服务器上处理依赖安装、权限问题几乎没有优势,不推荐
SSH+Docker ComposeCI构建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流水线的核心。我设计四个阶段:testbuilddeploynotify

# .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秒,然后检查容器的健康状态。如果unhealthystarting超时,触发回滚。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.21.581.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

如果你也打算转型,建议先拿测试环境跑通流程,再切生产。别一上来就全量切换,给团队一个适应期。

代码都在上面,直接复制改改就能用。