ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

基于GitLab CI/CD的PHP项目自动化流水线改造实践

基于GitLab CI/CD的PHP项目自动化流水线改造实践 上周我把Arbess项目的发版流程从纯手工变成了GitLab流水线自动跑整体耗时从原来的一次四十分钟压缩到十几分钟中途还顺手揪出了两处被忽略了很久的代码问题。这篇内容就是记录这次改造的全过程重点讲清楚每一段流水线为什么这么设计、配置上踩过哪些坑以及最终效果到底怎么样。Arbess是一个运行了很久的PHP业务系统代码规模不算小历史包袱也不轻。以前每次发布都是运维同事手动登录服务器拉代码、清缓存、跑迁移忙活半天还得靠聊天软件来回确认版本号。这种模式在团队小、发布频率低的时候勉强能撑但需求一多就各种乱。所以我决定用GitLab自带的CI/CD能力给Arbess搭一条自动化流水线把检查、测试、构建、部署全部串起来。如果你是第一次给PHP项目上CI/CD这篇内容应该能帮你少走不少弯路。文里不会只贴一份能跑的配置文件更多的会讲清楚每个阶段背后的考虑以及实际运行时才会暴露的细节问题。1. 先把Arbess项目的家底摸清楚为什么PHP项目也需要自动化流水线1.1 项目现状与历史包袱Arbess项目最早是几个人快速迭代出来的内部系统代码仓库在GitLab上PHP版本从5.6一路升级到了7.4框架用的是老的CI风格没有统一的代码规范工具测试覆盖也基本是零。数据库迁移脚本散落在各个版本的发布说明里每次上线前运维都要手动确认当前库结构到底是哪个版本。这种项目有个共性特点跑起来没问题但没人敢保证改了一个文件不会影响另一个模块。因为没有自动化检查手段所有风险都压在“发版人”的脑子里。我接手之后第一件事不是写流水线而是先把项目现状摸清楚明确哪些环节是适合自动化的哪些环节在现阶段自动化反而会添乱。摸家底的时候我列了一张清单重点看四块内容代码仓库的分支管理方式、依赖管理工具是否统一、测试环境怎么搭建、部署目标是服务器还是容器。Arbess的情况是主干分支直接发布依赖用的是Composer测试环境是一台独立的内网服务器部署方式是传统的压缩包加软链切换。这些信息直接决定了流水线的形态。1.2 手工发布流程的痛点清单在动手之前我把之前每次发布的完整步骤记录下来按时间顺序排了一遍痛点一下子就清晰了没有统一的代码检查入口语法错误经常是上线之后才暴露单元测试数量虽然不多但没有人每次发布都手动跑一遍服务器上拉取代码依赖当前目录的Git状态经常出现代码和配置不一致发布过程依赖某个同事的记忆换个人操作就可能漏步骤回滚完全靠备份目录手工恢复时间越长越难定位到具体版本。这些痛点对应到流水线上其实就是四个能力自动跑检查、自动跑测试、自动构建产物、自动部署到目标环境。把人工操作变成固定的流水线之后每次发布都是同一个过程结果可预期出问题也能快速定位到是哪一步挂的。1.3 流水线要解决的核心问题我不建议一上来就把流水线设计得很复杂。对Arbess这种存量项目核心目标其实很朴素只要能保证“每个合并到主干的分支都经过语法检查、依赖安装、基础测试并且部署时用同一个构建产物”就已经比原来的手工流程强太多了。所以这条流水线要解决的核心问题有三个代码质量的门禁问题合并到主干之前先自动跑一遍语法检查和静态扫描构建过程的一致性问题不再在服务器上现场拉代码而是提前打成一个可发布的产物包部署过程的可回溯问题每次部署用哪个提交、哪个产物包都有记录回滚有明确的目标。想清楚这三个问题之后流水线的框架基本就定下来了。2. 流水线整体设计四段式结构是怎么定出来的2.1 阶段划分与职责边界Arbess的流水线我最终分成了四个阶段test、build、deploy、notify。每个阶段有明确的边界前一阶段挂了后一阶段就不执行避免带着问题继续往下走。test阶段负责代码语法检查、静态扫描、单元测试还顺带做依赖安全检查build阶段负责生成部署用的压缩包记录代码版本号和构建时间deploy阶段只在主干分支的合并事件和手动触发时运行负责把产物包推到目标服务器并完成软链切换notify阶段不管成功失败都把结果发到团队聊天工具省得有人一直盯着流水线页面。这个设计的核心思路是“多阶段串联、关键节点并联”。比如test阶段里面的语法检查和依赖安全扫描是互相独立的就放到同一个阶段的多个job里并行跑速度更快。而build必须等test全部通过才能开始避免把没通过检查的代码打成产物。2.2 Runner的选择与环境准备GitLab流水线本身只是个编排框架真正跑任务是Runner。Arbess项目一开始用的是共享Runner后来因为PHP版本和扩展的问题决定自己维护一个专用的Docker Runner。我给Runner选择的基础镜像是官方PHP 7.4镜像在此基础上额外装了Composer、Git和几个项目必需的PHP扩展比如pdo_mysql、redis、bcmath。这里要注意一个细节Runner上装的扩展必须跟生产环境对齐不然后面测试阶段跑得过、部署上去就挂排查起来特别费劲。Runner注册的时候我指定了标签例如php74然后在流水线的job里通过tags关键字锁死。这样做的好处是流水线不会被其他人乱调度到不合适的Runner上。2.3 并行与缓存策略PHP项目的CI有一个特殊优势没有编译过程依赖安装完之后代码检查、单元测试这些任务之间没有共享状态。所以我的第一阶段里多个job可以放心并行前提是每台Runner都有缓存目录。缓存这一块是重点。Composer安装依赖如果每次全量拉四十分钟都不一定够。我在.gitlab-ci.yml里配置了缓存路径是vendor/缓存的key根据composer.lock的哈希来变化。只要lock文件没变Runner会直接把之前缓存好的vendor目录拿过来用速度提升非常明显。缓存策略有几个注意点不要在缓存里放.env之类的环境配置缓存目录要能容忍并发写入如果公司内网有Composer镜像务必在Runner里配好速度还能再快一截。3. 逐段配置GitLab CI从.gitlab-ci.yml到部署脚本3.1 基础配置与变量管理.gitlab-ci.yml是整个流水线的入口文件。Arbess项目的核心配置长这样stages: - test - build - deploy - notify variables: COMPOSER_CACHE_DIR: /cache/composer PHP_VERSION: 7.4 cache: key: files: - composer.lock paths: - vendor/ workflow: rules: - if: $CI_PIPELINE_SOURCE merge_request_event - if: $CI_COMMIT_BRANCH $CI_DEFAULT_BRANCH - if: $CI_COMMIT_TAG这里关键的是workflow.rules它决定了什么时候才真正启动流水线。我给Arbess定的规则是三种情况跑创建合并请求时、推到主干分支时、打了Tag时。其他情况一律不跑避免一堆乱七八糟的分支触发一堆无意义的流水线。变量管理上我坚持一个原则存储库里的敏感信息一律不放配置里。数据库密码、服务器密钥这类变量全部放在GitLab的CI/CD Variables里用的时候通过$VAR_NAME引用。项目用到的变量我整理了一张表变量名用途是否敏感存放位置DEPLOY_SERVER目标服务器地址否CI VariablesDEPLOY_USER部署账号否CI VariablesSSH_PRIVATE_KEY部署用私钥是CI Variables类型选FileMYSQL_TEST_PASSWORD测试库密码是CI Variables类型选Variable3.2 代码质量检查阶段test阶段我拆成了三个job语法检查、静态扫描、依赖安全审查。三个job并行执行。语法检查最简单暴力直接遍历所有PHP文件用php -l检查php-lint: stage: test image: registry.example.com/php74-ci:latest tags: [php74] script: - find . -path ./vendor -prune -o -name *.php -print0 | xargs -0 -n1 php -l rules: - if: $CI_PIPELINE_SOURCE merge_request_eventphp -l是PHP自带的lint命令能快速抓出语法级错误。对于存量老项目第一步把它跑通就已经能拦住不少低级问题了。静态扫描我选的是PHPStan级别先设在level 5。Arbess是老项目直接上level 8肯定一片红根本没法落地。我的做法是先把级别调到当前代码能接受的程度在配置里用reportUnmatchedIgnoredErrors: false忽略掉暂时清理不掉的报错然后逐步提高标准。依赖安全审查用的是Composer自带的composer audit命令这个命令会检查依赖包是否存在已知安全漏洞。老项目最容易在依赖上翻车这一步在流水线里跑一次胜过人工去翻安全公告。3.3 单元测试与覆盖率Arbess说起来也是个老项目但基础再差也还是有少量核心模块的测试。这部分的job是这样配置的phpunit: stage: test image: registry.example.com/php74-ci:latest tags: [php74] script: - composer install --prefer-dist --no-progress --no-interaction - cp .env.ci .env - php artisan migrate --force - php vendor/bin/phpunit --coverage-text --colorsnever artifacts: when: always reports: junit: report.xml expire_in: 7 days有几个容易忽略的细节测试用的数据库必须每次从头迁移不能复用上次的数据否则测试结果会被脏数据污染.env.ci是专门给CI用的环境配置里面的数据库地址指向Runner能访问的测试库跟开发环境隔离JUnit报告要引到GitLab的Merge Request里这样在MR页面能直接看到哪些用例挂了不用翻原始日志。覆盖率报告我暂时没有把它作为门禁条件只是生成出来给人看。老项目覆盖率低是事实直接卡死会导致团队抵触不如先展示、再逐步提高。3.4 构建产物与镜像Arbess用的部署方式不是容器所以我build阶段做的是把代码、vendor、配置文件模板打成一个tar包并附上一个build_info.txt记录版本信息。build-artifact: stage: build image: registry.example.com/php74-ci:latest tags: [php74] script: - composer install --prefer-dist --no-dev --no-interaction - mkdir -p dist/Arbess - cp -a app bootstrap config database routes public resources storage $CI_PROJECT_DIR/dist/Arbess/ - cp -a vendor $CI_PROJECT_DIR/dist/Arbess/ - cd dist tar czf arbess_${CI_COMMIT_SHORT_SHA}.tar.gz Arbess - echo ${CI_COMMIT_SHORT_SHA} ${CI_COMMIT_TIMESTAMP} ${CI_JOB_NAME} Arbess/build_info.txt - sha256sum arbess_${CI_COMMIT_SHORT_SHA}.tar.gz arbess_${CI_COMMIT_SHORT_SHA}.tar.gz.sha256 artifacts: paths: - dist/*.tar.gz - dist/*.sha256 expire_in: 30 days这里有个非常关键的点build阶段安装的是--no-dev依赖test阶段安装的是完整依赖。两者的vendor目录绝对不能混用。我之前见过别的项目直接在同一个Runner工作目录里覆盖vendor结果把开发版依赖打进了生产包白白引入一堆多余的包。产物包里不带.env只带.env.example模板。真正生效的配置在部署阶段由部署脚本从服务器上的配置目录里读取并写入这样构建产物可以在任意环境复用不会把测试环境或者生产环境的密钥泄露到包里。3.5 部署与通知部署阶段是整条流水线的核心所在也是最需要慎重设计的环节。Arbess的部署脚本我没有选择远程执行一长串命令而是写成服务器上的独立脚本/usr/local/bin/arbess-deploy.sh流水线只负责上传产物包和触发该脚本。这样设计是因为把部署逻辑全塞在.gitlab-ci.yml里会越来越难维护而且每次更新部署逻辑都要改代码库里文件再跑一次流水线太蠢。独立脚本放在服务器上更新的是部署工具本身跟应用代码解耦。流水线里的部署job里写了这些事情用CI变量里的SSH私钥建立连接上传tar包到服务器的/data/releases/目录执行远端部署脚本完成解压、配置注入、软链切换执行数据库迁移命令输出部署结果。软链切换的流程是服务器上有两个目录/data/arbess_current是当前版本/data/releases/arbess_日期_短SHA是本次版本。部署脚本会先创建新目录、解压、写入配置然后把旧版本目录改名为_backup再把新目录软链成arbess_current。一旦新版本起不来只需要把软链指回backup目录就能回滚。数据库迁移要单独说一句我把它放在软链切换之后而不是之前。原因是多数PHP项目的业务代码和数据库结构要匹配先切代码再跑迁移如果迁移失败代码已经切过去了此时需要立即回滚代码并手动处理迁移残留代价很大。而先跑迁移再切代码风险在于新代码还没上老代码已经用旧结构跑了一段时间万一迁移脚本不兼容老代码连带影响更大。Arbess项目我把数据库迁移放在软链切换之后靠的是部署前备份数据库、迁移失败立即回滚的机制兜底并且迁移前先执行一次全量结构备份。这一块的取舍不绝对但我个人更看重“代码和结构同步切换”配合自动备份和回滚脚本把损失控制在最小范围。notify阶段我用一个简单的curl请求把结果发到团队聊天工具。这里不需要写特别多的逻辑一个job、一个请求就够但是要把when: always加上保证不管成功失败都发通知。4. PHP项目在CI中的典型踩坑记录4.1 Composer依赖安装的慢与乱第一个坑就是依赖安装。Arbess项目的composer.lock里有一堆历史遗留的包第一次在CI里composer install跑了将近半小时慢得让人怀疑人生。排查下来主要原因有两条Runner访问packagist的网络链路差以及cache配置没生效。处理方式分两步。一是在Runner的Composer全局配置里指到内网镜像源二是在.gitlab-ci.yml里把cache的key绑定到composer.lock。这里一定要注意cache的key如果绑定的是分支名那不同分支之间永远不可能命中缓存等于白配。绑定lock文件才是正确的做法。还有个坑是--prefer-dist这个参数一定要加。默认情况下Composer可能去拉源码包在CI环境里慢不说还容易因为缺少Git历史而出问题。--prefer-dist强制优先用压缩包速度差别非常明显。4.2 测试环境与开发环境的差异Arbess的单元测试最开始在本地跑得好好的一上CI就报连不上数据库。最后查出来是CI的测试库字符集跟开发库不一致同样一段中文字符数据开发库里没事CI库里直接报编码错误。这种问题非常隐蔽表面看是“环境问题”其实是项目里测试基类建表时没有显式指定字符集吃到了数据库的默认配置。后来我在测试环境里把数据库字符集统一改成utf8mb4同时把建表语句里涉及字符集的地方显式写死才算彻底解决。所以如果你要给已有PHP项目配CI一定要先确认三件事PHP版本一致、扩展列表一致、数据库字符集一致。这三样只要差一样测试结果就不可信。4.3 文件权限与属主问题部署结束后的第一件事是清缓存Arbess用的是文件缓存目录写在storage/下。CI打包的时候本地目录属于Runner上的用户解压到服务器后文件的属主变成了部署账号而Web服务器跑在另一个用户下结果缓存目录没权限写页面直接白屏。这个问题我折腾了挺久。最后的方案是在发布目录里把上传的文件属主统一改成部署账号同时把storage/和bootstrap/cache/两个目录通过部署脚本设置成Web服务器组可写。这个细节看起来小但漏掉了真的是线上事故级别的坑。4.4 清理掉进产物包的临时文件Build阶段从项目目录复制文件时有一个很常见的疏忽把开发期产生的临时文件打进了产物包。Arbess项目就曾经把storage/logs/laravel.log带进了线上环境虽然影响不大但说明构建过程的文件过滤做得不够干净。建议在build打包脚本里添加一步把.env、storage/logs/*.log、.git、.gitlab-ci.yml、tests/从产物包里面剔除掉确保线上拿到的包里没有多余内容。5. 部署回滚与多环境管理5.1 分支策略与各环境对应关系Arbess现在有三套环境测试、预发、生产。流水线里我并没有把所有环境都串成一条链而是通过分支来区分。测试环境对应主干分支的自动部署预发和生产环境通过打Tag来触发人工部署。具体规则是合入主干后自动部署测试环境日常联调都在这套环境上需要发预发时打一个staging-日期的Tag流水线跑完测试后部署到预发预发验证通过后打一个release-版本号的Tag同样跑测试后部署到生产。分支与环境的对应关系清晰之后团队内部沟通成本低了很多不再需要口头确认“现在到底部署的哪套环境”。5.2 回滚方案设计老项目的回滚尤为重要。Arbess以前回滚是拿备分目录手工覆盖费时费力。流水线跑起来之后我专门设计了回滚脚本/usr/local/bin/arbess-rollback.sh。脚本做的事情很简单读取当前软链指向的版本目录把上一个版本目录找出来改软链指回去然后重新载入PHP-FPM。由于每个版本的代码是完整的配置文件也在版本目录里回滚不会出现“代码回滚了但配置还是新的”这种错位问题。这里要说一个我自己悟出来的教训回滚脚本一定要在第一次部署之前就写好并且在测试环境演练过。否则等真正出问题的时候才去写脚本人紧张的状态下写出来的东西本身就容易埋雷。5.3 发布记录与审计GitLab本身的流水线记录已经能支撑大部分审计需求。Artifacts里保留了构建产物包流水线的job日志记录了部署的目标环境、触发人和代码版本。在此基础上我还让部署脚本把每次部署的时间、版本、操作人追加到服务器上的deploy_history.log里。这样无论从GitLab端还是服务器端都能追踪到一次发布到底发生了什么。一旦线上出了问题第一步永远是看部署历史确认当前版本和上一次版本之间有哪些提交而不是登录服务器一顿翻。6. 实测效果与后期还能往哪走6.1 流水线跑通之后的变化改造完成之后Arbess项目的日常发布流程变得非常顺利。原来一次发布需要运维手动折腾大半天现在只需要在MR里看到流水线全绿然后打一个Tag剩下的交给流水线。代码质量也明显提升php -l和PHPStan确实拦住了不少会在特定场景下才会暴露的问题。时间账算下来是这样的手工发布一次大概40到50分钟流水线自动跑完大概12到15分钟其中大头在Composer依赖安装和单元测试。注意这个时间是在缓存生效的前提下说的如果缓存没命中时间会翻倍。为了减少缓存未命中我定期清理Runner里长期不用的缓存目录并提醒团队尽量不要随便改composer.lock。6.2 后续可以继续做的几件事Arbess目前的流水线已经解决了“从手工到自动”的问题但离“从自动到智能”还有一段距离。我自己的计划里排了这几件事把PHPStan的级别从level 5逐步提升到level 7以上存量报错分批清理增加集成测试环节把核心业务链路用真实测试库跑一遍引入自动化的环境变量校验部署前检查服务器上的配置格式沉淀一套标准的PHP项目流水线模板新项目直接复制过去改改就能用。另外想提醒一点不要为了追求流水线好看而加入一堆看似高级的门禁。Arbess项目最开始我也考虑过加代码覆盖率硬性门禁但考虑到存量项目的实际情况暂时没有开。自动化是手段让团队稳定、高效地交付才是目的把握住这个度很重要。我自己操作下来的最大感受是给老项目搭流水线最难的不是写那些YAML配置而是下定决心把以前靠人点头确认的环节一个个变成可以自动化验证的步骤。这个过程会很琐碎也会遇到不少意料之外的报错但一旦跑通了后面每个版本的发布都会变得轻松很多。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进