跳到主要内容

第 14 章:构建多容器任务环境

单容器 Task 很容易形成一种错觉:只要进程启动、测试能运行,环境就算正确。真实的 LLM 工程很少如此整齐。一个故障诊断 Agent 往往要面对 API、数据库、反向代理和初始化任务;任何一个服务启动过早、名称解析错误、配置没有重载,都会让同一个 HTTP 502 看起来像完全不同的问题。

本章继续构建 Dataset llmkb/system-diagnosis,新增 Task llmkb/compose-topology-repair。环境中的 Python API 已经能启动,但 Nginx 把请求转发到错误端口。Agent 必须通过服务发现和跨服务证据定位故障,修复共享配置,再让一次真实请求同时留下代理访问记录和数据库审计记录。我们不把“容器都在运行”当作成功,也不相信 Agent 自己写出的报告;独立 Verifier 会交叉检查三个来源。

全书仍锁定 Harbor Framework v0.18.0、提交 527d50deb63a5d279e8c20593c18a2cbc7f61f9e,该版本要求 Python 3.12 或更高版本。1

完成本章后,你应该能够:

  • 把 Harbor 的 main 服务与 Compose sidecar 组织成一个可复现 Task;
  • 区分“进程已启动”“服务已就绪”和“任务目标已完成”;
  • 从指定 sidecar 采集证据,并在隔离的 Verifier 环境中验收;
  • 为镜像、Secret、卷、资源限制和清理失败设计明确边界;
  • 在迁移到云 Provider 前,用能力契约而不是 Provider 名称猜测兼容性。

14.1 两层编排,而不是两套真相

Harbor 与 Docker Compose 都参与环境生命周期,但职责不同:Compose 描述服务拓扑,Harbor 描述一次 Trial 的评测协议。

问题应由谁回答本章中的实现
有哪些容器、卷、依赖Composemaindbproxyseed
Agent 在哪里执行Harbor固定服务名 main
启动后何时允许 Agent 进入Compose 与 Harbor容器 healthcheck,加顶层环境 healthcheck
Agent 结束后从哪里取证Harbor[[verifier.collect]][[artifacts]]
任务是否成功独立 Verifier报告、数据库和代理日志交叉验证
Trial 结束后是否删除资源Harbor/Providerstop(delete=...),并审计残留

在 Docker Provider 中,只提供 environment/Dockerfile 时,Harbor 会使用内部 Compose 文件创建 main;如果同目录还有 docker-compose.yaml,它会作为后续层合并,用于增加 sidecar 或覆盖字段。main 不是任意名称,而是 Harbor 的固定主服务契约。2

这个分工带来两个直接规则。

第一,不要在任务 Compose 文件中另造 agent 服务。Agent 的 shell、文件上传、超时和用户身份都落在 main。数据库、代理和消息队列才是 sidecar。

第二,不要脱离 Harbor 单独运行一份 Task Compose 后,就宣布验证了真实环境。Harbor 实际使用多份 -f 文件,还会生成挂载、资源和可选网络控制层。独立 docker compose -f environment/docker-compose.yaml config 适合发现 YAML 错误,却不等价于 Harbor 的最终合并配置。Docker Provider 的启动路径会先 build,再清理同项目名的旧容器,随后执行 docker compose up --detach --wait3

14.2 设计贯穿任务

14.2.1 拓扑和成功条件

任务拓扑如下:

Agent
│ exec in main

main:8000 ───────────────► db:5432
▲ │
│ └── audit_events

proxy:8080

│ edit /workspace/topology/default.conf
shared topology volume

seed (one-shot reset)

seed 每次启动都把故障版 Nginx 配置写入命名卷,避免上一次异常退出留下的“已修复配置”污染下一次 Trial。main 运行 API,也承载 Agent;db 保存审计事件;proxy 监视配置变化,只有 nginx -t 成功才 reload。

初始配置把 upstream 写成 main:8001,而 API 实际监听 8000。Agent 的目标不是把某个文件改到指定字符串,而是让以下事实同时成立:

  1. http://proxy:8080/incident/42 返回成功响应;
  2. PostgreSQL 中出现 incident_id=42status=resolved 的审计事件;
  3. Nginx 有对应的成功访问记录;
  4. /workspace/final-report.json 是满足契约的诊断报告;
  5. 最终有效配置确实通过 main:8000 转发,而不是用 return 200 伪造结果。

Compose 默认会为应用建立网络,服务可以用服务名互相发现;容器 IP 会随重建变化,所以配置应写 db:5432proxy:8080main:8000,不能缓存 IP。4

注意:本例不发布宿主机 ports,只用容器网络。Agent 在 main 内访问 proxy:8080,Verifier 依靠 Artifact 取证。把端口映射到宿主机既不是服务发现所必需,也会扩大并行 Trial 的端口冲突和攻击面。

14.2.2 目录骨架

compose-topology-repair/
├── task.toml
├── instruction.md
├── environment/
│ ├── Dockerfile
│ ├── docker-compose.yaml
│ ├── requirements.in
│ ├── requirements.lock
│ ├── app.py
│ ├── db/
│ │ ├── Dockerfile
│ │ └── 10-init.sh
│ └── proxy/
│ ├── Dockerfile
│ └── default.conf
├── solution/
│ └── solve.sh
└── tests/
├── Dockerfile
├── test.sh
└── verify.py

solution/solve.sh 只供 Oracle 和作者回归使用,不能复制进 Agent 可读镜像。tests/ 在本例中构建独立 Verifier 环境;它也不应出现在 main

14.2.3 先固定任务接口,再隐藏答案

