GitLab CI/CD自动部署:从提交到上线
发布日期: 2026/08/19 阅读总量: 1

凌晨两点半的 git pull

2023 年 7 月,我在给一个 Laravel 项目加日志告警。改完代码 push 到 GitLab,然后 SSH 登录服务器,执行 git pull。拉下来的代码发现 .env 文件被污染了——因为服务器上的 .env 是手动管理的,git pull 时产生了冲突。

那一晚,我在服务器上花了 20 分钟解决冲突,然后 php artisan config:clearphp artisan migrate,再重启队列。凌晨两点半,我盯着终端里的日志,问了自己一个问题:这套流程为什么不能自动化?

三个星期后,我用 GitLab CI/CD 把整个流程重写了。标准从「push 到生产」只需要 4 分 37 秒。本文记录完整的实施过程,包括我踩过的坑。

问题:手动部署的痛点

环节耗时(人工)风险点
SSH 登录 + git pull2-5 分钟冲突、分支切换遗漏
composer install1-3 分钟vendor 和 composer.lock 不一致
执行 migrate30 秒-2 分钟漏执行导致表结构错误
清缓存/重启队列1 分钟漏执行导致代码不生效
回滚10-30 分钟需要本地备份或重新打包

这不是我一个人的痛点。和我协作的另一个后端同事,有次部署时忘了跑 php artisan migrate,结果线上接口 500 了十分钟。我们在群里看到报警,SSH 上去补跑迁移才恢复。这种情况发生过两次。

方案对比:手动 vs 半自动 vs CI/CD

方案 A:手动 + SSH(现状)

最原始。每次部署都是一个「高危操作」,依赖操作者记住全部步骤。

方案 B:Webhook + 服务器钩子

在服务器上跑一个 HTTP 服务,收到 GitLab Webhook 后执行部署脚本。省掉 SSH 登录,但需要自己维护 Webhook 服务,而且没有构建隔离,失败时排查困难。我见过很多人用这种方式,最后都被「脚本挂在某一行」搞崩溃过。

方案 C:GitLab CI/CD + Docker(本文方案)

  • 构建在独立 Runner 的容器里执行,与生产环境隔离
  • 流水线状态可视化,失败了能直接看日志
  • 天然支持多分支、多环境(master → 生产,dev → 测试)
  • 产物以 Docker 镜像形式交付,回滚只需要换 tag
维度手动 SSHWebhookGitLab CI/CD + Docker
平均部署耗时约 15 分钟约 6 分钟4 分 37 秒(自动)
构建环境一致性依赖服务器环境依赖服务器环境容器化,可复现
失败可观测性脚本输出完整日志 / 通知
回滚速度分钟级-小时级分钟级30 秒内(切镜像 tag)
运维成本中(需要维护 Runner)

方案选型结论:团队超过 2 人、项目超过 1 个环境、出过至少一次「漏操作」事故——直接上 CI/CD。

部署架构

我用的环境如下,已在生产稳定运行 4 个月:

  • GitLab:16.6.2(EKS 部署)
  • GitLab Runner:16.6.1,executor 为 docker
  • 应用服务器:Ubuntu 22.04,Docker 24.0.5
  • 目标环境:Nginx 1.25 + PHP-FPM 8.2(Laravel 11 项目)

核心思路:

开发 push 到 master
  → GitLab 触发 Runner
  → Runner 起一个 php:8.2-fpm 容器执行:
      composer install --no-dev
      php artisan test --stop-on-failure
      docker build 并推送镜像到 Registry
  → 部署脚本通过 SSH 到生产服务器:
      docker pull 新镜像
      切换 container tag
      执行 migrate + 清理缓存
      健康检查

步骤一:安装并注册 GitLab Runner

Runner 我装在单独的一台 2C4G 的 ECS 上,Ubuntu 22.04,不和生产环境混部。

# 1. 添加 GitLab 官方仓库
curl -L "https://packages.gitlab.com/install/repositories/runner/gitlab-runner/script.deb.sh" | sudo bash

# 2. 安装 runner(版本 16.6.1)
sudo apt-get install gitlab-runner=16.6.1

