☰
PHP8.5怎么配置CI-CD回滚机制
2026/10/3 18:00:36 网站建设 项目流程

前言

绝大多数团队第一次上线 PHP 应用时,部署脚本是这样的:git pull && composer install && chmod -R 777 storage。它能工作,但一旦出事,回滚就只有一条路可走——git revert再跑一次完整流程。于是你会见到这些症状:


  • 出事时手忙脚乱,回滚一次要重新构建、重新装依赖,五到十五分钟过去,故障还在扩大;

  • 回滚完成、代码确实回到旧版本了,但页面还是报新代码的错——opcache 里还留着上一版的字节码;

  • 回滚脚本跑到一半失败,代码目录处于「一半旧、一半新」的状态,比不回滚还糟;

  • 代码回滚了,可数据库迁移已经把字段删掉了,旧代码一读就Unknown column;

  • 回滚完老用户开始报__PHP_Incomplete_Class——session 里存的是新版本类的序列化对象。


这些症状指向同一个结论:回滚不能是「反向执行一遍部署」,而必须是一次原子切换。也就是发布时把新版本挂上去、出问题时把指针切回旧版本,整个过程只有一个瞬间在变,不涉及任何重新构建。

本文以PHP 8.5+ php-fpm 为例,搭一套「不可变产物 + 软链接原子切换 + 健康检查自动回滚」的机制。它对 PHP 版本本身没有依赖,8.0 到 8.5 都适用;文中会顺带指出几个 PHP 独有的回滚陷阱。

一、回滚的本质:把「切换」做成原子的

传统部署是「就地覆盖」:把文件rsync到同一个目录。这个过程必然有一段不完整的时间窗口,而且是不可逆的——旧文件已经被覆盖了。

改用版本化发布目录 + 符号链接:

/srv/app/ ├── releases/ │ ├── 20260920-3f9a1c08b2d4/ ← 每次发布一个新目录,永不修改 │ ├── 20260924-8c1b7e02aa91/ │ └── 20260929-a41d9f3c7e55/ ← 当前线上版本 ├── shared/ │ └── storage/ ← 上传文件、日志,所有版本共享 ├── state/ │ └── previous ← 记录上一个版本,供回滚使用 └── current -> releases/20260929-a41d9f3c7e55

nginx / php-fpm 的root指向/srv/app/current/public,回滚就是把current这个软链接指回旧目录。旧目录还在原地、内容一字未改,所以回滚不存在「构建失败」这种可能。

有一个细节必须小心:ln -sfn不是原子操作。它会先 unlink 再 symlink,中间存在一个current不存在的瞬间,正好被请求撞上就是 500。正确做法是利用同文件系统下的rename(2)原子性:

ln -sfn "$TARGET" /srv/app/.current.tmp mv -T /srv/app/.current.tmp /srv/app/current # GNU coreutils 的 -T:直接替换链接本身

mv -T把目标当成普通文件替换,底层就是rename(),内核保证原子性。前提是.current.tmp和current在同一文件系统,所以临时链接必须放在/srv/app/里,不能放/tmp。

部署方式切换是否原子回滚耗时回滚是否依赖重新构建
就地覆盖(git pull)否,有明显窗口一次完整构建是
双目录互切(手动)否,容易漏配快否
版本目录 +ln -sfn有极短窗口快否
版本目录 +mv -T是秒级否
蓝绿 / 容器滚动是取决于流量切换否

二、让 PHP 真的「换掉代码」:opcache、fpm 与 session

这是 PHP 部署和 Java/Go 最大的不同:文件换了,进程不一定换。php-fpm 是常驻进程,opcache.validate_timestamps=0时它根本不会去 stat 文件,所以你切完软链接,它还在跑旧字节码。

生产环境的推荐组合是:

; 生产:字节码只在进程启动时读一次,性能最好 opcache.validate_timestamps=0 opcache.enable=1 opcache.memory_consumption=192 opcache.max_accelerated_files=20000

配上它,每次发布和回滚都必须让 php-fpm 重新加载,否则回滚等于没做:

# 优雅重启:先接新连接,处理完存量请求再退出旧 worker kill -USR2 "$(cat /run/php/php8.5-fpm.pid)"

如果进程是用 systemd 或容器管理的,用对应手段:

systemctl reload php8.5-fpm # 等价于发 SIGUSR2 docker compose exec php kill -USR2 1 # 容器内 PID 1 是 php-fpm 主进程时

注意opcache_reset()不能替代重启。它清的是共享内存里的字节码,而每个 fpm worker 进程还有自己独立的realpath_cache,缓存着「文件路径 → 真实路径」的映射。软链接切换后,旧 worker 的 realpath 缓存可能仍指向旧目录,于是出现「一半请求新、一半请求旧」的诡异现象。

session 是第二个 PHP 特有的坑。如果用了文件或 Redis 存 session,session 里往往是序列化后的对象。代码回滚后类结构变了,反序列化出来会变成__PHP_Incomplete_Class或者属性缺失的幽灵对象,报错信息极其隐晦。对策是:


  • session 里只存标量和数组,不存对象;

  • 或者让 session 数据结构向后兼容:新字段用??兜底,回滚后旧代码读得到但不会崩。


