1. 从一台裸服务器到全自动发布:这套流水线到底解决了什么
PHP 项目做容器化,很多人第一反应是"PHP 不就是往 Nginx 目录里扔代码吗,搞什么 Kubernetes"。我一开始也这么想,直到手上同时维护三个 PHP 服务、两个测试环境、一个预发环境,每次发版靠 SSH 上去git pull,回滚靠"我记得上一版是哪个 commit"。真正让我下决心重构的,是一次线上事故:某个接口因为依赖的扩展版本不一致,测试环境正常、线上 500,排查了两个小时才发现是两台机器的 PHP 扩展编译参数不同。
这套PHP 项目 Kubernetes 部署与 Jenkins CI/CD 流水线要解决的核心问题就三件事:环境一致性、发布可重复、回滚可秒级。Kubernetes 负责把"运行环境"变成一份可以版本化的声明式配置,Jenkins 负责把"从提交代码到新版本上线"这条链路自动化。两者结合之后,PHP 项目也能拥有和 Java、Go 项目一样的工程化发布体验。
这篇文章适合三类人看:一是手上有一堆 PHP 老项目、想逐步迁移到容器化但不知道从哪下手的后端同学;二是已经会用 Docker 但没在生产用过 Kubernetes、对 Ingress、探针、滚动更新这些概念一知半解的运维同学;三是想搭一套完整 CI/CD 但被 Jenkins 各种插件和配置绕晕的开发者。我会按"镜像怎么打、K8s 资源怎么写、Jenkins 流水线怎么串、Ingress 怎么暴露、出问题怎么查"这条主线,把每一步的取舍理由和踩过的坑都讲清楚。
需要提前说明的是,下面所有配置都基于一个典型场景:Nginx + PHP-FPM 分离部署的 PHP 应用,代码通过镜像打包,配置通过 ConfigMap 注入,敏感信息走 Secret。如果你用的是 Laravel、ThinkPHP 还是原生框架,差异只在构建阶段,运行阶段完全通用。
2. PHP 镜像构建:为什么不能直接把代码 COPY 进去就完事
2.1 基础镜像选型:alpine 还是 debian
PHP 官方镜像有两套主流基础:php:8.2-fpm-alpine和php:8.2-fpm(基于 Debian)。很多人图体积小直接选 alpine,结果在装扩展时踩坑。alpine 用的是 musl libc,而很多 PHP 扩展(尤其是涉及图像处理、加密、OCR 的)在编译时依赖 glibc 特有的行为,docker-php-ext-install能过,但运行时行为可能和开发机不一致。
我的建议是:生产环境优先用 Debian 版,除非你对体积有极致要求。alpine 版镜像大概 80MB,Debian 版 150MB 左右,这点差异在现在这个网络环境下几乎可以忽略,但省下的排查时间非常值钱。下面是一个生产可用的 Dockerfile:
FROM php:8.2-fpm AS base # 换国内源,构建速度提升明显 RUN sed -i 's/deb.debian.org/mirrors.aliyun.com/g' /etc/apt/sources.list.d/debian.sources RUN apt-get update && apt-get install -y --no-install-recommends \ libzip-dev libpng-dev libjpeg-dev libfreetype6-dev \ libonig-dev libxml2-dev libcurl4-openssl-dev \ && rm -rf /var/lib/apt/lists/* RUN docker-php-ext-configure gd --with-freetype --with-jpeg \ && docker-php-ext-install -j$(nproc) \ pdo_mysql mysqli zip gd mbstring bcmath opcache # 安装 redis 扩展(pecl 方式) RUN pecl install redis-5.3.7 && docker-php-ext-enable redis # 生产环境 opcache 配置 COPY conf/opcache.ini /usr/local/etc/php/conf.d/opcache.ini COPY conf/php.ini /usr/local/etc/php/conf.d/app.ini WORKDIR /var/www/html这里有个细节值得展开:为什么把扩展安装和代码 COPY 分成两个阶段。Docker 的层缓存机制是按指令顺序的,只要扩展安装这一层不变,后续改代码重新构建时这一层直接命中缓存,构建时间从几分钟降到几秒。如果你把COPY . .放在扩展安装之前,每次改一行代码都要重装一遍扩展,这是新手最常犯的效率错误。
2.2 代码打包方式:镜像内 vs 挂载
PHP 项目有个历史习惯,代码放在宿主机目录,容器挂载进去。在 Kubernetes 里这么做不是不行,但会带来几个问题:一是代码版本和镜像版本脱钩,回滚镜像时代码没回滚;二是多副本时如果挂载的是同一个 PVC,会出现读写竞争;三是没法利用镜像的不可变性做灰度。
我的做法是代码打进镜像,通过多阶段构建控制体积:
FROM composer:2 AS vendor WORKDIR /app COPY composer.json composer.lock ./ RUN composer install --no-dev --no-scripts --prefer-dist --optimize-autoloader FROM base AS production COPY --from=vendor /app/vendor ./vendor COPY . . RUN chown -R www-data:www-data /var/www/html \ && chmod -R 755 /var/www/html/storage 2>/dev/null || true USER www-datacomposer install单独放在 vendor 阶段,好处是 composer 的缓存和最终镜像分离,最终镜像里没有 composer 二进制,体积更小、攻击面更小。--no-dev去掉开发依赖,--optimize-autoloader生成 classmap,PHP 的自动加载在生产环境能快 20% 到 30%。
注意:如果你的项目有
storage、runtime这类需要写权限的目录,一定要在镜像里提前chown给www-data,否则容器以非 root 用户启动时会因为权限问题直接崩。这个坑我在三个项目里都遇到过。
2.3 镜像标签策略:别再用 latest
latest标签在 CI/CD 里是灾难。Jenkins 构建出来的镜像如果都叫latest,Kubernetes 的滚动更新会因为镜像 ID 没变而不触发,你以为发布了其实没发。正确的做法是用Git commit SHA 或构建号做标签:
IMAGE_TAG=$(git rev-parse --short HEAD) docker build -t registry.example.com/php-app:${IMAGE_TAG} . docker push registry.example.com/php-app:${IMAGE_TAG}同时打一个latest作为"最新稳定版"的别名,方便人工排查时快速定位,但部署时永远用具体 SHA。这样回滚就是kubectl set image换回上一个 SHA,几秒钟的事。
3. Kubernetes 资源编排:PHP-FPM 和 Nginx 为什么要拆开
3.1 单容器还是双容器:一个架构决策
PHP 应用在 K8s 里最常见的两种部署形态:单 Pod 双容器(Nginx + PHP-FPM 在同一个 Pod)和两个独立 Deployment(Nginx 一个,PHP-FPM 一个)。我两种都用过,最后倾向后者,理由如下。
单 Pod 双容器的好处是网络走 localhost,Nginx 通过fastcgi_pass 127.0.0.1:9000直接连 PHP-FPM,延迟最低。但问题是:Nginx 和 PHP-FPM 的扩缩容绑死了,而这两者的资源画像完全不同——Nginx 是 IO 密集型、内存占用小,PHP-FPM 是 CPU 密集型、内存占用大。绑在一起扩,资源浪费严重。
两个独立 Deployment 的方案,Nginx 通过 Service 名访问 PHP-FPM,多了一跳网络,但在 K8s 内部这跳延迟在毫秒级,可以接受。换来的是独立扩缩容和独立发布:改 Nginx 配置不用重启 PHP-FPM,反之亦然。下面按这个架构展开。
3.2 PHP-FPM 的 Deployment 与探针配置
apiVersion: apps/v1 kind: Deployment metadata: name: php-fpm namespace: production spec: replicas: 3 strategy: type: RollingUpdate rollingUpdate: maxSurge: 1 maxUnavailable: 0 selector: matchLabels: app: php-fpm template: metadata: labels: app: php-fpm spec: containers: - name: php-fpm image: registry.example.com/php-app:${IMAGE_TAG} ports: - containerPort: 9000 resources: requests: cpu: 200m memory: 256Mi limits: cpu: 1000m memory: 512Mi readinessProbe: tcpSocket: port: 9000 initialDelaySeconds: 5 periodSeconds: 10 livenessProbe: tcpSocket: port: 9000 initialDelaySeconds: 15 periodSeconds: 20 envFrom: - configMapRef: name: php-app-config - secretRef: name: php-app-secret几个关键点解释一下。maxUnavailable: 0配合maxSurge: 1是零中断滚动更新的标准配置:先起一个新 Pod,等它 Ready 了再干掉一个旧的,全程可用副本数不低于期望值。代价是发布期间会多占一份资源,如果你的集群资源紧张,可以改成maxSurge: 1, maxUnavailable: 1。
探针这块,PHP-FPM 用tcpSocket探 9000 端口是最省事的,但有个隐患:FPM 进程活着不代表能处理请求。如果 PHP 代码有致命错误导致每个请求都 500,TCP 探针依然返回成功。更严谨的做法是用httpGet探一个健康检查接口,比如/health.php,里面真正连一下数据库和 Redis:
<?php // health.php $ok = true; try { new PDO('mysql:host='.getenv('DB_HOST').';dbname=test', getenv('DB_USER'), getenv('DB_PASS')); } catch (Exception $e) { $ok = false; } if ($ok) { http_response_code(200); echo 'ok'; } else { http_response_code(503); echo 'unhealthy'; }提示:健康检查接口一定要轻量,别在里面做复杂查询。我见过有人在 health 里跑全表 count,结果探针把数据库压垮了。
3.3 Nginx 的 ConfigMap 与热更新
Nginx 的配置通过 ConfigMap 挂载,这样改配置不用重新打镜像:
apiVersion: v1 kind: ConfigMap metadata: name: nginx-config namespace: production data: default.conf: | server { listen 80; server_name _; root /var/www/html/public; index index.php index.html; location / { try_files $uri $uri/ /index.php?$query_string; } location ~ \.php$ { fastcgi_pass php-fpm-svc:9000; fastcgi_index index.php; fastcgi_param SCRIPT_FILENAME /var/www/html/public$fastcgi_script_name; include fastcgi_params; fastcgi_read_timeout 60s; } location ~ /\. { deny all; } }注意fastcgi_pass指向的是Service 名php-fpm-svc,不是 Pod IP。K8s 的 DNS 会把 Service 名解析成 ClusterIP,再由 kube-proxy 负载均衡到后端 Pod。fastcgi_read_timeout默认是 60s,如果你的接口有耗时操作(比如导出报表),记得调大,否则会出现 Nginx 504 但 PHP 还在跑的情况。
ConfigMap 更新后,挂载的文件会同步更新(有几十秒延迟),但 Nginx 不会自动 reload。生产上我一般用kubectl rollout restart deployment nginx触发一次滚动重启,简单可靠。想做得更优雅可以加个 sidecar 监听文件变化发 reload 信号,但复杂度上来了,看团队维护能力决定。
3.4 Service 与 Ingress:流量怎么进来
PHP-FPM 的 Service 用 ClusterIP 就够了,它只对内提供服务:
apiVersion: v1 kind: Service metadata: name: php-fpm-svc namespace: production spec: selector: app: php-fpm ports: - port: 9000 targetPort: 9000Nginx 的 Service 也是 ClusterIP,对外暴露交给 Ingress:
apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: php-app-ingress namespace: production annotations: nginx.ingress.kubernetes.io/proxy-body-size: "50m" nginx.ingress.kubernetes.io/proxy-read-timeout: "120" spec: ingressClassName: nginx tls: - hosts: - app.example.com secretName: app-tls rules: - host: app.example.com http: paths: - path: / pathType: Prefix backend: service: name: nginx-svc port: number: 80proxy-body-size默认只有 1m,PHP 项目上传文件很容易超,这个必须调。proxy-read-timeout对应后端处理时间,和 Nginx 的fastcgi_read_timeout要匹配,否则会出现"后端还在跑、Ingress 先超时"的诡异现象。TLS 证书用 Secret 挂载,配合 cert-manager 可以自动续期,这里不展开。
4. Jenkins 流水线:从 git push 到 Pod 更新的完整链路
4.1 流水线整体设计:为什么用 Declarative Pipeline
Jenkins 有两种流水线写法:Scripted(Groovy 脚本)和 Declarative(声明式)。老项目里 Scripted 多,但新项目我强烈建议 Declarative,原因是结构清晰、语法校验强、团队协作友好。Scripted 写起来灵活,但三个月后你自己都看不懂那段node { if (...) { ... } }到底在干嘛。
整条流水线的阶段划分:Checkout → Build → Push → Deploy → Verify。每个阶段职责单一,失败时能快速定位是代码问题、镜像问题还是部署问题。下面是一个完整的 Jenkinsfile:
pipeline { agent any environment { REGISTRY = 'registry.example.com' IMAGE_NAME = 'php-app' NAMESPACE = 'production' IMAGE_TAG = "${env.GIT_COMMIT.take(7)}" } options { timeout(time: 30, unit: 'MINUTES') buildDiscarder(logRotator(numToKeepStr: '20')) } stages { stage('Checkout') { steps { checkout scm } } stage('Build Image') { steps { sh """ docker build -t ${REGISTRY}/${IMAGE_NAME}:${IMAGE_TAG} . docker tag ${REGISTRY}/${IMAGE_NAME}:${IMAGE_TAG} ${REGISTRY}/${IMAGE_NAME}:latest """ } } stage('Push Image') { steps { sh """ docker push ${REGISTRY}/${IMAGE_NAME}:${IMAGE_TAG} docker push ${REGISTRY}/${IMAGE_NAME}:latest """ } } stage('Deploy') { steps { sh """ kubectl -n ${NAMESPACE} set image deployment/php-fpm \ php-fpm=${REGISTRY}/${IMAGE_NAME}:${IMAGE_TAG} kubectl -n ${NAMESPACE} rollout status deployment/php-fpm --timeout=180s """ } } stage('Verify') { steps { sh """ sleep 10 curl -sf https://app.example.com/health.php || exit 1 """ } } } post { failure { sh """ kubectl -n ${NAMESPACE} rollout undo deployment/php-fpm """ } } }buildDiscarder保留最近 20 次构建,不然 Jenkins 的磁盘很快被日志和产物撑爆,这是运维最常抱怨的问题之一。post.failure里做自动回滚,是这套流水线最值钱的部分——部署失败自动退回上一版,比任何人工响应都快。
4.2 Jenkins 环境变量:哪些能用哪些是坑
Jenkins 内置了一堆环境变量,常用的有GIT_COMMIT、GIT_BRANCH、BUILD_NUMBER、WORKSPACE。但有几个坑必须说清楚。
第一,GIT_COMMIT只有在checkout scm之后才有值,如果你在environment块里直接引用它,拿到的是空字符串。正确做法是在 stage 里用,或者用sh动态赋值。上面代码里IMAGE_TAG = "${env.GIT_COMMIT.take(7)}"能工作是因为 Declarative Pipeline 的 environment 块是延迟求值的,但为了保险,我一般还是在 stage 里重新算一遍。
第二,GIT_BRANCH的值可能是origin/main而不是main,做分支判断时要用env.GIT_BRANCH.contains('main')而不是==。这个坑我调了半小时才反应过来。
第三,多分支流水线里,BRANCH_NAME才是当前分支名,GIT_BRANCH在 PR 构建时可能是目标分支。做条件部署时用BRANCH_NAME更准。
4.3 构建缓存与清理:别让 Jenkins 磁盘爆掉
PHP 项目构建会产生大量中间层,Docker 的构建缓存如果不清理,几个月就能吃掉几十 G。我的做法是在流水线里加一个定期清理任务,或者用docker builder prune按时间清理:
# 清理 7 天前的构建缓存 docker builder prune -f --filter "until=168h" # 清理悬空镜像 docker image prune -fJenkins 本身的清理靠buildDiscarder,但工作区(workspace)不会自动清。如果构建频繁,WORKSPACE目录会越来越大。可以在流水线开头加cleanWs(),或者配置options { skipDefaultCheckout() }配合手动清理。我一般用cleanWs()放在post.always里,每次构建完清干净,代价是下次构建要重新拉代码,但换来的是磁盘稳定。
注意:如果你的构建依赖本地缓存(比如 composer 缓存目录),
cleanWs()会把它清掉,导致每次构建都重新下载依赖。这种情况要把缓存目录挂到 workspace 外面,或者用 Jenkins 的缓存插件。
4.4 部署策略:滚动更新之外的灰度思路
上面的流水线用的是kubectl set image触发滚动更新,简单直接。但如果你的项目对可用性要求极高,可以考虑金丝雀发布:先更新一个副本,观察几分钟,没问题再全量。
K8s 原生支持这个,通过两个 Deployment 共享 Service 的 label 实现流量切分:
# 稳定版 Deployment,replicas: 9 # 金丝雀 Deployment,replicas: 1 # 两者 label 都是 app: php-fpm,Service selector 匹配 app: php-fpm这样 10% 的流量会打到新版本。观察指标(错误率、响应时间)正常后,把金丝雀的 replicas 调到 10,稳定版调到 0,完成切换。这个方案不需要额外组件,但需要人工判断,适合发布频率不高的项目。发布频繁的话建议上 Argo Rollouts 或 Flagger,能自动根据指标决定是否继续。
5. 上线之后:那些只有跑起来才会暴露的问题
5.1 PHP-FPM 进程数与 K8s 资源限制的匹配
PHP-FPM 的pm.max_children决定了同时能处理多少请求,这个值必须和容器的内存 limit 匹配。计算公式很简单:
max_children = 容器内存 limit / 单个 PHP 进程平均内存假设 limit 是 512Mi,单个 PHP 进程平均占 40Mi,那max_children最多设 12。如果你设成 50,高峰期进程数上来直接把容器 OOM Kill,K8s 会重启 Pod,用户看到的就是 502。
我的经验是留 20% 余量:512Mi 的 limit,按 400Mi 算,max_children设 10。同时把pm.max_requests设成 500 左右,让进程处理一定请求后自动重启,避免内存泄漏累积。这个参数在 PHP 项目里特别重要,因为很多老代码有全局变量泄漏问题。
5.2 日志收集:别再用 error_log 写文件
容器里的日志如果写到文件,Pod 一重启就没了。正确做法是输出到 stdout/stderr,由 K8s 的日志驱动收集。PHP-FPM 的catch_workers_output = yes和php_admin_value[error_log] = /proc/self/fd/2可以把错误输出到 stderr:
; php-fpm.conf catch_workers_output = yes php_admin_value[error_log] = /proc/self/fd/2 php_admin_flag[log_errors] = on应用层的日志(比如业务日志)建议用 Monolog 之类的库,配置成输出到 stdout,格式化成 JSON,方便 ELK 或 Loki 解析。我见过太多项目把日志写到runtime/log然后挂 PVC,结果多副本时日志互相覆盖,排查问题时根本对不上。
5.3 配置热加载:ConfigMap 更新后应用怎么感知
ConfigMap 更新后,挂载的文件会变,但 PHP-FPM 不会自动重载配置。如果你的应用配置(比如数据库连接、第三方密钥)放在 ConfigMap 里,更新后需要重启 Pod 才生效。这就有个矛盾:配置更新要不要触发重启。
我的做法是分两类:启动时读取的配置(数据库、Redis 地址)放 ConfigMap,更新后滚动重启;运行时动态读取的配置(业务开关、限流阈值)放配置中心(比如 Apollo、Nacos),应用定时拉取。这样既保证了基础配置的稳定性,又满足了业务配置的灵活性。
如果不想引入配置中心,也可以在 Pod 里加个 sidecar,监听 ConfigMap 挂载目录的变化,变化时给 PHP-FPM 发USR2信号触发 reload。但 PHP-FPM 的 reload 是平滑重启,会短暂影响正在处理的请求,高频更新场景要谨慎。
5.4 排查链路:Pod 起不来时按这个顺序查
PHP 项目在 K8s 里出问题,排查顺序很重要。我总结了一个固定链路:
| 现象 | 第一步 | 第二步 | 第三步 |
|---|---|---|---|
| Pod Pending | kubectl describe pod看 Events | 检查资源配额 | 检查节点污点 |
| Pod CrashLoopBackOff | kubectl logs --previous | 检查启动命令 | 检查挂载权限 |
| Pod Running 但 502 | 检查 readiness 探针 | kubectl exec进容器 curl 本地 | 检查 Service selector |
| 接口 504 | 检查 Ingress timeout | 检查 Nginx fastcgi timeout | 检查 PHP 执行时间 |
| 镜像拉取失败 | 检查 imagePullSecrets | 检查镜像 tag 是否存在 | 检查仓库网络 |
kubectl logs --previous是排查 CrashLoopBackOff 的关键,因为容器已经重启了,kubectl logs看到的是新容器的日志(可能还没输出),--previous才能看到崩溃前那次的日志。这个技巧我教过很多人,几乎每次都有人不知道。
6. 几个让我印象深刻的真实坑
6.1 时区问题:容器里是 UTC,数据库里是东八区
PHP 容器默认时区是 UTC,date('Y-m-d')出来的日期和北京时间差 8 小时。这个问题在测试环境不明显(大家都不看日志时间),上线后订单时间、日志时间全乱。解决方案是在镜像里设置时区:
RUN ln -snf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \ && echo "Asia/Shanghai" > /etc/timezone同时在 php.ini 里设date.timezone = Asia/Shanghai。数据库连接也要注意,PDO 的time_zone参数要单独设,否则 MySQL 会话时区还是 UTC。这个坑我在两个项目里都踩过,现在成了我的镜像构建 checklist 第一条。
6.2 文件上传:临时目录权限和大小限制
PHP 上传文件走upload_tmp_dir,默认是/tmp。容器里/tmp的权限如果不对,上传直接失败。更隐蔽的是upload_max_filesize和post_max_size的配合:post_max_size必须大于upload_max_filesize,否则大文件上传时$_FILES是空的,但$_POST可能有值,排查起来很迷惑。
我的配置是upload_max_filesize = 50M、post_max_size = 52M,留 2M 给表单其他字段。同时 Ingress 的proxy-body-size也要大于post_max_size,Nginx 的client_max_body_size同理。这三个值形成一条链,任何一个小了都会在上传大文件时失败,而且报错信息各不相同。
6.3 会话共享:多副本下的 session 一致性
PHP 默认的 session 存文件,多副本部署时用户请求打到不同 Pod,session 就丢了,表现为"登录后一会儿又变未登录"。解决方案有两个:session 存 Redis或用 Ingress 的会话保持。
我推荐前者,配置简单且可靠:
session.save_handler = redis session.save_path = "tcp://redis-svc:6379?auth=xxx"Ingress 的会话保持(nginx.ingress.kubernetes.io/affinity: cookie)也能用,但它是把同一用户固定到一个 Pod,那个 Pod 挂了用户就掉线,而且扩缩容时会话分布不均。Redis 方案虽然多一次网络请求,但胜在稳定。
6.4 构建号与镜像标签的对应关系
有次线上出问题要回滚,运维问"回滚到哪个版本",我说"回滚到上一个稳定版",结果发现镜像标签是 commit SHA,没人记得上一个稳定版的 SHA 是多少。后来我在 Jenkins 里加了一步,把每次构建的BUILD_NUMBER、GIT_COMMIT、IMAGE_TAG写到一个文件里归档,回滚时直接查这个文件。
更规范的做法是用 Git tag 触发构建,镜像标签用 tag 名(比如v1.2.3),这样版本语义清晰。但 PHP 项目很多没有打 tag 的习惯,退而求其次用 commit SHA 也行,关键是要有地方能查到"哪个 SHA 对应哪个功能"。
7. 后续可以继续深化的方向
这套流水线跑通之后,还有几个方向可以继续优化。镜像安全扫描可以在 Push 阶段加一步 Trivy 扫描,发现高危漏洞直接阻断发布。多环境管理可以用 Kustomize 或 Helm,把 dev、staging、prod 的差异抽成 overlay,避免复制三份几乎一样的 YAML。自动扩缩容可以上 HPA,根据 CPU 或自定义指标(比如队列长度)动态调整 PHP-FPM 副本数,应对流量高峰。
我个人在实际操作中的体会是:CI/CD 的价值不在于"自动化"本身,而在于"可重复"。手工部署也能上线,但每次的结果可能不一样;流水线部署的价值是每次都用同样的步骤、同样的镜像、同样的配置,出了问题能确定是代码问题而不是环境问题。这个确定性,才是工程化的真正意义。