☰
K8s部署WordPress:有状态应用容器化与高可用运维实践
2026/10/27 12:29:36 网站建设 项目流程

1. 单机 WordPress 扛不住的时候,问题究竟卡在哪里

很多人第一次把 WordPress 跑起来,都是在一台云主机上装个 LNMP 或 LAMP 环境,改改wp-config.php,绑个域名,就算上线了。这套路子在小流量站点上能活很久,我也是这么过来的。但当站点开始有真实业务,比如文章量上千、装了三四个重型插件、白天有并发访问、后台还要做定时任务,单机架构的短板就藏不住了。我第一次遇到的暴击是白天的一次流量高峰,PHP-FPM 进程池被打满,随后整台机器负载飙到 20 以上,后台进不去,连 SSH 都要排队等连接,那一次我花了四十分钟才把服务拉回来。也正是在那之后,我开始认真考虑用 Kubernetes 来承载 WordPress。

先把话说明白:K8s + WordPress 不是为了让你的博客更快,而是为了让你的站点在故障面前有退路、在扩容面前有抓手。它解决的是三个核心痛点:一是单点故障,机器挂了服务就没了;二是容量弹性,流量来了只能手忙脚乱升配置;三是交付一致性,换台机器重装一遍环境,配置总有出入。而 WordPress 本身是一个非常“传统”的 PHP 应用,它带着文件系统依赖、数据库依赖、状态写在磁盘里的特性,所以把它搬上 K8s 不是简单的kubectl apply,而是要对它的有状态部分做一次彻底的拆解和规划。

这篇内容适合三类人看:一是在单机上跑 WordPress 跑出过事故、想升级架构的运维或后端同学;二是刚开始接触 K8s、想找一个“有真实状态、又不至于太复杂”的练手项目的人;三是手上有多个 WordPress 站点、希望统一交付流程的团队。我会从架构决策讲到 YAML 落地,再讲到上线之后真正会咬人的坑。文中所有 YAML 都是可以直接改改就用,但请务必把密码、域名、容量这些占位值换成你自己的。

需要提前说清楚的一点:下面所有演示都基于自建或托管的 K8s 集群,版本建议 1.25 以上,容器运行时用 containerd,Ingress 控制器用 ingress-nginx,存储走集群已有的 StorageClass。如果你手上是 1.24 之前的集群,思路完全一样,只是部分 API 版本要换,这个后面会单独提。

2. 拆解 WordPress 的“状态”边界:架构决策的推演过程

2.1 哪些东西该进容器,哪些东西必须留在磁盘上

在 K8s 里做任何应用迁移,第一步永远是画一条线:什么是无状态的、什么是必须持久化的。WordPress 的目录结构里,代码部分(wp-admin、wp-includes、核心 PHP 文件)本质上来自镜像,属于无状态;真正会变的是三块:wp-content/uploads里的媒体文件、wp-content/plugins与themes里后期安装的扩展、以及数据库。再加上wp-config.php这个配置文件,它是配置而非数据,应该由环境变量或 ConfigMap 驱动。

这条线画清楚之后,架构自然就出来了:WordPress 用 Deployment 承载,数据库用 StatefulSet 或者独立实例承载,两块持久化存储分别用 PVC 挂载。但这里有个关键岔路口,很多人第一次做会直接踩进去:要不要把整个/var/www/html目录挂成一个 PVC?

2.2 整目录挂载 vs 只挂 uploads:两种方案的取舍

我刚做的时候选的是整目录 PVC,理由很简单——省事,插件装了、主题传了、WordPress 后台自动升级了,文件都在,重启 Pod 也不丢。这个方案在单副本场景下确实稳定,官方镜像的 entrypoint 会检测到挂载目录为空,自动把/usr/src/wordpress里的文件复制进去,所以第一次启动不会出问题。

但它有两个隐患。第一,副本数永远只能是 1,因为多个 Pod 同时读写同一份 PHP 代码,插件自动更新时会互相覆盖,甚至出现半个文件被读到的诡异错误。第二,WordPress 后台的在线升级会直接改动/var/www/html,导致容器里的文件和镜像内的版本彻底脱钩,下次换镜像升级时你根本不知道线上跑的是哪个版本。

后来我改成了第二种方案:代码走镜像,PVC 只挂wp-content/uploads这一个子目录。这样换来的是完全的无状态化,可以放心开多副本、可以随时滚动升级、可以用 HPA 扩容。代价是插件和主题的安装不能靠后台点按钮解决,必须固化进镜像或者用 initContainer 拉取。这就引出了一个现实问题:你要的是省事,还是要的是可运维性。

