HanyanOS 运维手记

1. 引言

当你在一台 N100 上跑着 21 个容器、34 个镜像和 21 个卷——而系统盘只有 120GB——容器的生命周期就不是”docker run 一下就行”的事了。

让我看一下现在的状态:

1
2
3
4
5
6
$ docker system df
TYPE TOTAL ACTIVE SIZE RECLAIMABLE
Images 34 20 21.42GB 19.18GB (89%) ← 89% 可回收
Containers 21 10 164.4MB 101MB (61%)
Local Volumes 21 4 1.178GB 980MB (83%) ← 83% 是垃圾
Build Cache 0 0 0B 0B

89% 的镜像体积是垃圾,83% 的卷数据没有在用。 这不是运维失误——这是容器化应用的天然属性:镜像层堆积、退出容器的 overlay 残留、孤立卷的 _data 目录在磁盘深处悄悄膨胀。如果不定时清理,一个 120GB 的系统盘会被 Docker 自己吃光,而且毫无感知。

这篇文章记录 HanyanOS 上的容器全生命周期管理:从拉取镜像到最终清理,”生老病死”每个阶段的实操和教训。

2. 容器生命周期全景

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
拉取镜像

docker pull / compose pull


创建容器 / 启动

docker create → docker start
docker run(创建+启动一步到位)


运行中 (Up) ← 日常状态

├── 主动停止 → Exited (0) ← 正常退役
├── OOM 杀死 → Exited (137) ← 资源不足
├── 崩溃退出 → Exited (1) ← 配置错误或代码 bug
├── 重启策略 → 回到运行中 ← unless-stopped / always
└── 永远 Created ← 忘了 docker start 的孤儿


清理
docker rm / compose down / docker system prune

2.1 Running (Up) — 活着的容器

HanyanOS 当前有 10 个活跃容器,持续运行的天数从 2 到 5 天不等:

1
2
3
4
5
6
7
8
9
10
11
CONTAINER ID   NAMES                     STATUS                    IMAGE
a8454e75741f speedtest Up 28 hours adolfintel/speedtest
859f3873b4b3 headscale Up 45 hours headscale/headscale:latest
60e7971ec530 crawl4ai Up 2 days (healthy) unclecode/crawl4ai:latest
cb18cfb48738 ai-redis Up 2 days redis:7-alpine
9cba38a9311b snappymail Up 3 days djmaze/snappymail:latest
a384456b580d stalwart-mail Up 4 days (healthy) stalwartlabs/stalwart:latest
3d96452d83c6 serena-wp Up 3 days wordpress:6.7-php8.3-apache
c3e99c93e897 qdrant Up 4 days qdrant/qdrant:latest
f176c2becb6c hanyan-db Up 39 hours (healthy) mysql:8.0
c45b46b79130 wordpress Up 4 days (healthy) mariadb:10.11

几个观察:

  • stalwart-mail 本周期重启过一次(RestartCount 1, 启动时间距退出仅 3 分钟)——那三次启动间隔不是一个正常的”因为有更新所以重启”,更像是初始化阶段失败然后 unless-stopped 重试了一次。这提醒我们:重启策略不能替代健康检查。 幸好它有 HEALTHCHECK,不然可能会无限重试直到系统资源耗尽。
  • crawl4ai(healthy) 标签——Docker HEALTHCHECK 在执行。它的定义值得学习(一般 crawl 类容器内存敏感,把健康检查间隔设长点可以减少虚假告警)。
  • hanyan-dbmariadbqdrant 等数据库类容器全部稳定在线,这是 --restart=always(或 compose 里的 restart: unless-stopped)的功劳。

实战建议:重要容器永远带 restart policy + HEALTHCHECK。 两者缺一,深夜被 pager 叫醒的概率显著上升。

2.2 Created — 活在”创建”的两个幽灵

1
2
0fafb52a42b1   derper                    Created                   tailscale/tailscale
1369167d2692 stoic_mahavira Created headscale/headscale:latest

Created 状态比 Exited 更特殊:容器创建成功了,但从没人 docker start 过它。它分配了容器文件系统空间(几十 MB),占着 overlay 层资源,却什么也不做。

