资讯动态

PHP项目Kubernetes部署与Jenkins CI/CD流水线实战

发布时间:2026/9/26 17:48:54 来源:尧图企业网站定制
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 还是 debianPHP 官方镜像有两套主流基础php:8.2-fpm-alpine和php:8.2-fpm基于 Debian。很多人图体积小直接选 alpine结果在装扩展时踩坑。alpine 用的是 musl libc而很多 PHP 扩展尤其是涉及图像处理、加密、OCR 的在编译时依赖 glibc 特有的行为docker-php-ext-install能过但运行时行为可能和开发机不一致。我的建议是生产环境优先用 Debian 版除非你对体积有极致要求。alpine 版镜像大概 80MBDebian 版 150MB 左右这点差异在现在这个网络环境下几乎可以忽略但省下的排查时间非常值钱。下面是一个生产可用的 DockerfileFROM 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 --fromvendor /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生成 classmapPHP 的自动加载在生产环境能快 20% 到 30%。注意如果你的项目有storage、runtime这类需要写权限的目录一定要在镜像里提前chown给www-data否则容器以非 root 用户启动时会因为权限问题直接崩。这个坑我在三个项目里都遇到过。2.3 镜像标签策略别再用 latestlatest标签在 CI/CD 里是灾难。Jenkins 构建出来的镜像如果都叫latestKubernetes 的滚动更新会因为镜像 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和两个独立 DeploymentNginx 一个PHP-FPM 一个。我两种都用过最后倾向后者理由如下。单 Pod 双容器的好处是网络走 localhostNginx 通过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 代码有致命错误导致每个请求都 500TCP 探针依然返回成功。更严谨的做法是用httpGet探一个健康检查接口比如/health.php里面真正连一下数据库和 Redis?php // health.php $ok true; try { new PDO(mysql:host.getenv(DB_HOST).;dbnametest, 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对外暴露交给 IngressapiVersion: 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默认只有 1mPHP 项目上传文件很容易超这个必须调。proxy-read-timeout对应后端处理时间和 Nginx 的fastcgi_read_timeout要匹配否则会出现后端还在跑、Ingress 先超时的诡异现象。TLS 证书用 Secret 挂载配合 cert-manager 可以自动续期这里不展开。4. Jenkins 流水线从 git push 到 Pod 更新的完整链路4.1 流水线整体设计为什么用 Declarative PipelineJenkins 有两种流水线写法ScriptedGroovy 脚本和 Declarative声明式。老项目里 Scripted 多但新项目我强烈建议 Declarative原因是结构清晰、语法校验强、团队协作友好。Scripted 写起来灵活但三个月后你自己都看不懂那段node { if (...) { ... } }到底在干嘛。整条流水线的阶段划分Checkout → Build → Push → Deploy → Verify。每个阶段职责单一失败时能快速定位是代码问题、镜像问题还是部署问题。下面是一个完整的 Jenkinsfilepipeline { 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 --timeout180s } } 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 until168h # 清理悬空镜像 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 实现流量切分# 稳定版 Deploymentreplicas: 9 # 金丝雀 Deploymentreplicas: 1 # 两者 label 都是 app: php-fpmService 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 KillK8s 会重启 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 Pendingkubectl describe pod看 Events检查资源配额检查节点污点Pod CrashLoopBackOffkubectl 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 容器默认时区是 UTCdate(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_sizeNginx 的client_max_body_size同理。这三个值形成一条链任何一个小了都会在上传大文件时失败而且报错信息各不相同。6.3 会话共享多副本下的 session 一致性PHP 默认的 session 存文件多副本部署时用户请求打到不同 Podsession 就丢了表现为登录后一会儿又变未登录。解决方案有两个session 存 Redis或用 Ingress 的会话保持。我推荐前者配置简单且可靠session.save_handler redis session.save_path tcp://redis-svc:6379?authxxxIngress 的会话保持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 的价值不在于自动化本身而在于可重复。手工部署也能上线但每次的结果可能不一样流水线部署的价值是每次都用同样的步骤、同样的镜像、同样的配置出了问题能确定是代码问题而不是环境问题。这个确定性才是工程化的真正意义。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