多容器任务最常见的作者错误,是把环境知识都留在自己脑中。Agent 不需要知道标准解,却必须知道哪些接口是稳定契约。instruction.md 应说明:工作目录是 /workspace;允许修改 /workspace/topology/default.conf;目标入口是 http://proxy:8080/incident/42;数据库和代理是可观察的外部服务;最终报告必须写到 /workspace/final-report.json。它不应直接说“把 8001 改成 8000”,也不应要求 Agent 复述作者预设的命令序列。

可以把报告契约写成下面的结构说明,字段值只是格式示例,不是一次真实运行输出:

{
"incident_id": 42,
"status": "resolved",
"root_cause": "用可核验事实描述根因",
"changes": ["列出实际修改"],
"evidence": ["列出请求、代理和数据库证据"]
}

API 本身也要有窄而稳定的契约:GET /live 只报告进程存活,GET /ready 还验证最小权限账号能查询表;GET /incident/42 在事务中写入 audit_events 后返回事件 JSON;其他 incident ID 返回 404。数据库表和应用角色由 sidecar 初始化脚本创建。这样,任务作者可以替换 Python Web 框架而不改变评测目标,Agent 也能用 curl、Python 标准库或日志完成诊断。

提示:把“可变实现”和“稳定接口”分开。镜像、应用框架和内部日志格式可以演进;服务名、端口、允许修改的路径、目标请求和报告 schema 一旦进入 Dataset,就应由回归测试保护。

14.3 就绪不是答题成功

Compose 的启动顺序只能解决依赖时序,不能定义业务成功。短格式 depends_on 只保证按顺序启动;若依赖方必须真正可用,应使用长格式条件 service_healthy,一次性初始化任务则使用 service_completed_successfully。Compose 只有在服务定义了有意义的 healthcheck 时,才能把“healthy”与“running”区分开。5

本例使用三层门:

检查内容失败归因
容器 healthcheck数据库角色/表/权限契约;API /readynginx -t环境构建或服务启动失败
Harbor 环境 healthcheckmain 能解析并连接 dbproxy,本地 API 与数据库 ready拓扑不具备可诊断性
Verifier端到端请求、数据库状态、代理记录和报告一致Agent 未完成目标或评分基础设施异常

pg_isready 只检查 PostgreSQL 服务器的连接状态;官方文档甚至明确说明,获得服务器状态并不要求用户名、密码或数据库名正确。它不能证明 10-init.sh 已创建 app 角色、audit_events 表和授权。初始化窗口中即使服务器已经接受连接,应用契约仍可能尚未成立。因此 db healthcheck 必须执行目录与权限查询;PostgreSQL 的 has_table_privilegehas_schema_privilegehas_database_privilege 都支持用角色与对象 OID 做精确检查。6

关键点是:代理初始 upstream 错误不能让环境 healthcheck 失败,否则 Agent 尚未进入,Trial 就被基础设施门拦下。proxy/proxy-live 可以直接返回 204,用于证明 Nginx 存活;业务路径仍然因错误上游失败。Harbor 会在 Agent setup 之前运行环境 healthcheck,其重试、间隔、超时和启动宽限由 HealthcheckConfig 控制。7

下面是任务协议的关键部分:

schema_version = "1.3"

[[artifacts]]
source = "/workspace/final-report.json"
destination = "candidate/final-report.json"

[[artifacts]]
source = "/tmp/db-state.tsv"
destination = "evidence/db-state.tsv"
service = "db"

[[artifacts]]
source = "/tmp/proxy-effective.conf"
destination = "evidence/proxy-effective.conf"
service = "proxy"

[[artifacts]]
source = "/var/log/nginx/benchmark-access.log"
destination = "evidence/proxy-access.log"
service = "proxy"

[task]
name = "llmkb/compose-topology-repair"

[[task.authors]]
name = "LLMKB"

[metadata]
dataset = "llmkb/system-diagnosis"
category = "multi-container-diagnosis"
difficulty = "intermediate"

[agent]
timeout_sec = 600
user = 1000

[verifier]
timeout_sec = 120
user = 1001
environment_mode = "separate"
network_mode = "no-network"

[[verifier.collect]]
service = "db"
user = "postgres"
timeout_sec = 20
command = """
psql -U postgres -d app -At -F '|' \
-c "select incident_id,status from audit_events order by incident_id" \
> /tmp/db-state.tsv
"""

[[verifier.collect]]
service = "proxy"
timeout_sec = 10
command = "nginx -T > /tmp/proxy-effective.conf 2>&1"

[verifier.environment]
os_type = "linux"
cpus = 1
memory_mb = 384
network_mode = "no-network"

[environment]
os_type = "linux"
workdir = "/workspace"
build_timeout_sec = 900
cpus = 2
memory_mb = 1536
network_mode = "public"

[environment.env]
DB_ADMIN_PASSWORD = "${HARBOR_TASK_DB_ADMIN_PASSWORD}"
DB_APP_PASSWORD = "${HARBOR_TASK_DB_APP_PASSWORD}"

[environment.healthcheck]
command = """
python - <<'PY'
import socket
import urllib.request

for host, port in (("db", 5432), ("proxy", 8080)):
with socket.create_connection((host, port), timeout=2):
pass
for url in ("http://127.0.0.1:8000/ready",
"http://proxy:8080/proxy-live"):
with urllib.request.urlopen(url, timeout=2) as response:
if response.status not in (200, 204):
raise SystemExit(f"unexpected status: {response.status}")
PY
"""
interval_sec = 2
timeout_sec = 5
start_period_sec = 5
start_interval_sec = 1
retries = 10

service 字段让采集命令和 Artifact 指向 sidecar;路径必须是该服务内的绝对路径。Harbor v0.18.0 会在启动 Trial 前拒绝“Provider 没有 Compose 能力”或“Task 没有 Compose 定义却引用 sidecar”的组合。真正的服务执行、上传和下载则通过 Provider 的 per-service 原语完成。8

