凌晨两点半的 git pull
2023 年 7 月,我在给一个 Laravel 项目加日志告警。改完代码 push 到 GitLab,然后 SSH 登录服务器,执行 git pull。拉下来的代码发现 .env 文件被污染了——因为服务器上的 .env 是手动管理的,git pull 时产生了冲突。
那一晚,我在服务器上花了 20 分钟解决冲突,然后 php artisan config:clear、php artisan migrate,再重启队列。凌晨两点半,我盯着终端里的日志,问了自己一个问题:这套流程为什么不能自动化?
三个星期后,我用 GitLab CI/CD 把整个流程重写了。标准从「push 到生产」只需要 4 分 37 秒。本文记录完整的实施过程,包括我踩过的坑。
问题:手动部署的痛点
| 环节 | 耗时(人工) | 风险点 |
|---|---|---|
| SSH 登录 + git pull | 2-5 分钟 | 冲突、分支切换遗漏 |
| composer install | 1-3 分钟 | vendor 和 composer.lock 不一致 |
| 执行 migrate | 30 秒-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
| 维度 | 手动 SSH | Webhook | GitLab 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 | 平均耗时 | 说明 |
|---|---|---|
| test | 1分48秒 | Composer install 占大头 |
| build | 3分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
后续优化方向
这套方案跑通之后,我又加了两个小东西:
- 流水线结束通知到钉钉群(webhook + 简单 curl)
- deploy 阶段加
when: manual手动确认,避免非工作时间自动部署造成炸线
钉钉通知的脚本很简单,不需要引入 SDK:
curl -X POST \
-H "Content-Type: application/json" \
-d "$(cat <
写在最后
这套 CI/CD 方案替换了我手动操作三个星期的流程。现在每次 push,我只需要盯着流水线进度条,喝杯水,4 分钟后线上就是新代码。回滚也变成一条命令——把上一个 IMAGE_TAG 找出来,执行脚本即可。
如果你还在手动部署,从这篇文章开始改。按照文中的配置文件抄一遍,一个小时能跑通。跑通之后你会理解,为什么「部署」不应该是人去做的事情。