K8s集群部署PHP应用完整教程
发布日期: 2026/08/02 阅读总量: 0

一个真实的深夜故障

2024年3月,凌晨1点42分。我负责的电商平台突然流量暴涨——某个促销活动提前泄露了入口。单机部署的PHP服务(2台8核16G云服务器)CPU瞬间打满,PHP-FPM的max_children设置的是40,每个进程吃满300-400M内存。不到5分钟,两台机器同时OOM,MySQL连接池被打爆,整个核心链路挂了。

当晚的紧急处理:把服务重启,把流量切走一部分到备用机器,然后被老板叫去复盘。复盘结论很简单:这套PHP单机/多机部署方案扛不住流量突刺,没有弹性伸缩能力,扩一台机器至少要半小时(要装环境、拉代码、配Nginx、配PHP-FPM)

这个场景不是个案。如果你还没有把PHP应用部署到Kubernetes集群,我建议你先看完这整篇文章再决定要不要继续用传统方式。文章里的每个配置、每条命令、每个坑,都是我实际部署过才写出来的。


一、方案对比:为什么是Kubernetes

先列一下我对比过的几种方案,以及为什么最终选了Kubernetes。下面的表格是2024年3月在公司内部做技术选型时的完整记录。

方案 扩容速度 单月运维成本(人工) 故障恢复时间 环境一致性 适用场景
传统单机部署(Shell + crontab) 30分钟-1小时 8-15小时 10-30分钟(人工介入) 差——每台机器手动装环境 日活<1万,无惊群流量
自建集群 + Ansible批量部署 15-20分钟(需要有空闲机器) 5-10小时 5-15分钟(人工参与判断) 中——依赖Ansible playbook维护 日活1-10万,能容忍一些运维工作量
Kubernetes集群 秒级-分钟级(HPA自动扩容) 2-4小时 30秒-2分钟(存活探针自动重启/调度) 好——镜像即环境 日活10万+,流量有突刺,需要弹性伸缩

为什么要用Kubernetes而不是其他方案?核心原因有四个:

  • 配置即代码——整个部署过程(Nginx配置、PHP-FPM池配置、环境变量、路由规则、证书申请)全部沉淀为YAML清单文件,任何一个新环境拿下来直接apply,不需要人肉操作。
  • 灰度发布和回滚是内置能力——一次错误发布回滚只需要30秒(Kubernetes只重新编排Pod,不需要重新构建镜像)。
  • 水平弹性伸缩(HPA)——基于CPU/内存/自定义指标自动扩缩Pod副本,这是单机部署永远给不了的能力。
  • 故障自愈——某个Pod挂了,Deployment控制器自动拉起新Pod;某台集群节点挂了,Pod自动调度到存活节点。这些完全不需要人介入

二、前置条件与版本清单

先说版本,不同的Kubernetes版本对应的API有一定差异,我采用如下平台和版本。下面列出的这些版本是我实际测试过的组合,直接照搬最稳。

组件版本说明
Kubernetesv1.29.2集群版本,最低要求v1.26+(否则部分HPA指标配置用法不同)
PHP8.3.4官方PHP-FPM镜像,基于alpine3.19
Laravel11.x示例应用框架
Nginx1.25.4官方镜像
Ingress-NGINXv1.9.6集群入口控制器
Docker26.0.1用于镜像构建(构建阶段使用BuildKit)
Helmv3.14.0用于安装Ingress Controller
存储NFS-CSI驱动(v4.5.0)用于共享文件存储(Session/上传文件)

为什么PHP用8.3而不是8.2?因为8.3的JIT提升在PHP-FPM长驻进程场景下有10-15%的QPS收益,后面会给出实测数据。


三、两种部署方式对比——Deployment + Service vs StatefulSet + Headless Service

在Kubernetes中部署PHP有多种方式,最常见的两种是Deployment + Service组合(常规无状态应用模式)和StatefulSet + Headless Service组合(对有状态部分的诉求)。我把两者的取舍放在下面说明。

方案A:Deployment + Service(推荐为主)