14.4 编写 Compose 拓扑

下面的 environment/docker-compose.yaml 只声明本任务的覆盖和 sidecar;main 的 build context 仍由 Harbor 的内部层提供。

services:
seed:
build:
context: ./proxy
entrypoint:
- /bin/sh
- -ec
- |
chmod 0777 /shared
cp /seed/default.conf /shared/default.conf
chmod 0666 /shared/default.conf
volumes:
- topology:/shared
cpus: 0.10
mem_limit: 64m

db:
build:
context: ./db
environment:
POSTGRES_DB: app
POSTGRES_USER: postgres
POSTGRES_PASSWORD_FILE: /run/secrets/db_admin_password
secrets:
- db_admin_password
- db_app_password
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test:
- CMD-SHELL
- |
result="$$(psql -v ON_ERROR_STOP=1 -U postgres -d app -Atqc "
SELECT 1
FROM pg_roles AS r
JOIN pg_class AS c
ON c.oid = to_regclass('public.audit_events')
JOIN pg_namespace AS n ON n.oid = c.relnamespace
WHERE r.rolname = 'app'
AND r.rolcanlogin
AND NOT r.rolsuper
AND NOT r.rolcreaterole
AND NOT r.rolcreatedb
AND c.relkind = 'r'
AND n.nspname = 'public'
AND has_database_privilege(
r.oid, current_database(), 'CONNECT')
AND has_schema_privilege(r.oid, n.oid, 'USAGE')
AND NOT has_schema_privilege(r.oid, n.oid, 'CREATE')
AND has_table_privilege(r.oid, c.oid, 'SELECT')
AND has_table_privilege(r.oid, c.oid, 'INSERT')
AND has_table_privilege(r.oid, c.oid, 'UPDATE')
AND NOT has_table_privilege(r.oid, c.oid, 'DELETE')
AND NOT has_table_privilege(r.oid, c.oid, 'TRUNCATE')
")" && [ "$$result" = "1" ]
interval: 2s
timeout: 3s
retries: 20
start_period: 5s
cpus: 0.50
mem_limit: 512m

main:
command: ["python", "/workspace/app.py"]
environment:
DB_HOST: db
DB_PORT: "5432"
DB_NAME: app
DB_USER: app
DB_PASSWORD_FILE: /run/secrets/db_app_password
secrets:
- db_app_password
volumes:
- topology:/workspace/topology
depends_on:
seed:
condition: service_completed_successfully
db:
condition: service_healthy
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/ready', timeout=2)"]
interval: 2s
timeout: 3s
retries: 20
start_period: 5s

proxy:
build:
context: ./proxy
command:
- /bin/sh
- -ec
- |
nginx
old="$$(sha256sum /etc/nginx/conf.d/default.conf)"
while sleep 1; do
new="$$(sha256sum /etc/nginx/conf.d/default.conf || true)"
if [ "$$new" != "$$old" ] && nginx -t; then
nginx -s reload
old="$$new"
fi
done
volumes:
- topology:/etc/nginx/conf.d:ro
depends_on:
seed:
condition: service_completed_successfully
main:
condition: service_healthy
healthcheck:
test: ["CMD", "nginx", "-t"]
interval: 2s
timeout: 3s
retries: 20
start_period: 5s
cpus: 0.25
mem_limit: 128m

volumes:
topology: {}
pgdata: {}

secrets:
db_admin_password:
environment: DB_ADMIN_PASSWORD
db_app_password:
environment: DB_APP_PASSWORD

三个镜像都应锁定 digest,而不是只锁定 tag。mainenvironment/Dockerfile 使用:

FROM python:3.12-slim@sha256:c3d81d25b3154142b0b42eb1e61300024426268edeb5b5a26dd7ddf64d9daf28
WORKDIR /workspace
COPY requirements.lock /tmp/requirements.lock
RUN pip install --no-cache-dir --require-hashes -r /tmp/requirements.lock
COPY app.py /workspace/app.py
RUN groupadd --gid 1000 agent \
&& useradd --uid 1000 --gid 1000 --create-home agent \
&& chown -R agent:agent /workspace

requirements.in 只包含固定版本 psycopg[binary]==3.3.4;用同一版 uv 生成带哈希的完整锁文件,并把 requirements.lock 纳入 Task 源码,而不是在镜像构建时临时解析版本:

uv pip compile --generate-hashes \
environment/requirements.in \
--output-file environment/requirements.lock

Psycopg 3.3.4 要求 Python 3.10 或更高版本,因此与本章的 Python 3.12 镜像契约相容。9 锁文件会包含不同平台 wheel 的多条哈希,正文不省略到一个无法安装的伪锁;配套源码必须保存命令生成的完整文件。镜像中的 pip --require-hashes 会拒绝没有哈希或发生内容漂移的依赖。

最小 environment/app.py 不依赖 Web 框架,只用标准库提供 HTTP 层;数据库驱动负责参数化 SQL 和事务提交:

import json
import os
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path

import psycopg


def db_connect():
password = Path(os.environ["DB_PASSWORD_FILE"]).read_text().strip()
return psycopg.connect(
host=os.environ["DB_HOST"],
port=int(os.environ["DB_PORT"]),
dbname=os.environ["DB_NAME"],
user=os.environ["DB_USER"],
password=password,
connect_timeout=3,
)


def initialize() -> None:
with db_connect() as connection:
connection.execute("select 1 from audit_events limit 0")


class Handler(BaseHTTPRequestHandler):
def send_json(self, status: int, payload: dict) -> None:
body = json.dumps(payload, separators=(",", ":")).encode()
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)

