超过一百个 Docker 容器:我们的月度健康检查流程
多年逐步增加服务后,在生产环境中运行超过一百个 Docker 容器并不罕见。一个自托管 Supabase 栈就占十三个容器,再加上 Web 前端、多个 API 服务、后台工作进程、监控栈和日志聚合,数量增长往往超出计划。真正困难的不是部署这些容器,而是维护它们。
多数文档只讲如何启动容器,很少讲六个月后周日磁盘写满、发现三分之一容器没有重启策略,或 SSL 证书到期却因监控告警被静音而无人察觉时,该怎么办。本篇记录我们维护一台运行百余个容器的生产服务器时所采用的月度流程。
从磁盘开始
磁盘是长期运行的 Docker 环境中最常见的突发故障来源。Docker 积累的数据,标准系统工具并不能完整反映。运行 df -h 可以看到文件系统用量,却无法说明 Docker 是否占用了五十 GB 的已停止容器层、悬空镜像,以及六个月反复部署积累的构建缓存。
正确的起点是 docker system df,它按镜像、容器、本地卷和构建缓存分类显示磁盘用量。输出往往出人意料。我们曾遇到构建缓存比所有运行容器层加起来还大的服务器:几个月里,CI 触发的构建不断积累缓存,却从未清理。
真正最值得关注的是可回收空间一栏。操作前先建立基线:目前有多少空间可以回收,逐月趋势如何。如果可回收空间持续增长,就需要提高清理力度或频率。
我们按以下顺序清理。首先,处理不再被任何容器引用的匿名卷。自 Docker 23.0 起,docker volume prune 不会删除未使用的命名卷,除非加上 --all;添加该参数前,请确认未挂接的卷中没有仍需保留的数据:
docker volume prune -f
接着清理悬空镜像,即没有标签,也未被任何运行中或已停止容器引用的镜像层:
docker image prune -f
最后,如果已经确认能在恢复窗口内完成全部服务的重建,再清理所有超过七天的未使用镜像:
docker image prune -a --filter "until=168h" -f
-a 参数会删除所有未使用镜像,而不仅是悬空镜像。只有确认所有服务都能在可接受的恢复时间内从镜像仓库重建后,我们才运行这一命令。验证必须在执行命令前完成。
重启策略
重启策略决定了容器异常退出,或宿主机重启后 Docker 守护进程重新启动时会发生什么。多数部署指南只简要提及这一点。但策略设置错误,可能让服务悄然停掉两周都无人发现,因为监控告警指向了错误的端点。
Docker 提供四种重启策略。no 是默认策略,容器在任何情况下都不会自动重启。always 会在容器停止后重启它,且不受退出码影响;手动执行 docker stop 后,只有在守护进程重启或再次启动容器时,它才会恢复运行。unless-stopped 类似于 always,但保留明确的停止状态:如果在重启宿主机前运行 docker stop,容器在宿主机重启后仍保持停止。on-failure[:max-retries] 只在非零退出码时重启,并可设置放弃前的最大重试次数。
对于无状态 Web 服务和 API 工作进程,我们使用 unless-stopped。维护窗口中主动停止的容器,应在下一次宿主机重启后继续保持停止,而不是意外恢复。always则会在下一次守护进程重启时恢复容器运行,即使我们此前是有意停止它的。
数据库迁移容器和一次性初始化任务应使用 no。失败的迁移不应循环执行。on-failure:3 适用于需要短暂重试、等待临时不可用依赖的容器,例如等待消息代理恢复可访问状态的外部队列消费者,但不应无限重试。
每月检查时,我们用一条命令查看全部容器:
docker inspect --format '{{.Name}} {{.HostConfig.RestartPolicy.Name}}' $(docker ps -aq)
所有策略为 no、又并非有意设置为一次性任务的容器,都需要复核。多数情况下,这意味着某个服务在事件处理中通过 docker run 启动,却始终没有正式纳入 Compose 配置并设置合适的重启策略。
日志轮转
Docker 默认日志驱动为 json-file,默认没有大小限制。即使容器只持续输出少量日志,几个月后也可能积累数百 GB。这不是理论风险,而是未专门配置日志管理的生产服务器上,磁盘耗尽的常见原因之一。
解决方法是在 /etc/docker/daemon.json 中设置全局策略:
{
"log-driver": "json-file",
"log-opts": {
"max-size": "100m",
"max-file": "5"
}
}
这样,每个容器的日志最多保留五个文件,每个一百 MB,即每个容器最多五百 MB。修改后必须重启 Docker 守护进程;更关键的是,必须重新创建容器,仅重启容器并不能让新日志设置生效。
实际上,daemon.json 中的设置只适用于修改后创建的容器。已有容器会一直保留原来的日志配置。这是我们最常遇到的错误:设置了策略、重启了守护进程,就以为所有容器都已符合要求。事实并非如此。我们的月度检查会逐容器核验日志配置:
docker inspect --format '{{.Name}} {{.HostConfig.LogConfig}}' $(docker ps -q)
没有明确大小限制的容器,会在下一个维护窗口使用更新后的配置重新创建。重建顺序很重要:有状态服务的数据卷必须保留,依赖服务也必须按正确顺序启动。
SSL 证书到期检查
SSL 证书会到期,自动监控能够发现大多数情况。但自动监控也可能配置错误、造成告警疲劳,或与被监控服务一起悄然失效。因此,我们的月度流程包含一轮独立于自动化系统的人工核验。
对每个面向公众的域名,我们直接检查其证书:
echo | openssl s_client -connect domain.com:443 -servername domain.com 2>/dev/null | openssl x509 -noout -enddate
这条命令输出 notAfter 日期。任何三十天内到期的证书,都会立即进入续期队列,不受监控仪表盘显示的影响。人工检查是最后一道保障。
我们为几个内部服务运行了自管证书体系;对于这样的体系,需要将中间证书与叶证书分开检查。即使叶证书仍有效,中间证书到期也会导致整条证书链验证失败。这种故障比叶证书到期更难察觉:浏览器和客户端可能显示难以理解的错误,而不是多数工程师预期的明确“certificate expired”提示。
我们维护了一个 shell 脚本,遍历域名列表,通过 openssl 提取到期日期,对三十天内到期的证书输出警告,对七天内到期的证书输出关键告警。脚本通过 cron 定时运行,月度审查时也会手动执行,以再次确认 cron 输出准确。cron 任务悄然失败的频率比多数人预期的更高。
容器资源限制
没有内存限制时,异常容器可能耗尽宿主机 RAM,触发内核的 OOM killer,杀死无关进程。没有 CPU 限制时,失控进程可能长时间抢占资源,使同机容器得不到足够资源并造成级联故障。这两种情况都不是罕见的边缘案例。
月度审查会检查所有运行容器的资源限制:
docker stats --no-stream --format "table {{.Name}} {{.CPUPerc}} {{.MemUsage}}"
内存栏同时显示用量和上限;没有设置内存限制的容器会将宿主机的总内存显示为上限,而 0B / 0B 只会出现在未运行的容器上。我们会逐个复核,确定合适的上限。对于无状态 HTTP 服务,可以从实际观测工作集的两到四倍内存开始设置。目标不是精确估算,而是避免无限增长拖垮同机服务。
查看统计数据时,我们也关注 CPU 百分比。容器的 CPU 使用率持续接近 100%(在 docker stats 中,100% 相当于占满一个核心,因此多核宿主机上可能出现更高的数值),可能意味着进程失控,或分配的资源不足以应对工作负载。两种情况都应在下次月度检查前调查。
镜像更新检查
基础镜像会不定期收到安全补丁。六个月前还算最新的镜像,如今可能使用了已有已知漏洞的 nginx 或 PostgreSQL 版本。我们不会每月自动拉取并重新部署全部容器,这样做引入的风险比消除的更多。但我们会将运行中的版本与当前版本进行核对。
实际做法是:对每个固定镜像版本的服务,每月查看上游更新日志,核对所固定的版本。对于使用 latest 或 16-alpine 等浮动标签的服务,则拉取镜像并比较摘要,确认是否发生变化。有变化时,先审查变更内容,再部署。
更重要的是逐步弃用浮动标签。如果服务因为 latest 指向了新主版本,而在重新部署时悄然引入破坏性变更,排查难度会高于运行一个已知旧镜像的服务。应固定版本,再有意识地更新。
坚持定期维护
这套月度健康检查涵盖六项:磁盘用量与清理、重启策略核验、日志轮转设置、SSL 证书到期、容器资源限制,以及镜像是否需要更新。在文档完善的服务器上,这些任务总计不超过九十分钟。价值在于按固定周期、在故障发生前完成检查,而不只是每项检查本身。
生产系统会逐步退化:磁盘数据积累,日志增长,证书临近到期,镜像版本落后。这些变化往往只有跨过阈值才会触发告警。月度检查将维护从被动响应变为可预测的工作,运维方式也因此发生根本改变。
相关洞察
- 使用 FastAPI 和 TimescaleDB 构建 Amazon 数据仓库——将同样的基础设施运维规范应用于数据层
- 大规模精简运维:小团队如何管理数十个项目——我们如何安排大型项目组合中的定期维护