方案挂载范围适合副本数升级方式主要风险
整目录 PVC/var/www/html1后台点升级或替换目录多副本文件冲突、版本漂移
仅 uploads PVC/var/www/html/wp-content/uploads1 到 N替换镜像 tag 滚动发布插件需固化进镜像或共享存储
全量 RWX 共享整个目录共享N镜像或共享卷需要支持 ReadWriteMany 的存储类

我个人的结论是:小站、单副本、运维人力有限,第一种方案完全可以接受,先把服务稳下来;有多副本需求或者要接 CI/CD,第二种方案才是正路。这篇文章的主线会用第二种方案的思路来写,同时在多副本那一节专门讨论 RWX 的适用边界。

2.3 数据库选型:单实例、StatefulSet 还是托管服务

数据库这块的选择,比应用层要保守得多。K8s 里跑有状态数据库不是不行,但你要自己处理备份、主从、故障切换、慢查询这些事,成本和收益要算清楚。

我的建议分三档。如果站点是内部系统、日活几百,直接在集群里跑一个单实例的 MariaDB StatefulSet 完全够用,配好 PVC 和定期备份就行。如果是对外业务、不允许长时间中断,那就用托管数据库,把有状态的部分交给专业的人,K8s 里只跑无状态的 WordPress,这是最省心的组合。如果非要自建主从,那请务必先把备份恢复演练做过三遍再上生产,不然出问题时你连数据在哪都不确定。

数据库版本上有个非常经典的坑,我必须提前讲:MySQL 8.0 默认的认证插件是caching_sha2_password,部分老版本的 PHP mysqli 扩展连不上,报的是“Authentication plugin cannot be loaded”这类错误。如果你看到数据库日志显示连接建立又被拒绝,而账号密码明明是对的,八成就是这个问题。解决办法有两个:建用户时显式指定IDENTIFIED WITH mysql_native_password BY '...',或者干脆用 MariaDB,它的默认认证插件兼容性更好。我在生产里用 MariaDB 10.11 居多,稳。

2.4 Service 与 Ingress 的分工不能混

新手常犯的错是把 Service 当成对外入口,配个 NodePort 就以为完事了。正确的分层是:Service 负责集群内部的稳定访问地址和负载均衡,Ingress 负责七层的域名路由、TLS 终止和路径规则。WordPress 的 Deployment 暴露一个 ClusterIP 类型的 Service,Ingress 再指向这个 Service,外部流量从 Ingress 进来。

这样分层的好处是,你的应用内部永远通过wordpress-svc:80这样的 DNS 名访问,不关心 Pod IP 怎么变;而对外暴露的部分可以在 Ingress 层统一做证书、限流、访问日志。数据库的 Service 则必须是 Headless 的(clusterIP: None),这样 StatefulSet 的每个 Pod 才能有稳定的网络标识,这一点在自建主从时很关键。

3. 从零落地的 YAML:命名空间、密钥与数据库先行

3.1 Namespace 与资源配额的先行规划

不要把所有东西都塞进 default 命名空间,这是我在多租户集群里吃过亏换来的教训。给 WordPress 单独建一个命名空间,顺手加上 ResourceQuota 和 LimitRange,相当于给这个业务画了个圈,避免某个 Pod 内存失控把节点拖垮。

apiVersion: v1 kind: Namespace metadata: name: wp-prod --- apiVersion: v1 kind: ResourceQuota metadata: name: wp-quota namespace: wp-prod spec: hard: requests.cpu: "4" requests.memory: 8Gi limits.cpu: "8" limits.memory: 16Gi persistentvolumeclaims: "6"

这里我把 PVC 数量也限死了,因为 PVC 一旦创建就会占住底层存储,如果写错了名称反复创建,很容易把存储配额吃光。LimitRange 则用来给没写 resources 的 Pod 兜底,防止有人漏配导致调度器误判。

apiVersion: v1 kind: LimitRange metadata: name: wp-limit-range namespace: wp-prod spec: limits: - default: cpu: 500m memory: 512Mi defaultRequest: cpu: 100m memory: 128Mi type: Container

3.2 Secret 的正确写法与编码陷阱

数据库密码、WordPress 的盐值(salt)这些都要放 Secret。这里有个细节值得说:用data字段需要自己 base64 编码,很容易出错,尤其是密码里带特殊字符时,编码完粘贴错一位,Pod 起来就是连不上数据库,排查半天。用stringData字段可以让 K8s 帮你编码,可读性和正确率都高得多。