def do_GET(self) -> None:
if self.path == "/live":
self.send_json(200, {"status": "live"})
return
if self.path == "/ready":
try:
with db_connect() as connection:
connection.execute("select 1 from audit_events limit 0")
except psycopg.Error:
self.send_json(503, {"status": "not-ready"})
return
self.send_json(200, {"status": "ready"})
return
if self.path != "/incident/42":
self.send_json(404, {"error": "not found"})
return
try:
with db_connect() as connection:
row = connection.execute("""
insert into audit_events (incident_id, status)
values (%s, %s)
on conflict (incident_id)
do update set status = excluded.status
returning incident_id, status
""", (42, "resolved")).fetchone()
except psycopg.Error:
self.send_json(503, {"error": "database unavailable"})
return
self.send_json(200, {"incident_id": row[0], "status": row[1]})

def log_message(self, *_args) -> None:
pass


if __name__ == "__main__":
initialize()
ThreadingHTTPServer(("0.0.0.0", 8000), Handler).serve_forever()

数据库镜像也固定基础 digest,并用 environment/db/10-init.sh 分离管理员与应用权限:

FROM postgres:16-bookworm@sha256:92620daddcd947f8d5ab5ba66e848702fe443d87fed30c4cea8e389fd78dfc55
COPY 10-init.sh /docker-entrypoint-initdb.d/10-init.sh
RUN chmod 0555 /docker-entrypoint-initdb.d/10-init.sh
#!/bin/sh
set -eu
app_password="$(cat /run/secrets/db_app_password)"
psql -v ON_ERROR_STOP=1 \
--username "$POSTGRES_USER" --dbname "$POSTGRES_DB" \
--set=app_password="$app_password" <<'SQL'
CREATE ROLE app LOGIN PASSWORD :'app_password';
CREATE TABLE audit_events (
incident_id integer PRIMARY KEY,
status text NOT NULL CHECK (status IN ('resolved'))
);
GRANT SELECT, INSERT, UPDATE ON audit_events TO app;
SQL

app 没有 CREATEDROPDELETE 权限,所以 Agent 即使读取应用 Secret,也不能通过删表让 collect 失败。数据库 collector 在 db sidecar 中以 OS 用户和数据库角色 postgres 运行;管理员 Secret 从未授予 main

代理镜像 environment/proxy/Dockerfile 使用:

FROM nginx:1.27-alpine@sha256:65645c7bb6a0661892a8b03b89d0743208a18dd2f3f17a54ef4b76fb8e2f2a10
COPY default.conf /seed/default.conf

这些 digest 是本章写作时于 2026-07-16 通过 Docker Registry v2 对相应 tag 的 OCI manifest index 做 HEAD 查询得到的固定值;它们固定内容,不保证未来仍是作者想要的安全基线。Compose 的 image 语法允许 name@digest10

初始 default.conf 保持“代理活着、业务链路坏着”:

upstream app_backend {
server main:8001;
}

server {
listen 8080;
access_log /var/log/nginx/benchmark-access.log combined;

location = /proxy-live {
return 204;
}

location / {
proxy_pass http://app_backend;
}
}

作者的 solution/solve.sh 应通过公开入口完成目标,而不是直接向数据库伪造结果。下面的 Oracle 原子替换唯一的故障端口,轮询代理等待 reload,然后用真实响应生成严格报告:

#!/bin/bash
set -euo pipefail

python - <<'PY'
from pathlib import Path
import os

path = Path("/workspace/topology/default.conf")
text = path.read_text(encoding="utf-8")
if text.count("server main:8001;") != 1:
raise SystemExit("initial upstream contract changed")
tmp = path.with_suffix(".tmp")
tmp.write_text(text.replace("server main:8001;", "server main:8000;"),
encoding="utf-8")
os.replace(tmp, path)
PY

python - <<'PY'
import json
import time
import urllib.request
from pathlib import Path

url = "http://proxy:8080/incident/42"
last_error = None
for _ in range(20):
try:
with urllib.request.urlopen(url, timeout=2) as response:
payload = json.load(response)
if response.status == 200 and payload == {
"incident_id": 42, "status": "resolved"
}:
break
except Exception as exc:
last_error = exc
time.sleep(0.5)
else:
raise SystemExit(f"proxy did not converge: {last_error}")

report = {
"incident_id": 42,
"status": "resolved",
"root_cause": "proxy upstream referenced the wrong application port",
"changes": ["updated app_backend to main:8000"],
"evidence": [
"proxy returned the expected incident JSON",
"request traversed the application database transaction",
],
}
Path("/workspace/final-report.json").write_text(
json.dumps(report, ensure_ascii=False, sort_keys=True) + "\n",
encoding="utf-8",
)
PY

警告:不要把数据库密码直接写进 Task、镜像层或 Compose 文件。本例先由宿主环境变量解析 [environment.env],再让 Compose 分别从 DB_ADMIN_PASSWORDDB_APP_PASSWORD 创建 Secret。管理员 Secret 只授予 db;应用 Secret 授予 db 初始化脚本和 main,容器从 /run/secrets/... 读取。Compose Secret 只有在服务显式授权后才挂载。11

这里的密码只是任务内部凭据,不是模型 Provider 的 API Key。应用密码仍可能被 main 中的 Agent 读取,因为 Agent 必须调用数据库链路;Verifier 不能把“知道密码”视为完成任务的证据。

14.5 运行、观察与验收

14.5.1 作者回归

在包含 compose-topology-repair/ 的父目录执行:

export HARBOR_TASK_DB_ADMIN_PASSWORD="$(openssl rand -hex 24)"
export HARBOR_TASK_DB_APP_PASSWORD="$(openssl rand -hex 24)"