三、代码能回滚,数据和队列不一定能

变更类型能否回滚正确做法
PHP 代码能,秒级版本目录 + 软链接切换
配置(.env)能配置随 release 目录一起版本化,别放成全局单份
静态资源能文件名带 hash,或用独立 CDN 目录
数据库新增列/表能只加不删,旧代码忽略新列即可
数据库删列/改类型不能用「扩展—收缩」两阶段:本次只加新列、双写,下个版本再删旧列
队列里的任务视情况payload 里带schema_version,消费者按版本分派
已推送给第三方的事件不能回滚不了,只能靠幂等 + 补偿

数据库这里的关键词是expand/contract(扩展—收缩):任何一次发布都只能做「兼容新旧两版代码」的迁移。加字段可以立刻加;删字段必须等到「确认没有任何一版代码还在用它」之后,单独找一个发布做。这条纪律比任何回滚脚本都重要,因为再好的脚本也回滚不了一次DROP COLUMN。

四、完整可用的部署与回滚脚本

/srv/app/bin/deploy.sh,负责接收产物、原子切换、健康检查、失败自动回滚:

#!/usr/bin/env bash set -Eeuo pipefail BASE=/srv/app RELEASES="$BASE/releases" CURRENT="$BASE/current" STATE="$BASE/state" HEALTH_URL="http://127.0.0.1:8080/healthz" KEEP=5 RELEASE_ID="${1:?用法: deploy.sh <release-id> <artifact.tar.gz>}" ARTIFACT="${2:?缺少产物包路径}" TARGET="$RELEASES/$RELEASE_ID" [[ -e "$TARGET" ]] && { echo "release 已存在: $RELEASE_ID" >&2; exit 1; } mkdir -p "$RELEASES" "$STATE" "$BASE/shared/storage" # 1) 解到临时目录,解包失败不会污染 releases STAGE="$RELEASES/.staging-$RELEASE_ID" rm -rf "$STAGE" mkdir -p "$STAGE" tar -xzf "$ARTIFACT" -C "$STAGE" # 2) 挂上共享目录(上传文件、日志跨版本共用) ln -sfn "$BASE/shared/storage" "$STAGE/storage" # 3) 解包完成后再改名到最终目录,保证 releases 下只有完整版本 mv -T "$STAGE" "$TARGET" # 4) 记下当前版本,作为回滚目标 CUR="$(readlink -f "$CURRENT" 2>/dev/null || true)" [[ -n "${CUR:-}" && "$CUR" != "$TARGET" ]] && printf '%s\n' "$CUR" > "$STATE/previous" # 5) 原子切换 ln -sfn "$TARGET" "$BASE/.current.tmp" mv -T "$BASE/.current.tmp" "$CURRENT" reload_fpm # 6) 健康检查失败自动回滚 if ! wait_healthy 10; then echo "健康检查失败,自动回滚" >&2 "$BASE/bin/rollback.sh" || true exit 1 fi prune_old_releases echo "部署完成: $RELEASE_ID"

配套的函数与回滚脚本:

