ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Docker HEALTHCHECK与容器自愈:从探针配置到生产实践

Docker HEALTHCHECK与容器自愈:从探针配置到生产实践 先交代一下背景。我最早接触 Docker 里的健康检查是在一次线上事故之后。当时容器里的 Nginx 进程一直活着但上游 PHP-FPM 已经卡死负载均衡器却还在往这个容器转发流量结果用户看到的就是一排 502。后来我把 HEALTHCHECK 加进去又配合重启策略把服务“捞”回来才算是真正搞明白了什么叫“让容器自己照顾自己”。如果你也在用 Docker 跑应用或者被“进程还活着但服务不可用”这种事坑过这篇东西应该能帮你省下不少半夜起来敲命令的时间。1. 为什么需要 HEALTHCHECK进程活着不代表服务健康很多人在刚开始学 Docker 的时候都有过一个直觉容器没退应用就是好的。这个直觉在本地开发环境可能勉强成立但一旦放到生产环境基本就是定时炸弹。我来举个最典型的例子。你用一个基础镜像跑一个 Java 应用启动命令是java -jar app.jar。某一天JVM 堆内存被打满垃圾回收一直在 Full GC应用线程全部阻塞。这时候操作系统的视角是java进程还在退出码是 0容器状态是 Up。但从业务视角看所有接口都在超时页面打不开用户已经在骂街了。Docker 本身没法区分这两种状态。它只关心一件事容器的主进程是否退出。只要java这个进程没有 kill 自己Docker 就认为容器一切正常。这正是 HEALTHCHECK 要解决的痛点。它的本质是让 Docker 引擎定期“探访”一次容器内部执行你指定的命令根据命令的退出码判断容器是否仍然健康。退出码为 0视为 healthy非 0视为 unhealthy。这个过程完全由 Docker 引擎驱动不需要外部监控系统参与。再往深一层说健康检查的意义不只是“发现问题”更重要的是给自动化处理创造条件。你已经知道容器状态是 unhealthy那接下来怎么处理是把请求切走还是把容器重启或者干脆发个告警让人介入这都需要一个明确的状态信号作为前提。HEALTHCHECK 恰恰就是那个信号源。所以我一直认为健康检查不是可选项而是 Docker 应用上生产的必备配置。它解决的不只是“检测”而是把“应用是否可用”这个模糊问题变成了一个可以被程序读取和响应的明确指标。2. HEALTHCHECK 指令的核心细节每一行配置背后都有讲究HEALTHCHECK 的语法并不复杂网上搜一下就能看到但很多人只是抄过来用不明白每个参数到底在控制什么。一旦出了状况根本不知道怎么调。2.1 指令语法与参数解析标准写法是这样的HEALTHCHECK --interval30s --timeout3s --start-period5s --retries3 CMD curl -f http://localhost/ || exit 1四个参数的作用我用大白话翻译一下--interval30s是探访周期。Docker 每 30 秒执行一次健康检查命令。这个值不是越小越好也不是越大越好。太频繁了资源开销和无效请求会变多太稀疏了故障发现会变慢。--timeout3s是单次执行超时。如果健康检查命令在 3 秒内没有执行完这次检查就算失败。这个值的设置要结合你健康检查命令的实际耗时如果一个检测命令逻辑里带了复杂的数据库查询3 秒可能就不够。--start-period5s是启动宽限期。容器刚启动时应用正在初始化很可能连不上、响应慢这不能算“不健康”。所以 Docker 会给一个宽限时间在这个窗口期内即使检查失败也不会增加失败次数。--retries3是连续失败次数。连续 3 次检查失败容器才会被标记为 unhealthy。这个参数是防止偶发抖动导致误判的比如因为网络抖动或者瞬时 CPU 飙高单次失败不应该直接判死刑。还有一个不常被提到的参数是--start-interval这是新版本 Docker 里才有的专门用于在启动阶段控制检查频率。不过大多数场景下默认的--interval加上--start-period已经够用了这个参数用不用看你手头 Docker 版本的实际情况。2.2 常见的健康检查命令写法命名的选择直接决定了健康检查的可靠程度。我见过不少人写的健康检查脚本问题很多这里我列几个反例都是实际踩过的坑。第一个坑在容器里用curl localhost但容器里根本没有 curl。很多精简的镜像比如alpine默认不带 curl。解决方法是改用 wget但 wget 不一定有或者用node的 fetch但稍微重了点。我习惯的做法是优先看镜像里自带什么工具尽量不为了健康检查额外装软件包。第二个坑健康检查命令本身有副作用。比如健康检查里做了数据库写入操作等于每次探访都在生产数据库里产生一条废数据。这个习惯非常危险尤其在高频检查的情况下等于自己给自己制造垃圾数据。第三个坑检查的深度不对。curl -f http://localhost只能说明 Web 服务器在监听端口但不代表业务逻辑可用。我见过一个案例服务因为依赖的 Redis 挂了已经无法正常处理业务请求但 Nginx 还在响应静态页面的 200健康检查依然通过。我推荐的做法是分层设计如果你不知道怎么写先用最基本的“端口通”检查保证有信号然后逐步优化在关键服务上加上“依赖检查”和“关键接口检查”。另外有条件的话给检查接口加一个轻量的业务探针比如查询一次配置表或者执行一次简单的内存读这能大幅提升检测的真实性。下面是一个比较平衡的写法兼顾了容器内工具的可用性和对业务状态的判断HEALTHCHECK --interval30s --timeout5s --start-period10s --retries3 \ CMD wget -q -O - http://127.0.0.1:8080/healthz || exit 1如果你确实想检查依赖是否可达可以在检查命令里加一个超时可控的探测指令。MySQL 如果装了 mysql client可以用mysqladmin ping -h localhost --silentRedis 可以用redis-cli ping返回PONG。这些命令很快就返回适合放到 HEALTHCHECK 里。2.3 从指令到状态Docker 怎么记录和暴露健康状态Docker 会把健康状态记录在容器的状态结构里。你运行docker ps健康状态会显示在 STATUS 列正常是(healthy)连续失败后是(unhealthy)启动初期是(starting)。用docker inspect可以看到更详细的信息docker inspect --format{{json .State.Health}} container_name输出结果里包含一个Log数组记录着每次检查的退出码、输出日志和时间戳。这个是在排查时非常有用的信息——你可以直接看到哪一次检查失败了失败输出的内容是什么不用再猜。另外一点很重要在 Dockerfile 里定义 HEALTHCHECK 之后docker restart不会因为容器健康状态而重置失败计数但新启动的容器会重新计算。这个“失败计数”细节等会儿讲自愈原理的时候会用到先记在心里。3. 自愈是这样实现的把 HEALTHCHECK 和重启策略接起来HEALTHCHECK 本身只做检测不解决恢复问题。真正让应用“自愈”的是把健康检查的结果交给 Docker 的进程守护机制。3.1 重启策略的三种典型场景Docker 的restart策略分为几种我用最直白的方式解释no不自动重启。容器退出之后是什么状态就是什么状态需要人工介入。on-failure[:max-retries]容器主进程异常退出时重启也就是退出码非 0。可以加一个次数限制比如on-failure:3超过 3 次就不再重启了。always任何情况退出都尝试重启包括正常退出和异常退出。unless-stopped跟always差不多但如果你手动把容器 stop 了Docker 不会在 Docker 守护进程重启后把它拉起来。关键点在这默认情况下restart 策略的重启对象是“异常的容器”但 Docker 本身并不会因为容器状态变成 unhealthy 就触发重启。如果你只配了 HEALTHCHECK然后看着容器状态变成 unhealthy 但什么都不做它是不会自愈的。想让 unhealthy 触发动作你需要明确地告诉进程管理器状态变了该介入了。3.2 进程守护 vs 容器编排这就需要分两种情况来讨论。如果你是单机用 Docker 跑一个服务最简单粗暴的方案是在容器启动时加一个外部监督脚本。这个脚本循环执行docker inspect --format{{.State.Health.Status}} container一旦发现 unhealthy就执行docker restart container然后继续循环。本质上就是自己做了一个进程守护逻辑。如果你用的是 Docker ComposeCompose 的restart策略同样不会自动感知 HEALTHCHECK 结果。但 Compose 生态里有一些工具可以配合常见的方式是使用一个 sidecar 容器专责监控主容器的健康状态并触发重启。这种方式的好处是部署时只要docker compose up -d不需要在宿主机上额外跑脚本。如果你用的是 Docker Swarm 或者 Kubernetes 这种编排平台那就简单多了。Swarm 和 K8s 的原生控制器天然围绕“健康状态”做了判定副本不健康就会被摘除流量然后重建一个健康的副本。这就是标准的自愈逻辑。所以我的建议是如果能上编排平台尽量别自己写监督脚本。自愈这种和生命周期相关的能力平台化支持远比自己写的脚本可靠。但也别急着全部推翻自写脚本在轻量级场景里依然有意义比如过渡阶段、单机小服务、或者不方便引入编排环境的地方。3.3 一个最小可用的自愈脚本示例下面这个脚本是我在迁移到 Swarm 之前用的逻辑非常简单但足够解决问题#!/bin/bash container_namemy-web-app while true; do status$(docker inspect --format{{.State.Health.Status}} $container_name) if [ $status unhealthy ]; then echo $(date) unhealthy detected, restarting... docker restart $container_name fi sleep 10 done你可以在宿主机上通过systemd、supervisor或者干脆nohup让它常驻。这个脚本每 10 秒看一下健康状态一旦 unhealthy 就重启容器。不过这个方案有两个非常明显的短板我必须提前告诉你第一这个脚本本身是单点。如果宿主机重启了脚本没有设置为开机自启自愈能力就消失了。第二脚本里的docker restart只是重启容器进程如果容器内应用有持久化状态比如数据库写了一半重启后可能进入脏数据状态。不是所有服务都适合用docker restart自愈的。接着说一个更“正统”的思路。很多人没意识到** HEALTHCHECK 不一定要命令健康检查一次就 EC 0你可以在命令本身里做一点“恢复动作”。**也就是说把自愈的第一步从容器外部移到容器内部。在一个脚本里先尝试清理临时文件、重启自身进程再执行真正的探测。这样一些轻量故障在进入 unhealthy 之前就被处理掉了。这个思路在应用层做健康检查时也有类似的价值。当然如果你部署的是无状态 Web 服务用docker restart自动重启问题不大但如果是有状态服务比如 MySQL、Redis 主从这种重启策略就不合适会引入新的问题。判断服务是否适合自愈要先问自己这个服务重启后会不会丢数据或损坏数据这是一个比“怎么配 HEALTHCHECK”更重要的前置判断。4. 在 Docker Compose 中落地自愈以 MySQL 和 Nginx 为例Compose 是现在最普及的 Docker 部署方式我单独用一节来讲因为它跟纯 Dockerfile 命令行还是有区别。4.1 Compose 里的健康检查配置Compose 文件里配置健康检查的语法是这样的services: web: image: my-web-app:latest healthcheck: test: [CMD, curl, -f, http://localhost/] interval: 30s timeout: 5s retries: 3 start_period: 10s这里test的值是一个数组形式上很像exec调用。如果你的镜像里有 shell也可以写成字符串形式比如test: [CMD-SHELL, wget -q -O - http://localhost/ || exit 1]注意区分CMD和CMD-SHELL。CMD方式表示直接执行数组指定的程序不经过 shell 解析CMD-SHELL则会将后面的字符串交给 shell 来解释。如果你的检查命令里要写管道符、环境变量、通配符之类的就必须用CMD-SHELL否则||和会被当作普通字符传给程序导致命令执行出错。这个细节很值得多说一句。我见过不少人把test: [CMD, curl -f http://localhost || exit 1]这么写结果因为括号里的内容被当成一个整体找不到名为 “curl -f http://localhost || exit 1” 的可执行文件而导致健康检查永远失败。正确写法是拆成数组或者改用 CMD-SHELL。4.2 利用 depends_on 让服务按依赖顺序启动Compose 的depends_on让你能控制服务启动顺序但很多人对它的理解还停留在“先启动 A再启动 B”这个层面。在新版本的 Compose 规范里depends_on可以配合健康检查做到“等待依赖服务健康后再启动当前服务”services: web: image: my-web-app:latest depends_on: db: condition: service_healthy db: image: mysql:8.0 healthcheck: test: [CMD, mysqladmin, ping, -h, localhost, -uroot, -p$$MYSQL_ROOT_PASSWORD] interval: 10s timeout: 5s retries: 5 start_period: 30s这里有个很容易被忽略的细节MySQL 官方镜像里的环境变量是容器内可以直接访问的但在 HEALTHCHECK 命令里使用环境变量带不带 shell 会造成不同的处理。如果你直接写成[CMD, mysqladmin, ping, -uroot, -p$MYSQL_ROOT_PASSWORD]Docker 的 exec 模式不会替你展开$MYSQL_ROOT_PASSWORD命令会拿着字面上的$MYSQL_ROOT_PASSWORD去认证结果显而易见。正确写法是用CMD-SHELL或者像上面那样把$转义成$$让 Compose 把它解释为$而不是尝试在宿主机上做变量替换。这个坑非常隐蔽但只要栽过一次后面就记住了。4.3 一个模拟自愈的最小示例我在本机做了一个实验用两行配置模拟了 unhealthy 自动重启的效果你可以直接照着跑一下services: app: image: alpine:latest container_name: healthcheck-demo command: [sh, -c, i0; while true; do echo $i; i$((i1)); sleep 5; done] healthcheck: test: [CMD, sh, -c, test $(( $(date %s) % 10 )) -lt 8] interval: 5s timeout: 3s retries: 2 start_period: 3s restart: always这个 healthcheck 的逻辑是当前时间戳对 10 取模小于 8 就算健康否则失败。所以大约 20% 的时间容器会处于 unhealthy 状态。加上restart: always之后你会看到容器反复重启每次重启之后会重新获得一段健康期。这个实验的目的不是教你怎么写一个“半疯”的检查命令而是让你直观地看到 HEALTHCHECK 和 restart 的配合到底是怎么运转的。实际生产环境不会用这种随机策略但理解了它的运转机制后你再去看自己的服务健康检查就清楚多了。4.4 为什么我不建议在有状态服务上用 docker restartMySQL、Redis 这些服务做主从复制的时候如果主节点因为事务提交到一半导致进程卡死docker restart可能带来更恶劣的后果——重启后 MySQL 要跑崩溃恢复恢复期间服务完全不可用而且恢复失败的话数据可能处于不一致状态。这时候正确做法是区分两个概念“重启”和“重新调度”。重启是把同一个容器拉起来磁盘数据没变崩溃恢复依然要跑重新调度是在新的容器里挂载同一份数据卷同时应用可能需要做更复杂的角色切换。后者需要编排平台级别的协调不是 HEALTHCHECK restart 就能解决的。所以我在生产环境里的选择标准是无状态服务Nginx 静态页面、API 无会话、定时任务 worker放心用restart: always HEALTHCHECK。有状态但可恢复MySQL 单实例、Redis 单机建议 HEALTHCHECK 负责检测但要人工或通过外部编排处理不要直接用 docker restart 自动恢复。有状态且强一致数据库主从、消息队列集群不建议做任何形式的自动重启应该依赖专门的集群管理工具来处理故障切换。这个判断标准是在我踩过几次坑之后总结出来的。一开始我也是无脑restart: always直到有一次 MySQL 容器在写入事务中途崩溃重启后 InnoDB 恢复花了几分钟业务方电话差点打爆。自愈不是目的可靠才是。5. 实战中高频踩坑HEALTHCHECK 的常见问题与排查实录我一直觉得 HEALTHCHECK 的坑不在配置本身而在配置里的那些“想当然”。一个看似合理的检查命令一旦放到真实环境里可能因为一个很小的问题而完全失效。这一节整理几个高频问题每一个我都真实碰到过。5.1 镜像里没有 curl/wget健康检查直接失败先看现象容器启动正常应用端口也正常监听但健康检查一直失败docker inspect里看到的是ExitCode: 127。这是最典型的坑。基础镜像如果是精简版 Linux里面未必自带 curl 或 wget。curl在 alpine 里需要单独安装很多官方镜像为了瘦身都不带它。我建议你在处理这个问题之前先做一个基准测试进入容器里手动执行你的健康检查命令看看能不能跑通。跑不通的话再想为什么。ExitCode: 127通常意味着“命令不存在”。你可以用docker exec -it container sh进去看一眼文件系统里到底有哪些工具。解法有几种一是改健康检查命令用镜像里已有的工具比如wget、nc可以用command -v确认路径二是换用更轻量的探测方式比如通过/dev/tcp配合 Bash 内置能力做端口探测三是在镜像构建阶段装好这些工具但这样会增加镜像体积需要权衡。5.2 检查周期太短把服务拖垮这个场景比较隐蔽。一个瞬时耗时的健康检查如果间隔设得太短比如--interval5s而检查命令里又带了数据库连接、缓存查询那等于每 5 秒就多一次对后端的压力。在高峰期这种无效流量可能会放大故障。我见过一个真实案例某服务本身已经因为数据库慢查询处于亚健康状态健康检查里又有一个查库操作间隔 5 秒。结果健康检查请求比正常业务请求还频繁数据库被压得更垮形成恶性循环。调整建议健康检查的“成本”越高间隔就要越长。纯端口探测可以 10 秒一次带一个简单业务逻辑的探针30 秒一次比较合理如果探针里涉及外部依赖访问60 秒一次甚至更长都不过分。另外健康检查的timeout也很重要。如果命令超时这次检查被记为失败而你在排查时从docker inspect的日志里看到的可能是Timeout exceeded而不是命令本身的报错。遇到这种情况先确认你的探测命令在压力下需要多久返回给 timeout 留出足够余量。5.3 start-period 没设容器启动阶段就被误杀容器刚启动时应用往往要经历进程拉起、配置文件加载、依赖连接、缓存预热等阶段。如果在这些阶段进行健康检查大概率是失败的。如果retries又很小连续几次失败后容器直接进入 unhealthy。我见过的一次线上事故Java 应用启动大概需要 40 秒但健康检查配置里的start-period只有 5 秒retries是 2。结果每次发布容器都会先被标记为 unhealthy然后触发服务摘除整个发布流程乱成一团。正确做法是估算应用最坏的启动时间把start-period设置为比它长 10 秒以上。这个参数的作用就是允许启动期间“免检”不给失败计数的阶段。千万不要省。5.4 restartalways 但容器一直处于崩溃循环这个场景比较头疼不是 HEALTHCHECK 配错了而是服务本身启动就失败restart: always导致容器反复重启。此时健康检查看得到的状态一直是starting或unhealthy。命令排查要分三步走第一步看容器日志docker logs --tail 50 container第二步看具体退出码和重启次数docker inspect --format{{.RestartCount}} {{.State.ExitCode}} container第三步如果实在找不到原因在宿主机上手动前台运行镜像用docker run --rm -it image sh之类的方式进去复现。先确认是应用本身的问题再考虑是不是健康检查配置误伤。很多人一看到容器在重启循环就把矛头指向 HEALTHCHECK结果查半天发现是应用代码里一个空指针。方向错了排查效率自然低。5.5 健康检查命令里用了特殊字符导致报错用CMD-SHELL还是CMD这个坑前面已经讲过。这里再补充一个场景如果你在 Compose 文件里写健康检查命令并且命令包含$符号Compose 会把它当成变量引用。如果你不是故意引用 Compose 环境变量就需要写成$$来转义。这个在数据库类镜像里特别常见因为要传密码给命令密码里很可能包含$或其他 shell 特殊字符。处理办法是将密码放到环境变量文件.env中在 HEALTHCHECK 命令里使用$$引用容器内的环境变量不要直接在命令里明文写密码。5.6 健康检查日志里提示 “exec format error”这个报错很容易让人摸不着头脑。一般发生在你的健康检查命令指向一个脚本文件但脚本文件没有可执行权限或者脚本的 shebang 行指定的解释器在容器里不存在。排查方法docker exec -it container /bin/sh进到容器里手动执行脚本看看能不能运行。如果出现exec: /healthcheck.sh: permission denied说明没有chmod x如果是not found可能是脚本第一行的/bin/bash在容器里不存在需要用/bin/sh取代。5.7 docker inspect 里显示健康状态为 starting但应用早就起来了这里要注意 Docker 健康检查状态机的细节容器启动后状态会先处于starting持续到start-period结束之后才会变为healthy或unhealthy。如果你用的是老版本 Dockerstart-period的语义可能跟新版不太一样所以你可能看到starting状态非常久。建议在明确新版本语义的情况下再依赖这个状态判断。在生产环境里我不会把健康检查的状态当成唯一的决策信号而是配合外部监控一起看。6. 从单机到集群编排平台里的自愈能力更值得依赖单机场景用 HEALTHCHECK restart 确实能解决很多问题但如果你管理的不只一台机器或者服务需要多副本这套逻辑就不够用了。6.1 究竟什么时候该转向 Swarm 或 Kubernetes先说一个最简单的信号当你发现自己开始用脚本定期检查容器状态并执行docker restart的时候就该考虑上编排平台了。脚本自愈的能力边界太窄它不知道服务有几个副本也不知道如何负载均衡更不知道如何跨机器迁移容器。Swarm 和 Kubernetes 在设计上就内置了“期望状态”的机制。你声明“我要 3 个副本”平台会持续比对当前状态和期望状态一旦某个容器 health 检查失败平台会把节点上的容器摘掉然后新建一个。这个闭环是完全自动的不需要额外的监督脚本。如果你已经在跑 DockerSwarm 是对现有体系改动最小的选择。它不需要引入新的编排概念Compose 文件稍作调整就能docker stack deploy。在 Swarm 里健康检查的服务配置跟 compose 类似但关键区别是Swarm 会根据健康状态停止向不健康的副本转发请求并且可以配置update-config和restart-policy让任务自动替换。这套机制比我前面写的 shell 脚本强大得多。6.2 K8s 的 Readiness 和 Liveness 是干什么的如果你入了 K8s 的门会发现它把健康状态拆得更细。readinessProbe决定“这个 Pod 能不能接流量”livenessProbe决定“这个 Pod 是否要重启”它们可以独立配置。这个设计其实跟 Docker HEALTHCHECK 解决的是完全不同的两个问题。Docker 的 HEALTHCHECK 更接近一个“统一状态报告器”而 K8s 把“是否纳入负载均衡”和“是否需要重建”拆成两个独立维度。这样精细化的设计在处理服务发版和流量切换时非常有用。不过这里我不展开 K8s 的细节了因为标题里讲的是 Docker HEALTHCHECK。你只要知道在容器世界里健康检查是一个被所有编排平台都重视的基础能力走到哪一步都需要它。把 Docker 的这一个概念真正吃透后面接触任何编排平台都会顺很多。7. 最后再分享一点我个人的实操体会如果你要把 HEALTHCHECK 用好我最大的建议是不要只把它当成 Docker 的“探针”要想清楚它产出的状态到底被谁消费。在生产环境里我维护过一套 Docker 化的服务集群刚开始每个人的 健康检查配置都是自己拍的有人 5 秒一次有人 60 秒一次检查命令有的探端口有的探接口标准非常混乱。后来我把健康检查的模板统一了规定了探针的类型、间隔、超时、启动等待时间并且要求对外暴露一个/healthz端点里面包含了依赖状态的最小探测逻辑。配置一统一线上因为健康检查误判引发的告警基本消失了。还要提醒一点写了健康检查不代表就可以托管甩手。它只是一个信号源真正的自愈闭环需要和重启策略、编排平台、告警系统结合起来。你可以把 HEALTHCHECK 看成是汽车的“仪表盘警示灯”你要做的是让警示灯点亮之后系统能够自动或通过人工触发正确的操作而不是灯亮了接着开。另外如果你的服务是长期运行的建议把健康状态做成一个可观测的指标暴露出来无论是放进 Prometheus 抓取还是同步到日志平台都能给后续排查提供依据。Docker HEALTHCHECK 的日志默认不会滚动太多真到事故复盘时想回头看历史趋势光靠 docker inspect 是不够的。从最朴素的“端口探测”到逐步加上业务探针从单机 shell 脚本到 Swarm、K8s 的自动调度健康检查是一条贯穿整个容器化实践的主线。把这一节吃透不光是学了一条 Dokerfile 指令更是掌握了容器化应用运维的底层思路。剩下的细节就靠你在自己项目中慢慢打磨了。
返回列表