跳到主要内容

第 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 先定义“复现成功”

可复现不是“命令又跑了一遍”,也不必一开始就要求镜像每个字节相同。本章使用三个逐层收紧的标准:

  1. 定义相同task.toml、Dockerfile、构建上下文和输入文件有相同内容;基础镜像及依赖指向不可变对象。
  2. 初态相同:容器启动后,工作目录、文件哈希、权限、进程、监听端口和故障探针结果相同。
  3. 运行边界相同: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.mdtask.tomlenvironment/tests/test.shsolution/ 供 Oracle 使用,但普通 Task 解析并不要求它存在。Harbor 的 TaskPaths 将这些名字映射为固定路径,Task.is_valid_dir() 还会解析 TOML,并根据目标 OS 检查 instruction 与测试脚本。1

对 Docker Provider,Environment 定义可以来自 environment/Dockerfileenvironment/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_sec6

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

cpusmemory_mbstorage_mbgpusgpu_types 都是 Task Environment 字段,但字段存在不等于每个 Provider 都能执行相同约束。v0.18.0 的 Docker Provider 只声明 CPU 与内存硬上限能力:auto 在该 Provider 中也落为 limit;requestguarantee 会被拒绝。它生成的资源覆盖文件只含 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/amd64linux/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 amd64arm64 的不同子镜像 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 frontendDocker Engine、Compose、BuildKit 版本
系统包不额外安装若增加则固定 snapshot、版本与制品
Python 包版本、universal wheel、SHA-256PyPI 可用性;离线时保存 wheel
输入与故障文件随构建上下文复制;端口错配固定文件权限、换行、Task 内容摘要
时间/地区TZ=UTCC.UTF-8,日志输入无当前时间UTC 不会冻结时钟;测试需注入固定时间
网络Trial baseline 为 no-networkDocker Linux runtime 的 egress-control 能力;构建网络另管
CPU/内存Task 值 + Docker limit 策略宿主机竞争;不要用紧墙钟阈值评分
存储不声称 Harbor Docker 配额宿主/VM 空间与任务峰值占用
GPUgpus = 0Docker v0.18.0 不接受正 GPU 请求
缓存内容寻址镜像名;依赖不可变同时做有缓存与无缓存构建审计

表中每一项都应进入运行记录,而不是留在作者脑中。最小环境清单可以保存 Task/Trial lock 摘要、docker version 的 Server OS/Arch、docker compose versiondocker buildx version、基础镜像索引和平台子镜像 digest、python --versionpip freeze --all、关键输入 SHA-256、CPU/内存策略、网络模式及各阶段 timeout。比较两次 Trial 时先 diff 这份清单;只有控制变量相同,才讨论 Agent trajectory 为什么不同。

缓存优化与确定性经常方向相反。Docker 对 RUN 通常只比较指令字符串,不检查命令访问的远端仓库是否改变;因此 RUN apt-get update 可以在旧缓存上“稳定成功”,清空缓存后却得到另一组包。COPYADD 的输入变化会使该层及后续层失效。18 正确策略不是永远禁用缓存,而是让缓存只包住不可变输入,并定期验证 --no-cache --pull 构建也能得到同一可观察初态。

--force-build 也不能当作“无缓存”同义词。在 v0.18.0 的 Docker Provider 中,当 docker_image 与 Dockerfile 同时存在时,它会让 Dockerfile 路径取代预构建镜像;实际 Compose 命令仍是普通 build,没有追加 --no-cache19 若审计远端依赖漂移,应显式运行原生 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 builddownup --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 或把该环境单独标为不兼容,不能静默降级为 public21

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

COPYnot 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.logexception.txtconfig.jsonlock.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.jsonincident.logpip 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 练习

  1. 为示例增加一个固定的系统工具。使用 Debian snapshot 与精确包版本,分别执行有缓存和 --no-cache --pull 构建,记录制品哈希是否一致。
  2. 把 Dockerfile 与 [environment].workdir 故意改成不同路径。用 v0.18.0 的 harbor task start-env 或 Docker 单元测试方法观察 pwd,解释哪个目录被 Agent 的普通 exec 使用。
  3. network_mode 改为 public,让服务启动时下载配置。构造两份不同响应,说明为什么 Task 内容摘要不变却出现不同初态,再把输入改回构建上下文文件。
  4. linux/amd64linux/arm64 各运行一次初态指纹。区分必须相同的 Task 文件/纯 Python依赖和允许不同的平台镜像/系统库,并设计结果分组键。
  5. 分别设置 --cpus limitstorage_mb = 1024gpus = 1 运行 Docker Provider。根据 v0.18.0 源码、资源覆盖文件和异常,验证三者依次对应“被用作硬上限”“字段存在但未执行”和“被拒绝”;再把第一项改成 --cpus request,确认 request 同样会被拒绝。

参考资料