uv run --project /path/to/harbor-v0.18.0 harbor run \
-p ./compose-topology-repair \
-e docker \
-a oracle

unset HARBOR_TASK_DB_ADMIN_PASSWORD HARBOR_TASK_DB_APP_PASSWORD

前置条件是检出精确提交的 Harbor、可用的 Docker Engine 和 Compose 插件,以及能拉取上述镜像的网络或本地缓存。不要在共享 shell 历史中手写真实生产密码。

一次合格的回归应核对,而不是只看最终 Reward:

  • 环境 healthcheck 在 Agent 开始前通过,但初始业务请求失败;
  • Oracle 修改共享配置后,watcher 只在 nginx -t 通过时 reload;
  • artifacts/manifest.json 中四个必需条目的状态都是 ok
  • 数据库证据、访问日志与报告都指向 incident 42;
  • Verifier 在 separateno-network 环境中给出确定性结果;
  • Trial 结束后没有残留同一项目名的容器和卷。

本章作者机器没有可用的 Docker CLI,因此上述是复现与验收协议,不是本机实测结果。本章交稿时实际完成的是 Task.load/TaskConfig 校验、Compose YAML 语法解析、app.py 编译、依赖锁文件 dry-run、严格 Verifier 正反例、三态 Reward 桥接、固定版本源码测试,以及镜像 digest 的 Registry 查询;运行期的 build/up/health/reload/down 必须在 Docker 主机上补做。这个边界也应写入任务发布报告,不能用静态与脚本级校验冒充端到端通过。

14.5.2 独立 Verifier 的证据链

Harbor 在 separate 模式下会先从原环境采集 Artifact,再把它们恢复到独立 Verifier 的原始 source 路径destination 只组织宿主机归档目录。主服务与 sidecar 的采集是尽力而为,宿主侧 manifest 可记录 okfailedemptyskipped。manifest 本身不会因为存在于归档目录就自动成为 Verifier 输入:容器内 Verifier 应要求每个必需源路径存在且非空;发布审计则另外要求对应 manifest 条目为 ok。两道门都应 fail closed。12

separate 模式还有一个容易忽略的时序:Harbor 先采集 main 的 Artifact,再尝试停止 main,然后才执行 sidecar collect 和下载。这样可以减少遗留 Agent 进程在数据库取证期间继续写入的机会;但停止失败只会告警,sidecar 状态也仍可能是 Agent 早先通过服务接口造成的结果。因此取证脚本应输出窄、确定、可比较的状态,而不是把整个数据库目录打包后交给评分器猜测。12

独立镜像不需要数据库或 Nginx 客户端,因为 collect 已把窄证据转换成文本。tests/Dockerfile 可以保持很小:

FROM python:3.12-slim@sha256:c3d81d25b3154142b0b42eb1e61300024426268edeb5b5a26dd7ddf64d9daf28
RUN groupadd --gid 1001 verifier \
&& useradd --uid 1001 --gid 1001 --create-home verifier
COPY --chown=1001:1001 . /tests/
RUN chmod 0555 /tests/test.sh

tests/test.sh 负责把 Python 检查的三态退出码转换为 Harbor Reward:0 表示通过,10 表示一份可读候选没有满足契约,其他状态表示评分基础设施异常。只有前两种状态才原子写 reward.json;基础设施异常不伪造为候选零分。

#!/bin/sh
set -u
umask 077
readonly LOG_DIR=/logs/verifier
readonly REWARD="$LOG_DIR/reward.json"
mkdir -p "$LOG_DIR" || exit 70
rm -f "$REWARD" "$LOG_DIR/reward.txt"

env -u CH14_VERIFY_ROOT python3 /tests/verify.py
status=$?
case "$status" in
0) score=1 ;;
10) score=0 ;;
*)
echo "verifier infrastructure error (exit=$status)" >&2
exit "$status"
;;
esac

tmp="$LOG_DIR/.reward.json.$$"
printf '{"reward":%s}\n' "$score" > "$tmp" || exit 73
mv -f "$tmp" "$REWARD" || exit 73

tests/verify.py 本身执行完整的严格检查。它不导入候选文件;候选报告的缺失、非法 JSON 或结构错误是正常零分,而权威 sidecar 证据未被恢复才是基础设施错误:

import json
import os
import re
import sys
from pathlib import Path

ROOT = Path(os.environ.get("CH14_VERIFY_ROOT", "/"))


def at(absolute_path: str) -> Path:
return ROOT / absolute_path.lstrip("/")


class CandidateFailure(Exception):
pass


class InfrastructureFailure(Exception):
pass


def candidate(condition: bool, message: str) -> None:
if not condition:
raise CandidateFailure(message)


def nonempty_string(value) -> bool:
return type(value) is str and bool(value.strip())


def string_list(value) -> bool:
return (
type(value) is list
and 1 <= len(value) <= 8
and all(nonempty_string(item) for item in value)
and len(set(value)) == len(value)
)


def strict_report(path: Path) -> dict:
if not path.is_file():
raise CandidateFailure("final report is missing")
try:
raw = path.read_bytes()
except OSError as exc:
raise CandidateFailure(f"cannot read final report: {exc}") from exc
candidate(0 < len(raw) <= 16_384, "report size is outside 1..16384 bytes")

def no_duplicates(pairs):
result = {}
for key, value in pairs:
candidate(key not in result, f"duplicate JSON key: {key}")
result[key] = value
return result

def no_constants(token):
raise CandidateFailure(f"non-finite JSON constant: {token}")

