{/* This page is auto-generated from the skill's SKILL.md by website/scripts/generate-skill-docs.py. Edit the source SKILL.md, not this page. */}
Hermes S6 Container Supervision
修改或调试 Hermes Docker 镜像中的 s6 服务。
Skill 元数据
| 来源 | 可选——使用 hermes skills install official/devops/hermes-s6-container-supervision 安装 |
| 路径 | optional-skills/devops/hermes-s6-container-supervision |
| 版本 | 1.0.0 |
| 作者 | Hermes Agent |
| 许可证 | MIT |
| 平台 | linux |
| 标签 | docker, s6, supervision, gateway, profiles |
| 相关 skill | hermes-agent |
参考:完整 SKILL.md
以下是 Hermes 在触发此 skill 时加载的完整 skill 定义。这是 skill 激活时 agent 所看到的指令内容。
Hermes s6-overlay 容器监管
何时使用此 skill
当你正在处理以下工作时加载此 skill:
- 在 Hermes Docker 镜像中添加或移除一个静态服务(每次容器启动都应被监管的东西,例如 dashboard)
- 诊断某个按 profile 的网关为何不启动、不重启,或无法在
docker restart后存活 - 理解为什么容器的 CMD 是
/opt/hermes/docker/main-wrapper.sh,以及带前导短横线的参数如何到达用户的程序 - 修改
cont-init.d启动脚本(UID 重映射、卷种子填充、profile 对账) - 修改为按 profile 网关渲染的 run-script(Phase 4)
如果你只是运行 Hermes Agent 并想使用 Docker,请改看 website/docs/user-guide/docker.md。
架构一览
/init ← PID 1 (s6-overlay v3.2.3.0)
├── cont-init.d ← oneshot setup, runs as root
│ ├── 01-hermes-setup ← docker/stage2-hook.sh
│ │ ├── UID/GID remap
│ │ ├── chown /opt/data
│ │ ├── chown /opt/data/profiles (every boot)
│ │ ├── seed .env / config.yaml / SOUL.md
│ │ └── skills_sync.py
│ └── 02-reconcile-profiles ← hermes_cli.container_boot
│ ├── chown /run/service (hermes-writable for runtime register)
│ └── walk $HERMES_HOME/profiles/<name>/gateway_state.json
│ → recreate /run/service/gateway-<name>/
│ → auto-start only those with prior_state == "running"
│
├── s6-rc.d (static services, in /etc/s6-overlay/s6-rc.d/)
│ ├── main-hermes/run ← exec sleep infinity (no-op slot)
│ └── dashboard/run ← if HERMES_DASHBOARD=1, runs `hermes dashboard`
│
├── /run/service (s6-svscan watches; tmpfs)
│ ├── gateway-coder/ ← runtime-registered per-profile
│ │ ├── type ("longrun")
│ │ ├── run ("#!/command/with-contenv sh ... exec s6-setuidgid hermes hermes -p coder gateway run")
│ │ ├── down (marker — present means "registered but don't auto-start")
│ │ └── log/run (s6-log → $HERMES_HOME/logs/gateways/coder/current)
│ └── ...
│
└── CMD ("main program") ← /opt/hermes/docker/main-wrapper.sh
└── routes user args: bare exec | hermes subcommand | hermes (no args)
— exec'd by /init with stdin/stdout/stderr inherited (TTY for --tui)
关键文件
| 路径 | 作用 |
|---|---|
Dockerfile | s6-overlay 安装 + cont-init.d 接线 + ENTRYPOINT ["/opt/hermes/docker/entrypoint-dispatch.sh"] |
docker/entrypoint-dispatch.sh | PID-1 分发器:当镜像占用 PID 1 时,exec /init + main-wrapper;在被包装的运行时(Fly Machines、docker run --init)上,则直接回退到 stage2-hook + main-wrapper,并先恢复 s6 辅助工具 PATH(#38349)。 |
docker/stage2-hook.sh | "旧的 entrypoint 逻辑"——UID 重映射、chown、种子填充、skills 同步。作为 cont-init.d/01-hermes-setup 运行。 |
docker/cont-init.d/02-reconcile-profiles | 每次启动调用 hermes_cli.container_boot,从持久化卷恢复 profile 网关槽位。 |
docker/main-wrapper.sh | 容器的 CMD。路由用户参数,通过 s6-setuidgid 降权到 hermes,再 exec 选定的程序。 |
docker/s6-rc.d/main-hermes/run | 空操作 sleep infinity——槽位存在是为了让 s6-rc user bundle 合法;主 hermes 作为 CMD 运行,而非被监管的服务。 |
docker/s6-rc.d/dashboard/run | 条件服务——除非 HERMES_DASHBOARD 为真,否则 exec sleep infinity。 |
docker/entrypoint.sh | 向后兼容垫片,exec 到 stage2 hook。那些硬编码了旧 entrypoint 路径的外部脚本仍然可用。 |
hermes_cli/service_manager.py | S6ServiceManager:register_profile_gateway、unregister_profile_gateway、start/stop/restart/is_running、list_profile_gateways。 |
hermes_cli/container_boot.py | reconcile_profile_gateways()——遍历持久化 profiles,重新生成 s6 槽位,输出 container-boot.log。 |
hermes_cli/gateway.py::_dispatch_via_service_manager_if_s6 | 拦截 hermes gateway start/stop/restart,在容器中运行时路由到 s6。 |
为什么采用架构 B(CMD 作为主程序,而非由 s6 监管)
最初的计划(v1–v3)要求主 hermes 作为受监管的 s6-rc 服务运行。s6-overlay v3 的两个真实机制阻断了这条路:
- cont-init.d 脚本收不到 CMD 参数——因此 stage2 hook 无法解析
docker run <image> chat -q "hi"来设置HERMES_ARGS供服务的run脚本消费。 /run/s6/basedir/bin/halt不会传播写入/run/s6-linux-init-container-results/exitcode的退出码。无论如何容器总是以 143(SIGTERM)退出。skarnet(s6 作者)在 issue #477 中确认:"if you want a container shutdown, you need to either have your CMD exit, or, if you have no CMD, write the container exit code you want then call halt"。
因此我们通过分发器采用 s6-overlay 原生的 CMD 模式:ENTRYPOINT ["/opt/hermes/docker/entrypoint-dispatch.sh"],它在 PID 1 下 exec /init /opt/hermes/docker/main-wrapper.sh "$@"。wrapper 会被自动前置到用户参数之前——所以 docker run <image> --version 变成 /init main-wrapper.sh --version,而 --version 不会被 /init 的 POSIX shell 拦截。wrapper 通过 s6-setuidgid 降权到 hermes,然后 exec 选定的程序。程序的退出码即容器退出码,与 s6 之前的 tini 契约完全一致。当 entrypoint 不是 PID 1 时(Fly Machines、docker run --init),分发器完全跳过 /init(否则会以 can only run as pid 1 中止),恢复 s6 辅助工具 PATH,运行 stage2-hook.sh,并直接 exec main-wrapper.sh——这条路径上没有受监管的服务(#38349)。
权衡:主 hermes 在 s6 下不受监管。这与它在 tini(s6 之前的镜像)下的行为完全一致。Dashboard 监管是唯一的新增保证——而 /run/service/ 下的按 profile 网关获得完整监管。
快速配方
验证运行中的容器里 s6 是否为 PID 1
docker exec <c> sh -c 'cat /proc/1/comm; readlink /proc/1/exe'
# 期望:s6-svscan 或 init / /package/admin/s6/.../s6-svscan
查看某个 profile 网关服务
# /command/ 不在 docker-exec 的 PATH 上——使用绝对路径
docker exec <c> /command/s6-svstat /run/service/gateway-<name>
# "up (pid …) … seconds" → 运行中
# "down (exitcode N) … seconds, normally up, want up, …" → s6 希望它起来但进程持续退出(崩溃循环)
# "down … normally up, ready …" → 用户手动停止了它
手动拉起 / 停止服务
docker exec <c> /command/s6-svc -u /run/service/gateway-<name> # 拉起
docker exec <c> /command/s6-svc -d /run/service/gateway-<name> # 停止
docker exec <c> /command/s6-svc -t /run/service/gateway-<name> # SIGTERM(重启)
查看 cont-init 对账日志
docker exec <c> tail -n 50 /opt/data/logs/container-boot.log
# 2026-05-21T06:18:05+0000 profile=coder prior_state=running action=started
# 2026-05-21T06:18:05+0000 profile=writer prior_state=stopped action=registered
新增一个静态服务
- 创建
docker/s6-rc.d/<name>/type,内容为longrun\n;创建docker/s6-rc.d/<name>/run(使用#!/command/with-contenv sh+# shellcheck shell=sh)。 - 在 run 顶部通过
s6-setuidgid hermes降权到 hermes(除非你明确需要 root)。 - 创建空的
docker/s6-rc.d/<name>/dependencies.d/base,使其等待 base bundle。 - 创建空的
docker/s6-rc.d/user/contents.d/<name>,使其加入 user bundle。 - Dockerfile 中的
COPY docker/s6-rc.d/会自动收录它——无需其他改动。
修改按 profile 网关的运行命令
编辑 hermes_cli/service_manager.py 中的 S6ServiceManager._render_run_script。该函数在启动对账时也被 hermes_cli/container_boot.py::_register_service 调用,因此它是唯一权威来源。相应更新 tests/hermes_cli/test_service_manager.py::test_s6_register_creates_service_dir_and_triggers_scan 中的断言。
运行 docker 测试套件
docker build -t hermes-agent-harness:latest .
HERMES_TEST_IMAGE=hermes-agent-harness:latest scripts/run_tests.sh tests/docker/ -v
# 期望在 s6 镜像上:19 passed, 0 xfailed
测试套件位于 tests/docker/,在 Docker 不可用时会跳过。每个测试的超时已提高到 180s(见 tests/docker/conftest.py)。
常见陷阱
通过 docker exec 报 "command not found"
/command/(s6-overlay 放置其二进制的位置)仅对监管树派生的进程在 PATH 上——服务、cont-init.d、main-wrapper.sh。docker exec <c> s6-svstat … 会报 "command not found";始终使用绝对路径 /command/s6-svstat。hermes 二进制可用是因为 Dockerfile 把 /opt/hermes/.venv/bin 加进了运行时 ENV PATH。
Profile 目录归属
cont-init 对账器以 hermes 身份运行(02-reconcile-profiles 中的 s6-setuidgid hermes)。如果某个 profile 目录最终归 root 所有(例如因为 docker exec <c> hermes profile create … 默认以 root 运行),对账器无法读取 SOUL.md 并报 PermissionError。缓解措施:stage2-hook.sh 在每次启动时幂等地把 $HERMES_HOME/profiles chown 给 hermes。不要删除该段。
通过 docker exec 写入的文件归 root 所有
docker exec 默认以 root 运行。要么传 --user hermes,要么等下次重启时 stage2 的 chown 清扫。不要手动以 root 在 $HERMES_HOME/profiles/<name>/ 下写文件——下次对账会清扫它们,但进行中的操作可能遇到权限错误。
服务槽位存在,但 s6-svstat 说 "s6-supervise not running"
服务目录在 tmpfs 上,容器重启时被清空。可能是 cont-init 对账器尚未运行(docker restart 后稍等片刻),或它失败了。查看 docker logs <c> | grep '02-reconcile'。
网关启动后立即退出(svstat 显示 down (exitcode 1))
很可能是该 profile 没有配置模型或认证。服务槽位是对的——网关本身未配置。先运行 hermes -p <profile> setup。s6 监管器会不断重启它;这正是期望行为(修好配置后,下一次尝试就会成功并保持运行)。
对账器跳过了某个 profile
对账器以是否存在 SOUL.md 作为"真实 profile"的标记。hermes profile create 总会生成它。如果某个 profile 目录缺少 SOUL.md(残留目录、部分恢复、备份进行中),对账器会有意跳过它。添加一个 SOUL.md(哪怕是空的)即可重新纳入。
"救命,容器退出码是 143!"
检查是否有东西在调用 s6-svscanctl -t 或 /run/s6/basedir/bin/halt——两者都会让 /init 进入第 3 阶段关闭,但返回 143(SIGTERM)而非期望的退出码。这就是 Phase 2 从架构 A 转向 B 的原因。要让容器以真实退出码关闭,必须让 CMD(main-wrapper.sh)正常退出;不要试图从 finish 脚本控制退出码。
相关 skill
hermes-agent-dev:通用的 hermes-agent 代码库导航hermes-tool-quirks:特定的 Hermes 工具变通方法(sed/grep 等)——在调试 s6 栈与 hermes 内置工具的交互时加载。