多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

用GitLab CI/CD实现PHP项目自动化流水线:Arbess实战解析

用GitLab CI/CD实现PHP项目自动化流水线:Arbess实战解析 开头我先把话撂这儿如果你现在维护的PHP项目还在“本地敲敲测测→手动压缩→扔服务器→执行迁移→重启一遍”这条老路上那Arbess项目这次做的事情就是你迟早要补的课。所谓Arbess项目实战本质上是我用GitLab CI/CD把一个PHP后端服务从提交代码到测试环境、再到生产发布的全过程做成了全自动流水线。你可能会觉得“PHP项目搞自动化流水线是不是有点杀鸡用牛刀”但在交付频率上来之后你会发现真正耗时、容易出错的压根不是写代码而是手动走那一套流程少装一个依赖、漏执行一次迁移、配置文件环境写错任何一个低级失误都会让“上线”变成“救火”。这篇博文就围绕GitLab搭建的这套PHP流水线展开完全不涉及复杂微服务编排或者云原生那一堆概念。我会把整体设计、Runner和.gitlab-ci.yml配置思路、代码质量关卡、单元测试门槛、自动部署方案、以及我在Arbess项目里踩过的高频坑全部讲透每个阶段都有可直接抄走的参数和写法。适合三类人一是刚接手PHP项目、想快速把GitLab CI用起来的工程师二是已经在跑CI但测试老挂、部署老翻车的朋友三是想从“能跑”升级到“稳定跑”的偏运维向开发者。说明教程里涉及项目背景、部署环境均为模拟场景所有配置都是真实可用的通用写法你可以直接套到自己的项目上改一改。1. 项目背景与流水线设计思路1.1 为什么一个PHP项目需要自动化流水线Arbess这个项目的技术栈很常见PHP 8.2、Laravel框架、MySQL数据库、Redis做缓存代码托管在GitLab上。它虽然不是什么高并发明星服务但内部要对接多个业务端接口文档、数据变更、修复补丁几乎每周都有几次迭代。早期阶段团队靠手动构建包上传服务器一次发布要串起“开发机拉代码、安装依赖、本地验证、打包、上传、解压、改配置、迁移、重启”九个动作听上去没什么难度痛点在于重复劳动和隐藏错误打包时经常忘记把vendor/目录同步进去上传上去接口直接500。本地能跑的测试一放服务器就报错最后定位只是扩展没启用。数据库迁移脚本没人统一执行测试库和生产库的结构总是差几个字段。多人同时改.env配置时谁改了哪个环境根本对不上。这次我在Arbess项目上搭自动化流水线目标就三个代码提交后自动跑检查、测试结果自动出报告、验证通过后自动部署。核心思路是把“人肉流程”映射成GitLab里的Pipeline阶段让每一步都有产出、可回溯、还能阻止问题代码进入下一环节。1.2 流水线阶段划分提交到上线的五道关卡在设计Pipeline时我没有直接照搬别人那种十几个job的复杂模板而是按Arbess项目的实际情况拆成五个阶段阶段职责关键产物validate依赖校验和代码语法扫描Composer校验结果code-qualityPHP-CS-Fixer风格检查和PHPStan静态分析检查报告testPHPUnit单元测试与覆盖统计JUnit报告、覆盖率HTMLbuild安装生产依赖并生成可发布包带版本号的构建产物deploy按分支和标签推送到不同环境部署日志和健康检查结果这五个阶段严格串行执行前面任何一步失败后面的构建和部署自动跳过。你可能会问为什么不用更快的并行策略PHP项目不同于前端构建那种“编译完就跑”后端流水线的每一环都依赖上一环的结果比如静态分析想拿到真实化的Composer商级依赖、测试必须依赖完整的vendor、构建又要基于测试通过的代码串行是稳的。后面如果项目变大了可以把测试里互不依赖的那几个job提出来跑并行但这个阶段先求“稳定可见”。1.3 工具选型为什么用GitLab CI/CD而不是其他方案市面上做自动化流水线的工具不少Jenkins、GitHub Actions、Buildkite都能完成类似事情。我最终还是选了GitLab CI/CD作为Arbess项目的底座理由一句话概括项目托管在GitLabCI能力是捎带手的不需要额外起一套服务来维护。对比几个维度你可以感受一下配置维护成本GitLab的.gitlab-ci.yml直接放在仓库根目录跟随代码版本迭代不需要单独维护Jenkins任务配置。改流水线就像改代码一样走Merge Request审查历史一目了然。Runner调度便宜GitLab Runner可以注册到服务器上共享执行不用像Jenkins那样必须单独维护一个master-agent架构单体Runner对一个中小团队完全够用。集成度MR页面直接展示Pipeline状态、测试报告、覆盖率变化。Arbess团队惯用GitLab的Issue和MR做协作在这个逻辑闭环里CI是天然的拼图。当然GitHub Actions也很好但前提是你的代码托管在GitHub。对Arbess这种已经在GitLab生态里的项目何必为了CI硬搬一次仓库呢工具选型从来不是“谁的广告多选谁”而是看它能不能以最小成本融进你现有的工作流。2. 环境准备与基础配置2.1 Runner安装注册一个容易被忽视的执行环境自动化流水线的执行肉身是Runner。很多人在.gitlab-ci.yml上花费大量精力结果Pipeline一跑就红最后发现Runner环境本身缺这缺那。我在Arbess项目里用的是Docker Executor类型的Runner而不是Shell Executor。原因很直接Docker Executor每次job都从镜像启动全新容器环境干净隔离不会因为上一次构建残留把这次结果污染。Shell Executor虽然少绕一层容器性能略好但时间一长宿主机上装的扩展、依赖、系统库互相干扰排查起来非常痛苦。Runner注册时我的建议是docker run -d --name gitlab-runner --restart always \ -v /var/run/docker.sock:/var/run/docker.sock \ -v /srv/gitlab-runner/config:/etc/gitlab-runner \ gitlab/gitlab-runner:latest注册环节用一条命令搞定docker exec -it gitlab-runner gitlab-runner register \ --non-interactive \ --url https://gitlab.example.com \ --registration-token 你的注册Token \ --executor docker \ --docker-image php:8.2-cli \ --docker-volumes /opt/shared-cache:/cache \ --description php-docker-runner几个容易被忽略的点我在Arbess项目上吃过亏的一定要指定--docker-volumes作为共享缓存目录否则每个job都是全新的容器上一轮装好的Composer缓存和依赖直接全废每次构建都从零下载。Runner所在宿主机和容器内网络要求稳定特别是要拉取Composer包和Docker镜像的环境网络抖动会让超时概率成倍上涨。注册时选定的--docker-image只是默认镜像实际每个job里可以覆盖image:字段更常用的是按job职责选择php:8.2-cli或composer:2这类专用镜像。2.2 项目根目录的.gitlab-ci.yml骨架Arbess项目的.gitlab-ci.yml初始版本并不复杂核心骨架长这样stages: - validate - code-quality - test - build - deploy variables: MYSQL_DATABASE: arbess_test MYSQL_USER: arbess MYSQL_PASSWORD: secret MYSQL_HOST: mysql cache: key: $CI_COMMIT_REF_SLUG paths: - vendor/ - node_modules/ policy: pull-push before_script: - php -v - composer --version这里有几个设计含义值得解释一下。cache.key用分支名作为维度目的是让不同分支的构建缓存隔离避免开发分支和生产分支在同一个vendor/目录上互相覆盖。policy: pull-push的意思是job结束自动上传缓存、新job开始自动拉取缓存在当前阶段最省事。如果你发现缓存经常失效可以再细分key比如加一个$CI_COMMIT_SHA的维度把缓存颗粒度做成按提交隔离但代价是缓存命中率下降Arbess项目还没有精细到那一步。before_script里我放的是环境自检。别觉得啰嗦我在调试阶段至少有三四次是Pipeline卡了半小时才发现Runner里PHP版本还是8.1。自检日志留在Pipeline输出里等于给每一次执行留了“环境快照”这比回查服务器历史要靠谱得多。2.3 变量分层密钥、环境标识与保护策略PHP项目流水线里最容易被写死在代码里的就是各种配置和密钥。Arbess项目早期也犯过把数据库密码直接写在.env并提交进仓库的低级错误后来GitLab频繁报警邮件才被迫整改。正确做法是把所有敏感信息放进GitLab的CI/CD Variables里我这里给几个实际使用的例子变量名用途保护策略DB_PASSWORD测试数据库密码Masked打码显示SSH_PRIVATE_KEY部署用的SSH私钥Protected仅受保护分支可用PROD_DEPLOY_HOST生产环境主机地址Protected MaskedDEPLOY_USER部署使用的系统用户普通变量具体设置位置在GitLab项目页面的Settings → CI/CD → Variables。我建议把生产环境类变量全部勾上“Protected”这样只有main分支或tag触发的Pipeline才能读取推送到合并请求里的临时变量就拿不到生产配置等于给流水线做了权限分层。密钥使用上有一个高频坑GitLab Runner执行job时默认不会把非Protected变量传给受保护分支之外的管道但你如果把SSH_PRIVATE_KEY设成普通变量它反而会在所有job的日志里可以被任意项目成员间接看到。Arbess项目我用了两层保险一是变量设为Protected二是Runner自身只有固定的几个受信成员有注册权限。另一个细节是环境区分。很多人喜欢在变量里写ENVdev或ENVprod然后靠一个job跑全部部署。我建议把环境标识隐藏在分支名和Tag里而不是做成一个“谁都能改的开关”。比如Arbess项目里main分支的Pipeline只能部署到测试环境。打了v*开头的Git tag才允许触发生产环境部署。MR中的Pipeline只跑检查和测试不执行任何部署。这个约束在Runner上并不好强做需要在job的rules里卡死分支和Tag条件。3. 核心阶段实战从代码检查到自动部署3.1 代码质量关卡PHP-CS-Fixer与PHPStan配置要点代码质量阶段我用了两个工具PHP-CS-Fixer负责风格统一PHPStan负责静态分析。前者管“好不好看”后者管“会不会出事”。这个job的写法是code-quality: stage: code-quality image: php:8.2-cli script: - composer install --no-interaction --prefer-dist --no-progress - vendor/bin/php-cs-fixer fix --config.php-cs-fixer.php --dry-run --diff - vendor/bin/phpstan analyse --memory-limit1G --level5 artifacts: paths: - phpstan-report.txt when: always expire_in: 1 week这里我要重点讲两个参数--dry-run的含义是只检查不修改。PHP-CS-Fixer默认是有可能直接改写你的代码文件的如果放在CI里它会把开发者本地不一样的风格直接改掉这样反而制造大量无关diff。CI里用dry-run发现风格问题后把失败信息反馈给开发者开发者本地再执行fix这是规范团队工作流的关键。PHPStan的--level我选了5而不是最高的8并不是我不想追求更高标准而是Arbess项目的代码是渐进式老项目直接用level 8会把几百个类全部拉爆开发排期根本来不及改。先定level 5把明显隐患扫掉然后每两周提升一级最终向level 8靠近这个节奏对存量PHP项目更现实。artifacts的设置也有讲究。我让报告在失败时也保留下来when: always这样开发者打开Pipeline页面就能直接下载phpstan-report.txt看具体是哪个文件哪一行出了问题不用自己重新拉分支跑一遍。这个细节在多人协作时价值很大。3.2 单元测试关卡PHPUnit与覆盖率门槛测试阶段我用的组件是PHPUnit并引入一个不一定内置在官方PHP镜像里的MySQL服务来跑真实集成测试。Arbess项目里很多Repository和Model层的逻辑光靠简单单元测试是暴露不出SQL语法问题的。基础的测试job配置test: stage: test image: php:8.2-cli services: - name: mysql:8.0 alias: mysql variables: MYSQL_DATABASE: arbess_test MYSQL_ROOT_PASSWORD: rootpass MYSQL_USER: arbess MYSQL_PASSWORD: testpass script: - docker-php-ext-install pdo_mysql - composer install --no-interaction --prefer-dist - cp .env.ci .env - php artisan key:generate - php artisan migrate --databasemysql - vendor/bin/phpunit --log-junit report.xml --coverage-text --coverage-html coverage artifacts: paths: - coverage/ - report.xml reports: junit: report.xml coverage: /Lines:\s*(\d\.\d\%)/三个容易被坑到的地方第一PHP官方镜像默认没有pdo_mysql扩展。你必须在job里临时编译安装。如果嫌慢可以直接用别人做好的扩展镜像或者预先构建一个团队私有镜像把扩展塞进去。Arbess项目一开始每天都要花几十秒装扩展后来我直接锁定了一个加了扩展的自定义镜像测试job从90秒降到了25秒。第二数据库服务连通。GitLab里services网络层和job容器是隔离的PHPUnit测的时候数据库主机名要用服务别名mysql不是localhost。.env.ci文件里我把DB_HOST写成了mysql这也是为什么有人把.env直接复制过来后测试连不上库。第三覆盖率正则要跟实际输出匹配。--coverage-text默认输出格式里“Lines”的字样是带缩进和冒号的如果不加正则会显示不出覆盖率数字。这个小地方我调了很久实际上先在本地跑一次看输出再写正则就精准了。除了跑通测试我还给Arbess项目设了完成线单元测试命令返回非零即视为流水线失败不设置“允许失败”的例外。一条失败的测试如果被放过后面所有环节都将建立在错误的基础上这种宽容在自动化流水线里害死人。3.3 构建与产物管理从依赖安装到可发布包测试全部通过之后进入构建阶段。这里的核心逻辑是拒绝使用开发环境的vendor目录重新安装生产依赖并生成一个干净的可发布包。build: stage: build image: composer:2 script: - composer install --no-interaction --prefer-dist --no-dev - php -r file_put_contents(.env.production, APP_ENVproduction . PHP_EOL); - mkdir -p dist - tar czf dist/arbess-$CI_COMMIT_TAG.tar.gz --exclude.git --excludetests --excludedist . artifacts: paths: - dist/*.tar.gz expire_in: 2 weeks这里有几个点急于解释--no-dev是关键中的关键。PHP项目有个天然陷阱本地开发装了一堆dev依赖PHPUnit、Mockery这些如果不加--no-dev生产包体积膨胀而且某些开发用的扩展在生产环境根本没启用。Arbess项目在改流水线之前就出过“代码里用了个只有PHPUnit才提供的类部署后直接崩”的事件。--no-dev能把这个风险从根上掐掉。发布包要不要排除.git大多数场景应该排除。仓库历史体积不小打成tar包纯属浪费部署时也完全用不到。但如果你们产品有“发布后根据git commit号快速回滚定位”的需求则可以把.git保留或者额外把一个COMMIT_HASH文件写进包里。Arbess项目我用了后一种体积和可追溯性都兼顾了。构建产物本身是带版本号的压缩包。用$CI_COMMIT_TAG命名可以让生产环境只存放带版本标识的发布物。后续运维想看“线上跑的是哪个版本”打开服务器目录就能看到对应文件名根本不需要登录代码仓库反查。3.4 部署执行两种实战可行的推送方案部署是整个流水线里最敏感的部分也是Arbess项目迭代过程中方案变化最多的环节。初期用的是SSH加rsync后来因为服务器多了转成了Docker镜像推送加远程拉取。我先把两种方案的适用场景说清楚你根据自己的基础设施选。方案一rsync直推服务器适合单机或少量服务器deploy-prod: stage: deploy image: alpine:3.18 script: - apk add --no-cache openssh-client rsync - eval $(ssh-agent -s) - echo $SSH_PRIVATE_KEY | tr -d \r | ssh-add - /dev/null - mkdir -p ~/.ssh - chmod 700 ~/.ssh - rsync -avz --delete dist/arbess-$CI_COMMIT_TAG.tar.gz $DEPLOY_USER$PROD_DEPLOY_HOST:/var/www/arbess/releases/ - ssh $DEPLOY_USER$PROD_DEPLOY_HOST cd /var/www/arbess ./deploy.sh arbess-$CI_COMMIT_TAG.tar.gz only: - tags用rsync注意两个硬性细节一是eval $(ssh-agent -s)初始化ssh-agent否则密钥不会被加载二是tr -d \r去掉密钥换行符Windows端添加的私钥经常因为换行符问题第一轮验证失败。服务器上我维护一个简单的deploy.sh脚本做的事情很直白解压包到新目录、执行数据库迁移、切换软链接、重启PHP服务。这样CI端只负责“把包送到并触发脚本”具体的服务管理逻辑留在服务器上后续要加健康检查也不用改流水线。方案二Docker镜像推送适合容器化环境Arbess项目部分服务后来容器化了部署就改成先把PHP代码打进镜像推到私有仓库再由服务器拉取。这个方案更顺滑而且天然携带运行环境但需要额外维护一个Dockerfile。这里我没有展开写因为让一个纯PHP项目直接跨上Docker部署对团队运维能力要求高了一截不一定适合所有人。真心建议初期部署先别追求花哨技术rsync 远程脚本这组方案足够稳定遇到问题日志也直观。等团队对自动化发布的信任建立起来后再考虑容器化方案完全来得及。4. 高频问题与排查经验实录4.1 Runner执行环境与宿主机不一致现象Pipeline在Runner上跑得好好的部署到服务器就报“Class not found”或“Call to undefined function”。Arbess项目早期最典型的一次是宿主机PHP装了bcmath扩展但php:8.2-cli基础镜像里没有导致某段金额计算逻辑在CI里通过、上到生产后直接白屏。排查路径并不复杂先在Pipeline日志里看php -m导出的模块列表再去生产服务器上执行同样的命令两边对比差异。Root Cause通常出在你以为“PHPPHP”其实是“镜像内的PHP”和“服务器编译的PHP”压根不是一套东西。我后来根治了这个问题的办法是构建一个团队内部固定的PHP镜像把生产环境所需的扩展在Dockerfile里提前装好CI和部署都以这个镜像为唯一基准。虽然多花了一点点镜像维护成本但彻底消除了“同一份代码两种环境行为不一致”的隐患。4.2 Composer依赖安装稳定性差现象测试阶段反复出现Composer install失败报错内容五花八门——连接超时、包校验失败、某个包源远不可用。Arbess项目依赖了不少第三方包每次都在Pipeline里现拉现装相当于每次构建都被网络波动绑架。我做了三层优化共享缓存目录。把所有依赖包装到Runner宿主机的持久目录作为Composer缓存连续构建时命中率显著提升这也能节省大量时间。锁定Composer版本。在before_script固定COMPOSER_HOME并指定COMPOSER_MEMORY_LIMIT-1避免默认内存限制导致的安装中断。配置镜像源。在CI的composer.json层面配置可直达的国内镜像源大幅降低境外源超时的概率。要注意的是镜像源策略最好写在composer.json里并提交到仓库避免各人本地配置不一致。这三层下来Arbess项目的依赖安装失败率从每天几次直接降到一两周才出一次。4.3 数据库迁移在流水线里的顺序陷阱现象测试阶段php artisan migrate成功但部署后业务查询报“column not found”。定位的时候发现测试库的迁移确实跑过了但生产库的迁移脚本可能没执行到或者顺序不对。这类问题的核心在于迁移应该由部署脚本统一执行而不是在CI测试阶段执行。Arbess项目最初的流水线把迁移也塞进了测试job导致两个问题一是测试环境和生产环境共用一套迁移脚本但执行时机不可控二是如果部署中途失败迁移已经执行了回滚时新旧代码和旧库结构对不上。我现在把迁移时机拆成两段测试环境测试job启动前自动迁移确保测试数据表结构跟上代码。生产环境不直接在打包环节执行迁移而是在服务器上执行deploy.sh时先备份数据库、再执行迁移、最后切换软链接。如果迁移失败新包根本不会被激活服务还在旧版本上。这个顺序变更解决了Arbess项目至少三次部署事故。设计任何流水线都要问一句“你的操作是幂等的吗失败后能回滚吗”数据库迁移天生不是纯幂等的所以它理应被放到最需要谨慎的部署步骤里并接受特殊处理。4.4 部署途中失败与回滚降级处理现象部署job执行到一半rsync同步完成但deploy.sh里重启PHP服务失败Package已经在新目录里了软链接还没切换旧版本还在服务这时候从流水线上看是“失败”但从服务稳定性角度看反而不可怕。我建议在设计流水线时提前规划回滚接口。Arbess项目服务器上的目录设计保留了releases/和current - releases/v_xxx这种经典结构每次发布新增一个带版本号的目录切换软链接指向新版本如果部署后健康检查失败只要再切换软链接回旧版本即可。这段回滚逻辑独立于GitLab流水线由运维人员直接在服务器上执行比依赖CI重新构建要快得多。还有一个容易忽略的点部署成功后的健康检查要放在流水线里而不是只靠人肉打开网页确认。我在Arbess项目的部署job最后加了一个HTTP探活步骤curl -fsS http://127.0.0.1:8080/health || exit 1返回非零就让流水线标红即使部署脚本返回值是0这个检查依然能拦截那些“部署命令执行完但服务本身没起来”的隐性失败。5. 将这些能力扩展到新项目Arbess项目的流水线跑顺之后我发现这套经验迁移到其他PHP项目时非常高效。只要做三件事复制.gitlab-ci.yml到新仓库。把variables、部署主机、数据库配置替换成新项目对应的值。确认.env.ci和.env.production里数据库主机名、缓存驱动这些环境差异项正确。这套模式在团队内部几个PHP项目里轮转后基本上一个新项目从零开始到流水线全绿半天时间就能搞定。真正节省的不是“配置时间”而是省去了“每个项目都要重新想一遍自动化方案”的心智负担。我的建议是如果你们团队成员多、项目类型相似完全可以把这套模板抽成一组公共配置统一维护。把公共job用extends关键字抽出来。比如把composer-install、phpunit-test定义成隐藏模板每个项目只需覆盖少量项目特有的变量和部署主机流水线就从“项目的可选项”变成了“项目的默认项”团队自然不会绕过它。最后说几句个人体会。搭Arbess项目这条流水线的过程其实是在逼我把“部署”从一件靠脑子记的事情变成一件靠流程管的事情。中间踩过的数据库迁移坑、Runner权限坑、镜像扩展缺失坑回头看我并不觉得是浪费反而觉得“能出错的地方都在流水线里红了一轮”比在线上半夜拉警报强一百倍。自动化是目标但让我真正有信心发布的不只是“脚本能跑”而是整个链路里每一步都有日志、每个失败都能定位、每个环境都有明确的切换和回滚手段。如果你也在改造PHP项目的交付流程别急着把Pipeline写得很华丽先把“提交代码检查、测试、构建、部署”这四个环节跑通把权限和环境隔离做好再考虑加更多花活。稳下来的自动化流水线才是真正能让你安心下班的东西。
返回列表