DryDock 分布式容器更新监控部署实录:一屏管五台机器

我有 5 台机器跑着 Docker,分布在 NAS、内网几台小主机、还有一台荷兰 VPS。每次更新镜像要 SSH 进去逐个 docker compose pull,漏了哪台全靠运气发现。

调研了一圈镜像更新监控工具,Watchtower 太粗暴(动不动自动重启),Diun 只通知不管理。最后选了 DryDock—— 一个 Controller 管所有 Agent,Web UI 统一查看,支持 23 个镜像注册表,20+ 通知渠道。

环境:fnOS NAS(Controller)、AI 主机(.216)、n8n 工具机(.181)、Frigate NVR(.160)、Bandwagon VPS(云主机)。

架构

┌─────────────────────────────────────┐
│ fnOS (<NAS_IP>) │
│ DryDock Controller :24300 │
│ 统一面板 · 通知 · 自动更新 │
│ agent.env ← 所有 Agent 连接配置 │
└──┬──────┬──────┬──────┬────────────┘
│ │ │ │
▼ ▼ ▼ ▼
Agent Agent Agent Agent
:3000 :3000 :3000 :34300
AI主机 n8n Frigate Bandwagon
.216 .181 .160 云主机
✅ ✅ ✅ ✅

通信方式:Controller 主动连 Agent(SSE over HTTP)。Agent 只需要 Docker socket 访问,不暴露给外部 —— 通过 docker-socket-proxy 做一层隔离。

Controller 配置(fnOS)

Controller 跑在 NAS 上,核心是两个容器:DryDock 本体 + docker-socket-proxy(用于监控本机容器)。

services:
drydock:
image: codeswhat/drydock
container_name: drydock
restart: unless-stopped
env_file:
- agent.env # ← Agent 连接独立文件
depends_on:
socket-proxy:
condition: service_healthy
environment:
- DD_WATCHER_LOCAL_HOST=socket-proxy
- DD_WATCHER_LOCAL_PORT=2375
- DD_AUTH_BASIC_ADMIN_USER=***
- "DD_AUTH_BASIC_ADMIN_HASH=***"
35 collapsed lines
ports:
- 24300:3000
deploy:
resources:
limits:
memory: 512M
cpus: "1.0"
logging:
driver: json-file
options:
max-size: "20m"
max-file: "3"
security_opt:
- no-new-privileges:true
socket-proxy:
image: tecnativa/docker-socket-proxy
container_name: socket-proxy
restart: unless-stopped
volumes:
- /var/run/docker.sock:/var/run/docker.sock
environment:
- CONTAINERS=1
- IMAGES=1
- EVENTS=1
- SERVICES=1
- POST=1
- NETWORKS=1
- INFO=1
healthcheck:
test: wget --spider http://localhost:2375/version || exit 1
interval: 5s
timeout: 3s
retries: 3
start_period: 5s

agent.env 设计

Agent 连接配置不写在 docker-compose.ymlenvironment 里,而是拆到独立的 agent.env

# === AI 主机 (<AI_HOST_IP>) ===
DD_AGENT_AI_HOST=<AI_HOST_IP>
DD_AGENT_AI_SECRET=***
# === n8n 工具机 (<N8N_HOST_IP>) ===
DD_AGENT_N8N_HOST=<N8N_HOST_IP>
DD_AGENT_N8N_SECRET=***
# === Frigate NVR (<FRIGATE_IP>) ===
DD_AGENT_FRIGATE_HOST=<FRIGATE_IP>
DD_AGENT_FRIGATE_SECRET=***
# === Bandwagonhost VPS (<VPS_IP>) ===
DD_AGENT_VPSNL2_HOST=<VPS_IP>
DD_AGENT_VPSNL2_PORT=34300
3 collapsed lines
DD_AGENT_VPSNL2_SECRET=***
DD_AGENT_ALLOW_INSECURE_SECRET=*** # LAN 明文 HTTP

Agent 部署模板