reload_fpm() { local pidfile=/run/php/php8.5-fpm.pid if [[ -f "$pidfile" ]]; then kill -USR2 "$(cat "$pidfile")" else systemctl reload php8.5-fpm fi } wait_healthy() { local tries="${1:-10}" for ((i = 0; i < tries; i++)); do if curl -fsS --max-time 2 "$HEALTH_URL" >/dev/null; then return 0 fi sleep 2 done return 1 } prune_old_releases() { # 保留最新 KEEP 个版本,其余删除(current 指向的那个必然在最新的里面) ls -1dt "$RELEASES"/*/ 2>/dev/null | tail -n +$((KEEP + 1)) | xargs -r rm -rf }

rollback.sh只做一件事:把指针切回去,然后重载。

#!/usr/bin/env bash set -Eeuo pipefail BASE=/srv/app PREV="$(cat "$BASE/state/previous" 2>/dev/null || true)" if [[ -z "${PREV:-}" || ! -d "$PREV" ]]; then echo "没有可回滚的版本" >&2 exit 1 fi CUR="$(readlink -f "$BASE/current")" # 交换:当前版本变成下一次的回滚目标,回滚可以来回切 printf '%s\n' "$CUR" > "$BASE/state/previous" ln -sfn "$PREV" "$BASE/.current.tmp" mv -T "$BASE/.current.tmp" "$BASE/current" kill -USR2 "$(cat /run/php/php8.5-fpm.pid)" 2>/dev/null || systemctl reload php8.5-fpm echo "已回滚到: $PREV"

健康检查接口本身要够「实」——只返回{"ok":true}是没用的,它应该至少验证「能连上数据库」和「能读到当前版本号」:

<?php declare(strict_types=1); // 最低 PHP 8.0;建议 PHP 8.5 header('Content-Type: application/json; charset=utf-8'); $version = trim(@file_get_contents(__DIR__ . '/../RELEASE') ?: ''); try { $pdo = new PDO( dsn: 'mysql:host=127.0.0.1;dbname=app;charset=utf8mb4', username: 'app', password: getenv('DB_PASSWORD') ?: '', options: [PDO::ATTR_TIMEOUT => 2, PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION], ); $pdo->query('SELECT 1'); } catch (Throwable $e) { http_response_code(503); echo json_encode(['ok' => false, 'error' => 'db'], JSON_UNESCAPED_UNICODE); exit; } echo json_encode(['ok' => true, 'version' => $version], JSON_UNESCAPED_UNICODE);

CI 侧的 GitHub Actions 写成「构建」和「部署」两个 job,产物在构建阶段生成一次,部署阶段只是搬运:

name: ci-cd on: push: branches: [main] # 同一时间只允许一个部署,避免两个发布互相踩脚 concurrency: group: production-deploy cancel-in-progress: false jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: shivammathur/setup-php@v2 with: php-version: '8.5' extensions: mbstring, intl, pdo_mysql, opcache coverage: none - name: 安装依赖 run: composer install --no-dev --no-interaction --prefer-dist --optimize-autoloader - name: 平台需求自检(缺扩展时这里就会红) run: composer check-platform-reqs - name: 语法检查 run: find . -name '*.php' -not -path './vendor/*' -print0 | xargs -0 -n1 -P4 php -l - name: 单元测试 run: vendor/bin/phpunit - name: 写入版本号并打包 run: | echo "${GITHUB_SHA}" > RELEASE tar -czf release.tar.gz --exclude=.git --exclude=tests --exclude=node_modules . - uses: actions/upload-artifact@v4 with: name: release path: release.tar.gz retention-days: 7 deploy: needs: build runs-on: ubuntu-latest environment: production # 可在这里配置人工审批 steps: - uses: actions/download-artifact@v4 with: name: release - name: 推送到服务器并原子切换 run: | scp release.tar.gz deploy@app-prod:/srv/app/incoming/ ssh deploy@app-prod "/srv/app/bin/deploy.sh '${GITHUB_SHA:0:12}' /srv/app/incoming/release.tar.gz"

常见坑点


  1. ❌ 用ln -sfn 新目录 current直接切


✅ln -sfn "$T" .current.tmp && mv -T .current.tmp current,靠rename(2)保证原子性


  1. ❌ 回滚脚本只切软链接,不重启 php-fpm


✅ 生产开了opcache.validate_timestamps=0时,必须kill -USR2重载,否则跑的还是旧字节码


  1. ❌ 以为opcache_reset()就够了


✅ 每个 worker 还有独立 realpath cache,跨软链接的场景必须重启进程才干净


  1. ❌ 把所有发布解的包都留在服务器上也从不清理,磁盘最终被写满


✅ 保留最近 N 个版本,ls -1dt | tail -n +$((N+1)) | xargs -r rm -rf,且务必确认 current 指向的版本不在删除列表里


  1. ❌ 迁移和代码一起发,这次DROP COLUMN、下次ADD COLUMN


✅ 一律「扩展—收缩」:先双写兼容、下一版再收缩;DROP永远单独发一次


  1. ❌ 把.env放在/srv/app/.env这种全局单份位置


✅ 配置随 release 目录版本化,回滚时代码和配置一起回;密钥用环境变量注入的,回滚流程里也要把它一起切


  1. ❌ 健康检查只判断进程存活或返回 200


✅ 检查项要覆盖数据库连接、缓存连接、以及当前版本号,否则「切过去了但没生效」这种情况发现不了


  1. ❌ 部署脚本没有set -Eeuo pipefail,中途失败后残留半截目录,脚本继续往下跑


✅ 加上set -Eeuo pipefail,并让解包先落到.staging-*临时目录,成功后才mv -T到最终目录


  1. ❌ session 里存了完整对象,回滚后满屏__PHP_Incomplete_Class


✅ session 只放标量与数组;必须放对象时保证结构向后兼容

总结

环节关键手段为什么必须这样
产物不可变 release 目录,一次构建多处使用回滚不需要重新构建,没有失败可能
切换ln -sfn+mv -T借助rename(2)做到零窗口
生效kill -USR2重载 php-fpmopcache 与 realpath cache 不随文件变化
兜底健康检查失败自动调 rollback人反应不过来,脚本可以
数据expand/contract,删列单独发代码能回滚,DROP COLUMN不能
并发concurrency串行化 + 部署锁两次发布交叉执行会互相覆盖指针
验证健康检查返回版本号确认「切过去的」真的是「在跑的」

回滚机制的价值不在回滚本身,而在于它让「发布」这件事从不可逆变成可逆。做到这一点只需要三件事:产物不可变、切换原子化、进程必须重载。其余的全是围绕这三点的工程细节;而唯一真正无法回滚的是已经落地的数据变更,所以迁移策略必须比部署脚本更保守。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询