Footnotes

  1. Harbor Framework Team,src/harbor/models/task/paths.pysrc/harbor/models/task/task.py,Harbor v0.18.0,https://github.com/harbor-framework/harbor/blob/527d50deb63a5d279e8c20593c18a2cbc7f61f9e/src/harbor/models/task/paths.py#L11-L138https://github.com/harbor-framework/harbor/blob/527d50deb63a5d279e8c20593c18a2cbc7f61f9e/src/harbor/models/task/task.py#L35-L173,访问于 2026-07-16。

  2. Harbor Framework Team,src/harbor/environments/definition.py,Harbor v0.18.0,https://github.com/harbor-framework/harbor/blob/527d50deb63a5d279e8c20593c18a2cbc7f61f9e/src/harbor/environments/definition.py#L7-L72,访问于 2026-07-16。

  3. Harbor Framework Team,src/harbor/environments/docker/docker.pydocker-compose-build.yaml,Harbor v0.18.0,https://github.com/harbor-framework/harbor/blob/527d50deb63a5d279e8c20593c18a2cbc7f61f9e/src/harbor/environments/docker/docker.py#L234-L249https://github.com/harbor-framework/harbor/blob/527d50deb63a5d279e8c20593c18a2cbc7f61f9e/src/harbor/environments/docker/docker.py#L312-L372https://github.com/harbor-framework/harbor/blob/527d50deb63a5d279e8c20593c18a2cbc7f61f9e/src/harbor/environments/docker/docker-compose-build.yaml,访问于 2026-07-16。

  4. Harbor Framework Team,src/harbor/environments/definition.pytests/unit/environments/test_environment_definition.py,Harbor v0.18.0,https://github.com/harbor-framework/harbor/blob/527d50deb63a5d279e8c20593c18a2cbc7f61f9e/src/harbor/environments/definition.py#L75-L114https://github.com/harbor-framework/harbor/blob/527d50deb63a5d279e8c20593c18a2cbc7f61f9e/tests/unit/environments/test_environment_definition.py#L131-L187,访问于 2026-07-16。

  5. Harbor Framework Team,src/harbor/models/task/config.pysrc/harbor/environments/docker/docker.py 与 Docker Environment 测试,Harbor v0.18.0,https://github.com/harbor-framework/harbor/blob/527d50deb63a5d279e8c20593c18a2cbc7f61f9e/src/harbor/models/task/config.py#L460-L464https://github.com/harbor-framework/harbor/blob/527d50deb63a5d279e8c20593c18a2cbc7f61f9e/src/harbor/environments/docker/docker.py#L1061-L1076https://github.com/harbor-framework/harbor/blob/527d50deb63a5d279e8c20593c18a2cbc7f61f9e/tests/unit/environments/test_docker.py#L343-L415,访问于 2026-07-16。

  6. Harbor Framework Team,src/harbor/models/task/config.pysrc/harbor/trial/trial.py,Harbor v0.18.0,https://github.com/harbor-framework/harbor/blob/527d50deb63a5d279e8c20593c18a2cbc7f61f9e/src/harbor/models/task/config.py#L330-L369https://github.com/harbor-framework/harbor/blob/527d50deb63a5d279e8c20593c18a2cbc7f61f9e/src/harbor/models/task/config.py#L416-L464https://github.com/harbor-framework/harbor/blob/527d50deb63a5d279e8c20593c18a2cbc7f61f9e/src/harbor/trial/trial.py#L981-L1027https://github.com/harbor-framework/harbor/blob/527d50deb63a5d279e8c20593c18a2cbc7f61f9e/src/harbor/trial/trial.py#L1093-L1112,访问于 2026-07-16。

  7. Harbor Framework Team,src/harbor/environments/base.py,Harbor v0.18.0,https://github.com/harbor-framework/harbor/blob/527d50deb63a5d279e8c20593c18a2cbc7f61f9e/src/harbor/environments/base.py#L1320-L1372,访问于 2026-07-16。

  8. Harbor Framework Team,Task Environment 模型、资源能力校验、Docker Provider 资源实现与测试,Harbor v0.18.0,https://github.com/harbor-framework/harbor/blob/527d50deb63a5d279e8c20593c18a2cbc7f61f9e/src/harbor/models/task/config.py#L416-L443https://github.com/harbor-framework/harbor/blob/527d50deb63a5d279e8c20593c18a2cbc7f61f9e/src/harbor/environments/base.py#L729-L741https://github.com/harbor-framework/harbor/blob/527d50deb63a5d279e8c20593c18a2cbc7f61f9e/src/harbor/environments/docker/docker.py#L280-L301https://github.com/harbor-framework/harbor/blob/527d50deb63a5d279e8c20593c18a2cbc7f61f9e/src/harbor/environments/docker/docker.py#L449-L469https://github.com/harbor-framework/harbor/blob/527d50deb63a5d279e8c20593c18a2cbc7f61f9e/tests/unit/environments/test_docker.py#L1643-L1684,访问于 2026-07-16。

  9. Docker Inc.,Resource constraints,https://docs.docker.com/engine/containers/resource_constraints/,访问于 2026-07-16。

  10. Harbor Framework Team,Task OS 模型与 Docker OS 校验,Harbor v0.18.0,https://github.com/harbor-framework/harbor/blob/527d50deb63a5d279e8c20593c18a2cbc7f61f9e/src/harbor/models/task/config.py#L271-L276https://github.com/harbor-framework/harbor/blob/527d50deb63a5d279e8c20593c18a2cbc7f61f9e/src/harbor/environments/docker/docker.py#L739-L807https://github.com/harbor-framework/harbor/blob/527d50deb63a5d279e8c20593c18a2cbc7f61f9e/tests/unit/environments/test_docker.py#L1714-L1760,访问于 2026-07-16。

  11. Docker Inc.,Dockerfile reference:syntax 与 FROM,https://docs.docker.com/reference/dockerfile/#syntaxhttps://docs.docker.com/reference/dockerfile/#from,访问于 2026-07-16;Docker Inc.,Multi-platform builds,https://docs.docker.com/build/building/multi-platform/,访问于 2026-07-16。 2

  12. Docker Hub,library/python:3.12.10-slim-bookworm tag API,https://hub.docker.com/v2/namespaces/library/repositories/python/tags/3.12.10-slim-bookworm,访问于 2026-07-16。

  13. 《Harbor Framework 实战》项目,第 5 章写作与验证报告的“实际验证环境与限制”“实际执行的验证命令”与“审校后修订”,2026-07-16。 2 3 4

  14. Python Package Index,python-json-logger 3.2.1 JSON metadata,https://pypi.org/pypi/python-json-logger/3.2.1/json,访问于 2026-07-16。

  15. Python Packaging Authority,Repeatable Installs,https://pip.pypa.io/en/stable/topics/repeatable-installs/,访问于 2026-07-16;Secure installs,https://pip.pypa.io/en/stable/topics/secure-installs/,访问于 2026-07-16。

  16. Debian Project,snapshot.debian.org,https://snapshot.debian.org/,访问于 2026-07-16。

  17. Docker Inc.,Build context,https://docs.docker.com/build/concepts/context/#dockerignore-files,访问于 2026-07-16;Multi-stage builds,https://docs.docker.com/build/building/multi-stage/,访问于 2026-07-16。

  18. Docker Inc.,Build cache invalidation,https://docs.docker.com/build/cache/invalidation/,访问于 2026-07-16。

  19. Harbor Framework Team,src/harbor/environments/definition.py 与 Docker start(),Harbor v0.18.0,https://github.com/harbor-framework/harbor/blob/527d50deb63a5d279e8c20593c18a2cbc7f61f9e/src/harbor/environments/definition.py#L26-L36https://github.com/harbor-framework/harbor/blob/527d50deb63a5d279e8c20593c18a2cbc7f61f9e/src/harbor/environments/docker/docker.py#L837-L882,访问于 2026-07-16。 2

  20. Harbor Framework Team,Docker preflight 与单元测试,Harbor v0.18.0,https://github.com/harbor-framework/harbor/blob/527d50deb63a5d279e8c20593c18a2cbc7f61f9e/src/harbor/environments/docker/docker.py#L149-L167https://github.com/harbor-framework/harbor/blob/527d50deb63a5d279e8c20593c18a2cbc7f61f9e/tests/unit/test_environment_preflight.py#L237-L259,访问于 2026-07-16。

  21. Harbor Framework Team,Network Policy 文档与 Docker capability 实现,Harbor v0.18.0,https://github.com/harbor-framework/harbor/blob/527d50deb63a5d279e8c20593c18a2cbc7f61f9e/docs/content/docs/tasks/network-policy.mdx#L24-L67https://github.com/harbor-framework/harbor/blob/527d50deb63a5d279e8c20593c18a2cbc7f61f9e/src/harbor/environments/docker/docker.py#L182-L218https://github.com/harbor-framework/harbor/blob/527d50deb63a5d279e8c20593c18a2cbc7f61f9e/src/harbor/environments/docker/docker.py#L285-L301,访问于 2026-07-16。

  22. Harbor Framework Team,Trial 路径、结果模型、Environment setup 计时与异常记录,Harbor v0.18.0,https://github.com/harbor-framework/harbor/blob/527d50deb63a5d279e8c20593c18a2cbc7f61f9e/src/harbor/models/trial/paths.py#L78-L136https://github.com/harbor-framework/harbor/blob/527d50deb63a5d279e8c20593c18a2cbc7f61f9e/src/harbor/models/trial/paths.py#L267-L284https://github.com/harbor-framework/harbor/blob/527d50deb63a5d279e8c20593c18a2cbc7f61f9e/src/harbor/models/trial/result.py#L69-L87https://github.com/harbor-framework/harbor/blob/527d50deb63a5d279e8c20593c18a2cbc7f61f9e/src/harbor/trial/trial.py#L386-L402https://github.com/harbor-framework/harbor/blob/527d50deb63a5d279e8c20593c18a2cbc7f61f9e/src/harbor/trial/trial.py#L1093-L1112,访问于 2026-07-16。