第 5 章:构建可复现的容器环境
同一个“修复 Python 服务端口”的 Task,周一让 Agent 面对端口 8001 上的故障服务,周三却在端口 8000 上直接通过健康检查。两次目录内容看起来没有变化。区别藏在目录之外:基础镜像标签移动了,pip install 解析到新版本,第一次构建复用了旧缓存,第二次构建发生在另一种 CPU 架构上;服务还在启动时下载了一份会变化的配置。此时 Reward 的差异不能归因于 Agent。
本章贯穿的问题是:怎样证明每次 Trial 开始时,Agent 面对的是同一个可观察故障,而不是一个名字相同、状态不同的容器? 我们将为“系统配置与故障诊断 Agent Benchmark”构造 python-port-repair Task:镜像内含一份固定故障日志和一个配置错误的 Python 服务,并将构建、启动与负向基线分别验收。
读完本章,读者应当能够:
- 组织 Harbor v0.18.0 能识别的 Task 与
environment/构建上下文; - 固定基础镜像、Python 依赖、工作目录、输入文件和初始故障;
- 正确解释 Docker Provider 对 CPU、内存、存储、GPU、OS、网络与超时的边界;
- 区分内容缓存、镜像体积优化与环境确定性;
- 按“解析—构建—启动—健康检查—终态”的顺序定位环境故障。
5.1 先定义“复现成功”
可复现不是“命令又跑了一遍”,也不必一开始就要求镜像每个字节相同。本章使用三个逐层收紧的标准:
- 定义相同:
task.toml、Dockerfile、构建上下文和输入文件有相同内容;基础镜像及依赖指向不可变对象。 - 初态相同:容器启动后,工作目录、文件哈希、权限、进程、监听端口和故障探针结果相同。
- 运行边界相同:Provider、Docker Engine/Compose/BuildKit 版本、OS/架构、网络策略、CPU/内存上限和各阶段超时相同;时间、随机数及外部服务不会悄悄改写任务。
第三层仍不保证指令调度和耗时逐毫秒一致。CPU 限额只给出上限,宿主机负载仍会影响时延;网络关闭也不会冻结系统时钟。因此不要用脆弱的墙钟阈值代替状态验收。对于本章项目,“两次初态文件哈希一致、端口 8001 可用而 8000 不可用、依赖清单一致”比“镜像 ID 一致”更接近评测真正需要的等价关系。
还要分清两个网络阶段。构建镜像需要拉取基础镜像和 Python wheel;Task 的运行时 network_mode 约束的是 Environment 启动后的网络。即使 Trial 运行时为 no-network,第一次构建仍可能依赖 Registry 或 PyPI。要做到离线重放,必须另外保存已核验的镜像和 wheel,而不是只在 task.toml 中关闭网络。
5.2 Harbor 怎样把目录变成 Environment
Task 目录不是任意 Docker 项目
单步 Task 的核心约定是根目录含 instruction.md、task.toml、environment/ 和 tests/test.sh;solution/ 供 Oracle 使用,但普通 Task 解析并不要求它存在。Harbor 的 TaskPaths 将这些名字映射为固定路径,Task.is_valid_dir() 还会解析 TOML,并根据目标 OS 检查 instruction 与测试脚本。1
对 Docker Provider,Environment 定义可以来自 environment/Dockerfile、environment/docker-compose.yaml,或 [environment].docker_image。本章只使用单容器 Dockerfile;Compose 会增加服务启动顺序、卷、网络和侧车状态,应该在确有多服务能力目标时再引入。缺少上述定义时,v0.18.0 会直接报告环境目录没有定义,而不是猜测一个基础镜像。2
tasks/python-port-repair/
├── instruction.md
├── task.toml
├── environment/ # Docker 构建上下文就是这里
│ ├── .dockerignore
│ ├── Dockerfile
│ ├── entrypoint.sh
│ ├── requirements.lock
│ ├── app/
│ │ ├── config.json # 故意把服务放在 8001
│ │ ├── control.py
│ │ └── server.py
│ └── input/
│ └── incident.log # 固定的故障输入
└── tests/
└── test.sh
为使目录可直接通过单步 Task 静态校验,instruction.md 至少写清可观察终态,不把标准答案塞进题面:
# 修复 Python 服务端口
阅读 `/workspace/input/incident.log`,诊断服务健康检查失败的原因。
修复 `/workspace/app/config.json`,重启服务,使
`http://127.0.0.1:8000/health` 返回 JSON `{"status":"ok"}`。
不要删除故障日志,也不要修改 `/tests`。
这段 instruction 约束“结果是什么”,Environment 才负责构造“开始时是什么”。如果把“把 listen_port 从 8001 改为 8000”直接写进指令,环境仍可复现,但 Task 已泄露诊断答案;如果只写“修复服务”,Verifier 又会检查一个 Agent 无从得知的端口。环境确定性不能弥补任务契约含糊,二者必须分别评审。
Harbor 为简单 Dockerfile Task 叠加内部 Compose 文件,其中构建上下文取 environment/ 的绝对路径;默认命令让主容器保持运行。由此可以推断,COPY ../instruction.md ... 不会把 Task 根目录加入上下文,而且依赖镜像自身 CMD 启动服务也不可靠。本章用 ENTRYPOINT 启动一个前台 supervisor;它先启动服务,再把 Harbor 传入的保持运行命令作为受管子进程运行。3
从内容到启动的调用链
Docker Environment 的关键路径如下:
environment/ 内容 + docker_image(若有)
│
├─ environment_content_hash → hb__<内容摘要> 镜像名
│
├─ docker compose build(可复用 Docker layer cache)
│
├─ 校验 daemon OS 与镜像 OS
│
├─ down --remove-orphans
│
├─ up --detach --wait
│
└─ Harbor healthcheck → Agent setup/run
v0.18.0 的环境摘要按相对路径与文件内容计算,忽略 .DS_Store、.git、__pycache__ 和符号链接;Docker Provider 用它生成内容寻址的本地镜像名。内容变化会得到新名字,但这并不固定 FROM 标签、远端包仓库或构建时钟,所以摘要是缓存身份,不是复现证明。4
工作目录也有两层。Dockerfile 的 WORKDIR 定义镜像默认目录;[environment].workdir 会让 Docker Provider 在未显式传入 cwd 时给 docker compose exec 增加 -w。两处都写成 /workspace,可以避免 Agent 命令、健康检查和手工调试落到不同目录。v0.18.0 的单元测试也明确验证了“显式 cwd > Task workdir > 容器默认”的 Docker 执行行为。5
路径一致还不等于权限一致。宿主机生成文件时的可执行位、CRLF 换行和大小写差异,都可能在 Linux 容器里改变行为。脚本应随 Task 保存为 LF,并在 Dockerfile 中显式 chmod;不要依赖作者机器上的 umask。v0.18.0 的 [agent].user 为空时使用 Environment 默认用户。本例允许默认用户修改系统配置,是因为能力目标包含服务修复;若目标只测日志分析,就应把输入设为只读、把报告目录单独设为可写,并让 Agent 使用非特权用户。权限是任务设计变量,不是镜像打包细节。
build_timeout_sec 并非只包住 Dockerfile 的 RUN。Trial 用它包住整个 environment.start(),其中还包括构建/拉取、OS 校验、清理旧容器和 compose up --wait;超时后记录 EnvironmentStartTimeoutError。Agent 与 Verifier 则分别使用 [agent].timeout_sec 和 [verifier].timeout_sec。6
5.3 构造固定的故障环境
先写资源与运行契约
在 tasks/python-port-repair/task.toml 中写入:
schema_version = "1.3"
[metadata]
category = "system-administration"
tags = ["python", "service", "configuration"]
[agent]
timeout_sec = 180.0
[verifier]
timeout_sec = 60.0
[environment]
build_timeout_sec = 600.0
os = "linux"
workdir = "/workspace"
cpus = 1
memory_mb = 512
gpus = 0
network_mode = "no-network"
[environment.healthcheck]
command = "python -c \"import urllib.request; urllib.request.urlopen('http://127.0.0.1:8001/health', timeout=2).read()\""
interval_sec = 1.0
timeout_sec = 3.0
retries = 10
这里的健康检查故意访问 8001:它只证明“带故障的服务已经准备好”,不能把 Task 的目标端口 8000 当成 Environment 就绪条件,否则正确的初始故障会阻止 Agent 开始。Healthcheck 成功即返回;失败超过重试数才抛错。7
cpus、memory_mb、storage_mb、gpus 和 gpu_types 都是 Task Environment 字段,但字段存在不等于每个 Provider 都能执行相同约束。v0.18.0 的 Docker Provider 只声明 CPU 与内存硬上限能力:auto 在该 Provider 中也落为 limit;request 和 guarantee 会被拒绝。它生成的资源覆盖文件只含 CPU 与内存,不消费 storage_mb。Docker Provider 的 GPU capability 默认为 false,因此 gpus > 0 在 Environment 初始化时被拒绝。8
所以本例显式请求 CPU-only,并在运行命令中使用 --cpus limit --memory limit。不要填写一个看似精确的 storage_mb 后便宣称 Docker 磁盘被限制;应记录宿主机/VM 可用空间,并把任务峰值磁盘占用控制在宽裕范围内。若 Benchmark 真正测 GPU 或硬磁盘配额,必须另选经源码核验的 Provider,并单独形成实验组,不能把云 Provider 能力泛化到 Docker。
资源上限控制的是边界,不是性能恒定器。512 MB 下运行成功,只能说明进程没有越过该容器的内存上限;它不能保证两台宿主机拥有相同 page cache、swap 或 CPU 调度。Docker 官方也说明容器在未设置限制时可使用宿主机允许的资源,内存不足时进程可能被 OOM 机制终止。9 因此看到返回码 137 或日志里的 Killed 时,应结合容器 inspect、daemon 日志和内存上限判断,不能仅凭一个数字断言 OOM;评分也应检查持久终态,不应要求“必须在 0.5 秒内修好”。
os = "linux" 也不是说明文字。Docker Provider 会核对 daemon 模式和镜像 OS;Linux Task 遇到 Windows daemon,或反过来,都会在容器启动前失败。架构则没有对应的 Task 字段:同一个多架构镜像索引 digest 会在 linux/amd64 与 linux/arm64 选择不同子镜像。因此结果记录必须保存 docker version --format '{{.Server.Os}}/{{.Server.Arch}}',不同架构默认分组比较。10
固定基础镜像与 Python 依赖
environment/Dockerfile 如下。这里锁定的是 Python 官方镜像的多架构索引 digest;它固定每个平台映射到的子镜像,但不让不同 CPU 架构变成同一种机器。
FROM python:3.12.10-slim-bookworm@sha256:fd95fa221297a88e1cf49c55ec1828edd7c5a428187e67b5d1805692d11588db
ENV LANG=C.UTF-8 \
LC_ALL=C.UTF-8 \
TZ=UTC \
PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
WORKDIR /workspace
COPY requirements.lock /tmp/requirements.lock
RUN python -m pip install \
--no-cache-dir \
--only-binary=:all: \
--require-hashes \
-r /tmp/requirements.lock
COPY app/ /workspace/app/
COPY input/ /workspace/input/
COPY entrypoint.sh /usr/local/bin/task-entrypoint
RUN chmod 0755 /usr/local/bin/task-entrypoint
ENTRYPOINT ["/usr/local/bin/task-entrypoint"]
本例故意不写 # syntax=docker/dockerfile:1:该引用会让 BuildKit 在构建前检查并拉取最新稳定版 Dockerfile frontend,不能作为不可变输入。本例没有使用外部 frontend 才提供的高级语法;构建记录仍须保存 Docker Engine 与 BuildKit 版本。11
上述索引 digest 于 2026-07-16 通过 Docker Hub tag API 核验;同一响应分别列出了 Linux amd64 与 arm64 的不同子镜像 digest。1213
Docker 允许 FROM image@digest,digest 不会像可变标签那样悄悄指向新内容;--platform 或构建目标则决定多平台索引中的平台。11 Python 依赖使用一个无传递运行依赖的纯 Python wheel,environment/requirements.lock 同时固定版本、制品类型与 SHA-256:
python-json-logger==3.2.1 \
--hash=sha256:cdc17047eb5374bd311e748b42f99d71223f3b0e186f4206cc5d52aefe85b090
该 wheel 文件名、py3-none-any 平台标记和 SHA-256 已与 PyPI 3.2.1 JSON 元数据及实际下载流复核。1413
pip 官方把 == 固定全部直接/传递版本、--require-hashes 校验每个制品、--only-binary :all: 拒绝临时源码构建列为逐步增强的可重复安装方法。wheelhouse 还能换取离线可用性,但含本机编译产物的 wheelhouse 通常与 OS/架构绑定。15
本例不执行 apt-get install。这是固定系统依赖最简单的办法:系统用户态来自已锁定的基础镜像。如果必须安装系统包,仅写 apt-get install curl 或固定包名版本仍不够,仓库索引和可用文件也会变化;应同时使用日期固定的 Debian snapshot、精确包版本,并归档仓库元数据或 .deb。Debian 的 snapshot 服务可按时间戳作为 APT 仓库使用。16
最后用 environment/.dockerignore 缩小上下文:
__pycache__/
*.pyc
.DS_Store
.dockerignore 会在上下文发送给 builder 前排除匹配文件。较小上下文能减少传输和无关缓存失效;--no-cache-dir 则避免把 pip 下载缓存留在最终层。不要为了体积删除 Agent 或诊断任务实际需要的 shell、证书和工具。多阶段构建适合把编译器留在 builder stage,只复制运行制品到最终 stage。17
固定 Python 服务与故障输入
environment/app/config.json 是故障开关:
{"listen_host":"127.0.0.1","listen_port":8001,"expected_port":8000}
environment/input/incident.log 是不可联网生成的输入文件;内容刻意不含当前时间:
health-probe target=127.0.0.1:8000 result=connection-refused
service expected_port=8000 ticket=INC-PORT-001
初始故障应当像测试夹具一样被版本化。不要在 entrypoint 中用随机数决定错误端口,不要从当前日期拼日志文件名,也不要在容器启动时查询“最新工单”。如果能力目标必须包含时间相关诊断,就把 observed_at 作为固定输入写进文件,并让程序从该字段读取“评测时间”;TZ=UTC 只统一时区解释,不会让两次 time.time() 返回相同值。类似地,随机负载应保存 seed 与生成器版本,最好直接保存生成后的输入。这样复现失败时可以比较输入字节,而不是猜测生成算法在哪一步漂移。
environment/app/server.py 从配置读取监听地址,并用已锁定依赖输出 JSON 日志:
import json
import logging
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from pythonjsonlogger import jsonlogger
CONFIG = Path("/workspace/app/config.json")
handler = logging.StreamHandler()
handler.setFormatter(jsonlogger.JsonFormatter("%(levelname)s %(message)s"))
logger = logging.getLogger("diagnosis-service")
logger.addHandler(handler)
logger.setLevel(logging.INFO)
class HealthHandler(BaseHTTPRequestHandler):
def do_GET(self) -> None:
if self.path != "/health":
self.send_error(404)
return
body = b'{"status":"ok"}\n'
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, format: str, *args: object) -> None:
logger.info(format, *args)
config = json.loads(CONFIG.read_text())
address = (config["listen_host"], config["listen_port"])
logger.info("starting service on %s:%s", *address)
ThreadingHTTPServer(address, HealthHandler).serve_forever()
environment/app/control.py 提供不依赖 systemd 的前台 supervisor 与有界重启入口:
import json
import os
import signal
import subprocess
import sys
import time
from pathlib import Path
from urllib.error import URLError
from urllib.request import urlopen
CONFIG = Path("/workspace/app/config.json")
LOG = Path("/workspace/service.log")
CONTROL_PID = Path("/tmp/diagnosis-control.pid")
GENERATION = Path("/tmp/diagnosis-service.generation")
SERVER = "/workspace/app/server.py"
POLL_INTERVAL = 0.05
STOP_TIMEOUT = 5.0
START_TIMEOUT = 5.0
RESTART_TIMEOUT = STOP_TIMEOUT + START_TIMEOUT + 1.0
def log_tail() -> str:
if not LOG.exists():
return "(service.log does not exist)"
return LOG.read_text(encoding="utf-8", errors="replace")[-1000:]
def terminate(process: subprocess.Popen, timeout: float = STOP_TIMEOUT) -> None:
if process.poll() is not None:
process.wait()
return
try:
process.terminate()
process.wait(timeout=timeout)
except ProcessLookupError:
process.wait()
except subprocess.TimeoutExpired:
process.kill()
process.wait(timeout=2.0)
def wait_for_health(process: subprocess.Popen) -> None:
config = json.loads(CONFIG.read_text())
url = f"http://{config['listen_host']}:{config['listen_port']}/health"
deadline = time.monotonic() + START_TIMEOUT
while time.monotonic() < deadline:
return_code = process.poll()
if return_code is not None:
process.wait()
raise RuntimeError(
f"service exited with code {return_code}\n{log_tail()}"
)
try:
with urlopen(url, timeout=0.2) as response:
if response.status == 200 and json.loads(response.read()) == {
"status": "ok"
}:
return
except (OSError, URLError, ValueError):
pass
time.sleep(POLL_INTERVAL)
raise TimeoutError(f"service did not become healthy at {url}\n{log_tail()}")
def start_service() -> subprocess.Popen:
stream = LOG.open("ab", buffering=0)
try:
process = subprocess.Popen(
[sys.executable, SERVER],
stdout=stream,
stderr=subprocess.STDOUT,
start_new_session=True,
)
finally:
stream.close()
try:
wait_for_health(process)
except BaseException:
terminate(process)
raise
return process
def supervisor_is_running(pid: int) -> bool:
try:
command = Path(f"/proc/{pid}/cmdline").read_bytes().split(b"\0")
except FileNotFoundError:
return False
return SERVER.replace("server.py", "control.py").encode() in command and (
b"supervise" in command
)
def read_generation() -> int:
return int(GENERATION.read_text())
def write_generation(value: int) -> None:
temporary = GENERATION.with_suffix(".tmp")
temporary.write_text(str(value))
temporary.replace(GENERATION)
def supervise(command: list[str]) -> int:
if not command:
raise SystemExit("usage: control.py supervise COMMAND [ARG ...]")
restart_requested = False
stopping = False
def request_restart(_signum: int, _frame: object) -> None:
nonlocal restart_requested
restart_requested = True
def request_stop(_signum: int, _frame: object) -> None:
nonlocal stopping
stopping = True
signal.signal(signal.SIGHUP, request_restart)
signal.signal(signal.SIGINT, request_stop)
signal.signal(signal.SIGTERM, request_stop)
service = None
keepalive = None
try:
CONTROL_PID.write_text(str(os.getpid()))
generation = 1
service = start_service()
write_generation(generation)
keepalive = subprocess.Popen(command)
while not stopping:
if keepalive.poll() is not None:
return keepalive.wait()
if restart_requested:
restart_requested = False
terminate(service)
service = start_service()
generation += 1
write_generation(generation)
elif service.poll() is not None:
return_code = service.wait()
raise RuntimeError(
f"service exited unexpectedly with code {return_code}\n{log_tail()}"
)
time.sleep(POLL_INTERVAL)
return 0
finally:
if service is not None:
terminate(service)
if keepalive is not None:
terminate(keepalive)
CONTROL_PID.unlink(missing_ok=True)
GENERATION.unlink(missing_ok=True)
def restart() -> None:
pid = int(CONTROL_PID.read_text())
before = read_generation()
if not supervisor_is_running(pid):
raise RuntimeError("service supervisor is not running")
os.kill(pid, signal.SIGHUP)
deadline = time.monotonic() + RESTART_TIMEOUT
while time.monotonic() < deadline:
if not supervisor_is_running(pid):
raise RuntimeError(f"service supervisor exited\n{log_tail()}")
if read_generation() > before:
return
time.sleep(POLL_INTERVAL)
raise TimeoutError(f"service restart timed out\n{log_tail()}")
if len(sys.argv) < 2:
raise SystemExit("usage: control.py {supervise|restart} ...")
if sys.argv[1] == "supervise":
raise SystemExit(supervise(sys.argv[2:]))
if sys.argv[1] == "restart":
restart()
else:
raise SystemExit(f"unknown action: {sys.argv[1]}")
environment/entrypoint.sh 保留 Harbor 传入的 command:
#!/bin/sh
set -eu
exec python /workspace/app/control.py supervise "$@"
supervisor 作为 PID 1 持有并回收服务与 Harbor keepalive 两个子进程。初始启动和每次重启都先轮询 /health;旧服务在 SIGTERM 后 5 秒仍未退出就升级为 SIGKILL,新服务提前退出或 5 秒内未健康都会清理子进程并返回失败。restart 只有观察到 generation 在新服务健康后递增才返回,因此不再用固定睡眠猜测端口是否释放。/proc/<pid>/cmdline 检查依赖本章已固定的 Linux Task,用来避免陈旧 PID 文件误伤其他进程。
这里要区分“预期故障”与“Environment 故障”。配置端口是 8001、目标端口是 8000,属于 Agent 应观察并修复的任务状态;Python 进程没启动、requirements.lock 校验失败、entrypoint 无执行权限,则属于评测基础设施失败。Healthcheck 专门验证前者已经被成功构造:服务必须在错误端口上稳定可用。只有这一步通过,随后 8000 的连接拒绝才是一条有意义的负向证据。否则同一个 Reward 0 可能混合“Agent 不会修”和“环境根本没起来”两种原因。
Task instruction 可以要求 Agent 阅读 /workspace/input/incident.log,修正 config.json,执行 python /workspace/app/control.py restart,并证明 8000/health 成功。tests/test.sh 至少应独立检查配置与端点;下例用标准库,避免 Verifier 临时联网安装测试框架:
#!/bin/sh
set -u
if python - <<'PY'
import json
import urllib.request
from pathlib import Path
config = json.loads(Path("/workspace/app/config.json").read_text())
assert config["listen_port"] == config["expected_port"] == 8000
body = urllib.request.urlopen(
"http://127.0.0.1:8000/health", timeout=2
).read()
assert json.loads(body) == {"status": "ok"}
PY
then
echo 1 > /logs/verifier/reward.txt
else
echo 0 > /logs/verifier/reward.txt
fi
5.4 时间、网络、架构和缓存怎样破坏复现
下表是提交 Task 前的确定性审计,不是所有 Provider 的能力表。
| 变量 | 本例控制 | 仍需记录或隔离 |
|---|---|---|
| 基础 OS/Python | 镜像 digest + Python 3.12.10 标签说明 | 实际子镜像 digest、daemon OS/架构 |
| 构建/编排工具 | 不使用可变外部 Dockerfile frontend | Docker Engine、Compose、BuildKit 版本 |
| 系统包 | 不额外安装 | 若增加则固定 snapshot、版本与制品 |
| Python 包 | 版本、universal wheel、SHA-256 | PyPI 可用性;离线时保存 wheel |
| 输入与故障 | 文件随构建上下文复制;端口错配固定 | 文件权限、换行、Task 内容摘要 |
| 时间/地区 | TZ=UTC、C.UTF-8,日志输入无当前时间 | UTC 不会冻结时钟;测试需注入固定时间 |
| 网络 | Trial baseline 为 no-network | Docker Linux runtime 的 egress-control 能力;构建网络另管 |
| CPU/内存 | Task 值 + Docker limit 策略 | 宿主机竞争;不要用紧墙钟阈值评分 |
| 存储 | 不声称 Harbor Docker 配额 | 宿主/VM 空间与任务峰值占用 |
| GPU | gpus = 0 | Docker v0.18.0 不接受正 GPU 请求 |
| 缓存 | 内容寻址镜像名;依赖不可变 | 同时做有缓存与无缓存构建审计 |
表中每一项都应进入运行记录,而不是留在作者脑中。最小环境清单可以保存 Task/Trial lock 摘要、docker version 的 Server OS/Arch、docker compose version、docker buildx version、基础镜像索引和平台子镜像 digest、python --version、pip freeze --all、关键输入 SHA-256、CPU/内存策略、网络模式及各阶段 timeout。比较两次 Trial 时先 diff 这份清单;只有控制变量相同,才讨论 Agent trajectory 为什么不同。
缓存优化与确定性经常方向相反。Docker 对 RUN 通常只比较指令字符串,不检查命令访问的远端仓库是否改变;因此 RUN apt-get update 可以在旧缓存上“稳定成功”,清空缓存后却得到另一组包。COPY 或 ADD 的输入变化会使该层及后续层失效。18 正确策略不是永远禁用缓存,而是让缓存只包住不可变输入,并定期验证 --no-cache --pull 构建也能得到同一可观察初态。
--force-build 也不能当作“无缓存”同义词。在 v0.18.0 的 Docker Provider 中,当 docker_image 与 Dockerfile 同时存在时,它会让 Dockerfile 路径取代预构建镜像;实际 Compose 命令仍是普通 build,没有追加 --no-cache。19 若审计远端依赖漂移,应显式运行原生 docker build --no-cache --pull,而不是从参数名字推断缓存已清空。
镜像体积同样是约束,不是目标函数。slim、小上下文、精确 COPY 和不保留下载缓存减少传输与冷启动成本;但把 Task 所需诊断工具删掉会改变能力目标。每次瘦身后都应重新跑初态指纹和 Oracle,而不是只看 docker image ls 的大小。
5.5 一个不可复现的反例
下面是教学性反例,不要用于 Benchmark:
FROM python:3.12-slim
WORKDIR app
RUN apt-get update && apt-get install -y curl
RUN pip install python-json-logger
ADD https://example.invalid/latest-incident.log /workspace/input/incident.log
CMD ["python", "/workspace/app/server.py"]
它同时留下六个漂移点:可变基础标签、相对工作目录、未固定 APT 仓库与包、未固定 Python 包、构建时网络输入,以及会被 Harbor 保持运行 command 覆盖的 CMD。若再把 network_mode 留在默认 public,Agent 还可能在 Trial 中下载不同修复材料。
下面的日志也是教学性构造,不是本机 Docker 实测:
#9 [3/5] RUN apt-get update && apt-get install -y curl
#9 ERROR: Temporary failure resolving 'deb.debian.org'
EnvironmentStartTimeoutError:
Environment start timed out after 600.0 seconds
第一行指向构建网络/DNS,第二行只是 Harbor 在外层看到启动阶段超过总预算。只调大 build_timeout_sec 会延迟失败,不能修复名称解析,也不能证明包集合固定。另一类常见现象是缓存构建成功、无缓存构建失败:这正说明缓存掩盖了外部依赖,而不是缓存提高了复现性。
5.6 构建与启动排错
按最早能失败的边界排查,可以避免把基础设施错误记为 Agent 失败。
1. 先验证 Task 与解析结果
在 Harbor v0.18.0 源码目录运行;TASK 指向你创建的本地 Task:
TASK="$PWD/tasks/python-port-repair"
uv run --frozen python - "$TASK" <<'PY'
import sys
from pathlib import Path
from harbor.environments.definition import environment_content_hash
from harbor.models.task.task import Task
task_dir = Path(sys.argv[1])
assert Task.is_valid_dir(task_dir)
task = Task(task_dir)
assert task.config.environment.os.value == "linux"
assert task.config.environment.workdir == "/workspace"
assert task.config.environment.cpus == 1
assert task.config.environment.memory_mb == 512
assert task.config.environment.gpus == 0
print(environment_content_hash(task.paths.environment_dir))
PY
uv run --frozen harbor run \
-p "$TASK" -a nop -e docker \
--cpus limit --memory limit \
--job-name environment-negative --print-config
前一条成功只证明目录、TOML 和字段可解析;后一条只证明 Job 配置解析,不会构建镜像。任一失败时,先修路径、TOML、OS 对应的 tests/test.sh 或字段拼写。
2. 再查 Docker preflight 与平台
docker info
docker version --format '{{.Server.Os}}/{{.Server.Arch}}'
docker compose version
docker compose up --help | grep -- '--wait'
docker buildx version
v0.18.0 的 Docker preflight 区分“CLI 不在 PATH”和“daemon 不可用”,两者分别给出安装与启动 Docker 的错误,但它不检查 Compose。Docker Provider 实际调用 docker compose build、down 和 up --detach --wait;因此 Compose plugin 缺失或 up 不支持 --wait 都是阻断条件,不能由 buildx 检查替代。2019 no-network 侧车镜像还通过 Buildx 构建,所以本例同时记录 Buildx 版本。no-network 在本章限定为兼容 egress-control 所需内核能力的 Linux container runtime;Windows container 模式不支持该策略,某些 macOS Docker VM 也可能因缺少 nftables fib 能力而被拒绝。遇到这种失败,应更换经核验的 Linux runtime 或把该环境单独标为不兼容,不能静默降级为 public。21
3. 脱离 Harbor 展开 Docker 构建日志
从 Task 根目录运行:
for run in 1 2; do
docker build \
--progress=plain \
--pull \
--no-cache \
-t "harbor-book/python-port-repair:clean-$run" \
./environment
done
COPY 报 not found 时先确认文件在 environment/ 上下文内并未被 .dockerignore 排除;hash mismatch 指向 requirements.lock 与实际 wheel 不一致;exec format error 则优先核对平台与本地二进制架构。若普通构建成功而 --no-cache 失败,检查被缓存层访问的包仓库,而不是删除锁文件。
4. 验证“带故障的健康状态”
for run in 1 2; do
container="ch05-port-repair-$run"
image="harbor-book/python-port-repair:clean-$run"
docker rm -f "$container" 2>/dev/null || true
docker run -d --name "$container" "$image" sleep infinity
docker exec "$container" python -c \
"import urllib.request; urllib.request.urlopen('http://127.0.0.1:8001/health', timeout=2).read()"
if docker exec "$container" python -c \
"import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=2).read()"
then
echo 'unexpected: target port is already healthy' >&2
exit 1
fi
{
docker exec "$container" sha256sum \
/workspace/app/config.json /workspace/input/incident.log
docker exec "$container" python -m pip freeze --all
} > "initial-state-$run.txt"
docker rm -f "$container"
done
diff -u initial-state-1.txt initial-state-2.txt
验收是:两轮都表现为 8001 探针成功、8000 探针失败,并且两个初态文件的 diff 为空。把这两个文件与 Docker/Compose/Buildx 版本一起归档;不要把 8000 失败当作构建失败,它正是 Agent 应接手的故障。
5. 最后运行 Harbor 负向基线
uv run --frozen harbor run \
-p "$TASK" -a nop -e docker \
--cpus limit --memory limit \
--force-build -n 1 -k 2 --yes \
--job-name environment-negative \
--jobs-dir ./jobs
Nop Agent 不修改环境;-n 1 -k 2 会串行产生两个 Trial,二者都应由 Verifier 写出 reward.txt = 0。这是预期结果,不是本章机器上的实测输出。13 两次都为 0 才通过“负向基线稳定”门禁;它仍不证明 Task 可解,可解性将在下一章用 Solution/Oracle 验证。
构建或启动异常时,查看该 Trial 下的 trial.log、exception.txt、config.json 与 lock.json。v0.18.0 会把异常 traceback 写到 exception.txt,并在 result.json 中保留 Environment setup 计时。22 如果日志只显示超时,回到原生 Docker 的 plain build;如果 up --wait 后失败,检查 entrypoint、服务日志与 Harbor healthcheck;如果进入 Agent 后才超时,构建环境已经不是首要嫌疑。
5.7 明确的交付验收
把下面清单作为 python-port-repair 进入 Dataset 前的门禁:
-
Task.is_valid_dir()为 true,harbor run --print-config显示 Docker、limit 策略与预期字段; - Dockerfile 的每个外部制品都有 digest/hash,Python 直接和传递依赖均锁定;
- Dockerfile 与
[environment].workdir都是/workspace; - 两次干净构建中
config.json、incident.log和pip freeze --all一致; - 每次启动均表现为
8001成功、8000失败,且故障不依赖当前时间或外网; - 记录 Harbor
v0.18.0、Task/Trial lock、Docker Engine/Compose/Buildx、Server OS/Arch、基础镜像 digest、CPU/内存策略; - Docker 下不声称
storage_mb或 GPU 能力,跨架构与其他 Provider 分组验证; - Nop 负向基线稳定为 0;完整 Oracle 验收留给可解性测试。
本章编写环境没有安装 Docker,不能声称上述镜像构建、容器启动或 Reward 已实测。已完成的证据是 v0.18.0 源码静态核对、示例 TOML 模型核验、Python 示例编译与 API 最小调用,以及环境定义、工作目录、网络、资源、OS、timeout 与 Docker 启动路径的 122 个定向单元测试;命令、输出和限制见本章验证记录。13 完整容器门禁必须在具备 Docker 的 Linux 主机上执行并保存输出。
5.8 本章小结
- 可复现 Environment 首先是可重复的可观察初态,其次才是镜像身份。
- Harbor 的
environment/是 Docker 构建上下文;Task 配置与 Dockerfile 应显式对齐工作目录和 OS。 - 基础镜像 digest、依赖版本与制品哈希必须一起固定;缓存不能修复可变输入。
- v0.18.0 Docker Provider 对 CPU/内存使用硬上限,不执行 Task
storage_mb配额,也不接受正 GPU 请求。 - 时间、构建网络、运行网络、架构和宿主机竞争都必须记录或隔离。
- 排错应从 Task 解析开始,依次经过 Docker preflight、build、up、healthcheck,再进入 Agent。
5.9 练习
- 为示例增加一个固定的系统工具。使用 Debian snapshot 与精确包版本,分别执行有缓存和
--no-cache --pull构建,记录制品哈希是否一致。 - 把 Dockerfile 与
[environment].workdir故意改成不同路径。用 v0.18.0 的harbor task start-env或 Docker 单元测试方法观察pwd,解释哪个目录被 Agent 的普通exec使用。 - 将
network_mode改为public,让服务启动时下载配置。构造两份不同响应,说明为什么 Task 内容摘要不变却出现不同初态,再把输入改回构建上下文文件。 - 在
linux/amd64与linux/arm64各运行一次初态指纹。区分必须相同的 Task 文件/纯 Python依赖和允许不同的平台镜像/系统库,并设计结果分组键。 - 分别设置
--cpus limit、storage_mb = 1024和gpus = 1运行 Docker Provider。根据 v0.18.0 源码、资源覆盖文件和异常,验证三者依次对应“被用作硬上限”“字段存在但未执行”和“被拒绝”;再把第一项改成--cpus request,确认 request 同样会被拒绝。