Prhub

#56766 [CI] Keep OTel bytecode out of mounted checkouts

原始 PR 作者 khluu 合并时间 2026-09-14 17:52 文件变更 2 提交数 1 评论 5 代码增减 +40 / -6

执行摘要

CI OTel helper 禁用字节码写入,避免 root 残留阻塞 checkout

PR body 明确说明:OTel helper 运行在 root 容器中、作用于宿主机挂载的 checkout,其 Python 导入会留下 root 所有的 .buildkite/scripts/ci-otel/pycache 文件;在 h200-ci-6-23 上这个残留文件让下一次 Buildkite checkout 无法清理工作区,重试时报 Permission denied,随后是 destination path '.' already exists and is not an empty directory。作者在评论中持续补充证据,把观测到的失败面从最初的 5 个 build 扩大到 13 个 job / 12 个 build,并强调 PYTHONDONTWRITEBYTECODE 只作用于 helper 进程,测试命令的环境与行为保持不变。

值得花 5 分钟左右精读,但重点不在代码本身,而在两点:一是「root 容器 + host 挂载 checkout + Python 默认写 pycache」这条诊断链,以及把防护收敛到 shell 包装层、同时补齐所有旁路调用点的做法;二是「预防性修复与一次性运维清理必须分开推进」的处置范例——PR 合并并不代表 CI 立刻恢复,评论里明确要求暂停 slot 而非浪费重试。如果团队维护 CI 或容器化测试环境,建议顺手排查是否还有其它 root 写回挂载路径(评论中的 vllm/logging_utils 已给出反例),并考虑给新用例补上 GPU 采样分支的断言。

讨论亮点

本 PR 的 5 条评论全部是作者 khluu 的运维侧进展更新,没有 reviewer 反对意见;唯一的 review 是 claude[bot] 声明因 fork PR 禁用自动审查。讨论的核心不是代码风格,而是「预防性修复无法替代一次性清理」以及失败面不断扩大的事实记录:影响范围从 6 jobs/5 builds 滚动到 13 jobs/12 builds,且出现了第二个与 OTel 无关的 root 所有路径 vllm/logging_utils/__pycache__/__init__.cpython-312.pyc,作者据此要求「递归清理整个 checkout,而不是只删第一个可见文件」,并在 12:26 UTC 主动暂停 h200-ci-6-23 12 小时作为运营遏制。作者也坦承修复在目标 slot 上尚无验证证据。

实现拆解

  1. 定位污染源与调用面:.buildkite/scripts/ci-otel/ci_otel.sh 里有四类 Python 执行点——文件头部的 import ci_otel 可用性探测、统一包装函数 _ci_otel_python()、ci_otel_start() 中后台启动的 ci_gpu.py GPU 采样、退出钩子 _ci_otel_on_exit() 中的 ci_otel.py flush。这些调用都以 root 身份在挂载 checkout 上执行,Python 默认把 __pycache__ 写在模块旁边,因此必须逐个堵住,不能只改一处。
  2. 收敛防护位置:_ci_otel_python() 是 shell 层共享入口,但内部有 timeout 存在/不存在两条分支,两边都加了 PYTHONDONTWRITEBYTECODE=1 前缀;这样后续新增的常规调用只要走包装函数就自动免疫。
  3. 补齐绕过包装函数的路径:可用性探测的 import ci_otel、ci_otel_start() 中的 ci_gpu.py(带 & 的后台进程,环境变量前缀仍随子进程生效)以及 _ci_otel_on_exit() flush 的两条分支都单独补上了该变量,对应 patch 中另外三个 hunk。
  4. 限定作用域:变量只作为 helper 子进程的前缀,不导出到 shell 全局,也不影响后续测试命令的环境,因此对被测代码零影响;OTel 遥测的采集、记录与 flush 语义完全不变。
  5. 测试配套:.buildkite/scripts/ci-otel/tests/test_ci_otel.py 新增 test_shell_wrapper_does_not_write_bytecode_to_helper_tree,用 shutil.copytree(..., ignore=shutil.ignore_patterns("__pycache__")) 复制出干净 helper 目录,以 /bin/sh -c 真正 source ci_otel.sh 并执行 ci_otel_start 1 true; ci_otel_finish 0,通过 CI_INFRA_OTEL_DIR、CI_INFRA_OTEL_SPOOL_DIR 重定向目录,并设置 CI_INFRA_GPU_SAMPLING=0 关闭 GPU 采样以保证稳定,最后断言 list(helper_dir.rglob("*.pyc")) == []。同时新增 import shutil。
文件 模块 状态 重要度
.buildkite/scripts/ci-otel/ci_otel.sh 遥测脚本 modified 4.51
.buildkite/scripts/ci-otel/tests/test_ci_otel.py 遥测测试 modified 5.03

关键符号

_ci_otel_python ci_otel_start _ci_otel_on_exit test_shell_wrapper_does_not_write_bytecode_to_helper_tree

关键源码片段

.buildkite/scripts/ci-otel/ci_otel.sh core-logic