# 3. 查看版本
gitlab-runner --version
# 输出示例:Version:      16.6.1
# 注册 runner。这个 token 在 GitLab 项目的 Settings → CI/CD → Runners 里找
sudo gitlab-runner register \
  --non-interactive \
  --url "https://gitlab.example.com" \
  --registration-token "替换成你的token" \
  --executor "docker" \
  --docker-image "php:8.2-cli" \
  --description "docker-runner" \
  --tag-list "docker"

# 启动 runner
sudo gitlab-runner start

注意:这里的 --docker-image 是默认镜像,实际每个 job 可以在 .gitlab-ci.yml 里单独指定 image: 覆盖。

检查 Runner 状态:

sudo gitlab-runner status

# 在 GitLab 项目 → Settings → CI/CD → Runners 里应该能看到
# 状态为 online

步骤二:项目 Dokerfile

我用 php:8.2-fpm 做基础镜像,安装了项目需要的扩展和 composer。镜像构建在 Runner 上执行,推送到 GitLab Registry。

# Dockerfile
FROM php:8.2-fpm

# 安装系统依赖和 PHP 扩展
RUN apt-get update && apt-get install -y \
    libzip-dev \
    libpq-dev \
    libonig-dev \
    libxml2-dev \
    git \
    unzip \
    && docker-php-ext-install \
    pdo_mysql \
    zip \
    opcache

# 安装 composer
COPY --from=composer:2.7.1 /usr/bin/composer /usr/bin/composer

WORKDIR /var/www/html

# 项目代码由 CI 在 build 阶段拷贝进镜像,这里不 COPY —— 避免层缓存

为什么不在 Dockerfile 里直接 COPY . .?因为构建上下文里会有 .env.git 之类的敏感或冗余文件。我在 CI 里生成 .env 再 COPY,见下文。

步骤三:编写 .gitlab-ci.yml

这是整个流水线的核心。

# .gitlab-ci.yml
stages:
  - test
  - build
  - deploy

variables:
  DOCKER_REGISTRY: "registry.gitlab.example.com"
  IMAGE_TAG: "$CI_COMMIT_SHORT_SHA"
  APP_NAME: "myapp"

# ---------- Stage 1: 自动化测试 ----------
test:
  stage: test
  image: php:8.2-cli
  before_script:
    - apt-get update && apt-get install -y libzip-dev libpq-dev unzip
    - docker-php-ext-install pdo_mysql zip
    - curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer
    - composer install --no-interaction --prefer-dist
  script:
    - cp .env.ci .env
    - php artisan key:generate
    - php artisan migrate --force
    - php artisan test --stop-on-failure
  only:
    - master

# ---------- Stage 2: 构建 Docker 镜像 ----------
build:
  stage: build
  image: docker:24.0.5
  services:
    - docker:24.0.5-dind
  variables:
    DOCKER_TLS_CERTDIR: "/certs"
  before_script:
    # 登录 GitLab Registry
    - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
    # 先生成 .env。CI/CD 变量里配置。
    # 注意:这里会生成一个生产环境用的 .env 拷贝进镜像。
    # 如果你追求更高安全,可以在运行时挂载 volume 注入,但这里从简
    - echo "APP_ENV=production" > .env
    - echo "APP_KEY=$APP_KEY" >> .env
    - echo "DB_HOST=$DB_HOST" >> .env
    - echo "DB_DATABASE=$DB_DATABASE" >> .env
    - echo "DB_USERNAME=$DB_USERNAME" >> .env
    - echo "DB_PASSWORD=$DB_PASSWORD" >> .env
    - echo "REDIS_HOST=$REDIS_HOST" >> .env
    - echo "REDIS_PASSWORD=$REDIS_PASSWORD" >> .env
  script:
    # 把代码打进 Docker 镜像。
    # 注意 .dockerignore 里排除 .git / .env / vendor / tests
    - docker build -t $DOCKER_REGISTRY/$APP_NAME:$IMAGE_TAG .
    - docker push $DOCKER_REGISTRY/$APP_NAME:$IMAGE_TAG
  only:
    - master