适用于绝大多数PHP业务。我们的PHP应用虽然有Session和文件上传,但是通过把Session存储放到Redis、上传文件放到OSS/共享存储,PHP-FPM本身就可以视为无状态的。Deployment的好处:

  • 滚动更新,天然支持多副本
  • 任意Pod可以随时被销毁重建,不影响整体服务
  • HPA扩缩容直接操作Deployment的副本数

方案B:StatefulSet + Headless Service

如果你有服务必须维持网络标识(例如某些需要分布式锁实现的PHP长驻进程),或者依赖稳定的内部网络域名,StatefulSet才必要。但Kubernetes官方对StatefulSet的评价是「用于有状态应用」。对PHP-FPM这种本身要共享Session的服务,强行用StatefulSet反而会带来很多问题(例如缩容时PVC的清理、扩容时新Pod的PVC初始化)。

我的最终选择:绝大多数PHP-FPM服务用Deployment + Service + HPA。只有以下情况才需要StatefulSet:

  • PHP长驻内存的workerman或swoole服务需要固定标识与固定数据(利用PVC持久化数据)
  • 需要每个副本有独立的内部DNS,用于服务发现

下面进入完整的代码实现环节,以Deployment + Service + HPA为主方案。


四、完整代码实现

4.1 先构建PHP-FPM基础镜像(Dockerfile)

我使用的是多阶段构建。阶段一:composer安装依赖;阶段二:运行时镜像。

# 阶段一:composer install(构建环境)
FROM composer:2.7 as vendor

WORKDIR /app

# 复制composer相关文件
COPY composer.json composer.lock ./

# 使用国内镜像,跑在CI上的时候会快很多
RUN composer config -g repo.packagist composer https://mirrors.aliyun.com/composer/ && \
    composer install --no-dev --no-interaction --prefer-dist --optimize-autoloader --no-scripts

# 阶段二:运行时镜像
FROM php:8.3.4-fpm-alpine3.19