derper 是 Tailscale 的 DERP 中继服务器,当初可能想搭建一个私有中继节点,后来发现官方 DERP 够用就没启动。stoic_mahavira 大概是测试 headscale 版本时的残留。

为什么它们还活着?因为 docker ps 默认不加 -a 就看不到它们。”眼不见为净”是运维大忌。

清理时机: 如果超过 7 天还是 Created,99% 的概率不会再用。

1
2
3
4
5
6
7
8
9
10
# 查找并清理 Created 超过 7 天的容器
docker ps -a --filter "status=created" --format "{{.ID}} {{.CreatedAt}} {{.Names}}" \
| while read id created name; do
created_epoch=$(date -d "$created" +%s)
now=$(date +%s)
if [ $((now - created_epoch)) -gt $((7*86400)) ]; then
docker rm "$id"
echo "🧹 已清理 Created 容器: $name"
fi
done

2.3 Exited (0) — 体面的退役

1
2
ff596eeff995   immich_redis              Exited (0) 3 days ago     valkey/valkey:9
57264002ca46 immich_machine_learning Exited (143) 3 days ago ghcr.io/immich-app/immich-machine-learning:release

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
2
3
4
5
6
7
8
9
# docker-compose 中正确的备份容器配置
services:
backup:
image: alpine:latest
deploy:
resources:
limits:
memory: 512M # 最多用 512MB
cpus: '0.5' # 最多用半个核

或者一行命令时带上:

1
docker run --memory="512m" --cpus="0.5" ...

配置了资源限制后,即使备份任务失败(OOM 里优雅退出),也不会拖垮整个系统。我的 N100 就是靠这条教训活下来的。

2.5 Exited (1) — 不明原因的暴毙

1
2
c44049b608fa   agitated_almeida          Exited (1) 9 days ago      d9e853e87e55
8f2993379459 quirky_elion Exited (1) 9 days ago 8d82cf8fc9c1

Exit code 1 是一个综合模糊的状态:运行时错误、配置文件格式错误、依赖服务未就绪。这两个匿名名字(Docker 自动生成)告诉我它们不是通过 compose 启动的——大概率是手动 docker run 测试某个镜像后忘了清理。

运维建议:所有带名字的容器都应当通过 docker-compose 管理。 手动 docker run 留下的东西跟乱丢垃圾没什么区别——没人知道它是干嘛的,没人敢删,永远留在那里。

1
2
3
# 查看匿名容器的日志,判断留还是删
docker logs c44049b608fa --tail 20
docker inspect c44049b608fa --format '{{.Config.Cmd}}'

3. 镜像管理:拉取、更新、GC

3.1 Pull 与版本锁定

docker pull 是一切的开端。HanyanOS 上的容器镜像有两个来源:

  • 固定标签:如 mysql:8.0wordpress:6.7-php8.3-apache — 大版本可控
  • latest 标签:如 headscale/headscale:latestsnappymail:latest — 需要注意

latest 不是恶魔,但在生产使用时要习惯:

1
2
# 更新前查看变更日志
docker pull n8nio/n8n:latest # 看最新版本有没有 breaking change

N100 上 34 个镜像占用了 21.42GB,其中 19.18GB 可以回收。这是什么概念?系统盘 120GB,光 Docker 镜像就吃掉了 17.8% 的空间,而其中 90.5% 是旧版本层。

3.2 docker pull — 常规更新流程

HanyanOS 的容器有两种更新模式:

模式 A:docker-compose 管理(推荐)

1
2
3
4
cd /path/to/service
docker compose pull # 拉取新镜像
docker compose up -d # 重新创建并启动容器
docker image prune # 清理旧镜像

模式 B:手动更新(适用于一次性任务容器)

1
2
3
docker pull redis:7-alpine
docker stop ai-redis && docker rm ai-redis
docker run -d --name ai-redis ... redis:7-alpine

3.3 镜像层与磁盘占用

每个 Docker 镜像由只读层组成。当你 docker pull nginx:latest,Docker 拉取的是这个版本的新层加上和旧版本共享的层。Linux 的 overlay2 文件系统使得相同的层只存储一次——这是为什么删镜像能够释放惊人空间的原因。

看下面的数据:

1
2
$ docker system df
Images 34 20 21.42GB 19.18GB (89%)

