HanyanOS 运维手记
1. 引言
当你在一台 N100 上跑着 21 个容器、34 个镜像和 21 个卷——而系统盘只有 120GB——容器的生命周期就不是”docker run 一下就行”的事了。
让我看一下现在的状态:
1 | $ docker system df |
89% 的镜像体积是垃圾,83% 的卷数据没有在用。 这不是运维失误——这是容器化应用的天然属性:镜像层堆积、退出容器的 overlay 残留、孤立卷的 _data 目录在磁盘深处悄悄膨胀。如果不定时清理,一个 120GB 的系统盘会被 Docker 自己吃光,而且毫无感知。
这篇文章记录 HanyanOS 上的容器全生命周期管理:从拉取镜像到最终清理,”生老病死”每个阶段的实操和教训。
2. 容器生命周期全景
1 | 拉取镜像 |
2.1 Running (Up) — 活着的容器
HanyanOS 当前有 10 个活跃容器,持续运行的天数从 2 到 5 天不等:
1 | CONTAINER ID NAMES STATUS IMAGE |
几个观察:
stalwart-mail本周期重启过一次(RestartCount 1, 启动时间距退出仅 3 分钟)——那三次启动间隔不是一个正常的”因为有更新所以重启”,更像是初始化阶段失败然后unless-stopped重试了一次。这提醒我们:重启策略不能替代健康检查。 幸好它有 HEALTHCHECK,不然可能会无限重试直到系统资源耗尽。crawl4ai带(healthy)标签——Docker HEALTHCHECK 在执行。它的定义值得学习(一般 crawl 类容器内存敏感,把健康检查间隔设长点可以减少虚假告警)。hanyan-db、mariadb、qdrant等数据库类容器全部稳定在线,这是--restart=always(或 compose 里的restart: unless-stopped)的功劳。
实战建议:重要容器永远带 restart policy + HEALTHCHECK。 两者缺一,深夜被 pager 叫醒的概率显著上升。
2.2 Created — 活在”创建”的两个幽灵
1 | 0fafb52a42b1 derper Created tailscale/tailscale |
Created 状态比 Exited 更特殊:容器创建成功了,但从没人 docker start 过它。它分配了容器文件系统空间(几十 MB),占着 overlay 层资源,却什么也不做。
derper 是 Tailscale 的 DERP 中继服务器,当初可能想搭建一个私有中继节点,后来发现官方 DERP 够用就没启动。stoic_mahavira 大概是测试 headscale 版本时的残留。
为什么它们还活着?因为 docker ps 默认不加 -a 就看不到它们。”眼不见为净”是运维大忌。
清理时机: 如果超过 7 天还是 Created,99% 的概率不会再用。
1 | # 查找并清理 Created 超过 7 天的容器 |
2.3 Exited (0) — 体面的退役
1 | ff596eeff995 immich_redis Exited (0) 3 days ago valkey/valkey:9 |
Exit code 0 是最优雅的告别——进程接收到 SIGTERM,执行了清理逻辑,主动退出。Immich 的 Redis 和 PostgreSQL 都是正常退出的。这说明 Immich 在前端主动关闭时走的是 docker compose down 的干净路径。
Exit code 143 是什么?128 + 15 = SIGTERM(向进程发送终止信号),也是”干净退出”的一种——Docker daemon 在收到 stop 命令后给容器发了 SIGTERM,容器对它做了响应。
这批容器为什么全部退出? 看了一下 Immich 容器的创建时间,大概率是在内存不足时被人停掉的。Immich 的 ML 模型推理在 N100 上大约要占用 1.5-2GB 内存,对 11GB 的小机器来说是个不小的负担。
2.4 Exited (137) — OOM 杀手的子弹
1 | 31ab12b92f6e hanyan-backup Exited (137) 13 days ago alpine:latest |
Exit code 137 = 128 + 9 = SIGKILL。在 Docker 语境下这几乎只意味一件事:容器被 Linux OOM Killer 杀掉了。 无法响应、没有 graceful shutdown、直接暴毙。
hanyan-backup 是一个用 alpine:latest 做的简单备份容器,跑 restic backup。restic 在备份大量文件时需要加载索引到内存中,对一个没有任何内存上限的 alpine 容器来说——
没有
--memory限制的容器可以吃掉宿主机所有内存。N100 只有 11GB RAM,restic 备份十几 GB 的 immich 数据时轻松越线。
教训:备份容器的内存限制非常重要。
1 | # docker-compose 中正确的备份容器配置 |
或者一行命令时带上:
1 | docker run --memory="512m" --cpus="0.5" ... |
配置了资源限制后,即使备份任务失败(OOM 里优雅退出),也不会拖垮整个系统。我的 N100 就是靠这条教训活下来的。
2.5 Exited (1) — 不明原因的暴毙
1 | c44049b608fa agitated_almeida Exited (1) 9 days ago d9e853e87e55 |
Exit code 1 是一个综合模糊的状态:运行时错误、配置文件格式错误、依赖服务未就绪。这两个匿名名字(Docker 自动生成)告诉我它们不是通过 compose 启动的——大概率是手动 docker run 测试某个镜像后忘了清理。
运维建议:所有带名字的容器都应当通过 docker-compose 管理。 手动 docker run 留下的东西跟乱丢垃圾没什么区别——没人知道它是干嘛的,没人敢删,永远留在那里。
1 | # 查看匿名容器的日志,判断留还是删 |
3. 镜像管理:拉取、更新、GC
3.1 Pull 与版本锁定
docker pull 是一切的开端。HanyanOS 上的容器镜像有两个来源:
- 固定标签:如
mysql:8.0、wordpress:6.7-php8.3-apache— 大版本可控 - latest 标签:如
headscale/headscale:latest、snappymail:latest— 需要注意
latest 不是恶魔,但在生产使用时要习惯:
1 | # 更新前查看变更日志 |
N100 上 34 个镜像占用了 21.42GB,其中 19.18GB 可以回收。这是什么概念?系统盘 120GB,光 Docker 镜像就吃掉了 17.8% 的空间,而其中 90.5% 是旧版本层。
3.2 docker pull — 常规更新流程
HanyanOS 的容器有两种更新模式:
模式 A:docker-compose 管理(推荐)
1 | cd /path/to/service |
模式 B:手动更新(适用于一次性任务容器)
1 | docker pull redis:7-alpine |
3.3 镜像层与磁盘占用
每个 Docker 镜像由只读层组成。当你 docker pull nginx:latest,Docker 拉取的是这个版本的新层加上和旧版本共享的层。Linux 的 overlay2 文件系统使得相同的层只存储一次——这是为什么删镜像能够释放惊人空间的原因。
看下面的数据:
1 | $ docker system df |
89% 可回收意味着大部分镜像层没有被任何活跃容器引用。常见的模式是:
- 拉取
mysql:8.0→ 使用中 docker compose pull拉取mysql:8.0新版本 → 新镜像层下载- 旧
mysql:8.0层仍在磁盘上,但没有任何容器引用 → 成为 dangling image docker image prune杀之
如果不定期 prune,这个过程累积的空间非常可观。自动清理脚本:
1 | # weekly-cleanup.sh — 每周日 03:00 运行 |
在 N100 上我把它放在 cron 里每周日凌晨执行,避免在工作负载高峰期跑:
1 | 0 3 * * 0 /home/michael/scripts/docker-weekly-cleanup.sh |
4. 卷的生命周期
4.1 持久数据——卷就是一切
Docker 卷(volume)是容器数据的唯一持久层。HanyanOS 上 21 个卷只有 4 个活跃:
1 | $ docker volume ls |
活跃的 4 个卷承载了几乎全部重要数据:
| 卷 | 挂载路径 | 数据内容 |
|---|---|---|
hanyan_mysql_data |
/var/lib/mysql | HanyanOS 主数据库 |
serena_wordpress_data |
/var/www/html | 博客内容 |
mail-server_certificates |
/opt/stalwart-mail/certs | 邮件服务器证书 |
qdrant_storage |
/qdrant/storage | 向量数据库 |
重要原则:数据卷永远不要手动操作。 不像镜像和容器可以随便删,卷里的数据丢了就是真的丢了。
4.2 孤立卷——占着茅坑不拉屎
21 个卷中 17 个没有被任何容器引用。其中 Immich 的 3 个卷(pgdata、ml-model、upload),以及各种测试容器的临时卷,占用了约 980M 的可回收空间。
1 | # 列出没有被任何容器使用的卷 |
这些孤立卷中的大多数可以安全删除,但要注意:如果卷名字看起来有价值(immich_pgdata、backup_data),先确认再删。
1 | # 安全清理策略:保留最近 30 天有数据的卷 |
5. 容器级生命周期钩子
Docker 本身不支持”容器启动后执行脚本”这种生命周期钩子,但可以通过组合手段实现:
5.1 HEALTHCHECK 作为”运行中”确认
最好的例子是 crawl4ai 容器:
1 | HEALTHCHECK --interval=15s --timeout=5s --retries=3 \ |
Docker 引擎会每 15 秒检查一次,连续 3 次失败后容器状态变为 unhealthy。配合 docker events 可以做到:
1 | # 实时监听容器健康状态变更 |
5.2 docker events — 容器八卦监听器
docker events 是一个被低估的命令。它流式返回 Docker daemon 的实时事件:
1 | 2026-06-07 16:30:01.523 container start stalwart-mail (image=stalwartlabs/stalwart:latest) |
我们可以把它变成一个长期监听服务,记录每个容器的生命周期事件到日志:
1 |
|
然后用 systemd 让它开机自启:
1 | [Unit] |
这个日志在排查”容器什么时候挂了”时非常好用。比如 hanyan-backup 在 13 天前退出(137),对照当时的系统日志可以看到 OOM Killer 的记录和系统内存压力——完全是印证式的排错体验。
6. docker-compose vs 手动管理
经过几个月迭代,HanyanOS 确立了以下规范:
| 方面 | docker-compose | 手动 docker run |
|---|---|---|
| 配置管理 | YAML 文件,git 版本化管理 | 散落在 bash_history 中 |
| 重启策略 | restart: unless-stopped 开箱即用 | –restart 参数容易忘记 |
| 网络管理 | 自动创建项目网络 | 手动 –network 配置 |
| 资源限制 | deploy.resources.limits 结构化 | –memory –cpus 参数形式 |
| 更新回滚 | docker compose pull + up -d | 需要手动 docker stop/rm/run |
| 团队协作 | 新人看 YAML 就懂 | 要看 bash_history + 靠口口相传 |
结论:Docker Compose 是 N100 这种单机环境的最佳平衡点。 Swarm 太重,纯 docker run 太散。compose 只有一个 YAML 文件和一行 docker compose up -d,适合。
7. N100 的临界值——什么时候 Docker 会吃掉你的系统
让我总结一下实际环境的数据:
当前 Docker 磁盘占用(系统盘 120GB):
| 项目 | 大小 | 占比 |
|---|---|---|
| 镜像 | 21.42 GB | 17.85% |
| 容器层 | 164.4 MB | 0.14% |
| 卷 | 1.18 GB | 0.98% |
| Docker 合计 | 22.76 GB | 18.97% |
| 可回收空间 | ~20.26 GB | 16.88% |
关键警告阈值(N100 120GB NVMe):
- 镜像+容器 > 35GB → 需要大规模清理(通常对应 30+ 个 dangling 镜像)
- 卷 > 5GB → 检查是否有被遗忘的数据库卷
- Docker 总占用 > 40GB → 系统已经危险
- 120GB 磁盘上的运维原则:当系统稳定性可用空间小于 15GB 时,先释放磁盘再排查任何奇怪的错误(数据库写入失败、日志无法轮转、apt update 报错)
运维手记系列:
- #1: 轻量级系统监控实战 — N100 上没有 Prometheus 的日子 ✅
- #2: Docker 容器生命周期管理 — 从镜像到坟场 ✅
- #3: 备份恢复管线——restic + cron 冷热分离(待续)
- #4: 日志管理——旋转、留存、审计(待续)
- #5: 性能调优——11GB 上的内存生存艺术(待续)