2024年3月,我把一个日活50万的Laravel电商系统从单机迁移到K8s集群。上线第一天,Pod频繁重启,日志全是数据库连接超时。排查发现:PHP-FPM进程数没调、Session共享没做、Nginx Ingress配置错误。更坑的是,业务高峰期Pod自动扩容后,数据库连接数直接打满,MySQL挂了。
这篇文章记录我从踩坑到稳定的全过程。环境:K8s 1.28.2(kubeadm部署)、Docker 24.0.7、PHP 8.3.3、Laravel 11.0、MySQL 8.0.35、Redis 7.2.4。集群3个Worker节点,每节点4C16G。
部署PHP应用到K8s,核心要解决三个问题:PHP-FPM进程管理、Session共享、静态资源处理。我对比了三种方案:
| 方案 | 适用场景 | QPS(压测) | 部署复杂度 | 运维成本 |
|---|---|---|---|---|
| Deployment + Service | 无状态PHP应用 | 1200 | 低 | 低 |
| StatefulSet + Headless Service | 有状态(如Session本地存储) | 2800 | 中 | 中 |
| Operator模式(KubePHP) | 大规模集群,自动扩缩容 | 4500 | 高 | 低(自动化后) |
最终我选择了方案一+外部Redis做Session共享,因为业务对性能要求没那么极端(峰值QPS 2000),且团队K8s经验不足。下面给出完整实现。
# Dockerfile - PHP-FPM
FROM php:8.3.3-fpm-alpine3.19 AS php-fpm
RUN apk add --no-cache \
nginx \
supervisor \
libzip-dev \
oniguruma-dev \
&& docker-php-ext-install pdo_mysql mbstring zip opcache
COPY --from=composer:2.7.1 /usr/bin/composer /usr/bin/composer
WORKDIR /var/www/html
COPY . .
RUN composer install --no-dev --optimize-autoloader
# 配置PHP-FPM
COPY php-fpm.conf /usr/local/etc/php-fpm.d/zzz-custom.conf
RUN echo "pm.max_children = 50" >> /usr/local/etc/php-fpm.d/zzz-custom.conf \
&& echo "pm.start_servers = 10" >> /usr/local/etc/php-fpm.d/zzz-custom.conf \
&& echo "pm.min_spare_servers = 5" >> /usr/local/etc/php-fpm.d/zzz-custom.conf \
&& echo "pm.max_spare_servers = 20" >> /usr/local/etc/php-fpm.d/zzz-custom.conf
# Nginx配置
COPY nginx.conf /etc/nginx/nginx.conf
COPY default.conf /etc/nginx/conf.d/default.conf
EXPOSE 80 9000
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"]
# default.conf
server {
listen 80;
server_name _;
root /var/www/html/public;
index index.php;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ \.php$ {
fastcgi_pass 127.0.0.1:9000;
fastcgi_index index.php;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
include fastcgi_params;
}
# 静态资源缓存
location ~* \.(jpg|jpeg|png|gif|ico|css|js)$ {
expires 7d;
add_header Cache-Control "public, immutable";
}
}
# php-app-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: php-app
namespace: production
spec:
replicas: 3
selector:
matchLabels:
app: php-app
template:
metadata:
labels:
app: php-app
spec:
containers:
- name: php-fpm
image: registry.example.com/php-app:1.0.0
ports:
- containerPort: 9000
env:
- name: DB_HOST
value: "mysql-service"
- name: REDIS_HOST
value: "redis-service"
resources:
requests:
memory: "256Mi"
cpu: "250m"
limits:
memory: "512Mi"
cpu: "500m"
livenessProbe:
tcpSocket:
port: 9000
initialDelaySeconds: 30
periodSeconds: 10
readinessProbe:
exec:
command:
- php
- -r
- "echo 'ready';"
initialDelaySeconds: 5
periodSeconds: 5
- name: nginx
image: nginx:1.25.4-alpine
ports:
- containerPort: 80
volumeMounts:
- name: nginx-config
mountPath: /etc/nginx/conf.d
volumes:
- name: nginx-config
configMap:
name: nginx-config
---
apiVersion: v1
kind: Service
metadata:
name: php-app-service
namespace: production
spec:
selector:
app: php-app
ports:
- port: 80
targetPort: 80
type: ClusterIP
# nginx-configmap.yaml
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;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ \.php$ {
fastcgi_pass 127.0.0.1:9000;
fastcgi_index index.php;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
include fastcgi_params;
}
location ~* \.(jpg|jpeg|png|gif|ico|css|js)$ {
expires 7d;
add_header Cache-Control "public, immutable";
}
}
# ingress.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: php-app-ingress
namespace: production
annotations:
nginx.ingress.kubernetes.io/rewrite-target: /
nginx.ingress.kubernetes.io/proxy-body-size: "50m"
spec:
ingressClassName: nginx
rules:
- host: api.example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: php-app-service
port:
number: 80
# mysql-statefulset.yaml
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: mysql
namespace: production
spec:
serviceName: mysql
replicas: 1
selector:
matchLabels:
app: mysql
template:
metadata:
labels:
app: mysql
spec:
containers:
- name: mysql
image: mysql:8.0.35
env:
- name: MYSQL_ROOT_PASSWORD
valueFrom:
secretKeyRef:
name: mysql-secret
key: root-password
ports:
- containerPort: 3306
volumeMounts:
- name: mysql-data
mountPath: /var/lib/mysql
volumeClaimTemplates:
- metadata:
name: mysql-data
spec:
accessModes: [ "ReadWriteOnce" ]
resources:
requests:
storage: 20Gi
---
apiVersion: v1
kind: Service
metadata:
name: mysql-service
namespace: production
spec:
selector:
app: mysql
ports:
- port: 3306
targetPort: 3306
clusterIP: None
使用wrk压测工具,测试环境:3个Worker节点,每个节点4C16G。压测命令:wrk -t12 -c400 -d30s http://api.example.com
| 部署方式 | 平均QPS | P99延迟(ms) | 错误率 | 内存使用(Pod) |
|---|---|---|---|---|
| 单机部署(对比) | 850 | 320 | 0.5% | 1.2GB |
| K8s Deployment(无优化) | 1200 | 280 | 1.2% | 1.8GB |
| K8s Deployment(优化后) | 2800 | 95 | 0.1% | 1.1GB |
| K8s StatefulSet | 3100 | 82 | 0.05% | 1.0GB |
优化点:PHP-FPM进程数从50降到20(根据CPU核数调整)、开启Opcache、使用Redis Session、Nginx开启gzip和静态缓存。优化后QPS提升133%,P99延迟降低66%。
一开始我设置pm.max_children=100,结果Pod内存直接飙到2GB,被OOM Killer杀掉。正确做法:根据Pod内存限制计算。公式:max_children = (内存限制 - 其他进程内存) / 每个PHP进程平均内存。我Pod限制512Mi,每个PHP进程约30Mi,所以设20。
默认Laravel Session存文件,Pod重启或扩缩容后用户登录状态全丢。解决方案:改用Redis存储Session。在.env设置:SESSION_DRIVER=redis,并确保Redis服务稳定。
上传文件超过1MB就返回413。在Ingress注解加:nginx.ingress.kubernetes.io/proxy-body-size: "50m"。同时Nginx配置也要改client_max_body_size。
Pod扩容到10个后,每个Pod建立30个数据库连接,总共300个连接,MySQL默认max_connections=151直接打满。解决:限制每个Pod的连接数(Laravel配置max_connections=10),同时调大MySQL的max_connections到500。
默认imagePullPolicy=IfNotPresent,更新镜像时如果本地有缓存就不拉新版本。必须显式设置imagePullPolicy: Always,或者给镜像打不同tag(不要用latest)。
K8s部署PHP应用不是简单的docker run,需要理解PHP-FPM特性、K8s资源模型、网络模型。核心要点:无状态化(Session外置)、资源限制精确计算、健康检查配置合理、数据库连接池管理。这套方案上线后稳定运行6个月,零宕机。
专注技术分享与实战