核心改动文件:在可用性探测、`_ci_otel_python()` 包装、`ci_otel_start()` 的 GPU 采样、`_ci_otel_on_exit()` flush 共四处调用点补上 PYTHONDONTWRITEBYTECODE=1,是阻止 root 残留 .pyc 的唯一实质手段。

# 可用性探测:helper 以 root 身份在挂载 checkout 上 import ci_otel,
# 探测本身也会写 __pycache__,所以这里同样要带 PYTHONDONTWRITEBYTECODE=1。
if [ -z "${CI_INFRA_OTEL_RUNTIME_DIR}" ] \
  || [ -z "${_CI_INFRA_OTEL_PYTHON}" ] \
  || [ ! -f "${CI_INFRA_OTEL_DIR}/ci_otel.py" ] \
  || ! mkdir -p "${CI_INFRA_OTEL_SPOOL_DIR}" \
  || ! PYTHONDONTWRITEBYTECODE=1 \
       PYTHONPATH="${CI_INFRA_OTEL_DIR}${PYTHONPATH:+:${PYTHONPATH}}" \
       "${_CI_INFRA_OTEL_PYTHON}" -c "import ci_otel" >/dev/null 2>&1; then
  # tracing 不可用时降级为普通执行,测试命令照常运行
  echo "vLLM CI OTel: tracing unavailable; test command will run normally" >&2 || :
fi
​
# 统一包装函数:把环境变量收敛在这里,覆盖 timeout 存在与不存在两条分支,
# 后续常规调用只要走 _ci_otel_python 就自动免疫 .pyc 落盘。
_ci_otel_python() {
  if command -v timeout >/dev/null 2>&1; then
    PYTHONDONTWRITEBYTECODE=1 timeout 2s "${_CI_INFRA_OTEL_PYTHON}" "$@"
  else
    PYTHONDONTWRITEBYTECODE=1 "${_CI_INFRA_OTEL_PYTHON}" "$@"
  fi
}
​
# GPU 采样绕过了包装函数,直接以后台子进程启动 ci_gpu.py,
# 环境变量前缀会随 & 启动的子进程继承,因此同样不会写字节码。
ci_otel_start() {
  if [ "${CI_INFRA_GPU_SAMPLING:-1}" != "0" ] \
    && [ -f "${CI_INFRA_OTEL_DIR}/ci_gpu.py" ] \
    && command -v nvidia-smi >/dev/null 2>&1; then
    PYTHONDONTWRITEBYTECODE=1 \
      "${_CI_INFRA_OTEL_PYTHON}" "${CI_INFRA_OTEL_DIR}/ci_gpu.py" "$$" </dev/null &
    _CI_INFRA_GPU_PID=$!
  fi
}
​
# 退出钩子里的 flush 是第三类旁路调用,两条分支都要显式加环境变量,
# 否则进程退出时会成为唯一仍会污染 checkout 的路径。
_ci_otel_on_exit() {
  # ...
  trap - 0
  ci_otel_finish "${_CI_INFRA_OTEL_EXIT_STATUS}" || :
  if command -v timeout >/dev/null 2>&1; then
    PYTHONDONTWRITEBYTECODE=1 timeout 4s \
      "${_CI_INFRA_OTEL_PYTHON}" "${CI_INFRA_OTEL_DIR}/ci_otel.py" flush || :
  else
    PYTHONDONTWRITEBYTECODE=1 \
      "${_CI_INFRA_OTEL_PYTHON}" "${CI_INFRA_OTEL_DIR}/ci_otel.py" flush || :
  fi
  # 若 runtime 目录由本进程创建,随后负责清理
}
.buildkite/scripts/ci-otel/tests/test_ci_otel.py test-coverage

新增回归测试 `test_shell_wrapper_does_not_write_bytecode_to_helper_tree`,在临时干净副本上跑完整 shell 生命周期并断言无 .pyc,是把「不再污染 checkout」这一约束固化下来的配套测试。

def test_shell_wrapper_does_not_write_bytecode_to_helper_tree(tmp_path):
    # 先把 helper 目录整体复制到临时目录并忽略已有的 __pycache__,
    # 这样「是否新生成 .pyc」才是唯一判定依据,也不会污染真实源码树
    helper_dir = tmp_path / "ci-otel"
    shutil.copytree(
        SCRIPTS_DIR,
        helper_dir,
        ignore=shutil.ignore_patterns("__pycache__"),
    )
    # 真正 source shell 包装脚本,覆盖 ci_otel_start → ci_otel_finish
    # 以及进程退出时 flush 的完整生命周期;只 import Python 模块
    # 无法验证 shell 层是否把环境变量透传给了子进程
    shell = (
        f'. "{helper_dir / "ci_otel.sh"}"; '
        f"ci_otel_start 1 {_quoted('true')}; ci_otel_finish 0"
    )
​
    result = subprocess.run(
        ["/bin/sh", "-c", shell],
        check=False,
        capture_output=True,
        text=True,
        env={
            **os.environ,
            # 把 helper 目录与 spool 目录都重定向到临时路径
            "CI_INFRA_OTEL_DIR": str(helper_dir),
            # 关闭 GPU 采样,避免依赖 nvidia-smi 造成测试不稳定
            "CI_INFRA_GPU_SAMPLING": "0",
            "CI_INFRA_OTEL_SPOOL_DIR": str(tmp_path / "spans"),
        },
    )