apiVersion: v1 kind: Secret metadata: name: wp-db-secret namespace: wp-prod type: Opaque stringData: MYSQL_ROOT_PASSWORD: "ChangeThisRootPwd" MYSQL_DATABASE: "wordpress" MYSQL_USER: "wpuser" MYSQL_PASSWORD: "ChangeThisUserPwd"

注意:Secret 只是把明文换了个存放位置,默认并没有加密,能读到 etcd 的人就能读到它。生产环境请打开 etcd 静态加密,并配合 RBAC 限制get secret权限。另外,把 Secret 提交进 Git 仓库是非常常见的坏习惯,尽量用外部密钥管理或者部署时注入。

WordPress 那侧还需要一组认证密钥,就是AUTH_KEY、SECURE_AUTH_KEY这些,一共八项。不要去手工编,用官方提供的随机生成接口取一次,或者用openssl rand -base64 48生成,一次性写进 Secret。这八项如果为空或者太简单,会导致登录态被伪造,属于安全隐患。

3.3 数据库的 StatefulSet 与 PVC 绑定验证

数据库我用 StatefulSet,虽然只有一个副本,但 StatefulSet 提供了稳定的网络标识和有序的卷挂载,比 Deployment 更契合有状态服务的语义。

apiVersion: v1 kind: Service metadata: name: wp-db namespace: wp-prod spec: clusterIP: None selector: app: wp-db ports: - port: 3306 name: mysql --- apiVersion: apps/v1 kind: StatefulSet metadata: name: wp-db namespace: wp-prod spec: serviceName: wp-db replicas: 1 selector: matchLabels: app: wp-db template: metadata: labels: app: wp-db spec: containers: - name: mariadb image: mariadb:10.11 args: - "--character-set-server=utf8mb4" - "--collation-server=utf8mb4_unicode_ci" envFrom: - secretRef: name: wp-db-secret ports: - containerPort: 3306 name: mysql volumeMounts: - name: data mountPath: /var/lib/mysql resources: requests: cpu: 500m memory: 1Gi limits: cpu: "2" memory: 2Gi readinessProbe: exec: command: - sh - -c - "mariadb-admin ping -h 127.0.0.1 -u root -p$MYSQL_ROOT_PASSWORD" initialDelaySeconds: 30 periodSeconds: 10 volumeClaimTemplates: - metadata: name: data spec: accessModes: ["ReadWriteOnce"] storageClassName: standard resources: requests: storage: 20Gi

这段 YAML 里,--character-set-server=utf8mb4这两个参数千万别省。我踩过一次坑:建库时没指定字符集,默认用了 latin1,结果后台发中文文章标题全部变成问号,而且已经入库的数据改起来极其痛苦。建库前就把字符集定死,比事后救火便宜一百倍。

部署完先别急着上 WordPress,先做一次验证:kubectl -n wp-prod get pvc看状态是不是Bound,kubectl -n wp-prod exec -it wp-db-0 -- mariadb -u wpuser -p -e "show databases;"确认业务库已经建好。我见过太多次 Pod 显示 Running 但数据库其实还在初始化、WordPress 上去就报连接失败的情况,多花两分钟验证能省半小时排查。

3.4 存储类的前置确认:不要假设有默认值

storageClassName这一项,很多人直接照抄别人的 YAML,结果 PVC 一直 Pending。先执行kubectl get sc看集群里到底有哪些存储类,哪个带了(default)标记。如果没有默认存储类,你又不写storageClassName,PVC 就会一直处于 Pending,事件里显示no persistent volumes available for this claim and no storage class is set。

另外要确认访问模式。绝大多数块存储只支持ReadWriteOnce,如果你的方案需要多副本共享uploads目录,就必须找支持ReadWriteMany的存储类,常见的是基于 NFS、CephFS 或者对象存储挂载的方案。这一条在选型阶段就要确认,别等上线前一天才发现存储类不支持 RWX。

4. WordPress 工作负载编排:镜像、探针与权限的细节

4.1 镜像 tag 的选择与 wp-config 的注入方式

WordPress 官方镜像有多种变体,-apache和-fpm是主流两种。用-apache的镜像省事,自带 web 服务器,直接监听 80;用-fpm的镜像则需要额外配一个 nginx 容器做 sidecar,性能更好但配置量大。我一般先用-apache版本跑通,等真的有性能压力再换 FPM 架构,这是典型的“先能跑,再跑好”。