每台目标机器上跑两个容器:docker-socket-proxy + drydock-agentcommand: --agent)。

services:
socket-proxy:
image: tecnativa/docker-socket-proxy
container_name: drydock-socket-proxy
restart: unless-stopped
volumes:
- /var/run/docker.sock:/var/run/docker.sock
environment:
- CONTAINERS=1
- IMAGES=1
- EVENTS=1
- SERVICES=1
- POST=1
healthcheck:
test: wget --spider http://localhost:2375/version || exit 1
18 collapsed lines
interval: 5s
timeout: 3s
retries: 3
drydock-agent:
image: codeswhat/drydock
command: --agent
container_name: drydock-agent
restart: unless-stopped
depends_on:
socket-proxy:
condition: service_healthy
ports:
- "3000:3000"
environment:
- DD_AGENT_SECRET=*** # 和 Controller agent.env 一致
- DD_WATCHER_LOCAL_HOST=socket-proxy
- DD_WATCHER_LOCAL_PORT=2375

已部署的 Agent 位置:

机器路径
AI 主机 (.216)/opt/drydock-agent/
n8n (.181)/opt/drydock-agent/
Frigate NVR (.160)/home/dpanel/compose/drydock-agent/
Bandwagon VPS(云主机)/opt/1panel/docker/compose/drydock-agent/,端口 34300

