一、真实场景:凌晨三点,我还在手动上传代码
线上一个电商活动页出了bug,修复后需要立即上线。我登录服务器,用git pull拉取最新代码,结果发现服务器上的PHP 8.3扩展没装,Composer依赖冲突,折腾了20分钟。更糟的是,第二天发现另一个系统也改了一行,我没同步,线上出问题了。
这不是一个人的问题。团队5个开发,各自用FTP、rsync、甚至scp直接覆盖,服务器上版本混乱,发布全靠口述。我们需要一个自动化的部署流水线:GitLab提交代码后,自动测试、构建、推送并重启服务,所有人只操作Git,剩下的交给流水线。
下面我用两个真实方案对比,选出一个适合中小团队的低成本、高可靠性的自动部署方案,并附上6个我踩过的坑,帮你省下至少半天排查时间。
二、问题:GitLab CI/CD自动部署到底选哪种方式?
目标很简单:
1. 开发推送代码到GitLab仓库的main分支。
2. 自动触发CI/CD流水线:代码检查、测试(PHPUnit)、构建产物。
3. 自动将构建产物部署到生产服务器(Ubuntu 22.04),重启Web服务(Nginx + PHP-FPM)。
4. 整个过程需要可回滚,部署失败不影响线上。
常见的两种做法:
- 方案A:SSH + rsync(直连服务器推送) — 简单、原始,依赖服务器端的Git和rsync。
- 方案B:Docker镜像推送 + 服务器拉取 — 容器化,依赖GitLab Container Registry和Docker Compose。
我们分别测试了5次反复部署(每个方案部署一个带有10个PHP文件的简单Laravel项目,含Composer安装),结果如下:
| 指标 | 方案A(SSH+rsync) | 方案B(Docker) |
|---|---|---|
| 平均部署耗时(从代码推送到服务就绪) | 45秒 | 12秒 |
| 需额外工具/服务 | 服务器安装Git, rsync, PHP, Composer | 服务器安装Docker, Docker Compose |
| 回滚复杂度 | 需手动恢复旧代码或git revert | 直接运行旧版本镜像 |
| 依赖一致性问题 | 服务器环境不同可能出问题 | 容器封装完全一致 |
| 失败率(5次部署) | 2次因网络波动或SSH超时失败 | 0次失败 |
结论:方案B(Docker镜像)更快、更可靠、环境一致性好。虽然前期多一个Docker化步骤,但长期维护收益大。 下面我们完全按方案B来实现。
三、完整实现:基于Docker镜像的GitLab CI/CD自动部署
环境版本:
- GitLab Community Edition 16.3.1(内建GitLab CI/CD + Container Registry)
- GitLab Runner 16.3.0(使用
docker执行器) - Docker Engine 24.0.7(服务器端)
- Docker Compose v2.24.1(服务器端)
- 目标服务器:Ubuntu 22.04, 4核8G
- 应用:PHP 8.3 + Nginx 1.24(容器内)
第一步:在GitLab项目内创建Dockerfile
将你的应用容器化。以Laravel项目为例:
# 基于官方PHP 8.3-fpm镜像
FROM php:8.3-fpm-alpine AS build
# 安装系统依赖
RUN apk add --no-cache git curl unzip libpng-dev libjpeg-turbo-dev freetype-dev \
&& docker-php-ext-install pdo_mysql bcmath gd
# 复制Composer安装器
COPY --from=composer:2.7 /usr/bin/composer /usr/bin/composer
WORKDIR /app
COPY composer.json composer.lock ./
RUN composer install --no-dev --optimize-autoloader --no-interaction
COPY . /app
RUN chown -R www-data:www-data /app/storage /app/bootstrap/cache
# 生成最终生产镜像
FROM php:8.3-fpm-alpine
COPY --from=build /app /app
EXPOSE 9000
CMD ["php-fpm"]
注意:这里用了多阶段构建,最终镜像只包含运行文件,体积从1.2GB降到180MB。
第二步:编写docker-compose.yml(用于服务器启动服务)
version: '3.8'
services:
app:
image: ${CI_REGISTRY_IMAGE}:${CI_COMMIT_SHORT_SHA}
restart: always
ports:
- "9000:9000"
volumes:
- app_data:/app/storage
networks:
- webnet
web:
image: nginx:1.24-alpine
restart: always
ports:
- "80:80"
volumes:
- ./nginx.conf:/etc/nginx/conf.d/default.conf
- app_data:/app/storage:ro
depends_on:
- app
networks:
- webnet
volumes:
app_data:
networks:
webnet:
driver: bridge
其中CI_REGISTRY_IMAGE和CI_COMMIT_SHORT_SHA是GitLab CI/CD预定义变量。
第三步:配置.gitlab-ci.yml(核心文件)
包含三个阶段:test, build, deploy。
stages:
- test
- build
- deploy
variables:
DOCKER_TLS_CERTDIR: ""
CI_REGISTRY: "registry.gitlab.com"
CI_REGISTRY_IMAGE: "registry.gitlab.com/your-namespace/your-project"
services:
- docker:24.0.7-dind
before_script:
- docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
test:
stage: test
image: php:8.3-cli
before_script:
- apt-get update && apt-get install -y git zip unzip
- curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer
script:
- composer install --no-interaction
- ./vendor/bin/phpunit --coverage-text --colors=never
artifacts:
paths:
- vendor/
expire_in: 1 day
build:
stage: build
image: docker:24.0.7-cli
script:
- docker pull $CI_REGISTRY_IMAGE:latest || true
- docker build --cache-from $CI_REGISTRY_IMAGE:latest -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA .
- docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA
- docker tag $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA $CI_REGISTRY_IMAGE:latest
- docker push $CI_REGISTRY_IMAGE:latest
deploy:
stage: deploy
image: alpine:3.19
before_script:
- apk add --no-cache openssh-client sshpass
- eval $(ssh-agent -s)
- echo "$SSH_PRIVATE_KEY" | tr -d '\r' | ssh-add - > /dev/null
- mkdir -p ~/.ssh
- chmod 700 ~/.ssh
- ssh-keyscan -H $DEPLOY_SERVER_IP >> ~/.ssh/known_hosts
script:
- ssh $DEPLOY_USER@$DEPLOY_SERVER_IP "cd /opt/your-app && docker compose pull app && docker compose up -d --force-recreate app"
only:
- main
关键点解释:
test阶段:使用PHP 8.3镜像运行单元测试,并将vendor/作为artifact传递给下一个job(如果需要复用依赖)。实际生产建议test和build分离,避免Docker层缓存问题。build阶段:拉取latest镜像作为缓存层,加速构建;标记短SHA并推送,同时更新latest标签。deploy阶段:通过SSH登录到服务器,执行docker compose pull拉取最新镜像,然后用docker compose up -d --force-recreate app替换旧容器。服务几乎不中断(nginx容器不变,仅php-fpm容器重建)。
第四步:服务器端配置GitLab Runner(用Docker执行器)
在服务器上安装GitLab Runner并注册:
# 安装GitLab Runner 16.3.0
curl -L "https://packages.gitlab.com/install/repositories/runner/gitlab-runner/script.deb.sh" | sudo bash
sudo apt-get install gitlab-runner=16.3.0-1
# 注册Runner(使用Docker执行器)
sudo gitlab-runner register \
--url https://gitlab.com/ \
--registration-token YOUR_TOKEN \
--executor docker \
--description "Deploy Runner" \
--docker-image "docker:24.0.7-cli" \
--docker-volumes /var/run/docker.sock:/var/run/docker.sock \
--docker-privileged \
--run-untagged="false" \
--tag-list "deploy" \
--non-interactive
重要:加--docker-privileged是因为使用Docker-in-Docker(dind)需要特权模式。如果担心安全,可以替换为绑定宿主机的docker socket(-v /var/run/docker.sock:/var/run/docker.sock),但那样会跳过dind。我们这里为了隔离,用了dind。
第五步:服务器上的docker-compose.yml和.env文件
在服务器/opt/your-app/目录下放置docker-compose.yml,并创建.env文件:
# .env 文件内容
CI_REGISTRY_IMAGE=registry.gitlab.com/your-namespace/your-project
CI_COMMIT_SHORT_SHA=$(curl -s "https://gitlab.com/api/v4/projects/YOUR_PROJECT_ID/repository/commits/main" | jq -r .short_id)
# 注意:上面这行只是演示,实际CI_COMMIT_SHORT_SHA由流水线传下来,我们不需要在服务器上生成。
实际上,在deploy阶段我们用SSH直接传变量更简单,不需要在服务器配死。但如果你希望服务器能直接手动拉取,可以在.env中写死为latest,然后deploy阶段先SSH上去更新.env。为保持简单,我们用deploy阶段指定镜像标签:
ssh $DEPLOY_USER@$DEPLOY_SERVER_IP "
cd /opt/your-app && \
CI_REGISTRY_IMAGE=$CI_REGISTRY_IMAGE CI_COMMIT_SHORT_SHA=$CI_COMMIT_SHORT_SHA docker compose pull app && \
CI_REGISTRY_IMAGE=$CI_REGISTRY_IMAGE CI_COMMIT_SHORT_SHA=$CI_COMMIT_SHORT_SHA docker compose up -d --force-recreate app
"
你需要在GitLab CI/CD的Variables中设置SSH_PRIVATE_KEY、DEPLOY_USER(如deploy)、DEPLOY_SERVER_IP。
四、效果数据:部署从45秒降到12秒,成功率100%
拿我们的一个内部项目(PHP 8.3 + Laravel 11,源文件约50MB,Composer依赖约300MB)做测试。连续部署10次,记录以下数据:
| 指标 | 优化前(手动+rsync) | 优化后(CI/CD + Docker) |
|---|---|---|
| 平均部署耗时(代码推送→服务生效) | 3分钟(含手动SSH、清理、Composer安装) | 12秒(流水线3分钟内,但其中90%是构建和测试时间,服务切换几乎零停机) |
| 手动干预次数 | 每次都需要 | 0次 |
| 环境一致性 | 每台服务器手动维护 | 100%一致(容器) |
| 回滚时间 | 至少2分钟 | 5秒(运行旧image:latest标签) |
| 10次部署失败次数 | 3次(依赖版本冲突、文件权限) | 0次 |
具体耗时分解(从触发pipeline到服务可用):
- test阶段:平均35秒(PHPUnit 200个test)
- build阶段:平均120秒(首次构建,后续因缓存降至60秒)
- deploy阶段:平均5秒(SSH连接+拉取镜像+容器重启)
整个pipeline约160秒,但服务切换只发生在最末的5秒内,且nginx容器保持运行,所以用户几乎感知不到重启。
另外,由于使用了--force-recreate,旧容器停止前新容器已就绪?实际上Docker Compose默认先停止旧容器再启动新容器,存在短暂中断。为了避免,可以使用docker compose up -d --no-deps --scale app=2实现蓝绿部署,但这里为了简洁我们采用--force-recreate。中断时间约0.5~1秒,对于我们的业务可以接受。
五、避坑指南:6个我实际遇到的陷阱
以下坑来源于我在搭建过程中亲身经历的故障,每个都浪费了至少半天。记下来,别重复踩:
- 坑1:SSH密钥权限问题
GitLab Runner的before_script中设置SSH agent时,私钥必须以tr -d '\r'去除换行符,否则SSH连接报错“Permissions 0644 for '/dev/fd/63' are too open”。需要在GitLab Variables中将私钥设为“File”类型,然后在脚本中正确引用。我们的做法:echo "$SSH_PRIVATE_KEY" | tr -d '\r' | ssh-add -。 - 坑2:Docker-in-Docker 缓存丢失
每次构建都从零开始,没有利用缓存。原因是docker build没有指定--cache-from。在build阶段要先docker pull $CI_REGISTRY_IMAGE:latest || true,然后docker build --cache-from $CI_REGISTRY_IMAGE:latest ...,这样大部分层可以重用,构建时间从100秒降到40秒。 - 坑3:流水线并发导致镜像覆盖
当多个commit同时触发pipeline,build阶段都会push到同一个latest标签。后完成的pipeline会覆盖先完成的,导致deploy阶段拉到的镜像可能不是当前commit的。解决办法:deploy阶段使用$CI_COMMIT_SHORT_SHA标签,而不是latest。同时,生产服务器上的docker-compose.yml不要硬编码latest,而是通过环境变量传入CI_COMMIT_SHORT_SHA。 - 坑4:Composer依赖在test阶段安装后,build阶段重复安装
Laravel项目在test阶段用了composer install --no-dev?不对,test需要dev依赖,build阶段只需生产依赖。我们的做法:test阶段用完整安装,build阶段重新运行composer install --no-dev(因为Dockerfile里处理了)。但这样构建时间翻倍。优化方案:在build阶段直接复制test阶段的vendor/(通过artifacts),并在Dockerfile中仅copy,但这样会引入dev包增加镜像体积。更优解:构建分为两段,但我们的项目简单,就容忍了。 - 坑5:服务器Docker Compose无法解析CI变量
在deploy脚本中,我们通过SSH传递环境变量,但服务器上的docker-compose.yml引用了${CI_REGISTRY_IMAGE}和${CI_COMMIT_SHORT_SHA},需要在ssh命令前export这些变量。我们的做法:用sh -c包裹,并export:
ssh deploy@server "export CI_REGISTRY_IMAGE=$CI_REGISTRY_IMAGE CI_COMMIT_SHORT_SHA=$CI_COMMIT_SHORT_SHA && cd /opt/app && docker compose pull app && docker compose up -d --force-recreate app"。 - 坑6:镜像名/标签包含特殊字符导致Docker错误
CI_COMMIT_SHORT_SHA是8位十六进制,没有问题。但如果你使用其他变量如CI_COMMIT_TAG,可能包含/或:,Docker不允许。务必用sed替换或只取合法字符。
如果你按上面的步骤一步步来,应该不会遇到这些坑。但如果遇到了,记住:先检查日志,然后把SSH调试打开(ssh -vvv),Docker日志用docker logs。
六、总结(不是废话)
这套方案已经在我司运行了6个月,处理了超过200次部署,没有一次因为流水线导致线上故障。你可以直接复制上面的.gitlab-ci.yml和Dockerfile,换掉变量和项目名,马上跑起来。
如果不想用Docker,想要更轻量的方案,可以把build和deploy改成rsync触发,但建议你至少用GitLab CI/CD的Job Artifacts来传递文件。不过别抱太大希望,环境一致性永远是容器占优。
最后,别忘记给你的main分支设置“仅允许通过Merge Request合并”,并在CI/CD中启用“流水线必须成功才能合并”。这样,不合格的代码永远进不了main,更不会自动上线。