tag 上绝对不要用latest。WordPress 大版本升级会改数据库结构,如果你某天重启 Pod 拉到了新版本镜像,数据库可能直接被升级,回滚就麻烦了。我的习惯是锁到小版本,比如wordpress:6.4-php8.2-apache,升级时明确改 tag、先备份、再在测试环境跑一遍。

配置文件这块,官方镜像支持通过环境变量生成wp-config.php,包括WORDPRESS_DB_HOST、WORDPRESS_DB_NAME、WORDPRESS_DB_USER、WORDPRESS_DB_PASSWORD。这比自己挂一个 ConfigMap 覆盖wp-config.php要稳,因为挂载的文件是只读的,WordPress 某些场景会尝试写配置,容易出权限问题。

apiVersion: apps/v1 kind: Deployment metadata: name: wordpress namespace: wp-prod spec: replicas: 2 strategy: type: RollingUpdate rollingUpdate: maxSurge: 1 maxUnavailable: 0 selector: matchLabels: app: wordpress template: metadata: labels: app: wordpress spec: containers: - name: wordpress image: wordpress:6.4-php8.2-apache env: - name: WORDPRESS_DB_HOST value: "wp-db.wp-prod.svc.cluster.local:3306" - name: WORDPRESS_DB_NAME valueFrom: secretKeyRef: name: wp-db-secret key: MYSQL_DATABASE - name: WORDPRESS_DB_USER valueFrom: secretKeyRef: name: wp-db-secret key: MYSQL_USER - name: WORDPRESS_DB_PASSWORD valueFrom: secretKeyRef: name: wp-db-secret key: MYSQL_PASSWORD - name: WORDPRESS_CONFIG_EXTRA value: | define('WP_HOME', 'https://blog.example.com'); define('WP_SITEURL', 'https://blog.example.com'); define('FS_METHOD', 'direct'); define('DISABLE_WP_CRON', true); ports: - containerPort: 80 name: http volumeMounts: - name: uploads mountPath: /var/www/html/wp-content/uploads resources: requests: cpu: 300m memory: 512Mi limits: cpu: "1" memory: 1Gi volumes: - name: uploads persistentVolumeClaim: claimName: wp-uploads-pvc

这里WORDPRESS_CONFIG_EXTRA是个非常好用的环境变量,它的内容会被追加进生成的wp-config.php。我用它做了三件事:固定站点地址避免反代后跳转错乱、强制FS_METHOD为direct解决插件安装要 FTP 密码的问题、关掉内置 wp-cron 改用 CronJob(后面会讲原因)。

PVC 定义就一行,但访问模式要提前想好:

apiVersion: v1 kind: PersistentVolumeClaim metadata: name: wp-uploads-pvc namespace: wp-prod spec: accessModes: ["ReadWriteMany"] storageClassName: nfs-client resources: requests: storage: 50Gi

4.2 探针配置:为什么 initialDelaySeconds 必须拉长

这是我最想强调的一节。WordPress 首次启动时,如果挂了空的 PVC,entrypoint 会把镜像里几万个文件复制到挂载目录,这个过程在低配节点上可能要几十秒。如果你把 livenessProbe 的initialDelaySeconds设成 5 秒、failureThreshold设成 3,那结果就是:Pod 刚启动还没复制完文件,探针连续失败三次,kubelet 直接把容器杀了重启,然后又从头复制一遍,如此循环,永远起不来。

我的经验值是这样的:

探针类型路径/方式关键参数说明
readinessProbeHTTP GET/wp-login.phpinitialDelay 40s, period 10s, failureThreshold 3首页可能返回安装页,登录页更能代表应用就绪
livenessProbeTCP Socket80 端口initialDelay 120s, period 20s, failureThreshold 5TCP 探测最轻,避免频繁执行 PHP 消耗资源
startupProbeTCP Socket80 端口failureThreshold 30, period 10s首次启动给足 5 分钟,起来后交给 liveness

用 startupProbe 是应对慢启动的最佳实践,它会在成功之前抑制 liveness 的执行,等于告诉 kubelet“先别急,给它时间”。这一条加上去之后,我再也没遇到 Pod 反复重启的问题。

另外,readinessProbe 探测/wp-login.php有个副作用:每次探测都会触发一次 PHP 执行和数据库查询。如果探针周期设得太短(比如 2 秒),在高副本场景下会白白消耗不少数据库连接数。设成 10 秒是个比较舒服的值。

4.3 资源限制的估算方法与踩过的 OOM 坑