try:
value = json.loads(
raw.decode("utf-8"),
object_pairs_hook=no_duplicates,
parse_constant=no_constants,
)
except CandidateFailure:
raise
except (UnicodeError, json.JSONDecodeError) as exc:
raise CandidateFailure(f"invalid report JSON: {exc}") from exc

expected = {"incident_id", "status", "root_cause", "changes", "evidence"}
candidate(type(value) is dict and set(value) == expected,
"report must contain exactly the public keys")
candidate(type(value["incident_id"]) is int and value["incident_id"] == 42,
"incident_id must be integer 42")
candidate(value["status"] == "resolved", "status must be resolved")
candidate(nonempty_string(value["root_cause"]), "root_cause must be nonempty")
candidate(string_list(value["changes"]), "changes must be unique strings")
candidate(string_list(value["evidence"]), "evidence must be unique strings")
return value


def authority_text(path: Path, limit: int = 1_048_576) -> str:
if not path.is_file():
raise InfrastructureFailure(f"required sidecar artifact missing: {path}")
try:
raw = path.read_bytes()
except OSError as exc:
raise InfrastructureFailure(f"cannot read sidecar artifact {path}: {exc}") from exc
if len(raw) > limit:
raise InfrastructureFailure(f"sidecar artifact exceeds {limit} bytes: {path}")
try:
return raw.decode("utf-8")
except UnicodeError as exc:
raise CandidateFailure(f"non-UTF-8 sidecar state: {path}") from exc


def check_database(text: str) -> None:
rows = []
for line in text.splitlines():
if not line:
continue
parts = line.split("|")
candidate(len(parts) == 2 and parts[0].isdigit(),
f"malformed database row: {line!r}")
rows.append((int(parts[0]), parts[1]))
candidate(rows == [(42, "resolved")],
f"database rows differ from expected state: {rows!r}")


def check_nginx(text: str) -> None:
clean = "\n".join(line.split("#", 1)[0] for line in text.splitlines())
def directives(block: str) -> list[str]:
return [" ".join(item.split()) for item in block.split(";") if item.strip()]

upstreams = re.findall(
r"\bupstream\s+app_backend\s*\{([^{}]*)\}", clean, re.DOTALL
)
candidate(len(upstreams) == 1, "expected exactly one app_backend upstream")
upstream_directives = directives(upstreams[0])
candidate(upstream_directives == ["server main:8000"],
f"unexpected upstream directives: {upstream_directives}")

business = re.findall(r"\blocation\s+/\s*\{([^{}]*)\}", clean, re.DOTALL)
candidate(len(business) == 1, "expected exactly one business location")
business_directives = directives(business[0])
candidate(business_directives == ["proxy_pass http://app_backend"],
f"unexpected business directives: {business_directives}")
candidate(not re.search(r"\blocation[^\{]*incident/42", clean),
"incident-specific location bypass is forbidden")


ACCESS = re.compile(
r'^\S+ \S+ \S+ \[[^\]]+\] '
r'"(?P<method>[A-Z]+) (?P<target>\S+) HTTP/\d\.\d" '
r'(?P<status>\d{3}) (?:\d+|-) "(?:[^"\\]|\\.)*" "(?:[^"\\]|\\.)*"$'
)


def check_access(text: str) -> None:
observations = []
for line in filter(None, text.splitlines()):
match = ACCESS.fullmatch(line)
candidate(match is not None, f"malformed combined access line: {line!r}")
observations.append((match["method"], match["target"], int(match["status"])))
candidate(("GET", "/incident/42", 200) in observations,
"no successful GET /incident/42 was observed")


def main() -> None:
strict_report(at("/workspace/final-report.json"))
check_database(authority_text(at("/tmp/db-state.tsv")))
check_nginx(authority_text(at("/tmp/proxy-effective.conf")))
check_access(authority_text(at("/var/log/nginx/benchmark-access.log")))
print("PASS: report, database, effective proxy config and access log agree")


if __name__ == "__main__":
try:
main()
except CandidateFailure as exc:
print(f"CANDIDATE_FAIL: {exc}", file=sys.stderr)
raise SystemExit(10)
except InfrastructureFailure as exc:
print(f"INFRA_ERROR: {exc}", file=sys.stderr)
raise SystemExit(20)
except Exception as exc:
print(f"INFRA_ERROR: unexpected verifier exception: {exc}", file=sys.stderr)
raise SystemExit(21)

这段实现已经拒绝重复键、NaN/Infinity、未知字段、布尔冒充整数、空列表、重复列表项、数据库多行/少行、模糊字符串命中和不可解析访问日志。配置检查针对 nginx -T 的有效输出,而不是只读取 Agent 声称修改的文件。不要再加“执行候选脚本确认修复”之类的捷径;那会把独立 Verifier 重新变成候选代码执行器。

即使 Artifact 来自 dbproxy,也不能自动视为可信真值:Agent 能通过服务 API 改写数据库,也能修改共享 Nginx 配置。sidecar 的价值在于建立不同状态面的交叉证据,不是建立绝对信任。对高对抗任务,应进一步把审计日志写到 Agent 无写权限的外部接收器,或者在 Agent 结束后由控制面发起新探针。

14.5.3 负例比一次 Oracle 通过更有信息量

作者回归至少应包含四个故意失败的候选:只写正确报告但不改配置;直接向数据库插入事件但不经过代理;让 Nginx 对目标路径固定返回 200;删除一个必需 Artifact。它们应分别被配置、访问日志、跨服务一致性和文件存在性检查拒绝。再加入一个“修复端口但写出非法 JSON”的候选,证明输出契约没有被端到端状态掩盖。

这些负例回答的是“评分器能否拒绝投机解”,而 Oracle 只回答“标准解是否能通过”。若二者只做其一,Task 仍不能发布。保存每个负例的候选变更、Artifact manifest、Verifier 标准输出和 Reward,后续升级基础镜像或 Provider 时原样重放;任何原本应失败的负例变成通过,都应视为评分回归。

