ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

PHP健康检查方案:从静态探针到K8s Liveness/Readiness实战

PHP健康检查方案:从静态探针到K8s Liveness/Readiness实战 我一直觉得健康检查是PHP项目里最容易被低估的一个环节。很多团队把“首页能打开”当成服务健康的唯一标准结果负载均衡把一个数据库连接池已经打满的实例继续拿出去接流量用户登录全部失败告警平台却一片安静。今天把我自己沉淀下来的一套PHP健康检查方案完整写出来——从最朴素的静态探针到K8s里的Liveness/Readiness区分再到我实际踩过的那些“探针引发的次生故障”一篇讲透。这套方案适合谁看如果你的PHP项目跑在Nginx PHP-FPM上已经接了负载均衡或者正准备容器化部署又或者你只是想把线上服务的可用性真正“看到”而不是靠用户骂了才知道那这篇文章值得你从头看到尾。我会给出可以直接抄走的代码和配置也会解释每一步为什么这么设计。1. 为什么PHP项目需要自己的“健康检查”入口先聊一个反直觉的事Nginx自带的负载均衡检测其实是非常粗糙的。它所谓的健康检查基本停留在TCP层或者HTTP状态码层——连不上、超时、收到502/504Nginx才认为后端挂了。但PHP项目里大量故障发生在更深的业务层光看端口通不通根本发现不了。1.1 负载均衡器探测与PHP进程模型的冲突Nginx作为反向代理时upstream块里最常用的参数是max_fails和fail_timeout。默认配置下Nginx会在fail_timeout默认10秒内累计max_fails默认1次次失败后把后端标记为不可用。这个机制对付“PHP-FPM进程全挂了”“Nginx返回502”这类崩溃型故障没问题但它有一个明显短板它判断的是“这一个请求失败没”而不是“应用还健康没”。而PHP-FPM的进程模型是短生命周期每个请求进来PHP-FPM分配一个worker处理处理完worker继续等下一个请求。只要worker进程还活着php-fpm.sock还接受连接Nginx就认为后端健康。哪怕底层MySQL已经连不上、Redis超时、磁盘读不出来只要请求在PHP超时之前返回了一个500甚至中间件把500吞了返回200负载均衡器都一无所知。我见过一个真实的案例某项目MySQL主库挂掉代码里的连接池耗尽导致所有请求阻塞在数据库连接上但首页因为有静态页面缓存Nginx探活依然200负载均衡器继续把流量往这个残废节点上打。直到用户大面积投诉登录和下单失败才有人手动去看后端日志。这就是“端口活着”和“业务健康”之间的巨大鸿沟。所以PHP项目必须有一个应用层面的健康检查入口——一个能够真实反映依赖状态、进程状态、存储状态的接口。这个接口要足够轻量不能在探活的时候把应用本身压垮又要足够全面能抓出那些“表面200、实际已残”的故障。1.2 “首页能打开”检查会漏掉哪些故障很多人图省事把首页当成健康检查页。我强烈不建议这么做原因很直接首页为了性能通常会做各种缓存优化——Nginx缓存、Redis缓存、OPcache、本地文件缓存。缓存存在的时候就算数据库挂了、Redis连不上首页照样能快速返回200。但用户真正在用的功能比如登录、下单、评论每一个都强依赖这些组件。我把常见被漏掉的故障整理过大概有这几类故障类型首页检查结果实际影响数据库连接池耗尽200命中缓存登录、订单等写操作全部失败Redis不可用200进程还在会话失效、缓存穿透、队列卡住Session目录不可写200页面无状态用户登录状态丢失反复要求登录磁盘满200读缓存正常日志写不进去、临时文件创建失败定时任务队列积压200页面正常消息处理延迟业务闭环断裂健康检查要解决的就是把这些故障变成可观测的指标在负载均衡和告警层面提前拦截。2. 设计一个生产级PHP健康检查端点从裸奔文件到依赖探测既然健康检查必须走PHP应用层那它应该怎么组织代码我的建议是不要把它挂在框架主路由里而是做一个独立的入口文件。原因有三第一框架路由会经过大量的中间件Session、CSRF、权限校验都会被执行探针请求本身就成了一个重请求第二框架升级或路由配置改动可能误伤探针路径第三独立文件可以绕过框架的添油加醋输出内容完全由自己控制。2.1 最小实现独立入口文件与JSON输出一个能用的最小健康检查可以简化成这样?php // healthz.php header(Content-Type: application/json); $status ok; $code 200; $checks []; // 检查PHP本身 $checks[php] [ status ok, version PHP_VERSION, ]; echo json_encode([ status $status, checks $checks, timestamp time(), ]); http_response_code($code);这个版本只能告诉你“PHP进程活着”但对生产环境来说远远不够。我们至少要让它能检查依赖组件并且对不同的故障给出不一样的判定。2.2 依赖探测的粒度与判定策略依赖探测最核心的设计原则是分清楚哪些是critical关键依赖哪些是warning非关键依赖。关键依赖挂了服务必须摘流量探针返回503非关键依赖挂了比如某个统计数据接口用的第三方API不稳定服务还能兜底运行探针可以返回200但把warning信息带出来。我在实际项目里一般把检查项分成三层核心层MySQL、Redis或Memcached、Session存储。挂了就返回503。存储层临时目录可写性、日志目录可写性、上传目录可写性。有一项不可写就预警。外部依赖第三方支付回调、短信服务、邮件服务。通常只记录状态不直接让服务下线。数据库检查最常用的办法是SELECT 1或SELECT NOW()。但光查这个还不够——它只能证明“连接建立成功”证明不了“业务数据表可读写”。更稳妥的做法是查一张业务必须存在的表比如users表取一条记录或者做一个SELECT COUNT(*) FROM config之类的轻量查询。这样既能验连接又能验权限和表存在性。Redis检查直接用PING返回PONG说明连接正常。注意要设置超时不能等默认的连接超时否则Redis挂了探针也会卡死。临时目录可写性检查建议用file_put_contents(sys_get_temp_dir() . /healthz.tmp, time())再unlink一次写入一次删除实测几十次也几乎无感。下面是一个我实际用过的依赖检查版核心逻辑?php function checkDatabase() { $timeout 2; $pdo new PDO( getenv(DB_DSN), getenv(DB_USER), getenv(DB_PASS), [PDO::ATTR_TIMEOUT $timeout] ); $stmt $pdo-query(SELECT 1 AS ok); return $stmt ! false; }这里有个容易被忽略的点PDO::ATTR_TIMEOUT设置的是连接超时如果你执行一条长时间查询还是可能把探针拖死。所以探针里的SQL必须是确定的、无锁的、不会因为业务数据量变大而变慢的。2.3 返回结构设计机器可读与人工可读兼得健康检查接口不仅要给负载均衡看还要给值班的人看。所以返回结构要平衡机器解析效率和人工排障体验。我推荐的返回格式是这样{ status: ok, checks: { mysql: ok, redis: ok, tmp_writable: ok }, version: v2.14.1, instance: web-03, uptime: 37264, timestamp: 1712990400 }status只能是ok或error探针脚本直接用它判断。checks明细状态方便人工看。version当前发布版本号方便确认探针探测的是哪个版本的服务。instance实例标识多实例时能快速定位哪个副本异常。uptime进程启动时间用time() - $_SERVER[REQUEST_TIME]的话只能拿到请求时间建议从缓存或环境变量里维护一个启动时间。HTTP状态码必须配合status一起用整体正常返回200有关键依赖挂了返回503。负载均衡器、K8s探针都是看状态码的别在这上面省事。3. Liveness、Readiness与Startup探针PHP-FPM在K8s里的正确姿势如果你的PHP项目已经容器化健康检查就绕不开Kubernetes的探针体系。这里很多人会踩同一个坑把三个探针都配成同一个路径甚至都指向同一个重依赖检查接口结果一个探针失败导致Pod被反复重启。3.1 三种探针的语义别把自己写成“见光死”用生活类比来说livenessProbe存活探针是在问“这哥儿们还活着吗”如果连进程都死透了或者进入死锁状态Kubelet会杀掉容器重新拉起。readinessProbe就绪探针是在问“这哥儿们能开始接客了吗”如果依赖还没就绪比如MySQL还在初始化就不该把流量打进来。失败时Pod不会被杀但会从Service的Endpoint列表里摘掉。startupProbe启动探针是针对“冷启动特别慢”的应用。它给应用一个额外的启动缓冲期期间存活探针不会执行。这三个探针必须对应不同的检查深度探针检查深度失败后果startup进程起来没继续等待不杀不摘liveness进程存活、无死锁、无致命异常杀掉重启readiness依赖服务、配置、对外能力完整性摘流量不重启很多PHP项目的问题是把readiness的检查深度用到了liveness上。比如livenessProbe里也去查MySQL、查Redis结果MySQL抖动一次节点直接被重启重启过程中其他节点压力陡增形成雪崩。正确做法是liveness只检查进程活着、没有死循环readiness才去检查重依赖。3.2 探针路径与超时参数的最优配置我在K8s里通常会准备两个探测路径/healthz/live仅供liveness使用只检查PHP-FPM进程响应不查任何重依赖。/healthz/ready供readiness使用检查数据库、Redis、存储目录等。对应YAML大致长这样livenessProbe: httpGet: path: /healthz/live port: 8080 initialDelaySeconds: 10 periodSeconds: 10 timeoutSeconds: 3 failureThreshold: 3 readinessProbe: httpGet: path: /healthz/ready port: 8080 initialDelaySeconds: 5 periodSeconds: 5 timeoutSeconds: 5 failureThreshold: 3这几个参数值得单独展开timeoutSeconds一定要比你的探针代码最大执行时间长。比如你在readiness里查数据库设置了2秒超时那探针的timeoutSeconds就不能小于3秒否则Kubelet在探针还没返回结果时就判定超时了。periodSeconds则要权衡太频繁会放大探针本身的性能损耗太稀疏又会让故障感知变迟钝。PHP这种轻量探针5秒到10秒是合理区间。3.3 Nginx容器与PHP-FPM容器并存时的探针选型PHP项目在K8s里最常见的形态是一个Pod里跑两个容器——Nginx容器负责对外HTTPPHP-FPM容器负责解析执行。这时候探针打在哪个容器上是有讲究的。最稳妥的方案是让Nginx容器承载httpGet探针让/healthz/ready和/healthz/live走Nginx的fastcgi_pass转发到PHP-FPM。这样探针同时校验了Nginx配置和PHP-FPM联通链路。但要注意如果Nginx容器里只有静态文件服务没有PHP-FPM的转发配置探针打到Nginx就永远返回静态文件永远测不到PHP-FPM。所以Nginx配置里必须给健康检查路径单独做一条location转发。另一种方案如果Pod里有独立的PHP-FPM容器没有Nginx那就不能用httpGet得用tcpSocket去探测9000端口livenessProbe: tcpSocket: port: 9000 initialDelaySeconds: 10 periodSeconds: 10但tcpSocket只能证明端口能连上证明不了PHP能正常响应。所以只要条件允许我都是走httpGet到Nginx再转发给PHP-FPM这样检测链路更完整。另外还有一个进阶做法在PHP-FPM里开启pm.status_path让探针除了看应用层还能看到进程池状态。这个我放在第5节详细讲。4. 健康检查引发的“次生故障”探针不是普通接口说了这么多怎么设计探针现在必须要聊一个几乎人人都遇到、但很少被写出来的问题探针本身成为故障源。4.1 每五秒一次的SELECT 1是怎样打爆数据库连接数的我接手过一个项目K8s上有20个PHP副本每个副本的readinessProbe每5秒检查一次数据库每次检查新建一个PDO连接执行SELECT 1。算一下20个Pod × 每分钟12次 每分钟240次数据库连接。看起来不大问题在于PHP是短连接进程每次新建连接都有TCP握手、认证、权限检查开销。当数据库高峰期线程数已经吃紧时这些探针连接会进一步挤占真正的业务连接导致业务请求排队排队时间一长又触发更多探针失败形成恶性循环——探针把数据库压死数据库反过来让业务崩掉。我现在的做法是探针里的数据库连接必须设置短超时一般2秒内没连上就直接判定失败绝不重试。给探针用单独的数据库账号只用SELECT权限避免探针误操作也便于从数据库侧单独统计探针流量。如果项目使用连接池如Swoole常驻进程探针优先复用池内连接而不是每次新建。在大规模集群里可以给所有Pod的探针增加一个随机抖动periodSeconds允许范围内避免所有Pod在同一秒打向数据库。4.2 Session文件、错误日志与日志风暴探针代码最隐蔽的坑是Session副作用。很多团队直接把健康检查挂在框架入口上而框架入口早就把Session启动了。探针每5秒跑一次每次都会生成新的Session文件。Session文件一多目录Inode耗尽真正的业务用户Session倒是写不进去了。这个问题我排查过一次凌晨磁盘满告警一查全是探针生成的403文件。解决思路健康检查入口必须独立不要走框架公共入口如果一定要走也要通过环境变量或请求参数把Session中间件禁用掉。我用的php.ini配置是if (PHP_SAPI cli) { // CLI模式不启动Session session_start(); }这是反面教材别再抄。正确的姿势是在探针入口顶部直接session_write_close()并把session.auto_start强制关闭或者干脆用php -S跑一个独立的本地探针服务。日志风暴同样值得警惕。探针请求走了Nginx access log每5秒写一行20个副本一天就有34万行探针日志。日志平台存储费用是一方面更重要的是排障时看日志全被探针请求刷屏真正的业务错误反而淹没了。我的建议是在Nginx里对探针路径单独关闭access loglocation /healthz/ready { access_log off; fastcgi_pass php_fpm_upstream; include fastcgi_params; }如果探针代码里自己打了log也要记得探针请求不记info级别的业务日志只把异常情况记下来。4.3 探针路径暴露在公网信息泄露与刷量风险健康检查接口本质上是一个“免鉴权”的接口它不应该对公网开放。但现实中很多探针路径是/health、/healthz这种常见路径扫描器一探一个准。如果探针返回的信息太详细——比如把所有依赖检查明细、版本号、实例IP都吐出来——那就等于给攻击者递了一份系统体检报告。我的处理原则有三个Nginx层限制来源IP只允许内网网段、负载均衡器IP、K8s节点IP访问探针路径。如果必须走公网比如云负载均衡的健康检查至少不能返回详细检查项统一返回{status:ok}即可。探针接口绝不回显环境变量、配置文件路径、堆栈信息。反向代理场景下还要注意如果探针前面经过了多层代理PHP通过$_SERVER[REMOTE_ADDR]拿到的可能是最后一个代理的IP而不是真实客户端IP。做IP白名单判断时要么信任代理IP要么正确配置X-Forwarded-For并只信任已知代理链。这个细节在我之前做云厂商负载均衡健康检查时特别重要云厂商的探针IP经常变写死IP会误杀。5. 与PHP-FPM状态页结合把进程视角加进健康检查应用层健康检查能反映依赖组件的状态但对PHP-FPM进程池本身的负载情况感知有限。比如worker全部被慢请求占满新请求排队这时候应用层探针很可能还是返回200——因为它还能执行。真正能反映FPM健康的是它自带的状态页。5.1 开启php-fpm的status_path在php-fpm.conf对应的pool配置块里加上pm.status_path /php-fpm-status然后在Nginx配置里放行location /php-fpm-status { access_log off; allow 127.0.0.1; allow 10.0.0.0/8; deny all; fastcgi_pass unix:/var/run/php-fpm.sock; include fastcgi_params; }如果不加鉴权这个状态页也会泄露信息所以IP限制是必须的。状态页默认是纯文本可以在请求时加参数变成JSON/php-fpm-status?json。这个JSON格式对机器解析友好得多。5.2 解析状态页的关键指标并联动健康判定我习惯从状态页里盯几个关键指标pool当前进程池名称。process managerdynamic还是ondemand。idle processes空闲worker数代表有余量接新请求。active processes正在执行的请求数高了说明负载重。max active processes历史峰值用来判断扩容是否需要。max children reached如果这个值大于0说明曾经因为进程数上限导致无法创建新worker。slow requests慢请求计数接近真实业务质量。我在健康检查脚本里会拉一次状态页然后对active processes和idle processes做阈值判断。比如当max_children 30active 25且idle 2时说明进程池接近打满探针返回503同时标记一个fpm_pressure字段当max children reached增长时同样告警。$status json_decode(file_get_contents(http://127.0.0.1/php-fpm-status?json), true); if ($status[max_children_reached] 0) { $checks[fpm][status] warning; } if ($status[active_processes] ($status[max_children] * 0.85)) { $checks[fpm][status] critical; }这样健康检查就不再停留在“能连上”层面而是能感知“快被压垮”的中间态。中间态才是线上事故真正的前兆。6. 从监控到排障健康检查数据怎么用起来健康检查不只是给负载均衡器一个200/503的开关它本身是很好的监控数据源。把探针检查结果用起来才能真正发挥“提前发现故障”的价值。6.1 让检查结果进入监控大盘最简单的方式是在探针返回里附上各检查项的耗时。每次探针执行的耗时数据打点就能画出一条“依赖健康度趋势线”。我通常会把探针的checks数组里每一项都带上耗时{ checks: { mysql: {status: ok, elapsed_ms: 12}, redis: {status: ok, elapsed_ms: 1} } }然后写一个采集任务周期性调用探针接口把数据推到Prometheus或Zabbix。如果不想引一堆上报SDK还有一种轻量做法直接用探针返回的elapsed_ms字段做业务指标在看板上渲染成柱状图。数据库响应从5ms涨到500ms这个趋势比“挂了没有”更有意义。告警分级也要配套。我的规则是单次探针503先摘流量记录事件不立刻告警。连续3次探针503产生告警说明不只是瞬间抖动。探针整体返回200但某个warning项持续10分钟产生低级别告警提前处理隐患。6.2 一套探针代码在物理机、Docker、K8s之间复用很多公司是混合部署——老项目在物理机/Nginx上新项目在K8s里。健康检查代码不应该因为部署形态不同而写两套。我用环境变量来做形态适配APP_ENVkubernetes时探针路径同时提供/healthz/live和/healthz/ready。APP_ENVclassic时只提供/healthz单一入口所有检查项聚合输出给机房负载均衡用。数据库、Redis的探测配置统一走环境变量避免每个环境改代码。上传这套代码时我额外做了两件事第一把healthz.php放在web根目录但禁止它在生产环境被外部访问Nginx里单独配置IP白名单第二在CICD里加了一条冒烟测试部署后自动请求一次探针确认返回200才继续挂流量。这样健康检查本身就成为了发布流程的最后一环。6.3 从排障案例里沉淀的检查清单最后分享几个我自己排查过的“探针正常但业务异常”的典型场景供你看到类似状况时快速定位探针检查的数据库地址是内网VIP但业务代码连的是另一套读写分离的实例VIP健康不代表实例健康。解决方案探针和业务必须使用同一套依赖配置。探针检查了Redis的PING但业务用的Redis Cluster的某个分片已经挂了PING恰好路由到健康分片。解决方案探针里对每个分片都做一次PING或者用业务实际访问的key做一次GET/SET。探针检查了目录可写但没有验证跨节点共享存储的可写性。容器化之后每个副本的磁盘是本地盘探针写本地盘当然成功但共享文件系统NFS/S3挂载的权限问题完全测不到。解决方案在探针里加一个共享目录的读写检查或者依赖Readiness中的外部健康检查。健康检查这个东西方案本身不难难在每个细节都要贴近你自己的业务形态。上面这套思路我用了快三年从单机PHP项目到K8s集群都验证过。如果你也正在设计PHP健康检查记住这句话探针的责任不是证明服务还活着而是证明服务可以被交出去接受流量。搞清楚这一点你就不会再把首页200当成服务健康了。
返回列表