资源配少了,Pod 会被 OOMKilled;配多了,调度器找不到节点,Pod 一直 Pending。我给的经验值是这样的:单副本 WordPress 容器requests.cpu 300m、requests.memory 512Mi,limits.memory给到 1Gi。这个值是拿一个装了 WooCommerce 加几个插件的站点实测出来的,PHP 进程单个大约占 40 到 80MB,Apache 的 prefork 模型并发 10 个左右就是 600MB 上下,留点余量就到 1Gi 了。

数据库那边更吃内存,InnoDB 的 buffer pool 默认会按可用内存的百分比分配,容器里如果 limit 给得小,很容易因为内存不足被杀。我一般给 MariaDBlimits.memory 2Gi,同时在配置里显式指定innodb_buffer_pool_size = 512M,避免它自己乱猜。

有个细节值得提醒:Java 之外的语言在容器里也要注意 JVM 之外的 cgroup 感知问题。PHP 本身会读 cgroup 限制,但 Apache 的MaxRequestWorkers不会自动调整,默认值可能是 256,一旦并发上来,进程数暴增直接把内存打满。如果你的镜像是 apache 版本,建议在自定义镜像里把MaxRequestWorkers调到一个合理值,比如 30 到 50。

4.4 多副本下的文件一致性问题与解法

前面提到,多副本的前提是uploads目录支持 RWX。但即便存储支持了,还有两个坑。

第一个是插件和主题的安装冲突。两个 Pod 同时响应后台的插件安装请求,一个在解压、一个在读文件,可能读到半截文件。好在 WordPress 后台操作一般只有管理员一个人做,冲突概率不高,但为了保险,我通常把后台访问限制在固定 IP,或者干脆把插件安装这一步挪到镜像构建阶段。

第二个是对象缓存的一致性。如果你用了 Redis 做对象缓存,多个副本连同一个 Redis 实例没问题;但如果每个 Pod 起了本地缓存,就会出现“A 副本改了设置,B 副本还读旧值”的现象。解决办法是统一用外部 Redis,别用本地文件缓存。

还有个更隐蔽的问题:WordPress 的wp-content/uploads目录下会按年月建子目录,如果两个副本同时上传,目录创建的竞争偶尔会失败。这个概率很低,但真遇到了会报“无法创建目录”。缓解办法是给uploads目录设好www-data的权限,并确保存储层的目录创建是原子的。

5. 对外暴露流量:Ingress、上传限制与真实 IP

5.1 Ingress 规则与 Service 的对接

Service 先起来,ClusterIP 类型就够了,Ingress 通过名字引用它。

apiVersion: v1 kind: Service metadata: name: wordpress-svc namespace: wp-prod spec: selector: app: wordpress ports: - port: 80 targetPort: 80 name: http --- apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: wordpress-ingress namespace: wp-prod annotations: nginx.ingress.kubernetes.io/proxy-body-size: "128m" nginx.ingress.kubernetes.io/proxy-read-timeout: "300" nginx.ingress.kubernetes.io/proxy-send-timeout: "300" nginx.ingress.kubernetes.io/ssl-redirect: "true" spec: ingressClassName: nginx tls: - hosts: - blog.example.com secretName: blog-tls rules: - host: blog.example.com http: paths: - path: / pathType: Prefix backend: service: name: wordpress-svc port: number: 80

ingressClassName这一项在新版本里是必须的,如果你的集群里有多个 Ingress 控制器,不写就会落到默认的那个上,行为可能和你预期不一致。

5.2 上传体积与超时:两个最常见的 413 和 504

ingress-nginx 的proxy-body-size默认是 1m。这意味着你在 WordPress 后台传一张手机原图,或者装一个稍大的主题包,直接返回 413 Request Entity Too Large。奇怪的是,Nginx 的报错页面是纯 HTML,WordPress 后台会显示一个很含糊的失败提示,新手根本想不到是 Ingress 层拦的。

我的做法是把这个值调到 128m,同时在 PHP 侧把upload_max_filesize和post_max_size也调到对应值,两边不一致的话,请求能过 Ingress 但会被 PHP 拒掉。PHP 的配置可以通过自定义镜像改php.ini,或者用.htaccess(apache 版本)加php_value。

504 则是另一个高频问题。WordPress 有些操作很耗时,比如导入大份内容、生成图片缩略图、跑备份插件,这些请求可能超过 60 秒的默认超时。Ingress 层把proxy-read-timeout调到 300 秒能缓解,但更根本的办法是让这类操作走异步,别塞在一个 HTTP 请求里等着。

5.3 反代之后的两类识别问题