新机器加入流程

  1. SSH 到目标机器
  2. 创建 /opt/drydock-agent/docker-compose.yml(复制上方模板)
  3. 确认 DD_AGENT_SECRET 与 Controller 的 agent.env 一致
  4. docker compose up -d
  5. 验证:curl -s -o /dev/null -w "%{http_code}" http://localhost:3000 → 应返回 401(正常,没有认证头)
  6. 在 Controller 的 agent.env 追加对应行(端口非 3000 时加 _PORT=xxxx
  7. Controller 重建:docker compose up -d
  8. 打开 http://<NAS_IP>:24300,左侧栏应出现新 Agent

通知配置

DryDock 支持 20+ 通知渠道(ntfy、Telegram、Lark、Discord 等)。当前配的是自建邮箱 + Lark Webhook 双通道,汇总模式(batch),所有容器更新合成一条消息。

邮件通知

# agent.env(Controller 侧)
DD_NOTIFICATION_SMTP_MAIN_HOST=<mail.your-domain.com>
DD_NOTIFICATION_SMTP_MAIN_PORT=465
DD_NOTIFICATION_SMTP_MAIN_FROM=<your@email.com>
DD_NOTIFICATION_SMTP_MAIN_TO=<your@email.com>
DD_NOTIFICATION_SMTP_MAIN_USER=<your@email.com>
DD_NOTIFICATION_SMTP_MAIN_PASS=***
DD_NOTIFICATION_SMTP_MAIN_TLS_ENABLED=true # ⚠️ 465 端口必须显式开启
DD_NOTIFICATION_SMTP_MAIN_MODE=batch

踩坑TLS_ENABLED 默认为 false。465 端口是隐式 TLS—— 服务器先等 TLS 握手,DryDock 发纯文本 SMTP,结果就是 Greeting never received,30 秒超时。必须设为 true。587 端口(STARTTLS)不受影响。

Lark(飞书)Webhook

DD_NOTIFICATION_HTTP_LARK_URL=https://open.larksuite.com/open-apis/bot/v2/hook/<hook-id>
DD_NOTIFICATION_HTTP_LARK_MODE=batch

Lark 可能间歇性返回 400,不影响实际送达。

Apprise 方案(已放弃)

曾尝试用 Apprise(caronc/apprise,130+ 通知服务)统一管理通知。部署了一个 sidecar 容器后发现两个问题:

  1. 资源开销大:默认按 CPU 核数 × 4 启动 worker(4 核 → 16 worker),需限制 512M 内存 + APPRISE_WORKER_COUNT=2
  2. Stalwart 兼容 Bug:Apprise 发完邮件后发 QUIT 时 Stalwart 已关连接,报 SMTPServerDisconnected。Gmail / QQ 等主流服务不受影响

仅为邮件通知多跑一个容器 + 16 worker 进程,且有兼容问题,回退到原生 SMTP。

三种通知模式:

模式效果
simple每个容器一条(刷屏)
batch全部汇总一条 ✅
digest每天定时摘要

踩坑记录

env_file 中的 $ 被 shell 吃掉

YAML compose 文件通过 SSH + heredoc 传到远程时,$argon... 变成了 argon...(丢了一个 $)。SSH + shell 对 $ 做了二次解释。解决:用 Python yaml.dump 写到远程,完全绕过 shell。

Agent 端口不匹配

VPS 上 Agent 映射了 34300:3000,但 Controller 的 agent.env 没指定端口,默认连 3000 → 连接失败。解决:加 DD_AGENT_{name}_PORT=34300

Agent 返回 401 是正常的

Agent 收到无认证的请求返回 401。Controller 通过 DD_AGENT_{name}_SECRET 认证后正常通信。用 curl 测试看到 401 说明 Agent 在跑,不是挂了。

Agent 显示 0 容器(v1.4.x handshake 竞态 bug)

部署完过了一段时间,打开 Web UI 发现几个 Agent 下面一个容器都不显示。SSH 上去 docker ps 明明都在跑,Agent 日志也正常。

翻 Controller 日志发现关键错误链:

Agent ai connected → Handshake successful. Received 5 containers. ✅
...(SSE 长连接断开)
SSE Connection failed: aborted
Agent ai connected → Handshake successful. Received 0 containers. ❌
Pruning container drydock-agent (removed on Agent) 🗑 全部清零
Pruning container frpc
...

—— 重连后报 0 容器,Controller 把之前同步的 5 个容器全部清理了。n8n 和 Frigate 的 Agent 更惨,SSE 断开后直接 ECONNREFUSED,放弃重试。

搜到 GitHub Issue #386 ,这是一个已知的 handshake 冷启动竞态问题:Controller 重连 Agent 的 SSE 流时立即握手(GET /api/containers),而此时 Agent 的第一个 watch cycle 还没跑完,容器 store 真的是空的 —— 握手正确返回 0。几秒后 Agent 的 watch 完成并推送真实数据,但 Controller 侧 UI 已经在握手阶段清零且不再刷新。

修复在 v1.5.0-rc.28:Agent 不在 watch cycle 完成前响应握手,返回空时 Controller 保留上一次的已知状态。修复后日志会看到:

Handshake returned 0 containers; preserving last-known state until the first watch cycle completes

SMTP 通知 Greeting never received

465 端口配了 SMTP 但 Controller 日志持续报错:

WARN Error (Greeting never received)
WARN Event handler timed out after 30000ms

容器内 openssl s_client 明明能连通。根因:TLS_ENABLED 默认为 false,465 端口需要隐式 TLS 但 DryDock 没开,发了纯文本 SMTP,服务器不响应。加一行解决:

DD_NOTIFICATION_SMTP_MAIN_TLS_ENABLED=true

升级 v1.5.x:修复与验证

当前运行的 v1.4.5 / v1.4.6 需要升级到 v1.5.0-rc.28+。实际升到了 rc.37(2026-06-15,覆盖多项 CVE 修复)。

fnOS 网络受限无法直接 docker pull,采用本地拉取 → 打包传输 → 远端加载的方式:

Terminal window
# 本地拉取
docker pull codeswhat/drydock:1.5.0-rc.37
# 打包
docker save codeswhat/drydock:1.5.0-rc.37 | gzip > /tmp/drydock-rc37.tar.gz
# 传输到各主机
sshpass scp /tmp/drydock-rc37.tar.gz <host>:/tmp/
# 各主机加载并重启
docker load < /tmp/drydock-rc37.tar.gz
sed -i 's|codeswhat/drydock$|codeswhat/drydock:1.5.0-rc.37|' <compose-file>
docker compose up -d

各主机的 compose 路径不同,别搞混:

主机compose 路径
fnOS (Controller)/vol1/1000/docker/drydock/docker-compose.yml
AI 主机 (.216)/opt/drydock-agent/docker-compose.yml
n8n (.181)/opt/drydock-agent/docker-compose.yml
Frigate NVR (.160)/home/dpanel/compose/drydock-agent/docker-compose.yaml
VPS(云主机)/opt/1panel/docker/compose/drydock-agent/docker-compose.yml

升级后所有 Agent 重连,Controller 日志确认正常:

Agent握手结果
AI 主机5 个容器(preserving last-known state)
n8n3 个容器(preserving last-known state)
Frigate4 个容器
VPS8 个容器
fnOS 本地26 个容器

Agent 升级过程中容器重建导致旧容器 ID 被 prune、新 ID 自动注册,最终数据一致。

标签配置:让 DryDock 识别镜像版本

DryDock 默认只对 semver 标签(如 1.2.3v2.0.1)自动检查更新。对于非 semver 标签(latestnightlyalpine)或未能自动识别的标签(16-alpine1.26.1),会显示黄标 / 灰标警告且不检查更新。

没有全局开关 —— 这是设计决策,因为 digest watching 会频繁调用 Docker Hub Pull API,容易触发限流(匿名 100 次 / 6h)。需要按容器逐个配置。

两类标签

场景Label示例
semver 但未被识别dd.tag.family=loosegitea:1.26.1postgres:16-alpine
非 semver,想跟踪更新dd.watch.digest=truersshub(无 tag)、ttrss:nightlylinkding:latest-alpine
自己 build 的镜像不加 labelhomer-healthscreenshot-to-code—— 没 registry 可查

批量添加

以 fnOS 为例,已为以下容器添加 label:

dd.watch.digest=true:rsshub、linkding、wallos、homebox、donetick、karakeep、homer、vcompose、doocs-md、rustpad、ttrss

dd.tag.family=loose:gitea

.26.1、postgres
(tt-rss)

不需要改动的:frpc

.69.1(已自动识别 semver)、meilisearch
.37.0(已自动识别)、drydock(自监控正常)、bili-sync-rs/mercury/opencc/redis/browserless/alpine-chrome(基础设施,不频繁更新)。

与 1Panel 的关系

DryDock 和 1Panel 互补,不是替代:

功能DryDock1Panel
镜像更新检测✅ 23 个注册表
更新通知推送✅ 20+ 通道
容器操作(启停)
网站 / SSL 管理
数据库管理
Docker Compose GUI

有 1Panel 的机器两者都跑;没 1Panel 的机器 DryDock Agent 就够了。

文件清单

fnOS:/vol1/1000/docker/drydock/
├── docker-compose.yml # Controller + socket-proxy
├── agent.env # Agent 连接列表
└── docker-compose.yml.bak
AI 主机:/opt/drydock-agent/
└── docker-compose.yml # 标准模板
n8n:/opt/drydock-agent/
└── docker-compose.yml # 标准模板
Frigate:/home/dpanel/compose/drydock-agent/
└── docker-compose.yaml # Dpanel 托管
VPS:/opt/1panel/docker/compose/drydock-agent/
1 collapsed line
└── docker-compose.yml # 1Panel 托管,端口 34300

总结

DryDock 填补了 Watchtower(太自动)和 Diun(只通知)之间的空白。Controller-Agent 架构对于多机器场景很顺手:加一台机器就是复制一份 compose + 加三行 env,5 分钟的事。

目前 5 台全部接入(Frigate NVR 已连通)。v1.4.x 的 0 容器 bug 通过升级到 v1.5.0-rc.37 修复,加了标签配置后非标准镜像也能正确检测更新。后续打算把通知切到 ntfy,邮件通知容易被 Gmail 归到垃圾箱。