14.6 资源、网络与清理边界

14.6.1 资源不是只给 main

[environment].cpusmemory_mb 会生成针对 services.main 的 Compose 资源覆盖;它不会自动为任务作者新增的 sidecar 分配同比例限制。Docker Provider 声明支持 CPU 和内存限制,但 sidecar 仍需在 Compose 中逐项写 cpusmem_limit13

因此本例的账本是:Harbor 给 main 2 CPU、1536 MiB,Compose 另给 db 0.50 CPU/512 MiB、proxy 0.25 CPU/128 MiB、seed 0.10 CPU/64 MiB。外层 Provider 或 CI runner 还必须容纳总量和镜像构建峰值。Compose 能解析限制,只证明配置存在;是否真正执行限制,要在目标运行时用压力测试和运行时指标验证。

OOM、磁盘写满或 PID 耗尽可能表现成数据库重启、代理 502 或 Agent 超时。排查顺序应先看容器退出原因和资源指标,再解释业务日志,不能把所有 502 都归因到 upstream。

14.6.2 网络模式的危险直觉

本例把 Task 环境设为 public,因为它专注于多服务诊断。不要据此推断所有 Compose sidecar 都具有相同网络隔离。

Docker Provider 在启用 egress control 时,只会把没有显式 networksnetwork_mode 的任务服务加入控制层;任务作者显式声明的网络会被尊重。由此可以推断:给 Task 写 [environment].network_mode = "no-network",再在 Compose 中随手声明自定义网络,不能单靠配置文字证明 sidecar 已断网。应在目标 Linux runner 中对每个服务分别做正、负连通性测试。14

第 15 章将专门处理受控网络。本章只保留一条发布门:没有逐服务网络探针证据,就不声称“多容器环境无外网”。

14.6.3 命名卷会比容器活得久

Compose 命名卷的生命周期独立于容器。普通 docker compose down 不会删除命名卷;down --volumes 才会移除 Compose 声明的命名卷与匿名卷,external 卷始终不会被该命令删除。15

Harbor v0.18.0 在 Docker Provider 的正常删除路径中使用 down --rmi local --volumes --remove-orphans;若要求保留容器则执行 stop,delete=false 时只执行 down。清理异常会记录 warning,属于尽力而为。16

这解释了 seed 的必要性:异常中断可能让 topology 卷保留已修复配置。不过 pgdata 也可能保留旧审计事件,单靠 seed 不能解决。发布回归应使用隔离的 Compose project/session,启动前断言数据库为空,结束后盘点容器、网络和卷;发现残留时隔离并调查,不要无差别删除宿主机上的所有卷。

14.7 Provider 兼容性:先问能力,再迁移

Harbor 的 Provider 能力对象包含 docker_composeper_service_execper_service_copyper_service_stop 等字段;Compose Task 的完整协议依赖这些能力。17

在锁定提交中,代表性实现如下:

Providerv0.18.0 中的 Compose 路径迁移前必须验证
Docker本地 Docker Compose,原生支持本章协议Docker/Compose 版本、Linux 网络控制、总资源
ModalCompose 模式运行于 DinD;该模式关闭部分网络隔离/allowlist 能力特权与 DinD 可用性、网络政策、构建缓存
DaytonaCompose 模式使用 DinD;Windows Compose 被拒绝,且该模式存在 Secret/网络能力限制Secret 注入替代方案、Linux runner、网络探针
GKECompose 模式使用 DinD;Compose 路径不提供 GPU/TPUPod 权限、存储、配额、无外网实测
OpenSandbox实现明确拒绝 Compose 环境不可直接运行本章 Task

这些结论只适用于本书锁定版本,不是永久的云产品对比。Docker、EC2 等 Provider 声明 Compose 能力,也不等于每个部署都已满足 DinD、Secret、网络策略和卷语义。迁移步骤应是:

  1. 读取目标 Provider 在固定提交中的能力与 Compose 分支;
  2. 用一个最小 Task 验证 per-service exec/copy/stop;
  3. 再验证健康检查、一次性服务、Secret、卷清理和逐服务网络;
  4. 最后运行 Oracle 与故障负例,比较 Artifact manifest 和 Reward。

只要其中一项缺证据,就把 Provider 标为“未验证”,而不是“应该兼容”。

14.8 失败模式与排查顺序

表现首先检查常见根因不应立即下的结论
up --wait 超时各服务 health 状态与日志healthcheck 命令缺依赖、DB 初始化慢、资源不足Agent 失败
环境门失败但容器 healthymain 做 DNS/端口探针服务名错误、显式网络拆分拓扑Compose 不支持 DNS
修改后仍为 502nginx -t、watcher、有效配置配置语法错、卷路径错、reload 未发生API 一定宕机
DB 有记录但代理无 200access log 与请求路径Agent 直连数据库、业务链路未修复任务已完成
报告正确但 Reward 为 0manifest 与三方证据报告自述与真实状态不一致Verifier 一定坏了
sidecar Artifact 缺失collect 退出码、用户、路径权限、命令超时、服务提前停止该服务从未运行
第二次运行一开始就成功卷与 session 残留上次清理失败、seed 未覆盖Task 太简单
本地通过、云端失败Provider 能力与运行时政策Compose/DinD、Secret、网络、资源差异Harbor 版本不一致即可解释全部问题

故障归因应沿生命周期前进:构建 → Compose 启动 → 服务健康 → Harbor 拓扑门 → Agent → sidecar collect → Artifact 传输 → 独立 Verifier → 清理。第一个可重复失败的边界才是当前根因候选。最终 Reward 为 0 只说明目标未被有效证明,不能反向定位是哪一层坏了。