WordPress 在反向代理后面,最容易出两个问题。第一是协议判断错误。Ingress 终止了 TLS,转发给 Pod 的是 HTTP,WordPress 看到的是http,于是它生成的站内链接全是 http,浏览器跳转后又被 Ingress 强制跳 https,形成重定向循环。解法就是前面提到的固定WP_HOME和WP_SITEURL为 https 地址,让 WordPress 不再依赖$_SERVER的协议判断。

第二是客户端真实 IP 丢失。所有访问者的 IP 都变成了 Ingress 控制器的 Pod IP,评论里的 IP 记录、防刷插件的限流全部失效。ingress-nginx 默认会设置X-Forwarded-For,但 WordPress 默认只读REMOTE_ADDR。有两种解法:在控制器配置里设置use-forwarded-headers: "true",或者在 WordPress 侧装一个可信代理插件。我倾向于前者,一处配置全站生效,不用每个站点都装插件。

注意:开启use-forwarded-headers之前要确认流量一定经过你自己的 Ingress,如果前面还有别的负载均衡层,需要配好proxy-real-ip-cidr,否则伪造 XFF 头就能绕过 IP 限制。

5.4 证书管理的一句话提醒

TLS 证书用 Secret 存放是最基础的做法,但手工续期迟早会忘。生产环境我更建议在集群里装 cert-manager,用注解自动签发和续期。这个组件本身不复杂,配好 Issuer 之后,Ingress 加一行注解就完事,比手工更新 Secret 可靠得多。

6. 上线后才会遇到的六个坑与完整排查链路

6.1 数据库连接被拒:从 DNS 到认证插件的四层排查

现象是 Pod 起来了,访问页面报 “Error establishing a database connection”。这个报错非常笼统,可能的原因有十几种,我的固定排查顺序是这样的。

第一步,看 Pod 日志里具体的错误信息。kubectl -n wp-prod logs deploy/wordpress通常会打出 PHP 的详细报错,包括 “Connection refused” 还是 “Access denied” 还是 “Unknown server host”。

第二步,验证网络连通性。在 WordPress 的 Pod 里执行getent hosts wp-db.wp-prod.svc.cluster.local,看 DNS 能不能解析。解析不出来,说明 Service 名字或命名空间写错了。

第三步,验证端口。nc -zv wp-db.wp-prod.svc.cluster.local 3306。连不上就说明数据库没起来或者 Service 的 selector 没匹配上,用kubectl get endpoints wp-db看一眼后端有没有 Pod IP。

第四步,如果网络通了但报 “Access denied”,就是账号密码或认证插件问题。这时候进数据库 Pod 用 root 登录,执行SELECT user, host, plugin FROM mysql.user;,看wpuser的 host 是不是%。如果只允许localhost,从别的 Pod 连过来必然被拒。顺便看一眼 plugin 字段,如果是caching_sha2_password而 PHP 版本较老,就按前面说的改成mysql_native_password。

这个排查链路我走下来,95% 的情况在前三步就能定位,剩下的基本都是认证或权限问题。

6.2 PVC 一直 Pending 的三种典型原因

PVC 的状态卡在 Pending,是最让人摸不着头脑的问题之一,因为 Pod 会一直停在 ContainerCreating,事件信息也很短。

第一种,storageClassName写了一个集群里不存在的名字,或者拼写错误。kubectl get sc对比一下就知道。

第二种,访问模式不匹配。你申请ReadWriteMany,但存储类只支持ReadWriteOnce,动态供给会直接失败。事件里会说no persistent volumes available for this claim,容易误以为是容量不够。

第三种,容量超过存储类允许的最大值,或者后端存储真的没空间了。这个要看 provisioner 的日志,如果是基于 CSI 的方案,kubectl -n kube-system logs <csi-provisioner-pod>里会有明确原因。

还有一种很隐蔽的情况:PVC 之前被删过,但pv的保护机制或者claims的 finalizer 卡住了。用kubectl get pvc -o wide和kubectl describe pvc能看到 Events 里的具体信息,别凭感觉猜。

6.3 502 与 504:分清是谁返回的

502 Bad Gateway 和 504 Gateway Timeout 是 Ingress 层返回的,意思是它没能从后端拿到有效响应。区别在于:502 是后端明确拒绝或连接断开,504 是后端在规定时间内没返回。

502 常见于后端 Pod 还没 Ready 就被路由了,或者容器内的 web 服务器进程崩溃了。检查kubectl get endpoints里对应 Service 的后端列表,如果为空说明 readiness 探针没过,请求自然没地方去。