​
    assert result.returncode == 0, result.stderr
    # 核心断言:整棵 helper 树里不允许出现任何 .pyc
    assert list(helper_dir.rglob("*.pyc")) == []

评论区精华

预防性修复与一次性清理的边界 other

作者在多条评论中反复强调,本 PR 只能阻止 helper 在干净 checkout 上重新生成 root 所有的 .pyc,无法修复 agent 已经删不掉的 checkout:「PR #56766 prevents new OTel helper bytecode after a clean checkout exists; it cannot repair a checkout that the agent is already unable to delete.」

结论:合并代码与恢复 slot 必须分成两步:先 root 递归清理 /chown 整个 checkout 并以 buildkite-agent 验证干净 clone,再把 h200-ci-6-23 放回调度;期间不应继续消耗 job 重试。 · 待处理

第二个 root 所有的 stale 路径暴露修复覆盖面不足 正确性

评论记录 #88780 因 `vllm/logging_utils/__pycache__/__init__.cpython-312.pyc` 全部六次 checkout 失败,该路径不在 ci-otel 目录内,说明还有其他以 root 身份写回挂载 checkout 的来源。作者据此指出「删除第一个可见 .pyc 是不够的」。

结论:本 PR 只闭合了 OTel helper 这一条污染链,非 OTel 的 root 写入源仍未处理,属于遗留问题。 · unresolved

失败影响面的滚动统计与运营遏制 other

作者分五次更新把观测面从 6 jobs/5 builds 累加到 13 jobs/12 builds,并指出这些 build 全部是测试前基础设施失败、不能视为对应 PR 的源码结论;最终在 2026-09-14 12:26 UTC 主动暂停 h200-ci-6-23 十二小时。

结论:确认是单 slot 级基础设施事故,采取暂停调度作为遏制手段,并要求 resume 前完成 root 清理与验证。 · 已解决

fork PR 自动审查被禁用 other

claude[bot] 留了一条说明:This pull request is from a fork — automated review is disabled,需要维护者评论 `@claude review` 才能触发一次性审查。

结论:没有自动化审查覆盖,改动由人工提交者与作者自测(60 个 pytest 用例、shellcheck、pre-commit、sh -n)验证。 · 已解决

风险与影响

  1. 预防不等于修复:作者原话是「PR #56766 prevents new OTel helper bytecode after a clean checkout exists; it cannot repair a checkout that the agent is already unable to delete」,合并后 13 个 job 的既有失败仍会持续,直到 h200-ci-6-23 被 root 递归清理或 recycle。
  2. 覆盖不完整:评论中出现的 vllm/logging_utils/__pycache__/__init__.cpython-312.pyc 不在 ci-otel 目录下,说明除 OTel helper 外还有别的 root 写入源会污染挂载 checkout,本 PR 只堵住了其中一处,同类 checkout 清理失败仍可能复发。
  3. 测试存在盲区:新增用例把 CI_INFRA_GPU_SAMPLING=0,因此 ci_otel_start() 中 ci_gpu.py 那条调用路径并未被真正执行;可用性探测分支也没有独立断言。若未来有人绕过 _ci_otel_python() 直接调用 Python,回归测试不一定能拦住。
  4. 验证证据缺失:作者明确表示首个 post-fix main build #88785 未落到 H200 lane,同 slot 的防复发证据尚待补,因此该改动在目标环境上属于「理论上正确、尚未实测」。
  5. 兼容性风险低:PYTHONDONTWRITEBYTECODE=1 只影响 helper 进程自身的字节码缓存写入,不改变进程语义与退出码,test_shell_wrapper_preserves_failure_status 等既有失败状态透传测试仍通过。

对用户与线上推理服务没有直接影响,本 PR 只改动 Buildkite 硬件 CI 的遥测 helper 执行环境。对系统而言,影响集中在 H200 硬件 CI 的 checkout 阶段:修复后 helper 不再向挂载 checkout 写入 root 所有的 .pyc,checkout 清理与重试可恢复正常。对团队而言,CI 维护者需要完成一次带 root 权限的递归清理/chown 并验证 buildkite-agent 能干净 clone,才能让该 slot 重新上线;所有在该 slot 上被误判为「代码失败」的 PR(如 Rust Frontend Tool Use、V1 Core、V1 Spec Decode 等)实际都是测试前基础设施失败。长期看,这次事件暴露出「容器内 root 进程写回宿主机挂载点」这一类通用 CI 卫生问题,可能需要更系统的防护策略。

CI 基础设施级阻塞 预防性修复无法清理已中毒环境 修复覆盖面不完整(存在其他 root 写入源) GPU 采样分支未被测试实际执行 目标 slot 尚无防复发验证证据

关联 Issue

未识别关联 Issue

当前没有检测到明确关联的 Issue 链接,后续同步到相关引用后会出现在这里。

完整报告

参与讨论