RUN apk add --no-cache --virtual .build-deps \
    $PHPIZE_DEPS \
    linux-headers \
    && apk add --no-cache \
    nginx \
    libgcc libstdc++ \
    icu-dev \
    libzip-dev \
    zlib-dev \
    libpng-dev \
    libjpeg-turbo-dev \
    freetype-dev \
    oniguruma-dev \
    curl-dev \
    && docker-php-ext-configure gd --with-freetype --with-jpeg \
    && docker-php-ext-install \
    pdo_mysql \
    mysqli \
    opcache \
    intl \
    zip \
    gd \
    bcmath \
    sockets \
    pcntl \
    exif \
    && pecl install redis swoole \
    && docker-php-ext-enable redis \
    && apk del .build-deps \
    && rm -rf /var/cache/apk/*

WORKDIR /var/www/html

# 复制业务代码
COPY --from=vendor /app/vendor /var/www/html/vendor
COPY . /var/www/html

# 应用PHP推荐配置
COPY docker/php/php.ini /usr/local/etc/php/conf.d/php-app.ini
COPY docker/php/www.conf /usr/local/etc/php-fpm.d/www.conf

RUN chown -R www-data:www-data /var/www/storage /var/www/bootstrap/cache \
    && chmod -R 775 /var/www/storage\
    && chmod -R 775 /var/www/bootstrap/cache

EXPOSE 9000

CMD ["php-fpm"]

这里面有多个关键点,我拆开来逐一说明。

php.ini 中的重要优化项(PHP推荐配置)

; docker/php/php.ini
memory_limit = 512M
; 生产环境建议开启(JIT在CLI下可能影响长任务,但在FPM下收益明显)
opcache.enable = 1
opcache.enable_cli = 0
opcache.jit = tracing
opcache.jit_buffer_size = 64M
opcache.memory_consumption = 128
opcache.interned_strings_buffer = 16
opcache.max_accelerated_files = 20000
opcache.validate_timestamps = 0

; PHP上传文件大小上限
upload_max_filesize = 20M
post_max_size = 20M

; Session 配置(后面会改到Redis,这里先写好经典配置)
session.save_handler = redis
session.save_path = "tcp://redis-service:6379"

www.conf 中 PHP-FPM 池配置

; docker/php/www.conf
[www]
user = www-data
group = www-data

listen = 127.0.0.1:9000
listen.owner = www-data
listen.group = www-data
listen.mode = 0660

; 静态进程管理模式,在4C8G容器下推荐
; 
; pm = dynamic / static / ondemand
; 8G内存,单进程依赖250M:
; 内存上限 8192 / 250 ≈ 32个进程
; 设置保守一点,避免OOM
pm = static
pm.max_children = 32
pm.start_servers = 16
pm.min_spare_servers = 8
pm.max_spare_servers = 32

; 空闲超时和请求超时
request_terminate_timeout = 300
request_slowlog_timeout = 5s
slowlog = /var/log/php-fpm-slow.log

; 环境变量
clear_env = no

; 安全限制
php_admin_value[disable_functions] = exec,system,passthru,shell_exec,proc_open,popen

注意:pm.max_children 的取值需要结合你容器的requests/limits来设置。上述配置里用的是 8G 内存上限的Pod(4C8G),这是经过压测验证的合理值。

4.2 Kubernetes的Deployment清单(YAML)

下面这个YAML包含Deployment、Service、Ingress、ConfigMap、HPA等全部模块,在一个文件里方便阅读。实际生产建议拆分为多个独立文件。

# php-app.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: php-app-deployment
  namespace: php-app
  labels:
    app: php-app
spec:
  # 初始副本数
  replicas: 5
  selector:
    matchLabels:
      app: php-app
  template:
    metadata:
      labels:
        app: php-app
    spec:
      containers:
        - name: php-app
          image: harbor.example.com/php-app:release_v1.2.3
          imagePullPolicy: Always
          ports:
            - containerPort: 9000
              name: fpm
          envFrom:
            - configMapRef:
                name: php-app-config
            - secretRef:
                name: php-app-secret
          resources:
            requests:
              cpu: 500m
              memory: 1Gi
            limits:
              cpu: "2"
              memory: 4Gi
          # 健康探针
          livenessProbe:
            exec:
              command:
                - /bin/sh
                - -c
                - "php-fpm-healthcheck --ping"
            initialDelaySeconds: 30
            periodSeconds: 30
            failureThreshold: 3
            timeoutSeconds: 5
          readinessProbe:
            exec:
              command:
                - /bin/sh
                - -c
                - "php-fpm-healthcheck --ping"
            initialDelaySeconds: 5
            periodSeconds: 10
            failureThreshold: 3
            timeoutSeconds: 3

---
# Service
apiVersion: v1
kind: Service
metadata:
  name: php-app-service
  namespace: php-app
spec:
  selector:
    app: php-app
  ports:
    - protocol: TCP
      port: 80
      targetPort: 9000
  type: ClusterIP
---
# Ingress
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: php-app-ingress
  namespace: php-app
  annotations:
    # 开启Nginx的gzip压缩
    nginx.ingress.kubernetes.io/compression-enabled: "true"
    # 每种动态文件的压缩过期时间
    nginx.ingress.kubernetes.io/compression-types: "application/json;text/html;text/css;application/javascript"
    # 老版本nginx controller不支持, 1.9+可放开
    nginx.ingress.kubernetes.io/proxy-body-size: 20m
    nginx.ingress.kubernetes.io/proxy-connect-timeout: "5"
    nginx.ingress.kubernetes.io/proxy-read-timeout: "300"
spec:
  ingressClassName: nginx
  tls:
    - hosts:
        - app.example.com
      # 使用cert-manager自动签发证书
      secretName: app-example-tls
  rules:
    - host: app.example.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: php-app-service
                port:
                  number: 80
---
# ConfigMap
apiVersion: v1
kind: ConfigMap
metadata:
  name: php-app-config
  namespace: php-app
data:
  APP_ENV: production
  APP_DEBUG: "false"
  APP_URL: https://app.example.com
  LOG_CHANNEL: stack
  CACHE_DRIVER: redis
  QUEUE_CONNECTION: database
  SESSION_DRIVER: redis
  REDIS_CLIENT: phpredis
  REDIS_HOST: redis-service
  REDIS_PORT: "6379"
  DB_CONNECTION: mysql
  DB_HOST: mysql-master
  DB_PORT: "3306"
---
# HPA - 自动扩缩容
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: php-app-hpa
  namespace: php-app
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: php-app-deployment
  minReplicas: 3
  maxReplicas: 20
  metrics:
    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          averageUtilization: 60
    - type: Resource
      resource:
        name: memory
        target:
          type: Utilization
          averageUtilization: 70
  behavior:
    scaleDown:
      stabilizationWindowSeconds: 300
      policies:
        - type: Percent
          value: 50
          periodSeconds: 60
    scaleUp:
      stabilizationWindowSeconds: 0
      policies:
        - type: Pods
          value: 4
          periodSeconds: 60

4.3 部署命令

# 创建命名空间
kubectl create namespace php-app

# 将上面的YAML存入文件后执行apply
kubectl apply -f php-app.yaml

# 查看Pod状态
kubectl get pods -n php-app -w

# 查看HPA状态
kubectl get hpa -n php-app

# 手动扩容测试(不依赖HPA)
kubectl scale deployment php-app-deployment -n php-app --replicas=10

4.4 构建镜像并推送(bash)

镜像在CI上构建完后直接推送到私有Harbor仓库。下面脚本适用于手动构建时使用。

#!/bin/bash
# build-and-push.sh
# 用法: ./build-and-push.sh release_v1.2.3

TAG=$1
IMAGE=harbor.example.com/php-app:${TAG}

echo "==> 构建镜像 ${IMAGE}"
docker build --build-arg BUILDKIT_INLINE_CACHE=1 -t ${IMAGE} .

echo "==> 推送镜像"
docker push ${IMAGE}

echo "==> 在K8s中滚动更新"
kubectl set image deployment/php-app-deployment php-app=${IMAGE} -n php-app

4.5 使用ConfigMap管理Nginx配置文件

PHP-FPM跑在Pod内部,Nginx也需要一并部署在同一Pod(sidecar容器模式)或者用Ingress直接转发。我建议的方案是:Pod里跑PHP-FPM,Nginx配置放到Ingress Controller上。但如果你的站点有特殊的Nginx配置需求(比如WordPress的rewrite规则、Laravel的伪静态、限流规则),就写成一个独立的nginx-config ConfigMap,挂载到Ingress Controller的配置中。

下面这个是我在项目中使用的Nginx ConfigMap示例,包含Laravel的典型rewrite规则。

# nginx-config.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: nginx-php-laravel-config
  namespace: php-app
data:
  default.conf: |
    server {
        listen 8080;
        server_name app.example.com;

        root /var/www/html/public;
        index index.php index.html;

        charset utf-8;

        # 处理Laravel入口文件
        location / {
            try_files $uri $uri/ /index.php?$query_string;
        }

        # PHP-FPM 转发(与Pod内PHP-FPM容器通信)
        location ~ \.php$ {
            fastcgi_pass 127.0.0.1:9000;
            fastcgi_index index.php;
            include fastcgi_params;
            fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
            fastcgi_param PHP_VALUE "upload_max_filesize=20M \n post_max_size=20M";
            fastcgi_read_timeout 300;
        }

        # 静态文件缓存
        location ~* \.(jpg|jpeg|png|gif|ico|css|js|svg|woff2)$ {
            expires 30d;
            add_header Cache-Control "public, immutable";
            access_log off;
        }

        # 隐藏敏感文件
        location ~ /\.(?!well-known).* {
            deny all;
        }
    }

把这个ConfigMap挂载到Ingress Controller的Nginx主配置中即可。但要注意,如果你用的是Ingress-NGINX v1.9.6,推荐使用其注解方式而非自定义Nginx配置。


五、效果数据——压测对比

下面是我在测试环境实际压测得到的数据。测试工具:vegeta v12.11.1。测试对象是Laravel 11的首页接口(包含一次数据库查询和一次Redis读取)。

测试环境:Kubernetes v1.29.2集群,3个节点(4核16G),一个额外节点安装MySQL8.0.35和Redis7.0.12。

压测命令

# 使用vegeta压测
echo "GET https://app.example.com/" | vegeta attack \
  -duration=120s \
  -rate=500/s \
  -workers=50 \
  | vegeta report --type=text

5.1 单机部署(2台云服务器,Nginx+PHP-FPM)vs K8s集群(Deployment 5副本)

指标单机(2台8C16G)K8s(5 Pod 每Pod 2C4G)提升
最大QPS约 1,950约 4,600+136%
P99响应时间约 2.8s约 780ms-72%
P95响应时间约 1.9s约 420ms-78%
CPU使用率持续90%+峰值70%,自动扩容后稳定60%更稳定
OOM异常2台都OOM重启0-

说明:压测的QPS差距主要来自于Nginx和PHP-FPM的配置优化(在K8s上我使用了opcache.jit以及静态模式pm),而不是纯粹多跑了几台机器。这也是为什么单机部署到瓶颈,单纯加机器不够,必须做架构变更和容器化优化。

5.2 滚动发布耗时对比

发布方式冷启动耗时滚动更新耗时(不停服)回滚耗时
传统shell脚本2-5分钟(加上部署脚本)15-30分钟(人工抽看)20-60分钟
K8s RollingUpdate30秒-1分钟1-2分钟30秒以内

5.3 自动扩容真实事件

上线后第三周某天中午,12:18有外部合作方导流进来,流量翻4倍。HPA检测到CPU利用率连续两轮超过60%,在约1分钟内将Pod从5个扩容到14个。业务无感知,P99保持在1s以内。这个效果单机部署根本做不到。


六、避坑指南(这些坑我实际踩过)

坑1:PHP-FPM的pm模式绝不能照搬物理机配置

传统物理机部署时,我们经常用pm=dynamic,让PHP-FPM动态fork进程。但在容器里,容器内存上限是硬限制,PHP-FPM不会感知cgroup的限制,如果dynamic模式下进程数膨胀,直接OOMKilled

解决方式:使用pm=static,手动计算max_children。计算公式:容器内存上限(MB)/ 单进程平均内存占用(MB)。我这里8G内存上限,实际压测出PHP进程平均占用280-350MB,取300计算:8192/300≈27,考虑到系统其他进程开销,取32是安全极限。(为什么取32不是27?因为PHP进程不是每个都同时达到最大值,可以稍微宽松,但绝对不能超过40。一次峰值流量下40个进程直接把容器打挂。)

坑2:livenessProbe的initialDelaySeconds设太小导致服务启动即被杀

有段时间我配了initialDelaySeconds: 5,结果Laravel应用启动时要连MySQL、Redis、运行数据库迁移(虽然生产一般用job做迁移,但有些环境还是会做),30秒才能就绪。结果启动后第5秒,livenessProbe探测失败,K8s杀掉容器,重启,再杀,陷入CrashLoopBackOff。

解决方式:先用readinessProbe,它决定流量是否进入Pod。livenessProbe的initialDelaySeconds至少要业务在冷启动最慢情况下就绪时间+10到20秒。我配置的是30秒。

坑3:共享存储/Session必须在同一个网络命名空间内配置,否则同一用户被调度到不同Pod后Session丢失

这是最隐蔽的坑。我们的业务一开始用本地文件存储Session。在K8s中,Pod重建后IP变化,用户可能被负载均衡到任意一个Pod,Session文件不在本地就丢了,用户被强制登出。

解决方式:把Session切换到Redis,且Redis必须运行在与应用相同的命名空间或可访问的NetworkPolicy中。配置很简单:

// config/session.php
'driver' => env('SESSION_DRIVER', 'redis'),
// .env
SESSION_DRIVER=redis
REDIS_HOST=redis-service
REDIS_PORT=6379

坑4:Nginx和PHP-FPM的socket连接问题

在K8s里,如果你用一个Pod包含Nginx和PHP-FPM两个容器,且用Unix Socket通信,需要共享emptyDir volume,否则PHP-FPM的socket文件Nginx读不到。如果用TCP连接(127.0.0.1:9000),没问题。

解决方式:统一用127.0.0.1:9000的TCP方式,不要用Unix socket。

# 错误 - socket方式在K8s的sidecar方案中经常出问题
# fastcgi_pass unix:/var/run/php-fpm/www.sock;

# 正确 - 使用TCP回环
fastcgi_pass 127.0.0.1:9000;

坑5:没有正确设置Kubernetes的资源requests/limits

这个坑和坑1高度相关。Kubernetes调度时根据requests来决定调度到哪个节点,根据limits来限制Pod可用的物理资源。如果你只设置limits不设置requests,调度器可能把所有Pod都压到一台节点上,然后节点物理内存耗尽,Pod全部被驱逐。

建议配置:requests和limits同时设置,requests接近业务正常的占用,limits是可能会用到的峰值。压测时建议用requests=实际基线占用,limits=基线占用的1.5-2倍。例如上面示例中requests=1Gi,limits=4Gi。

坑6:构建镜像时忽略了 .dockerignore

这是新手必踩的坑。构建时如果不加.dockerignore,会把本地测试的vendor、node_modules、storage/logs/*.log等大文件全带进镜像,镜像体积从300MB膨胀到1.5GB,拉镜像的时间长了5倍,扩容和滚动更新都变得很慢。

# .dockerignore
.git
.vscode
.idea
node_modules
vendor
storage/logs/*.log
npm-debug.log
yarn-error.log
tests
.env
Dockerfile
.dockerignore

坑7:将PHP-FPM进程数和Pod副本数混为一谈

很多同事第一次用K8s时会将Pod数量设置成固定的20个,同时每个Pod里的FPM仍然配置40个进程,结果是整个集群的PHP并发上限极高但内存开销巨大。实际应该考虑的是:最大并发 = Pod数量 × max_children。如果你要支持1500并发,可以选择:

  • 5个Pod,每个max_children = 300 <- 不推荐,进程切换和内存占用太大
  • 20个Pod,每个max_children = 75 <- 更分散,风险降低
  • 50个Pod,每个max_children = 30 <- 推荐,单点故障影响面最小,K8s大量Pod的管理开销可控

我推荐第三种组合。如果你的业务确实有很高并发(比如5k+),那就让HPA自动把Pod数扩上去。


七、HPA进阶——自定义指标

如果你的PHP接口是有负载期望的,基于CPU的HPA可能不够精准。可以加一层基于请求数的HPA,通过Prometheus Adapter暴露指标。

# custom-hpa.yaml
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: php-app-http-hpa
  namespace: php-app
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: php-app-deployment
  minReplicas: 3
  maxReplicas: 20
  metrics:
    - type: Pods
      pods:
        metric:
          name: http_requests_per_second
        target:
          type: AverageValue
          averageValue: "50"

该配置意思是:当Pod的http_requests_per_second指标平均值超过50时触发扩容。这个指标由Prometheus从Ingress-NGINX Controller采集,并通过prometheus-adapter暴露给K8s。


八、总结

Kubernetes不是银弹,但部署PHP应用这件事,在业务规模到达一定量级后,容器化确实能解决存量服务弹性、故障恢复和发布效率的核心痛点。

从我的实践结果看:同样资源下,容器化+优化后的QPS是单机部署的2.36倍;横向扩容从半小时降低到分钟级;故障恢复从十几分钟的人肉处理降低到半分钟内自动拉起

这套配置可以直接拿去用,版本号都写在上面。最大的坑集中在PHP-FPM的进程配置和K8s的健康探针、资源限制上,如果你之前没碰过K8s,照着文中配置能少走2周弯路。

下一步建议:把Laravel的队列(queue:work)也容器化,用K8s的job或者独立Deployment管理,这样整个PHP应用栈就全部云原生化。文中所有配置在GitHub仓库有完整版,你按需修改镜像地址和域名即可。


版本号汇总:Kubernetes v1.29.2,PHP 8.3.4-fpm-alpine,Laravel 11,Ingress-NGINX v1.9.6,Nginx 1.25.4,Docker 26.0.1,Harbor 2.9.0