504 多半是 PHP 执行太慢,或者数据库查询卡住。这时候别急着调大超时,先去数据库看慢查询日志,十有八九是某个插件的问题。我遇到过一次 WooCommerce 的订单列表页,因为没建索引,一张 5 万行的表全表扫描,页面响应 90 秒,加个索引直接降到 200ms。

提示:把 Ingress 的访问日志打开,能看到 upstream_response_time 这个字段,超过 1 秒的请求都值得看一眼。排查性能问题,日志比任何监控都直接。

6.4 后台装插件要求 FTP 凭据

这个现象是 WordPress 在后台点“安装插件”时,页面跳出“输入 FTP 用户名密码”。原因是 PHP 进程对wp-content/plugins目录没有写权限,WordPress 检测到无法直接写文件系统,就退回 FTP 模式。

解法有三层。第一层,确认目录权限,容器内www-data用户必须能写,可以在 initContainer 里执行chown -R www-data:www-data /var/www/html。第二层,在wp-config.php里定义FS_METHOD为direct,强制 WordPress 用文件系统直接写。第三层,如果挂的是 NFS,还要检查 NFS 的root_squash配置,否则 root 身份会被映射成匿名用户,写权限一样不对。

我更推荐的做法是:生产环境别在后台装插件,把它固化进镜像。用 Dockerfile 拉基础镜像,把插件目录复制进去,这样版本可控、可回滚,也不会因为某个 Pod 的权限问题导致行为不一致。

6.5 wp-cron 不触发与多副本下的重复执行

WordPress 的定时任务默认靠访客访问页面触发,逻辑写在wp-cron.php里。这个设计在小站上没什么问题,但在两个场景下会出事。

一是流量低的时候。站点没什么人访问,定时任务就一直不跑,备份、自动发布、插件清理全部停摆。二是多副本的时候。每个 Pod 都可能被触发,同一个任务被执行 N 遍,如果是备份任务,数据库瞬间被打爆。

标准解法是关掉内置 cron,改用 K8s 的 CronJob 定时请求:

apiVersion: batch/v1 kind: CronJob metadata: name: wp-cron namespace: wp-prod spec: schedule: "*/5 * * * *" concurrencyPolicy: Forbid jobTemplate: spec: template: spec: restartPolicy: OnFailure containers: - name: trigger image: curlimages/curl:8.5.0 command: - sh - -c - "curl -s -o /dev/null -w '%{http_code}' http://wordpress-svc.wp-prod.svc.cluster.local/wp-cron.php?doing_wp_cron"

配合容器里的DISABLE_WP_CRON为 true,这样只有一个 CronJob 在跑,不会重复。concurrencyPolicy: Forbid保证上一次没跑完不会并发起新的,避免任务堆积。

6.6 多副本下登录态与 nonce 的注意事项

有个说法流传很广,说 WordPress 多副本会掉登录态。我实测下来,只要数据库是同一个,登录态本身是没问题的,因为 WordPress 的登录凭证存在 cookie 里,服务端校验用的是数据库里的用户信息和 session token。

真正会出问题的是 nonce 和 transient。nonce 依赖用户会话和wp_nonce_salt,如果多个副本的AUTH_KEY这些盐值不一致,nonce 校验就会失败,表现是后台操作随机报“链接已过期”。所以八项认证密钥必须从同一个 Secret 注入,绝对不能每个 Pod 随机生成。这一点很多人第一次做会忽略,因为官方镜像在没有提供这些变量时确实会自动生成,生成在本地文件里,多副本之间就不一致了。

transient 走的是数据库,多副本共享没问题;但如果你开了对象缓存插件又只缓存本地,就会出现某些数据读不到的情况。统一用外置 Redis,所有副本连同一个实例,问题就消失了。

7. 扩容、备份与升级:把运维动作固化下来

7.1 HPA 的指标选择与实际效果

WordPress 的 HPA 指标选择要谨慎。默认按 CPU 扩缩,但 PHP 应用的 CPU 峰值往往集中在图片处理这类短时任务上,容易造成抖动式扩缩。我一般这样配:minReplicas设 2(保证高可用),maxReplicas按资源池余量定,CPU 利用率目标设 65% 到 70%,同时加上behavior控制扩缩速度,避免频繁波动。