# ---------- Stage 3: 部署到生产 ----------
deploy:
  stage: deploy
  image: alpine:latest
  before_script:
    - apk add --no-cache openssh-client bash
    # 部署用的私钥存在 CI/CD 变量里(SSH_PRIVATE_KEY)
    - mkdir -p ~/.ssh
    - echo "$SSH_PRIVATE_KEY" > ~/.ssh/id_rsa
    - chmod 600 ~/.ssh/id_rsa
    - ssh-keyscan -H $DEPLOY_SERVER_IP >> ~/.ssh/known_hosts
  script:
    - |
      ssh deployer@$DEPLOY_SERVER_IP "bash -s" <>> Pulling image: $DOCKER_REGISTRY/$APP_NAME:$IMAGE_TAG"
      docker login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" $DOCKER_REGISTRY
      docker pull $DOCKER_REGISTRY/$APP_NAME:$IMAGE_TAG

      echo ">>> Stopping old container"
      docker stop myapp-container || true
      docker rm myapp-container || true

      echo ">>> Starting new container"
      # 生产环境用 docker run,项目有 nginx + fpm 两个容器,
      # 但实际我用 docker-compose 管理,见下文步骤四。
      docker run -d \
        --name myapp-container \
        --restart unless-stopped \
        --network myapp-network \
        -e APP_ENV=production \
        -e APP_KEY="$APP_KEY" \
        -e DB_HOST="$DB_HOST" \
        -e DB_DATABASE="$DB_DATABASE" \
        -e DB_USERNAME="$DB_USERNAME" \
        -e DB_PASSWORD="$DB_PASSWORD" \
        -e REDIS_HOST="$REDIS_HOST" \
        -e REDIS_PASSWORD="$REDIS_PASSWORD" \
        $DOCKER_REGISTRY/$APP_NAME:$IMAGE_TAG

      echo ">>> Running migrations"
      docker exec myapp-container php artisan migrate --force

      echo ">>> Clearing caches"
      docker exec myapp-container php artisan config:cache
      docker exec myapp-container php artisan route:cache
      docker exec myapp-container php artisan view:cache

      echo ">>> Health check"
      sleep 5
      docker ps --filter name=myapp-container --format "{{.Status}}"
      EOF
  environment:
    name: production
  only:
    - master

关于 CI/CD 变量:上面用到的 $CI_REGISTRY_USER$CI_REGISTRY_PASSWORD 是 GitLab 预置变量,不用自己配。$DEPLOY_SERVER_IP$SSH_PRIVATE_KEY$APP_KEY $DB_* 这些去项目 Settings → CI/CD → Variables 里配。变量分两种:Masked(打码)和 Protected(只在 master 生效),密钥类的两个都勾上。

步骤四:生产服务器的 docker-compose 配置

部署脚本里直接 docker run 不便于维护。我最终用 docker-compose 管理生产环境的多容器编排。部署脚本改成在服务器上拉取 `docker-compose.yml` 更新再 up -d。

# /opt/myapp/docker-compose.yml
services:
  app:
    image: registry.gitlab.example.com/myapp:latest
    restart: unless-stopped
    env_file:
      - .env
    networks:
      - backend

  nginx:
    image: nginx:1.25-alpine
    restart: unless-stopped
    ports:
      - "80:80"
    volumes:
      - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
    depends_on:
      - app
    networks:
      - backend

networks:
  backend:
    driver: bridge

对应的 nginx 配置:

server {
    listen 80;
    server_name example.com;

    root /var/www/html/public;
    index index.php;

    location / {
        try_files $uri $uri/ /index.php?$query_string;
    }

    location ~ \.php$ {
        fastcgi_pass app:9000;
        fastcgi_index index.php;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
        include fastcgi_params;
    }
}

注意 nginx 容器通过 service 名 app 访问 PHP-FPM,这是 docker-compose 默认的 DNS 解析。

步骤五:优化后的 deploy 脚本

上面步骤三里我直接把 docker run 写在 CI 的 script 里。实际后来我把部署逻辑抽成了独立脚本,放在项目的 deploy/deploy.sh 里。好处是本地也能执行、好测试,CI 只负责 SSH 过去调用。

#!/usr/bin/env bash
# deploy/deploy.sh
set -euo pipefail

# 接收参数:IMAGE_TAG
IMAGE_TAG=$1

cd /opt/myapp/

echo ">>> Pulling image ${IMAGE_TAG}"
docker pull registry.gitlab.example.com/myapp:${IMAGE_TAG}

