
很多做 PHP 的同学对 Kubernetes 的第一反应是“这是 Java 那边的东西跟我没关系”。我一开始也这么想直到有一次线上流量半夜突然涨了三倍我守着几台裸机 NginxPHP 服务器手动加副本、改配置、重启进程忙到天亮才勉强撑住从那以后我就下定决心把这套环境整体搬到 Kubernetes 上。现在这套 Nginx PHP-FPM 的容器化部署方案已经在我们团队跑了一年多滚动发布、弹性伸缩、故障自愈全部交给平台我只需要关心代码本身。这篇文章不是给你抄一份“能用就行”的 YAML而是把我从架构选型到参数调优、从踩坑到排查的完整思路讲清楚。你会理解为什么 Nginx 和 PHP-FPM 要拆成独立工作负载为什么它们之间用 Service 通信而不是 Pod IP为什么代码要放到共享存储里以及最关键的——线上遇到 502、504、静态文件 404 时怎么一步一步定位根因。内容比较多建议先收藏后面实操时照着做不会错。1. 为什么把 NginxPHP 搬到 Kubernetes1.1 传统 LNMP 部署的老问题先聊聊我以前的部署方式。服务器装好系统以后手动编译或者用包管理器安装 Nginx、PHP-FPM然后把网站代码扔到/var/www/html改几份配置文件重启服务。单机跑一个小网站完全没毛病问题出在规模上来之后。第一个痛点是多台机器的配置漂移。我有十几台服务器每台都是我“亲手”装的但每次安装时的系统版本、软件版本、配置项都有一点点差异。某天一台机器出问题我把另一台的配置复制过去结果跑起来表现完全不一样。这种“环境不一致”问题排查起来极其痛苦。第二个痛点是扩容和恢复全靠人工。流量上涨了我要手动找一台空闲机器装环境、部署代码、接入负载均衡一套流程下来至少半小时。更惨的是机器硬件故障网站直接打不开因为单机部署没有自愈能力所有依赖这台机器的服务全部挂掉。第三个痛点是发布很危险。我更新代码或者改 Nginx 配置只能在一台机器上改完重启如果有问题需要马上回滚。但是“马上”这两个字在传统环境下很难做到要么备份没做全要么改动的文件太多记不清哪里动了。1.2 Kubernetes 补上什么能力Kubernetes 解决的核心问题不是让你能用容器跑 PHP而是给了你一套基础设施的“自动巡航”能力声明式部署我要跑 3 个 PHP-FPM 副本、2 个 Nginx 副本写在 Deployment 里集群负责维持这个状态。哪台机器挂了控制器自动在其他节点重建 Pod。滚动更新新版本镜像推上去以后Kubernetes 按策略逐步替换旧 Pod先起新的、等健康检查通过、再杀掉旧的。发布会风险降到最低。弹性伸缩配合 HPAHorizontalPodAutoscaler可以按 CPU 或自定义指标自动调整副本数。流量上涨时自己加 Pod流量回落时自己减 Pod。服务发现Nginx 通过 Service 名访问 PHP-FPM不需要关心 PHP-FPM 的 Pod IP 变到哪里。Pod 重建无数次访问地址始终不变。一句话总结传统环境里“人肉运维”的事情Kubernetes 用控制器和声明式配置帮你自动做掉了。1.3 什么情况下值得这么干我要先泼一盆冷水如果你的项目就是一个日 PV 几千的站点只有一台 2 核 4G 的云主机那完全没必要上 Kubernetes。搭集群、维护节点、学习排障的成本远高于你手动装一个 LNMP 的成本。 WordPress、小型企业站这些场景老老实实用宝塔面板反而效率最高。适合上 Kubernetes 的信号有三个第一你有多台服务器在跑同样的 PHP 应用配置分散、难以管理第二你的业务流量有明显的波峰波谷想把人工扩容变成自动化伸缩第三你希望代码发布达到“随时可发布、几分钟内完成、随时可回滚”的程度。这些需求一旦出现说明单机运维模式已经到瓶颈了Kubernetes 不是负担而是解药。2. 架构设计与工作负载拆分2.1 Nginx 和 PHP-FPM 各自扮演什么角色在传统 LNMP 环境里Nginx 和 PHP-FPM 跑在同一台机器上Nginx 收到请求后通过 FastCGI 协议把需要动态处理的请求转发给 PHP-FPM。PHP-FPM 把处理结果返回给 NginxNginx 再响应给客户端。容器化以后很多人第一反应是把 Nginx 和 PHP-FPM 装进同一个 Pod 的两个容器里。这的确是最简单的做法但在 K8s 里不是最优解。原因在于两者职责完全不同Nginx 是入口网关负责静态文件响应、负载均衡、SSL 终结、请求转发。它是无状态的扩容时可以随便加副本。PHP-FPM 是业务执行引擎处理 PHP 脚本。它承载了真正的业务逻辑往往需要连接数据库、Redis 等后端依赖。如果把它们放进同一个 PodPod 一扩就是一组Nginx 和 PHP-FPM 必须同时缩放没办法单独调整。但实际情况里Nginx 的负载通常远低于 PHP-FPM单独扩容 PHP-FPM 副本比整体扩容更节省资源也更精准。所以我最终选择把它们拆成两个 Deployment。2.2 两个 Deployment 的通信方式拆开之后两个 Deployment 各自有独立的 Pod IPPod 随时可能被重建IP 随时可能变化。Kubernetes 的解决办法是 Service。我为 PHP-FPM 创建一个 ClusterIP 类型的 Service名字叫php-fpm。Service 会稳定持有这个虚拟 IP并自动把流量转发给后端的 PHP-FPM Pod。Nginx 配置里写fastcgi_pass php-fpm:9000这里面的php-fpm是 Service DNS 名Kubernetes 集群内的 DNS 会自动解析到 Service 的 IP。这个设计最大的好处是解耦。Nginx 不关心 PHP-FPM 到底有几个副本、Pod IP 是多少它只认识 Service 名。PHP-FPM 扩容到 5 个 PodNginx 的配置一行都不用改Service 会自动完成负载均衡。2.3 代码与配置的分离策略在容器环境里我最强调的一个原则就是代码、配置、运行环境三者分离。运行环境由镜像固定下来。Nginx 镜像、PHP-FPM 镜像一旦构建完成里面的二进制、扩展、系统库就不会变。环境不一致的问题从根上被消灭。配置通过 ConfigMap 注入。Nginx 的配置文件、PHP 的php.ini、FPM 的www.conf这些和环境相关但又经常调整的内容全部写成 ConfigMap。改配置只需更新 ConfigMap然后滚动重启 Pod不需要重新构建镜像。代码通过共享存储挂载。PHP 项目代码会频繁更新不适合每次把代码打进镜像再到集群里拉取。更常见的做法是使用 PV/PVC 共享存储把代码放到 NFS、CephFS或者云厂商的 NAS 上Nginx 和 PHP-FPM 的 Pod 都挂载同一个 PVC。这样 PHP-FPM 执行代码Nginx 读取静态资源两边看到的是同一份文件。如果你的集群规模不大也可以先走“代码打进镜像”的简单路线但要注意PHP-FPM 和 Nginx 两个镜像都要打包同样的代码很容易出现两边代码版本不一致的问题。共享存储是最省心的方案后面我会给完整示例。3. 从零到一的落地步骤3.1 镜像选型与 PHP 扩展准备首先是选基础镜像。我在生产环境用的组合是nginx:1.25-alpine和php:8.2-fpm-alpine。Alpine 镜像体积小、启动快安全补丁也比较及时。如果是纯内网环境记得先把镜像推到私有仓库比如 Harbor节点上配好拉取凭证。官方 PHP-FPM 镜像最大的问题是不带业务扩展。我的项目需要pdo_mysql、redis、bcmath、opcache所以需要自己写一个 Dockerfile 重新构建FROM php:8.2-fpm-alpine # 安装系统依赖 RUN apk add --no-cache $PHPIZE_DEPS \ libzip-dev \ libpng-dev \ libjpeg-turbo-dev \ freetype-dev \ oniguruma-dev # 安装 PHP 扩展 RUN docker-php-ext-configure gd --with-freetype --with-jpeg \ docker-php-ext-install -j$(nproc) pdo_mysql bcmath gd zip \ pecl install redis docker-php-ext-enable redis # 安装 Composer RUN curl -sS https://getcomposer.org/installer | php -- --install-dir/usr/local/bin --filenamecomposer # 复制项目代码 COPY . /var/www/html WORKDIR /var/www/html这里有个实操细节要提醒pecl install redis需要$PHPIZE_DEPS里面包含autoconf、gcc这些编译工具。如果你不装它Redis 扩展编译会直接报错。另外官方镜像的docker-php-ext-install命令封装得很好它会自动处理.so文件生成和php.ini启用的步骤不一定需要你再手动写extensionredis.so除非你有特殊需求。3.2 准备共享存储 PVC我这边有一个 NFS 存储服务器所以用 NFS 作为 PV 提供方。先把代码目录挂载到共享存储上然后创建 PVCapiVersion: v1 kind: PersistentVolume metadata: name: php-code-pv spec: capacity: storage: 10Gi accessModes: - ReadWriteMany nfs: server: 192.168.10.20 path: /data/php-site storageClassName: nfs-standard --- apiVersion: v1 kind: PersistentVolumeClaim metadata: name: php-code-pvc spec: accessModes: - ReadWriteMany storageClassName: nfs-standard resources: requests: storage: 10Gi注意accessModes必须是ReadWriteMany因为 Nginx 和 PHP-FPM 的多个 Pod 都要同时读写这份代码。如果用了ReadWriteOncePVC 只能被一个节点上的 Pod 挂载多个副本会直接挂载失败。云环境用户不用自建 NFS直接用云厂商提供的 NAS 文件存储或者 CFS创建 PVC 时指定对应的 StorageClass 即可原理是一样的。3.3 创建 PHP-FPM 的 Deployment代码部分准备就绪接下来是核心的 Deployment 编排。PHP-FPM 的 Deployment 如下apiVersion: apps/v1 kind: Deployment metadata: name: php-fpm namespace: web spec: replicas: 2 selector: matchLabels: app: php-fpm template: metadata: labels: app: php-fpm spec: containers: - name: php-fpm image: harbor.example.com/php/php-fpm:8.2-v1.0.0 imagePullPolicy: IfNotPresent ports: - containerPort: 9000 name: fpm env: - name: TZ value: Asia/Shanghai - name: APP_ENV value: production resources: requests: cpu: 500m memory: 512Mi limits: cpu: 1 memory: 1Gi readinessProbe: tcpSocket: port: 9000 initialDelaySeconds: 5 periodSeconds: 10 livenessProbe: tcpSocket: port: 9000 initialDelaySeconds: 15 periodSeconds: 20 timeoutSeconds: 3 volumeMounts: - name: code mountPath: /var/www/html volumes: - name: code persistentVolumeClaim: claimName: php-code-pvc这里解释几个关键字段。readinessProbe用的tcpSocket因为 PHP-FPM 本身不提供 HTTP 接口用 HTTP 探测没有意义TCP 能连上 9000 端口就说明进程活着。livenessProbe的作用是发现 Pod 僵死以后自动杀掉并重建避免一个卡死的 PHP-FPM 进程一直占用流量。resources部分不能省。我给每个 Pod 设置了 CPU 和内存的requests与limits这样 Kubernetes 调度器可以合理分配 Pod 到不同节点保证集群不会出现某个节点资源严重超卖。3.4 创建 Nginx 的 DeploymentNginx 的 Deployment 和 PHP-FPM 共享同一个 PVC。但有一个问题Nginx 镜像里没有我们需要的 Nginx 配置所以把配置放到 ConfigMap 中挂载到/etc/nginx/conf.d/default.conf。先看 ConfigMapapiVersion: v1 kind: ConfigMap metadata: name: nginx-site-conf namespace: web data: default.conf: | server { listen 80; server_name _; root /var/www/html/public; index index.php index.html; access_log /dev/stdout main; error_log /dev/stderr warn; location / { try_files $uri $uri/ /index.php?$query_string; } location ~ \.php$ { fastcgi_pass php-fpm:9000; fastcgi_index index.php; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; include fastcgi_params; } location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ { expires 7d; access_log off; } location ~ /\. { deny all; } }有三个配置点非常关键新手经常在这里翻车第一fastcgi_pass必须写成服务的 DNS 名php-fpm而不是 IP。因为 PHP-FPM 的 Pod IP 是动态的写成 IP 一旦 Pod 重建就断了。第二fastcgi_param SCRIPT_FILENAME必须正确设置。如果去掉这一行PHP-FPM 会找不到要执行的脚本直接返回File not found或者空响应。注意这里用的是$document_root而不是硬编码路径因为root指令已经定义了代码根目录。第三try_files配置决定了前端路由是否正常。很多 PHP 框架Laravel、ThinkPHP都是单入口模式所有请求都转发到index.php。没有这行配置访问一个不存在的路由会直接 404而不是交给框架处理。然后是 Nginx DeploymentapiVersion: apps/v1 kind: Deployment metadata: name: nginx namespace: web spec: replicas: 2 selector: matchLabels: app: nginx template: metadata: labels: app: nginx spec: containers: - name: nginx image: harbor.example.com/php/nginx:1.25-alpine-v1.0.0 imagePullPolicy: IfNotPresent ports: - containerPort: 80 name: http resources: requests: cpu: 200m memory: 128Mi limits: cpu: 1 memory: 512Mi readinessProbe: httpGet: path: /index.php port: 80 initialDelaySeconds: 5 periodSeconds: 10 volumeMounts: - name: code mountPath: /var/www/html - name: nginx-conf mountPath: /etc/nginx/conf.d/default.conf subPath: default.conf volumes: - name: code persistentVolumeClaim: claimName: php-code-pvc - name: nginx-conf configMap: name: nginx-site-confNginx 的readinessProbe我用的是httpGet /index.php这个路径会真正经过 PHP-FPM 处理如果 Nginx 和 PHP-FPM 之间的链路有问题健康检查会立刻反映出来这个设计比单纯检查 Nginx 80 端口更有效。3.5 打通 Service 与对外访问两个 Deployment 创建完以后还需要一个 Service 把 PHP-FPM 的 9000 端口暴露给 NginxapiVersion: v1 kind: Service metadata: name: php-fpm namespace: web spec: selector: app: php-fpm ports: - port: 9000 targetPort: 9000这里不要type: LoadBalancer也不需要NodePort。PHP-FPM 是后端服务只需要集群内部访问用默认的ClusterIP就好。Service 的selector会自动匹配标签为app: php-fpm的所有 Pod并把流量均匀分发到它们。对于网站入口我这边是先创建一个 Nginx 的 ClusterIP Service再通过 Ingress 暴露到公网。如果你没有 Ingress也可以用type: NodePort或者LoadBalancer。但生产环境更推荐 Ingress 方案因为它天然支持域名、TLS 证书还能按域名做路由分流。创建完以上资源后在集群内随便找一个 Pod执行kubectl exec -it nginx-xxx -- curl http://localhost/index.php如果返回的是 PHP 页面输出说明 Nginx 到 PHP-FPM 的整条链路已经通了。4. 参数与细节调优4.1 Deployment 关键字段逐个说很多人部署完能跑就不管了但 Deployment 里有些参数直接关系到线上发布的稳定性和资源利用率。先看strategy字段。默认的滚动更新策略是RollingUpdate这个策略分为两步先启动新 Pod等它通过readinessProbe再销毁旧 Pod。但默认的maxUnavailable是 25%意味着在更新过程中旧实例最多可以有 25% 被提前杀掉。如果你的 PHP-FPM 只有 2 个副本那 25% 四舍五入之后可以同时杀掉整个过程发布期间服务可能出现抖动。我这边的生产建议是strategy: type: RollingUpdate rollingUpdate: maxSurge: 1 maxUnavailable: 0maxUnavailable: 0保证旧 Pod 在更新期间一个都不会少maxSurge: 1允许额外多启动一个新 Pod。这样发布过程是“先扩容、后缩容”全程没有容量缺口。再看terminationGracePeriodSeconds。这个字段控制 Pod 被删除时给程序的“打扫战场”时间。PHP-FPM 处理完当前请求后优雅退出默认 30 秒一般够用。如果你发现发布时经常有请求被中断可以用kubectl get pod -o wide查看旧 Pod 的状态然后适当调大这个值。imagePullPolicy也值得注意。如果不指定Kubernetes 会根据镜像 tag 自动决定。如果 tag 是latest默认每次都会拉取如果 tag 是具体版本号默认只在本地没有该镜像时才拉取。我建议开发环境用Always生产和预发布环境用IfNotPresent避免线上节点每次都去仓库拉一个没变化的镜像白白浪费带宽和时间。4.2 Nginx 与 PHP-FPM 配置优化Nginx 侧最重要的两个配置worker_processes auto。在容器环境里 CPU 核数就是limits.cpu拉伸后的核数设为auto能让 Nginx 自动匹配 CPU 个数。如果你的 Nginx 容器是 1 核就启动 1 个 worker如果是 4 核就有 4 个 worker。keepalive 1024。这个配置在upstream配置块里定义用来控制 Nginx 与 PHP-FPM 之间长连接的数量。PHP 是短生命周期请求每次请求都要建立 FastCGI 连接如果不用长连接高并发下 Nginx 会不断创建新连接文件描述符很快就耗尽了。PHP-FPM 侧的优化重点是www.conf的进程管理模式。我用的是动态模式pm dynamic pm.max_children 20 pm.start_servers 5 pm.min_spare_servers 3 pm.max_spare_servers 10 pm.max_requests 1000max_children是 FPM 能创建的最大 worker 进程数可以理解成同时能处理的 PHP 请求并发上限。它不能随便拍脑袋定要根据每个 PHP 进程平均内存来算假设单进程稳定内存是 40MBPod 内存上限是 1GB那么 max_children 最大不超过 25留出系统缓冲后我一般压到 20。pm.max_requests也很重要。它让每个 FPM worker 在处理 1000 个请求后自动回收重启能有效绕开 PHP 代码里可能存在的内存泄漏问题相当于一个“进程环卫工”。4.3 日志和监控设计在 Kubernetes 里日志最佳实践是输出到 stdout/stderr由集群的日志采集器统一收集。Nginx 官方镜像默认把日志写到/var/log/nginx/access.log这会导致日志只留在本地磁盘Pod 一销毁日志就丢了。我在上面 ConfigMap 里已经做了处理access_log /dev/stdout main; error_log /dev/stderr warn;这样 Nginx 的访问日志直接输出到标准输出kubectl logs就能看到。PHP-FPM 的日志同理可以通过php_admin_flag[log_errors]打开然后error_log /dev/stderr输出到标准错误。如果你的集群跑在云厂商的托管 K8s 上通常自带日志服务或者可以接 Loki、ELK。把所有 Pod 的 stdout 日志采集过去保留一个集中查看的入口线上排障效率会高很多。5. 常见问题排查与实战避坑5.1 高频故障速查表先整理一张表我在一年多的运维中遇到的绝大多数据问题都浓缩在里面了现象最可能原因快速诊断方法解决方案502 Bad GatewayNginx 连不上 PHP-FPMkubectl logs看 Nginx检查 Service selector检查 php-fpm Service 和 Deployment 标签是否匹配504 Gateway TimeoutPHP 进程执行超时看 PHP-FPM 日志确认慢请求调大fastcgi_read_timeout优化慢 SQLFile not foundSCRIPT_FILENAME错误在 Nginx Pod 里curl测试检查fastcgi_param SCRIPT_FILENAME配置静态资源 404root 路径不对进入 Nginx Pod 查看代码挂载检查 PVC 挂载路径和root指令容器启动后被杀内存超限kubectl describe pod看 OOMKilled调大 limits或者调小 FPM 的 max_children发布后访问异常代码新旧版本混跑查看 Pod 创建时间用 maxUnavailable0 的滚动更新策略5.2 502 问题排查实战记录有一次线上突然大面积 502我先看 Nginx Pod 的日志发现报错信息是connect() failed (111: Connection refused) while connecting to upstream。这说明 Nginx 连 PHP-FPM 的 Service 被拒了。于是我依次检查kubectl get pods -n web | grep php-fpm发现 PHP-FPM 的 Pod 全部处于CrashLoopBackOff状态。kubectl logs -n web php-fpm-xxx --previous看崩溃前的日志发现是ERROR: unable to allocate memory for pool说明 FPM 内存设置超过了 Pod 限制。kubectl describe pod php-fpm-xxx确认OOMKilled标记。根因很清晰pm.max_children 50而 Pod 内存只有 512MiPHP 进程数量一多就把内存打爆了。解决方式是调低max_children到 20同时适当调大 Pod 的 limits。这个排查链路只用了不到五分钟如果没有可靠的kubectl logs工具光靠猜可能要搞半小时以上。5.3 几个容易忽略的细节最后分享几个我踩过的、比较隐蔽的坑。第一个是 PHP Session 跨副本的问题。如果你部署了多个 PHP-FPM 副本PHP 默认把 Session 写到本地文件系统那同一个用户的请求被负载均衡到不同副本时Session 可能丢失。必须把 Session 存储改成 Redis安装 Redis 扩展后在php.ini里配置session.save_handler redis session.save_path tcp://redis-service:6379第二个是上传文件大小限制。Kubernetes 集群通常在前面还有一层负载均衡或网关Nginx、PHP-FPM、上游网关三层都有client_max_body_size/upload_max_filesize的限制。我在一次项目中遇到过前端上传大文件报 413最后排查发现是网关层限制了 1MB而 Nginx 和 PHP 配置的都是 10MB。提醒各位只要链路里有多个组件每一层都要检查一遍。第三个是文件权限。Nginx 解析 PHP 脚本不需要执行权限但以www-data身份运行的 Nginx worker 必须对代码目录有读权限PHP-FPM worker 要对 Session 临时目录和日志文件有写权限。共享存储挂载时特别容易忽略权限问题NFS 的根目录如果设置成755 root:rootPHP-FPM 跑起来之后会疯狂报Permission denied。我一般把代码目录的属主设为 33www-data的 UID权限用 775从根上避免这类问题。第四个是代码更新方式。用共享存储挂载代码以后上线新代码直接覆盖 PVC 里的文件即可PHP-FPM 会实时读取新文件不需要重建 Pod。这个方法很快但有个隐患是缓慢发布时新旧代码可能共存。如果你想做得严谨每次发版仍然走构建新镜像、更新 Deployment 的流程保留一个可回滚的版本。我现在的做法是日常小改动直接更新共享存储大版本迭代走镜像发布两种方式结合用。最后再分享一点个人经验这套 NginxPHP 容器化方案跑了一年多最直观的感受是发布从“手忙脚乱的重启服务”变成“一行命令滚动更新”扩容从“等新机器到位”变成“HPA 自动拉起新 Pod”故障从“半夜爬起来 SSH 上去重启”变成“集群自动把挂掉的实例拉起来第二天早上看告警邮件就行”。过程中踩过的坑不少但每一个都值回票价。尤其是共享存储、健康检查、资源限制这三件事建议刚上手的朋友优先花时间研究透它们决定了你在生产环境是不是会被 502 和 Pod 重启折磨得睡不好觉。如果你现在正打算把传统 LNMP 环境容器化我建议先拿一个非核心业务做试点按这篇文章的顺序搭建跑通以后观察两周再慢慢把其他业务迁移过来。Kubernetes 这东西上手有门槛但一旦跨过去你就再也不想回去手动改配置重启服务了。