89% 可回收意味着大部分镜像层没有被任何活跃容器引用。常见的模式是:

  1. 拉取 mysql:8.0 → 使用中
  2. docker compose pull 拉取 mysql:8.0 新版本 → 新镜像层下载
  3. mysql:8.0 层仍在磁盘上,但没有任何容器引用 → 成为 dangling image
  4. docker image prune 杀之

如果不定期 prune,这个过程累积的空间非常可观。自动清理脚本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# weekly-cleanup.sh — 每周日 03:00 运行
#!/bin/bash

echo "=== 清理未使用容器 ==="
docker container prune -f --filter "until=72h"

echo "=== 清理 dangling 镜像 ==="
docker image prune -f

echo "=== 清理未使用卷 ==="
docker volume prune -f

echo "=== 清理 build cache ==="
docker builder prune -f

echo "=== 清理后状态 ==="
docker system df

在 N100 上我把它放在 cron 里每周日凌晨执行,避免在工作负载高峰期跑:

1
0 3 * * 0 /home/michael/scripts/docker-weekly-cleanup.sh

4. 卷的生命周期

4.1 持久数据——卷就是一切

Docker 卷(volume)是容器数据的唯一持久层。HanyanOS 上 21 个卷只有 4 个活跃:

1
2
3
4
5
6
7
$ docker volume ls
DRIVER VOLUME NAME
local hanyan_mysql_data ← 活跃
local immich_pgdata ← 非活跃但是数据
local mail-server_certificates ← 活跃
local serena_wordpress_data ← 活跃
...

活跃的 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
2
# 列出没有被任何容器使用的卷
docker volume ls -qf dangling=true

这些孤立卷中的大多数可以安全删除,但要注意:如果卷名字看起来有价值(immich_pgdata、backup_data),先确认再删。

1
2
3
4
5
6
7
8
9
10
11
# 安全清理策略:保留最近 30 天有数据的卷
docker volume prune --filter "label!=keep" -f
# 更保守:只删匿名卷(没有明确名字的卷)
docker volume ls -qf dangling=true | while read vol; do
if [[ $vol == *"_"* ]]; then
echo "⚠️ 跳过命名卷: $vol"
else
echo "🧹 删除匿名卷: $vol"
docker volume rm "$vol"
fi
done

5. 容器级生命周期钩子

Docker 本身不支持”容器启动后执行脚本”这种生命周期钩子,但可以通过组合手段实现:

5.1 HEALTHCHECK 作为”运行中”确认

最好的例子是 crawl4ai 容器:

1
2
HEALTHCHECK --interval=15s --timeout=5s --retries=3 \
CMD curl -f http://localhost:8000/health || exit 1

Docker 引擎会每 15 秒检查一次,连续 3 次失败后容器状态变为 unhealthy。配合 docker events 可以做到:

1
2
# 实时监听容器健康状态变更
docker events --filter 'event=health_status' --format '{{.Actor.Attributes.name}} -> {{.Status}}'

5.2 docker events — 容器八卦监听器

docker events 是一个被低估的命令。它流式返回 Docker daemon 的实时事件:

1
2
3
2026-06-07 16:30:01.523 container start  stalwart-mail  (image=stalwartlabs/stalwart:latest)
2026-06-07 16:33:50.104 container die hanyan-backup (exitCode=137)
2026-06-07 16:35:12.890 container die immich-server (exitCode=143)

我们可以把它变成一个长期监听服务,记录每个容器的生命周期事件到日志:

1
2
3
4
#!/bin/bash
# docker-lifecycle-monitor.sh — 持续监听容器事件
docker events --filter 'type=container' --format '{{.Time}} {{.Action}} {{.Actor.Attributes.name}} (exit={{.Actor.Attributes.exitCode}})' \
>> /var/log/docker-lifecycle.log

然后用 systemd 让它开机自启:

1
2
3
4
5
6
7
8
9
10
11
12
[Unit]
Description=Docker Lifecycle Event Logger
After=docker.service
Requires=docker.service

[Service]
ExecStart=/home/michael/scripts/docker-lifecycle-monitor.sh
Restart=always
User=michael

[Install]
WantedBy=multi-user.target

这个日志在排查”容器什么时候挂了”时非常好用。比如 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 上的内存生存艺术(待续)