echo ">>> Backing up current .env"
cp .env .env.bak.$(date +%s)

echo ">>> Update docker-compose image tag"
sed -i "s|image: registry.gitlab.example.com/myapp:.*|image: registry.gitlab.example.com/myapp:${IMAGE_TAG}|" docker-compose.yml

echo ">>> Stopping old and recreate container"
docker compose up -d --force-recreate app

echo ">>> Running migrations and cache clear"
docker compose exec -T app php artisan migrate --force
docker compose exec -T app php artisan config:cache

echo ">>> Checking container status"
sleep 3
docker compose ps

这个脚本执行逻辑是:拉镜像 → 改 compose 里的 tag → 重建容器 → 迁移 → 清缓存。回滚时只需要把 `IMAGE_TAG` 换成上一个镜像 tag。

完整效果:数据对比

所有数据来自我生产环境(Laravel 11 + MySQL 8.0.35,2C4G 服务器)的实际采集。

流水线耗时分解(平均数,抽样 20 次)

Stage平均耗时说明
test1分48秒Composer install 占大头
build3分05秒走缓存后降到 1分52秒
deploy约 30 秒主要是 pull 镜像和容器重建
总计约 5分23秒push 触发到健康检查通过

后来我加了 composer 缓存和 docker layer 缓存(Runner 的 concurrent=2 + cache 目录挂载),平均总耗时降到 4分37秒。人工部署平均 15 分钟(有时 20+),效率提升约 69%

关键性能数据

  • 构建产物 Docker 镜像大小:352 MB(含 PHP-FPM + nginx + 项目代码),相比不打生产依赖的镜像,小了约 91 MB(composer install --no-dev 的效果)
  • 镜像推送耗时:平均 41 秒(GitLab Registry 在内网)
  • 容器冷启动到可响应时间:5-7 秒(健康检查脚本里 sleep 5 后验证)
  • 回滚耗时:20-35 秒(重新 pull 上一个 tag + up -d)

与旧流程对比

指标旧:手动 SSH新:CI/CD提升
平均部署总耗时约 15 分钟4分37秒69% 提升
误操作(漏拉代码/漏跑迁移)每 10 次约 2 次0 次100%
失败定位看自己终端流水线日志 + 钉钉通知
回滚时间备份 + 上传 + 重启(20 分钟+)镜像 tag 回切 + up -d(30 秒内)约 97%
部署可审计性无记录GitLab CI 全程留痕

优化:Docker Layer Caching

第一次跑流水线的 build 阶段耗时 5 分多钟,因为每次从零拉 php:8.2-fpm 基础镜像 + apt-get install。后来我在 Runner 上加了这个优化:

# 在 /etc/gitlab-runner/config.toml 里,给 runner 增加缓存配置
[[runners]]
  name = "docker-runner"
  url = "https://gitlab.example.com"
  token = "xxx"
  executor = "docker"
  [runners.docker]
    image = "php:8.2-cli"
    volumes = ["/cache", "/var/run/docker.sock:/var/run/docker.sock"]
  [runners.cache]
    Type = "s3"
    Path = "gitlab-runner-cache"
    Shared = true

然后在 .gitlab-ci.yml 的 build 阶段加 cache 指令:

build:
  stage: build
  image: docker:24.0.5
  cache:
    key: "$CI_COMMIT_REF_SLUG-docker"
    paths:
      - vendor/
  script:
    # 利用 docker build 的 BuildKit 缓存
    - export DOCKER_BUILDKIT=1
    - docker build --cache-from $DOCKER_REGISTRY/$APP_NAME:$CI_COMMIT_REF_SLUG -t $DOCKER_REGISTRY/$APP_NAME:$IMAGE_TAG .

之后 build 阶段从 5 分钟降到 1分52秒。核心是 --cache-from 参数,让 Docker 复用上一次构建的层。

避坑指南

以下是这个过程中我实际踩过的坑,每个花的时间都不少于两小时,希望你能绕开。

坑 1:Runner 并发导致构建目录冲突

现象:配了 Runner 的 concurrent=4 之后,不同 job 构建时互相污染 vendor 目录,导致 test 阶段直接报 class not found。