14.9 发布前检查表

  • Harbor 版本、提交、Python 下限和 Task 校验和已记录;镜像使用审核过的 digest。
  • main 只承担 Agent 与应用职责;所有 sidecar 都有稳定服务名和必要的 healthcheck。
  • 环境 healthcheck 检查可诊断性,不提前要求 Agent 完成修复。
  • depends_on 对长期依赖使用 service_healthy,对 seed/migration 使用 service_completed_successfully
  • 没有不必要的宿主机端口;Secret 只授予必要服务,日志和 Artifact 不包含明文凭据。
  • 每个 sidecar 都有显式资源限制;外层运行时容量覆盖总量和构建峰值。
  • Artifact destination 唯一;必需 manifest 状态非 ok 时 fail closed。
  • Verifier 使用 separate/no-network 环境,不执行候选代码,并交叉检查不同状态面。
  • Oracle、初始失败负例、错误配置负例、空 Artifact 负例都已运行。
  • 对目标 Provider 实测 per-service 操作、逐服务网络、卷和 Secret;未测能力明确标为未验证。
  • 正常结束和强制中断后都执行残留审计,不把 best-effort cleanup 当作保证。

14.10 本章小结

  • Harbor 管 Trial 协议,Compose 管服务拓扑;main 是固定的 Agent 服务契约。
  • running、healthy 和任务成功是三个不同状态。环境门应让问题可诊断,Verifier 才判断修复完成。
  • sidecar Artifact 提供交叉证据,但仍可能受 Agent 行为影响,不能自动升级为可信真值。
  • main 的资源策略不会替 sidecar 完成容量规划;Secret、卷和清理也必须逐服务设计。
  • Compose 支持是 Provider 能力组合,不是云平台名称带来的保证;迁移结论必须来自固定版本源码和运行期探针。
  • 静态模型/YAML校验不能替代容器构建、健康检查、配置重载和清理回归。

14.11 练习

  1. db healthcheck 临时改成恒成功,但让 PostgreSQL 延迟初始化。记录 main 的首次失败,并改回 service_healthy 方案;解释为什么增加任意 sleep 不是等价修复。
  2. proxy 配置制造语法错误。验证 watcher 保留旧 worker,并设计一条 Artifact 证据区分“配置文件已改变”和“有效配置已重载”。
  3. 在隔离测试机中强制终止一次 Trial,盘点容器、网络和卷。重新运行时证明 topology 与数据库都从干净状态开始;不得使用删除所有 Docker 资源的命令。
  4. 为本章 Task 新增 Redis sidecar。写出健康检查、资源限制、Secret 授权、collect hook 和 Verifier 交叉证据,并说明 Agent 如何伪造每种单一证据。
  5. 选择一个非 Docker Provider,按 14.7 节协议制作兼容性报告。报告必须包含 Harbor 精确提交、能力源码位置、实际探针、未执行项和失败日志,不能只写“支持/不支持”。

参考资料

Footnotes

  1. Harbor Framework v0.18.0 源码:项目版本与 Python 要求

  2. Harbor Framework v0.18.0 源码:固定 main 服务名Compose 文件分层内部 build 层

  3. Harbor Framework v0.18.0 源码:Compose 命令构造Docker 环境启动。Docker 文档:compose up --wait,访问于 2026-07-16。

  4. Docker 文档:Compose networking,访问于 2026-07-16。

  5. Docker 文档:Control startup orderCompose services reference,访问于 2026-07-16。

  6. PostgreSQL 16 官方文档:pg_isready 只检查服务器连接状态,并说明状态检查不要求连接身份与数据库名正确;Access Privilege Inquiry Functions 定义 has_database_privilegehas_schema_privilegehas_table_privilege 的角色/对象 OID 形式,访问于 2026-07-16。

  7. Harbor Framework v0.18.0 源码:HealthcheckConfig环境 healthcheck 执行语义Agent setup 前执行 healthcheck

  8. Harbor Framework v0.18.0 源码:Compose 与 per-service 能力契约Task 启动前的 sidecar 引用检查Docker per-service 操作

  9. Python Package Index:psycopg 3.3.4,包括 Requires-Python >=3.10 元数据,访问于 2026-07-16。

  10. Docker 文档:Compose image 字段,访问于 2026-07-16。镜像 digest 取证方法:Docker Registry HTTP API V2 对 tag manifest 发起带 OCI/Docker manifest Accept 头的 HEAD 请求,读取 Docker-Content-Digest,查询于 2026-07-16。

  11. Docker 文档:Compose secrets referenceUse secrets in Compose,访问于 2026-07-16。Harbor Framework v0.18.0 源码:Task env 解析并注入 Compose 子进程Compose 环境变量合并

  12. Harbor Framework v0.18.0 源码:Artifact/collect 配置模型Artifact 源路径恢复与 manifest尽力而为的收集与恢复separate 模式采集顺序停止 main 后采集 sidecarmanifest 状态 2

  13. Harbor Framework v0.18.0 源码:Docker Provider 能力与资源支持资源覆盖只生成到 services.main。Docker 文档:Compose services 的 cpusmem_limit,访问于 2026-07-16。

  14. Harbor Framework v0.18.0 源码:显式网络与 egress control 服务选择

  15. Docker 文档:Volumesdocker compose down,访问于 2026-07-16。

  16. Harbor Framework v0.18.0 源码:Docker 环境停止与清理

  17. Harbor Framework v0.18.0 源码:Provider 能力模型DockerModal Compose 模式Daytona Compose 限制GKE Compose 模式OpenSandbox 拒绝 Compose