关于 hermes-s6-container-supervision
面向维护 Hermes Docker 镜像与容器启动流程的开发者,说明 s6-overlay 服务监督、配置初始化和按配置文件恢复网关的架构与排障方法。重点涵盖主进程与受监督服务的职责边界。
主要功能
- 01说明 s6-overlay 启动与监督架构
- 02介绍初始化脚本和配置文件恢复
- 03管理配置文件网关服务槽位
- 04提供服务状态检查与排障步骤
适用场景
- 01排查容器重启后网关未启动
- 02添加或修改 Docker 静态服务
- 03调整容器初始化或启动参数
- 04检查 s6 服务运行状态
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ nousresearch/ hermes-agent --skill hermes-s6-container-supervision安装后在 Claude Code 中输入/hermes-s6-container-supervision确认已加载。
详细介绍
Hermes s6-overlay 容器监督
何时使用此技能
在以下情况下加载此技能:
- 在 Hermes Docker 镜像中添加或移除静态服务(例如仪表盘这类应在每次容器启动时都受到监督的服务)
- 诊断为何每个配置文件对应的网关无法启动、重启或在
docker restart后继续运行 - 了解为何容器的 CMD 是
/opt/hermes/docker/main-wrapper.sh,以及以连字符开头的参数如何传递给用户程序 - 修改
cont-init.d启动脚本(UID 重映射、卷初始化、配置文件协调) - 更改每个配置文件对应网关的渲染运行脚本(第 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 时,执行 /init + main-wrapper;在包装运行时(Fly Machines、docker run --init)下,直接回退到 stage2-hook + main-wrapper,并先恢复 s6 辅助程序的 PATH(#38349)。 |
docker/stage2-hook.sh | “旧入口逻辑”——UID 重映射、chown、初始化、同步技能。作为 cont-init.d/01-hermes-setup 运行。 |
docker/cont-init.d/02-reconcile-profiles | 每次启动时调用 hermes_cli.container_boot,从持久卷恢复配置文件网关槽位。 |
docker/main-wrapper.sh | 容器的 CMD。路由用户参数,通过 s6-setuidgid 切换到 hermes 用户,并执行选定的程序。 |
docker/s6-rc.d/main-hermes/run | 空操作 sleep infinity——此槽位用于确保 s6-rc 用户 bundle 有效;main hermes 作为 CMD 运行,而非作为受监督的服务运行。 |
docker/s6-rc.d/dashboard/run | 条件服务——除非 HERMES_DASHBOARD 为真值,否则执行 exec sleep infinity。 |
docker/entrypoint.sh | 向后兼容的垫片,用于执行 stage2 hook。硬编码了旧入口路径的外部脚本仍可正常工作。 |
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()——遍历持久化配置文件,重新生成 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",也就无法设置供服务run脚本使用的HERMES_ARGS。 /run/s6/basedir/bin/halt不会传递写入/run/s6-linux-init-container-results/exitcode的退出码。无论如何,容器的退出码始终是 143(SIGTERM)。这一点已由 skarnet(s6 作者)在 issue #477 中确认:"如果你想关闭容器,就需要让 CMD 退出;或者,如果没有 CMD,就先写入你想要的容器退出码,然后调用 halt"。
因此,我们通过 dispatcher 使用 s6-overlay 原生的 CMD 模式:ENTRYPOINT ["/opt/hermes/docker/entrypoint-dispatch.sh"]。它会在 PID 1 下执行 /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 用户,然后执行所选程序。程序的退出码会成为容器的退出码,和使用 s6 之前基于 tini 的行为完全一致。当 entrypoint 不是 PID 1 时(Fly Machines、docker run --init),dispatcher 会完全跳过 /init(否则它会因 can only run as pid 1 而中止),恢复 s6 helper 的 PATH,运行 stage2-hook.sh,然后直接执行 main-wrapper.sh——这一路径上没有受监督的服务(#38349)。
权衡:主程序 hermes 在 s6 下不受监督。这与它在 tini 下(使用 s6 之前的镜像)的行为完全一致。Dashboard 监督是唯一新增的保障——而 /run/service/ 下的各配置文件网关则受到完整监督。
快速操作示例
验证运行中的容器里 s6 是 PID 1
docker exec <c> sh -c 'cat /proc/1/comm; readlink /proc/1/exe'
# Expect: s6-svscan or init / /package/admin/s6/.../s6-svscan
检查配置文件网关服务
# /command/ isn't on docker-exec PATH — use absolute path
docker exec <c> /command/s6-svstat /run/service/gateway-<name>
# "up (pid …) … seconds" → running
# "down (exitcode N) … seconds, normally up, want up, …" → s6 wants it up but the process keeps exiting (crash loop)
# "down … normally up, ready …" → user stopped it
手动启动/停止服务
docker exec <c> /command/s6-svc -u /run/service/gateway-<name> # up
docker exec <c> /command/s6-svc -d /run/service/gateway-<name> # down
docker exec <c> /command/s6-svc -t /run/service/gateway-<name> # SIGTERM (restart)
查看 cont-init reconciler 日志
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/会自动包含该服务,无需其他更改。
更改各配置文件网关的 run 命令
编辑 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
# Expect 19 passed, 0 xfailed against the s6 image
测试脚本位于 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。
配置目录所有权
cont-init 协调器以 hermes 身份运行(02-reconcile-profiles 中的 s6-setuidgid hermes)。如果配置目录最终归 root 所有(例如,docker exec <c> hermes profile create … 默认以 root 身份运行),协调器就无法读取 SOUL.md,并会因 PermissionError 失败。缓解措施:stage2-hook.sh 会在每次启动时将 $HERMES_HOME/profiles 的所有权改为 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'。
Gateway 启动后立即退出(svstat 中显示 down (exitcode 1))
最可能的原因是配置中没有模型或身份验证设置。服务槽位没有问题——未配置的是 Gateway 本身。请先运行 hermes -p <profile> setup。s6 监督器会持续重启它;这是预期行为(修复配置后,下一次尝试就会成功并保持运行)。
协调器跳过了某个配置
协调器以**是否存在 SOUL.md**作为“真实配置”的标记。hermes profile create 总会创建该文件。如果配置目录中缺少 SOUL.md(例如目录残留、恢复不完整或正在进行备份),协调器会有意跳过它。添加一个 SOUL.md(即使内容为空)即可重新启用该配置。
###“救命,容器以 143 退出了!”
检查是否有程序调用了 s6-svscanctl -t 或 /run/s6/basedir/bin/halt——两者都会导致 /init 开始第 3 阶段关机,但返回 143(SIGTERM),而不是预期的退出代码。这是从 A 到 B 的第 2 阶段架构转向所致。要让容器以实际退出代码关机,必须让 CMD(main-wrapper.sh)正常退出;不要尝试通过 finish 脚本控制退出。
相关技能
hermes-agent-dev:hermes-agent 代码库通用导航hermes-tool-quirks:Hermes 工具的特定问题处理方法(sed/grep 等)——调试 s6 堆栈与 hermes 内置工具的交互时加载。