apiVersion: autoscaling/v2 kind: HorizontalPodAutoscaler metadata: name: wordpress-hpa namespace: wp-prod spec: scaleTargetRef: apiVersion: apps/v1 kind: Deployment name: wordpress minReplicas: 2 maxReplicas: 6 metrics: - type: Resource resource: name: cpu target: type: Utilization averageUtilization: 70 behavior: scaleUp: stabilizationWindowSeconds: 60 policies: - type: Pods value: 1 periodSeconds: 60 scaleDown: stabilizationWindowSeconds: 300

scaleDown的稳定窗口设 300 秒,是为了防止流量短暂回落后立刻缩容,然后又被打上来,Pod 反复启停对 WordPress 这种启动慢的应用很不友好。

7.2 备份的两条线:数据库与 uploads 必须分开

备份是 Wordpress 在 K8s 里最容易被忽略的一环。很多人觉得挂了 PVC 就等于有备份,这是极大的误解——PVC 保护的是数据不随 Pod 消失,但误删、写坏、存储故障它一样保护不了。

我的做法是两条独立的线。数据库用 CronJob 定时mariadb-dump,导出成 SQL 文件传到对象存储,保留最近 30 天。uploads目录用存储侧的快照能力,或者用 restic 这类工具做增量备份。两条线分开的理由是恢复场景不同:误删一篇文章只需要恢复数据库,媒体文件丢了才需要恢复 uploads,全量恢复成本高得多。

apiVersion: batch/v1 kind: CronJob metadata: name: wp-db-backup namespace: wp-prod spec: schedule: "30 2 * * *" jobTemplate: spec: template: spec: restartPolicy: OnFailure containers: - name: dump image: mariadb:10.11 command: - sh - -c - | mariadb-dump -h wp-db.wp-prod.svc.cluster.local -u wpuser -p"$MYSQL_PASSWORD" \ --single-transaction --quick --routines wordpress \ | gzip > /backup/wp-$(date +%Y%m%d).sql.gz envFrom: - secretRef: name: wp-db-secret volumeMounts: - name: backup mountPath: /backup volumes: - name: backup persistentVolumeClaim: claimName: wp-backup-pvc

--single-transaction这个参数很关键,InnoDB 表在导出时不会锁表,线上业务不受影响。不加这个参数,导出过程中站点会假死。

备份做完了必须验证。我的习惯是每月挑一次备份,在一个临时命名空间里恢复一遍,跑起来看看首页和后台能不能正常打开。没验证过的备份,等于没有备份。

7.3 滚动升级的策略与回滚的准备

WordPress 升级分两个层面:应用层镜像升级和数据库层面的升级。应用层用 Deployment 的滚动更新就行,maxUnavailable: 0保证升级期间始终有可用副本。数据库那一层要谨慎得多,WordPress 大版本升级会自动改表结构,且不可逆。

我的升级流程是这样的:先把数据库做一次完整备份,在测试环境用同样的数据跑一遍升级流程,确认没有插件兼容问题;生产环境升级前,把 Deployment 的副本数临时锁定,避免升级过程中用户操作导致数据结构不一致;升级完成后观察 24 小时再解锁。回滚方案就是保留旧版本的镜像 tag,出问题时把 tag 改回去重新部署,同时用备份恢复数据库。

注意:WordPress 的自动更新功能在 K8s 环境里建议关掉。wp-config.php里加define('AUTOMATIC_UPDATER_DISABLED', true);,让升级动作只发生在你控制的那一次发布里,别让容器在你不知情的时候自己变了版本。

7.4 日志与监控:把排障成本降下来

K8s 里查日志,kubectl logs只能看当前容器的,Pod 一旦重启就没了。我建议在集群里部署一套日志采集,把容器日志统一收集起来,同时把 Apache 的 access log 和 error log 分开输出到 stdout,这样在日志系统里可以按流筛选。

监控方面,最少要盯住四个指标:Pod 的 CPU 和内存使用率、Ingress 的请求量和 5xx 比例、数据库的连接数和慢查询数、PVC 的使用率。前两个用 metrics-server 就够,后面两个需要数据库自身的 exporter。PVC 使用率特别容易忽略,等到磁盘满了,数据库直接起来失败,那时候就晚了。

我在实际踩过几次坑之后,现在启动一个新站点标准动作里有三件事必做:设置启动探针的宽限期、打开数据库的慢查询日志阈值、给 PVC 设一条 80% 使用率的告警。这三件事加起来花不了十分钟,但能在后面省下好几个通宵。最后再分享一个小技巧,做 WordPress 的 K8s 排障时,先别急着改 YAML,先kubectl describe pod看 Events,再kubectl logs --previous看上一个容器的日志,这两条命令能解决掉八成的问题,剩下的两成再去看数据库和存储。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询