原因:默认的 executor 是 shell,多个 job 在同一个 shell 会话里跑,工作目录用了同一个 /builds/group/project

解法:换 docker executor。docker executor 每个 job 是独立容器,天然隔离。在外部 shell 模式下,加 --docker 参数切换。如果用 docker executor 还有问题,那就是 concurrent 设置过大——我最终设置在 2,稳定运行。

坑 2:CI/CD 变量里的特殊字符转义

现象:数据库密码是 Abc@123!$%^,在 CI 变量里配好,但构建出来的 .env 里密码变成了 Abc@123。DB 连接失败。

原因:我用了这行命令:

echo "DB_PASSWORD=$DB_PASSWORD" >> .env

! 在 bash 交互模式下有特殊含义,会触发历史展开。但实际踩坑点是 $ 开头的内容被当成变量展开——比如密码是 pa$$word,出来的就是 pa + 空变量。

解法:用 cat <<EOF 代替 echo,并给 EOF 加引号:

cat <<'EOF' > .env
DB_PASSWORD=$DB_PASSWORD
EOF

单引号内的 EOF 不做任何展开。这个坑我调了一个晚上。

坑 3:Docker 镜像时区错误

现象:部署到生产后,日志时间比北京时间晚 8 小时。

原因:php:8.2-fpm 的基础镜像是 UTC 时区。

解法:在 Dockerfile 里写死时区。

ENV TZ=Asia/Shanghai
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone

坑 4:git pull 的 .env 冲突重新出现

现象:即使有 CI/CD,我还是有同事会把 .env 里的改动 git commit 进仓库。

原因:项目里的 .env 文件没有正确 gitignore。

解法:`.gitignore` 里加 .env,并强制删掉被追踪的文件:

git rm --cached .env
# 确认 .gitignore 有 .env
echo ".env" >> .gitignore
git commit -m "fix: remove .env from repository"

这个操作做了之后,再去 GitLab 后台把这个文件从仓库历史里清洗掉——如果你的仓库已经泄漏过密钥,建议轮换所有密钥。

坑 5:Docker-in-Docker 的 TLS 报错

现象:Runner 用 docker executor 的 dind service 时,build 阶段报 Cannot connect to the Docker daemon

原因:Docker 24 默认启 TLS,证书路径没配对。

解法:变量加 DOCKER_TLS_CERTDIR: "/certs",同时确保 docker client 和 dind service 的版本一致。我遇到过 dind 是 20.10、client 是 24.0,TLS 版本不匹配的情况。

坑 6:云厂商的安全组没放通 Runner → 生产服务器的端口

现象:流水线跑到 deploy 阶段,SSH 连不上生产服务器。

原因:生产服务器安全组只放通了办公 IP 的 22 端口。Runner 在云上,IP 会变。

解法

# 临时解法
# 把 Runner 的 ECS 安全组 IP 加到生产服务器的白名单
# 长期解法,我推荐用独立部署账号 + 仅允许 Runner 所在 VPC 网段访问

坑 7:迁移脚本并发执行导致锁表

现象:多个 job 同时执行 php artisan migrate,MySQL 报表锁冲突。

原因:不同的 job tag 触发了多次流水线,migration 不是原子的。

解法:deploy 阶段加 when: manual 确认,或者加 resource_group: production 让 GitLab 保证同一时刻只有一个 job 在部署。

deploy:
  resource_group: production

后续优化方向

这套方案跑通之后,我又加了两个小东西:

  1. 流水线结束通知到钉钉群(webhook + 简单 curl)
  2. deploy 阶段加 when: manual 手动确认,避免非工作时间自动部署造成炸线

钉钉通知的脚本很简单,不需要引入 SDK:

curl -X POST \
  -H "Content-Type: application/json" \
  -d "$(cat <

写在最后

这套 CI/CD 方案替换了我手动操作三个星期的流程。现在每次 push,我只需要盯着流水线进度条,喝杯水,4 分钟后线上就是新代码。回滚也变成一条命令——把上一个 IMAGE_TAG 找出来,执行脚本即可。

如果你还在手动部署,从这篇文章开始改。按照文中的配置文件抄一遍,一个小时能跑通。跑通之后你会理解,为什么「部署」不应该是人去做的事情。