自托管环境:部署到生产
在生产里运行自托管 runner:加固清单、网络要求与出口代理认证、git 配置、runner 镜像、CPU 与内存、Kubernetes 与 Compose 配方、关闭时序、预热检出、固定版本、已知问题与排障。
自托管环境目前处于 Team 和 Enterprise 套餐的公开测试阶段;本页讲在生产里运行机队,第一个 runner 和会话见快速开始。
自托管环境在你网络内部署的 runner 上运行 Claude Code 云端会话,在生产里这些会话代表每个能向该环境派发会话的人执行由模型指挥的代码。本页面向把可用环境推向生产的运营者,按顺序讲:连接真实系统前要锁定什么、机队需要的出口、会话如何向你的 git 主机认证、部署配方本身,以及会话行为异常时检查什么。
加固你的部署
自托管 runner 在你的基础设施上、代表每个能向其环境派发会话的人执行任意的、由模型指挥的代码;这包括你 Anthropic 组织的任何成员,以及能在 Owner 路由到该环境的范围内启动 Claude Tag 频道会话的任何人。在把环境连到生产系统之前,逐项落实:
- 临时的、每会话一个容器:在每个 runner 进程退出时销毁的全新容器或 VM 里运行它,用
--capacity 1和默认的--drain-grace-sec 0,让每个容器恰好服务一个会话。容量更高或排空宽限为正数时,一个容器会为同一个被锁定的所有者服务多个会话。不要在 runner 重启之间复用文件系统(刻意设置的预热检出除外),也绝不能跨所有者复用。 - 镜像里没有宽泛凭据:不要包含长期有效的 SSH 密钥、云提供商凭据,或授予比会话所需更多权限的个人访问令牌。会话期间用到的凭据(如推送或 API 令牌)从你的包装脚本按会话铸造。包装脚本运行之前发生的初始克隆,用
checkout生命周期 hook 或--use-anthropic-git-proxy(见「配置 git」)。 - 环境密钥不要放在运行会话的主机上:环境密钥可以注册 runner 并拾取环境上排队的任何会话。在固定机队上它存在每个 runner 主机上,任何会话的代码都能读取密钥文件。优先用按需 runner:密钥只留在从不运行用户代码的编排器主机上,每个 runner 收到一次性的工单,只注册恰好一个 runner。在固定机队上,要把环境密钥文件视为每个会话都可读,并在怀疑任何会话被攻破后轮换密钥。
- 默认拒绝的网络出口:在每个环境上,于你自己的网络边界限制 runner 和会话容器的出站流量;放行什么、为什么,见「默认拒绝出口」。
- 最小权限的主机 IAM:附加到 runner 主机的计算身份(如实例配置文件或节点服务账号)只应授予 runner 自己所需的权限;会话应通过你的包装脚本获取自己的凭据,而不是继承主机的。
- 阻止会话访问云元数据端点:让会话远离主机身份需要阻止它们访问云元数据端点;子网级的出口策略不拦截链路本地的元数据流量,所以要在容器里阻止:IMDSv2 加跳数限制 1;GKE Workload Identity 加元数据隐藏;或在会话容器的网络命名空间里对
169.254.169.254显式拒绝。该阻止同样适用于包装脚本和生命周期 hooks,因为它们共享容器。要用会话 JWT 向你自己的令牌服务做令牌交换,经已放行的出口进行,或使用基于文件的 web 身份,如 Amazon EKS 上的 IAM Roles for Service Accounts(IRSA)。 - 每个 runner 的文件系统隔离:每个 runner 进程有主机上其他进程都不能读写的自己的工作目录;让
--hooks-dir、包装脚本和主机的~/.claude/对会话只读,要么构建进镜像,要么只读挂载。 - 派发没有按环境的访问控制:你 Anthropic 组织的任何成员都能向它的任何环境派发会话。如果 Owner 把 Claude Tag 频道路由到该环境,Claude Tag 访问设置放行的人也可以启动在那里运行的频道会话(默认是已连接 Slack 工作区里的任何人,不论有没有 Claude 账号)。要把每个 runner 主机视为每个能向它派发的人都可以在其上执行代码,只在 runner 主机上放这些人都被允许读取的数据和凭据。
--lock-to-account限制某台主机执行哪个账号的会话,但不会缩小谁能向环境派发。要让自托管环境成为选择器里唯一的选项,Owner 可以在 Cloud environments 页为整个组织隐藏 Anthropic 托管环境。 - 强制仓库设置护栏:用
--confine-repo-settings选择护栏模式:默认warn记录违规但仍生成会话,enforce拒绝该会话,off禁用扫描。runner 扫描每个仓库提交的设置里的:解析到该会话自己工作区之外的授权(additionalDirectories条目、permissions.allow里的Edit、Write或NotebookEdit规则、sandbox.filesystem.allowWrite或allowRead条目);非空的env块;以及sandbox.enabled: false这样的运营者姿态覆盖。护栏不论--trust-workspace都运行,不涵盖仓库 hooks、.mcp.json或 Bash 规则(这些授权该放哪里,见「权限与工具批准」)。
注意:你组织的 IP 允许列表默认不涵盖自托管 runner 流量,不要把它当作 runner 或会话流量的网络控制,改在自己的网络边界应用默认拒绝的出口;想要对组织强制 IP 允许列表,联系你的 Anthropic 客户团队。
网络要求
runner 及其生成的会话子进程向下列主机发起出站连接。把会话容器的出口限制在这些主机和会话需要访问的特定内部服务。
始终需要的主机:
| 主机 | 端口 | 用途 |
|---|---|---|
api.anthropic.com | 443,HTTPS;仅 SCM 连接器用 WSS | runner 控制平面和会话流、模型推理、功能开关、产品分析、JWKS 密钥获取、提交签名、设了 --use-anthropic-git-proxy 时的 git 代理,以及设了 --scm-connector-host 时编排器的 SCM 连接器隧道 |
你的 git 主机,如 github.com 或你的 GitHub Enterprise 主机 | 443 或 22 | 克隆和推送仓库;runner 用 --use-anthropic-git-proxy 时不需要,它把 git 流量经 api.anthropic.com 路由 |
是否需要下列主机取决于配置:
| 主机 | 端口 | 何时需要 |
|---|---|---|
downloads.claude.ai | 443 | 安装时,用原生安装器在主机上安装或更新 Claude Code(install.sh 本身由 claude.ai 提供);会话运行时,只在会话从官方 Anthropic 市场安装插件时 |
storage.googleapis.com | 443 | 会话运行时,用于 /plugin 里显示的插件安装计数和元数据 |
code.claude.com 和 claude.com | 443 | 内置 claude-code-guide 智能体的文档查找,以及会话期间预批准的 WebFetch 请求;阻止它们只影响文档查找 |
*.frame.claudeusercontent.com | 443 | 只有在你组织的会话里 Artifact 工具可用时(默认值因套餐而异);在 runner 上设 CLAUDE_CODE_DISABLE_ARTIFACT=1 可不论组织设置都保持禁用 |
registry.npmjs.org | 443 | 会话安装插件时(获取 npm 源插件包以及安装插件的 Node.js 依赖),或运行 npx 启动的 MCP 服务器时 |
http-intake.logs.us5.datadoghq.com | 443 | Anthropic 运营指标;仅当设了 CLAUDE_CODE_BYOC_ENABLE_DATADOG=1,自托管环境里默认关闭 |
browser-intake-us5-datadoghq.com | 443 | Anthropic 错误报告上传;仅当会话账号启用了错误报告时发送,DISABLE_ERROR_REPORTING=1 或 DISABLE_TELEMETRY=1 可抑制 |
runner 不会访问 statsig.anthropic.com、*.sentry.io、claude.ai 或 platform.claude.com。这些主机出现在一些较旧的企业网络清单里,但对 runner 或会话流量不需要放行:功能开关获取走 api.anthropic.com,runner 用环境密钥认证而不是交互式 OAuth。有两个主机侧流程会访问 claude.ai,所以要在出口允许它的主机上运行,而不是放宽会话容器出口:一行安装器在安装时从 claude.ai 取 install.sh;交互式 claude auth login(引导式设置、doctor 的已登录模式和 CI 派发都用它)经 claude.ai、claude.com 和 platform.claude.com 登录。mcp-proxy.anthropic.com 同样不需要:自托管会话不使用它。
默认拒绝出口
把 runner 和会话容器部署在出站流量被限制到上表主机、你的 git 主机和会话需要访问的特定内部服务的网段或命名空间里。产品无法验证或强制这一点,所以要在每个环境的自有网络边界上应用。会话代码由模型指挥,可能尝试连接任意主机;网络层的默认拒绝出口限定这些尝试能到哪里。这与权限模式无关:默认预批准工具集已含 Bash,所以即使没有 auto 模式,shell 出口也无需提示就运行。每个会话发出什么遥测以及如何关闭,见「遥测」。
向出口代理认证
有些企业出口代理要求每个连接都带 Proxy-Authorization 头,而这个令牌的轮换往往太快,没法写进你设给 HTTPS_PROXY 的代理 URL 里。像往常一样把 HTTPS_PROXY 或 HTTP_PROXY 设为代理的 URL,再用 --proxy-authorization-command 或 --proxy-authorization-file 告诉 runner 从哪里读取头的值(两个标志都需要 v2.1.238 及以上)。
选择 Proxy-Authorization 的来源:--proxy-authorization-command <command> 用于按需生成的令牌,runner 运行 shell 命令并把其去空白的 stdout 用作头的值,如 Bearer <token>;--proxy-authorization-file <path> 用于被另一个进程原地轮换的令牌,runner 读取文件并把去空白的内容用作头的值。
runner 拒绝启动的配置:每个标志也有环境变量形式(见 runner CLI 标志参考)。在 runner 联系你的代理或控制平面之前,它检查这些标志及其变量,三种情况下拒绝启动:两个标志都设了(一个标志加另一个标志的环境变量也算都设);没有代理 URL(HTTPS_PROXY 和 HTTP_PROXY 都没有 http:// 或 https:// URL;runner 读取两者的大写或小写形式,不查 ALL_PROXY);把任一标志传给编排器子命令(self-hosted-runner orchestrator 不接受这些标志或其环境变量,要传给编排器启动的每个 runner)。
设了代理授权标志时 runner 的改动:设了任一标志,runner 启动自己的监听器,并把来自 runner 自身、其生命周期 hooks 和其会话的代理流量经该监听器发送,监听器在去往你代理的路上加上 Proxy-Authorization 头。监听器是 127.0.0.1 上的正向代理,runner 在向控制平面注册之前启动它,监听器无法启动就在启动时退出;runner 把你设置的 HTTPS_PROXY 和 HTTP_PROXY 改写为指向监听器,改写后的值到达 runner 自己、其生命周期 hooks 和它运行的每个会话;轮换后的令牌无需重启就生效,监听器每次打开到你代理的连接时,runner 都重新运行你的命令或读取你的文件,并把结果加为头;会话只能经监听器到达你的代理:在每个会话环境里,runner 移除 ALL_PROXY、移除你没设的任何 HTTPS_PROXY 或 HTTP_PROXY 拼写,并把 NO_PROXY 固定为 runner 自己的值;runner 从不记录头的值。
配置 git
runner 管理仓库检出,但默认不配置 git 身份或凭据。你控制 runner 的镜像和进程环境,所以你控制 git 配置,有两种做法:让 runner 配置 git——用 --configure-git 启动 runner,让它写入与 Anthropic 托管会话相同的身份和提交签名配置;在镜像里提供 git 配置——自己设置身份和推送凭据,例如以你自己的机器人身份提交。runner 主机上的 git 版本下限:--configure-git 的 SSH 提交签名需要 Git 2.34 或更高,--use-anthropic-git-proxy 需要 2.32 或更高,从 --push-outcome-on-release 推送的分支恢复会话需要 2.29 或更高;省略这三者并自己管理 git 身份时,Git 2.24 就够了。
让 runner 配置 git
用 --configure-git(或设 SELF_HOSTED_RUNNER_CONFIGURE_GIT=1)启动 runner,让它在启动时写入全局 git 配置:
user.name = Claude和user.email = noreply@anthropic.com,与 Anthropic 托管会话一致- SSH 格式的提交和标签签名,经 runner 管理的 shim 路由,用会话自己的凭据通过 Anthropic 的签名服务给每个提交签名;签名可在 GitHub 上对照 Anthropic 公布的 SSH 签名密钥验证
push.negotiate = true,让 git 在打包推送前询问你的 git 主机它已经有哪些提交(需要 v2.1.257 及以上)core.hooksPath指向 runner 管理的 hooks 目录,其commit-msg和prepare-commit-msghooks 给每个提交添加会话创建者的Co-authored-by:trailer,由CCR_SESSION_ACCOUNT_EMAIL里的邮箱构成,该变量未设时省略;如果你的镜像已设置core.hooksPath,runner 保留你的设置、跳过安装这些 hooks,并打印[runner:git]警告
提交签名需要 git 2.34 或更高;runner 在启动时检查,git 更旧就带着错误退出。该标志不配置推送凭据,推送凭据仍要你在镜像里提供。
在镜像里提供 git 配置
任何提交都需要 git 身份。在 Dockerfile 里设成系统级,这样不管 runner 进程以哪个用户运行,配置都适用:
RUN git config --system user.name "Claude" && \
git config --system user.email "noreply@anthropic.com"没有身份时,git commit 以 Please tell me who you are 失败,会话无法推进。可以改用你自己的机器人身份,runner 不会覆盖这些值。
不要把长期有效或范围宽泛的推送凭据烘进共享的 runner 镜像:镜像里的凭据对镜像运行的每个会话都可用,不论是谁启动的。改为从包装脚本按会话铸造短期、最小范围的令牌,使用从会话 JWT 解码出的会话创建者身份;并配合每会话一个的临时容器(要求 --capacity 1),这样没有凭据比铸造它的会话活得更久。如果必须在镜像级配置推送凭据(如只读的部署密钥),要尽可能收紧到 git 主机允许的范围:只限一个仓库的 SSH 部署密钥加 url.<base>.insteadOf 改写;返回最小范围令牌的 credential.helper;或指向窄范围密钥的 GIT_SSH_COMMAND。
不论你配置哪种机制,都必须无提示工作,因为 runner 内置的克隆和获取会禁用 git、SSH 和 Git Credential Manager 本来会显示的提示:runner 设 GIT_TERMINAL_PROMPT=0,git 不会询问用户名或密码;runner 以 BatchMode=yes 运行 SSH(若你设了 GIT_SSH_COMMAND 则追加到其后),SSH 不会询问口令或主机确认;runner 设 GCM_INTERACTIVE=never,Git Credential Manager 不会弹出登录对话框;runner 清除 core.askPass,所以如果你用 askpass helper,改通过 GIT_ASKPASS 环境变量设置。如果 git 主机拒绝凭据或你没配置,runner 会重试几次,然后在该仓库是会话推送结果的仓库时让仓库准备失败;对会话只读的仓库,runner 何时改为跳过它,见「排障」。runner 不会把这些设置传入会话环境。
把你在 GIT_SSH_COMMAND 或 GIT_ASKPASS 里指名的任何程序放在会话无法写入的地方,就像加固清单对 hooks 目录和包装脚本要求的那样;该程序命令行上的任何密钥或文件也一样,因为 runner 自己的 git 在克隆或获取时会运行该程序。如果检出目录的所有者 uid 与 runner 进程不同,git 会拒绝操作它们,要加 safe.directory:
RUN git config --system --add safe.directory '*'使用 Anthropic git 代理
用 --use-anthropic-git-proxy(或设 CLAUDE_RUNNER_USE_GIT_PROXY=1)启动 runner,让它经 Anthropic 的 git 代理克隆,用会话自己的短期令牌认证。对普通用户会话,代理使用为会话创建者存储的 GitHub 或 GitHub Enterprise OAuth 令牌;对机器人和智能体会话,使用你组织的 GitHub App 安装令牌。无论哪种,runner 镜像都完全不需要 git 凭据:没有 SSH 密钥、没有凭据 helper、没有 .netrc。这与 Anthropic 托管环境使用的是同一条认证路径。
代理要求 --capacity 1(因为代理 URL 是按会话的)和 git 2.32 或更高(因为更旧的 git 会忽略代理用来隔离会话的配置机制),任一不满足 runner 就拒绝启动。因为代理从 Anthropic 一侧获取,你的 git 主机必须能从 Anthropic 基础设施到达,与 Anthropic 托管会话的要求相同;对只在你网络内可路由的 git 主机,改用 checkout 生命周期 hook。每个 runner 进程一次处理一个会话,要并行就多跑副本。启用代理时,--git-host-rewrite 和 --git-ssh-rewrite 不起作用:代理 URL 指向 api.anthropic.com,不是你的 git 主机。
注意:本页的 Kubernetes 和 Docker Compose 配方用 --capacity 4。如果在其中加了 --use-anthropic-git-proxy 或 CLAUDE_RUNNER_USE_GIT_PROXY=1 却没把容量改为 1,runner 会在你的编排器每次重启它时都在启动时退出。要设 --capacity 1 并通过多跑副本获得并行。
runner 在注册时还会向 Anthropic 报告这一选择加入,并在启动时打印 Registering as opted in to Anthropic-managed git (--use-anthropic-git-proxy)(报告选择加入需要 v2.1.267 及以上,更早版本接受该标志但不报告也不打印该行)。选择加入的 runner 上的每个会话随后使用 Anthropic 托管的 git 或按会话的代理 URL;会话使用按会话的代理 URL 时,runner 记录一行 [runner:warn] 说明这一点。
用 Anthropic 托管的 git 信任私有证书颁发机构
本节适用于:你在运行会话使用 Anthropic 托管 git 的 runner 的环境里设了 GIT_SSL_CAINFO 或 GIT_SSL_NO_VERIFY;它描述的处理需要 runner 运行 Claude Code v2.1.283 及以上。当 runner 上的 git 必须信任私有证书颁发机构(CA,如 TLS 检查代理用来签名的那个)时,常见做法的结果是:系统证书存储——把你的 CA 装进 runner 主机的系统证书存储,git 不用任何变量就信任它;GIT_SSL_CAINFO——设为你的 CA 的 PEM 文件,如 GIT_SSL_CAINFO=/etc/ssl/corp-ca.pem;GIT_SSL_NO_VERIFY——在重新签名的代理后面没用,runner 自己经 Anthropic 托管 git 的克隆即使设了该变量也会检查证书,所以该克隆会失败,直到 git 通过另外两种做法之一信任你的 CA。
对携带会话令牌到 Anthropic 托管 git 的 git 连接,runner 这样应用这两个变量(command hook 从会话的环境启动,所以它得到会话内部的 git 所得到的):
GIT_SSL_CAINFO:git 用什么检查 Anthropic 托管的 git 取决于它在哪里运行。runner 自己的克隆和获取不带该变量运行,并对照 runner 写的每会话证书文件检查(该文件含 runner 主机的系统 CA 包加上你的文件里的证书);会话内部的 git 得到点名你的文件的http.sslCAInfo配置来代替变量,外加用每会话文件检查 Anthropic 托管 git 的http.<url>.sslCAInfo条目;checkout和post-sessionhooks 原样继承该变量。GIT_SSL_NO_VERIFY:哪些证书检查保持关闭取决于 git 在哪里运行。runner 自己的克隆和获取不带该变量运行,并检查所收到的证书;会话内部的 git 得到http.sslVerify=false配置来代替变量,所以对其他主机的检查保持关闭,同时得到让 Anthropic 托管 git 的检查保持开启的http.<url>.sslVerify=true条目;当会话有位于 Anthropic 托管 git 上的仓库时,checkout和post-sessionhooks 得到http.sslVerify=false配置来代替变量,同样带有让 Anthropic 托管 git 检查保持开启的http.<url>.sslVerify=true条目。
每会话证书文件需要 runner 主机上 /etc/ssl/certs/ca-certificates.crt 或 /etc/pki/tls/certs/ca-bundle.crt 处的系统 CA 包,还需要一个 runner 的用户可读、含 PEM CERTIFICATE 块且不超过 1 MiB 的 GIT_SSL_CAINFO 文件。runner 无法构建每会话文件时,会记录一行含 did not build the certificate file 和原因的 [runner:warn],此时 git 对 Anthropic 托管 git 按原样使用你的文件,要修复该行点名的问题。对每个使用 Anthropic 托管 git 的会话,runner 还会记录一行以 governed git: GIT_SSL_CAINFO is set 或 governed git: GIT_SSL_NO_VERIFY is set 开头的 [runner:warn],说明 runner 对该变量为它自己的 git、会话内部的 git 和你的生命周期 hooks 做了什么,并以是否需要你改动什么结尾。
为私有网络改写 git URL
仓库 URL 从控制平面以 HTTPS 形式到达,主机名是你的 git 主机;对 GitHub Enterprise,是你在 claude.ai 的 Claude Code 管理设置里为 GitHub Enterprise 集成配置的主机名。两个可重复的标志在克隆前改写这些 URL:--git-host-rewrite <from>=<to> 用于分离视图 DNS(Anthropic 经外部主机名到达你的 git 主机,但 runner 必须用内部的);--git-ssh-rewrite <host> 用于只接受 SSH 的 git 主机,把 https://<host>/owner/repo 改写为 git@<host>:owner/repo。主机改写先运行,所以两者都需要时要在 --git-ssh-rewrite 里列内部主机名。要完全控制检出,用 checkout 生命周期 hook。
构建 runner 镜像
Anthropic 不发布预构建的 runner 镜像。围绕 claude 二进制自己构建,并叠入你的仓库需要的任何工具链:语言运行时、编译器、包管理器和 MCP sidecar。下面的配方用 --capacity 4,所以一个容器最多为同一个被锁定的所有者服务四个并发会话。这不提供加固一节里的每会话容器隔离:把环境连到生产系统之前,要么在 --capacity 1 下每会话一个容器地运行这些配方,要么用按需 runner(它还能让环境密钥远离运行会话的主机)。给这些配方加 Anthropic git 代理时,也要把 --capacity 改为 1。这个 Dockerfile 是最小的起点:
FROM debian:bookworm-slim
ARG CLAUDE_CODE_VERSION
RUN apt-get update && apt-get install -y --no-install-recommends git curl ca-certificates openssh-client \
&& rm -rf /var/lib/apt/lists/*
RUN curl -fsSL "https://downloads.claude.ai/claude-code-releases/${CLAUDE_CODE_VERSION:?set with --build-arg CLAUDE_CODE_VERSION}/linux-x64/claude" \
-o /usr/local/bin/claude && chmod +x /usr/local/bin/claude
RUN git config --system user.name "Claude" \
&& git config --system user.email "noreply@anthropic.com" \
&& git config --system --add safe.directory '*'
ENTRYPOINT ["claude"]节点是 ARM 时把 linux-x64 换成 linux-arm64,基于 musl 的镜像(如 Alpine)换成 linux-x64-musl 或 linux-arm64-musl(musl 镜像需要的额外软件包见 Alpine Linux 设置)。该 URL 是标准的 Claude Code 发布位置,所以可以按「二进制完整性与代码签名」所述,对照发布的已签名清单验证下载的二进制。runner 需要 Claude Code 2.1.224 及以上。构建镜像,推送到你的镜像仓库,并在下面的配方里引用它:
docker build \
--build-arg CLAUDE_CODE_VERSION="$(curl -fsSL https://downloads.claude.ai/claude-code-releases/stable)" \
-t <your-registry>/claude-runner:latest .命令替换会查出当前 stable 发布号并作为构建参数传入,所以在新的 stable 发布之后运行同一命令会用较新的二进制重建下载层。要为可复现的构建固定某个发布,直接把版本号作为 CLAUDE_CODE_VERSION 传入。需要比 stable 通道更新的发布(如新推出的模型所要求的)时,把查询 URL 里的 stable 换成 latest。
为会话定 CPU 与内存
要按它运行的会话而不是 runner 进程来定容器或主机的大小。runner 本身轮询工作、准备每个会话的检出、运行你的生命周期 hooks,并启动和监督会话进程;负载来自会话:每个会话是一个 Claude Code 进程,加上它启动的任何东西,如构建、测试套件、包安装和 MCP 服务器。对一个会话,从下列值起步(用 Kubernetes 的 requests 和 limits 或你平台的等价物表示),把它们当作起点而不是要求:
- 内存:request 和 limit 各 4 GiB,满足 Claude Code 系统要求里 4 GB 的最低要求。两者保持相等,让调度器计入容器的全部内存。容器达到内存上限时,内核会杀掉其中的进程,这可能在任务中途结束会话。
- CPU:request 2 个 CPU、limit 4 个 CPU,让会话在构建期间能突发到 request 之上。内核在 CPU 上限处限流容器而不是杀掉其中进程,所以达到上限的会话运行得更慢但继续运行。
在 Kubernetes 容器规格里,用下面的 resources 块设置这些起步值:
resources:
requests:
cpu: "2"
memory: 4Gi
limits:
cpu: "4"
memory: 4Gi构建和测试通常是会话负载中最大且最不稳定的部分,所以对你的仓库运行一次有代表性的构建,测量它的峰值 CPU 和内存,并提高任何在该峰值之上没给 Claude Code 进程留出空间的起步值。runner 用 --capacity 限制它同时运行多少会话,但不在它们之间划分 CPU 或内存,所以一个 runner 上的会话共享容器的 CPU 和内存;要限制一个会话的份额,从包装脚本应用限制。因此给一个容器多少取决于它同时服务多少会话:每个 runner 一个会话——给每个容器一个会话的值,用于 --capacity 1(加固一节推荐的)和按需 runner(在你的 spawn-runner hook 提交的工作负载上设值,如 Kubernetes Job 的 pod 模板);每个 runner 多个会话——--capacity 大于一时,把一个会话的值乘以容量,因为容器里最多可同时运行那么多会话;Kubernetes 和 Docker Compose 配方以 --capacity 4 运行且不设 CPU 或内存限制,所以要加上按你所运行容量定大小的限制。
Kubernetes
runner 默认在 8080 端口提供 GET /healthz(可用 --health-port 配置),所以 Kubernetes 探针无需额外设置。该端点只要进程存活就返回 200,所以下面的探针只能发现已死的进程,不能发现卡住的;要发现停止轮询的 runner,对 /metrics 里的 last_poll_age_seconds 序列告警。下面的 Deployment 从 Kubernetes Secret 挂载环境密钥,把存活和就绪探针指向 /healthz,并设置 90 秒的终止宽限期(为什么宽限期重要,见「关闭时序」)。清单没有给 runner 容器设 CPU 或内存 resources,要按「为会话定 CPU 与内存」加上按你运行的容量定大小的块。
apiVersion: apps/v1
kind: Deployment
metadata:
name: claude-runner
namespace: claude-runners
spec:
replicas: 3
selector:
matchLabels:
app: claude-runner
template:
metadata:
labels:
app: claude-runner
app.kubernetes.io/part-of: claude-code-self-hosted-runner
spec:
terminationGracePeriodSeconds: 90
containers:
- name: runner
image: <your-registry>/claude-runner:latest
args:
- self-hosted-runner
- --environment-secret-file
- /etc/claude/environment-secret
- --capacity
- "4"
volumeMounts:
- name: environment-secret
mountPath: /etc/claude
readOnly: true
ports:
- name: health
containerPort: 8080
readinessProbe:
httpGet:
path: /healthz
port: 8080
initialDelaySeconds: 5
periodSeconds: 10
livenessProbe:
httpGet:
path: /healthz
port: 8080
initialDelaySeconds: 30
periodSeconds: 30
volumes:
- name: environment-secret
secret:
secretName: claude-runner-environment-secret上面的 Deployment 位于 claude-runners 命名空间,先创建它:
kubectl create namespace claude-runners用本地文件创建后备 Secret,文件里放你在管理界面 Copy environment key 步骤里复制的值,这样密钥不会出现在你的 shell 历史里。运行 (umask 077 && cat > ./environment-secret),粘贴密钥,按 Enter,再按 Ctrl-D。然后创建 Secret 并删除该文件:
kubectl create secret generic claude-runner-environment-secret -n claude-runners --from-file=environment-secret=./environment-secretDocker Compose
下面的 Compose 服务在 runner 每次退出时重启它,这涵盖崩溃和排空后的正常退出。Docker 的重启策略会带着可写层原样重启同一个容器,所以 runner 回来时用的是被复用的文件系统,而不是加固姿态推荐的全新文件系统;这个配方用于评估,生产里要么每次运行重建容器,要么用能做到这一点的编排器。Docker 在每次重启一个不断退出的容器之前会等得更久(有上限),所以在这个配方下无法启动的 runner 不会在紧密循环里不断重启;发生这种情况时要检查什么,见「runner 退出时」。
services:
claude-runner:
image: <your-registry>/claude-runner:latest
command:
- self-hosted-runner
- --environment-secret-file
- /run/secrets/environment-secret
- --capacity
- "4"
secrets:
- environment-secret
restart: always
stop_grace_period: 90s
secrets:
environment-secret:
file: ./environment-secret关闭时序
收到 SIGTERM 时,runner 停止接新工作,并且(除非设了 --defer-shutdown-max-min)最多等 --drain-wait-sec(默认零)让进行中的轮次结束,终止每个会话的进程树,然后运行 post-session 生命周期 hook;该进程树包含 Claude 在会话里仍在运行的命令。完整的排空路径最多需要 --session-stop-grace-sec + --drain-wait-sec + --post-session-hook-timeout-sec,外加 15 秒用于进程清理的固定开销,设了 --push-outcome-on-release 时再加 30 秒;默认值下是 80 秒,runner 在启动时记录这个总数。会话在这一个预算下并行排空,所以总数不随 --capacity 增长。
默认的 --drain-wait-sec 0 下,滚动重启会打断进行中的轮次;每个会话在另一个 runner 上恢复,丢失未推送的工作(见「已知问题」)。设 --drain-wait-sec 并相应调高宽限期,可让轮次先结束。在整个路径期间,runner 以零容量继续向控制平面发心跳,所以在 post-session hook 还在写出未提交工作时,会话租约不会过期并被重新排给另一个 runner;心跳在 runner 注销之前才停止。
在主机停止 runner 之前,要给它至少它在启动时记录的总数。在哪里设取决于你的主机如何停止:有 SIGTERM 宽限期——把 Kubernetes 的 terminationGracePeriodSeconds、Docker Compose 的 stop_grace_period 或你编排器的等价项设为至少该总数;Kubernetes 默认的 30 秒比 runner 的排空路径短,所以 Kubernetes 会在 runner 排空完成前停掉 pod;用 --retire-at——把退休时间与主机停止时间之间的余量定为能覆盖典型轮次,加上「runner 生命周期」所述的后台任务保持,再加同样的总数,并在每次启动时计算退休时间,如 date +%s 加上 runner 的预期寿命;用 --defer-shutdown-max-min——在排空路径总数上再加两部分:你配置的分钟数,以及下面所说的释放后宽限(默认值下 75 秒);设了该标志时,runner 还会在排空路径总数之后于启动时打印合并后的数字。
把排空推迟到第一个信号之后
如果希望正在重启的 runner 继续为它持有的会话服务最多 n 分钟,而不是在第一个信号时就排空它们,设 --defer-shutdown-max-min <n>。在第一个 SIGTERM 或 SIGINT 时,runner 停止接新工作并继续为它持有的会话服务,它保持轮询,以免控制平面把这些会话重新排队(需要 v2.1.238 及以上)。从第一个信号算起,runner 经历三个阶段,前两个阶段里 runner 释放会话,被释放的会话在其用户发下一条消息时在新 runner 上恢复:
- 前
n分钟:runner 正常服务其会话,并继续强制--startup-timeout-min和--kill-session-after-min;如果还设了--release-idle-session-min,runner 会释放用户空闲了那么久的任何会话,否则空闲会话留在 runner 上。 n分钟用完时:runner 释放它仍持有的每个会话,不论是否空闲;它等处于轮次中的会话的轮次结束,并在释放该会话前最多再等 60 秒让轮次的后台任务结束。- 释放后宽限用完时:runner 排空它仍持有的任何会话,控制平面立即把每个被排空的会话重新排给另一个 runner。释放后宽限从
n分钟用完时开始,默认 75 秒;如果把--drain-wait-sec设得大于 60 秒,释放后宽限改为--drain-wait-sec加 15 秒。
任何阶段,runner 一旦不持有会话就以 0 退出。第二个信号会缩短这些阶段:runner 立即排空,就像没有 --defer-shutdown-max-min 时在第一个信号上那样;排空一旦在进行,下一个信号会强制退出 runner,不论排空是由第二个信号还是释放后宽限用完启动的。
确定停止超时:给主机的停止超时至少以下三部分之和:你配置的 n 分钟、释放后宽限,以及上面「关闭时序」所述的完整排空路径。默认设置下释放后宽限是 75 秒、排空路径是 80 秒,所以要留 n 分钟加 155 秒;设了 --defer-shutdown-max-min 时 runner 在启动时打印这个和。如果停止超时在 runner 完成前用完,主机会杀掉 runner,它仍持有的会话得不到 post-session hook,runner 不会注销,控制平面约一分钟后把这些会话重新排队。如果无法给停止超时这个和,就不要设 --defer-shutdown-max-min,让 runner 在第一个信号时排空。
什么能到达运行中的 post-session hook
post-session hook 和 Claude 会话子进程各自运行在与 runner 分开的自己的 POSIX 进程组里,所以各种停止机制到达它们的方式不同:
- runner 已在排空时的
SIGTERM:立即强制退出 runner,跳过排空路径的剩余部分(没有--defer-shutdown-max-min时,那是 runner 收到的第二个SIGTERM)。没有任何东西向运行中的post-sessionhook 发信号,所以在由 init 进程收养孤儿的裸主机上它会自己结束,但不受监督:它的超时预算不再适用,写入已关闭的日志管道可能让它因SIGPIPE被杀,所以需要在强制退出中存活的 hook 应把自己的输出重定向到文件。在本页的容器配方里 runner 是容器的 PID 1,它退出就结束容器;在 systemd 默认的KillMode=control-group下,整个 cgroup 的杀死也会到达 hook;两种情况下都要把强制退出视为对 hook 致命,并依靠宽限期。 - 进程组范围的信号(如包装脚本里的
kill -- -<pid>、shell 作业控制或进程组范围的看门狗):到达 runner 和运行中的checkouthook 子进程(它有意保持与组关联),但不会到达运行中的post-sessionhook 或会话子进程。 - cgroup 范围的杀死(如 systemd 默认的
KillMode=control-group,或terminationGracePeriodSeconds到期时 Kubernetes 向整个容器投递的SIGKILL):到达一切,包括 hook;进程组隔离防不住这些,这就是宽限期必须覆盖完整排空路径的原因。 - hook 自己的超时:hook 超过
--post-session-hook-timeout-sec时,runner 向 hook 的整个进程组发SIGTERM,两秒后发SIGKILL,所以 hook 派生的工作进程(如 tar、rsync 或 git)会随包装 shell 一起终止,而不是作为孤儿存活。hook 的 stdio 关闭后 runner 的监督就结束了:把自己的输出重定向到文件并且活过SIGTERM阶段的工作进程,已超出 runner 的控制范围。
排空开始时以及强制退出时,runner 都会记录仍有多少 post-session hooks 在运行,让你能区分安静的排空和正处在快照中途的排空。
保持基础目录和容量在各 runner 间一致
如果 runner 在会话中途死亡,服务端会重新排队该会话,由环境里的另一个 runner 接手。该 runner 根据自己的 --base-dir 和 --capacity 推导检出路径:--capacity 1 直接在 --base-dir 下检出,--capacity 大于 1 则改用每会话的 worktree。同一环境里的 runner 对任一标志用不同的值时,恢复会话的工作目录会变,智能体之前在编辑、工具调用或自己的笔记里记下的绝对路径就指向一个不再存在的位置。所以环境里每个 runner 都要使用相同的 --base-dir 和 --capacity,不要用实例 ID 或主机名这类按主机的值。
基础目录默认是 /workspace(--base-dir 参考行记录了例外),runner 需要对其有写权限。启动时、注册之前,runner 创建该目录并确认能写入,无法做到时以 cannot create or write to base directory 退出。以 root 启动的 runner 会自己创建默认的 /workspace;对非 root 的 runner,要在启动前创建目录并把所有权给 runner 的用户,或把 --base-dir 指向该用户已拥有的目录。
复用预热的检出
对大型仓库,克隆可能占会话启动时间的大头。在 --capacity 1 且没有 checkout hook 时,runner 在 <base-dir>/<repo-owner>/<repo> 为每个仓库保留一个规范克隆,并在会话之间复用:它获取所请求的引用、分离 HEAD 并硬重置到它,变化不多时近乎瞬时。要跳过冷克隆,用两种方式之一提供克隆:在镜像里克隆——把克隆构建进你的 runner 镜像的那个路径,每个全新的容器都带着预热的克隆启动而无需复用磁盘;在持久卷上克隆——对用 --lock-to-account 预锁定到某一用户账号的 runner,把 --base-dir 指向持久卷,这样磁盘只服务那个账号(预锁定的 runner 从不接 Claude Tag 频道会话,所以该选项不适用于服务它们的 runner)。复用路径保证和不保证什么:
- 任何克隆形态都可以:路径上的完整、浅或单分支克隆原样使用。runner 往现有克隆里获取时从不传
--depth,所以完整的预热保留完整历史,浅的仍是浅的。CLAUDE_RUNNER_FETCH_DEPTH(full、0或数字;默认 50)只控制没有克隆时 runner 做的冷克隆。 - 已跟踪的改动被重置,未跟踪的文件保留:每个会话从硬重置开始,抹掉上一个会话对已跟踪文件的修改,但 runner 从不运行
git clean,所以锁定所有者早先会话留下的未跟踪文件会留在树里。 - 每会话目录也会保留:除检出外,runner 为它运行的每个会话在
<base-dir>/_sessions/下创建每会话条目:会话的 Claude 配置目录保存对话记录的本地副本,旁边是会话上传的文件(如果有),会话目录也在那里,它在会话运行时保存任何每会话 worktree 和checkouthook 的检出,并保留 Claude 在里面写的其他东西。默认 runner 在会话结束时把这些留在原处,所以在比 runner 进程活得久的磁盘上它们会累积;每个会话都以 runner 自己的用户运行,所以该磁盘服务的任何后续会话都能读取它们。如果保留持久的--base-dir,要按这种增长给卷定大小,任何在同一文件系统上重启 runner 的设置(包括 Docker Compose 配方)也一样。 - 用
--remove-session-state时,每会话目录不保留:用--remove-session-state启动 runner,让它在会话结束时删除每个会话的每会话目录。删除是尽力而为的,runner 在清理运行前被杀时这些目录会留着;规范克隆以及会话在主机其他位置(如临时目录)写的文件无论如何都保留。 - 用 git 代理时,重置变成检出:用
--use-anthropic-git-proxy时,runner 在每个会话之前清理克隆的.git/,保留对象库、引用和浅状态但删除索引,所以每个会话要付出一次完整的工作树检出而不是近乎瞬时的重置;它仍然从不重新克隆。代理下不支持子模块预热。 - 长时间克隆无需变通:runner 用 120 秒无进展看门狗和 30 分钟硬上限(而不是固定超时)限制每个 git 操作,所以持续报告进度的慢冷克隆会完成。
固定版本
每个会话的子 Claude Code 进程运行 runner 自己的二进制,runner 在它生成的会话里关闭自动更新,所以每个会话运行的是你在主机上安装或烘进镜像的版本;主机级的更新在 runner 下次启动时生效。你的会话使用的模型可能要求比它们运行的更新的 Claude Code 版本,此时服务端会以 Claude Code does not support this model 拒绝对该模型的请求,所以固定版本之前,要对会话使用的每个模型查「模型所需的 Claude Code 版本」。要把机队固定在一个版本:用固定版本构建镜像,或在裸主机上安装特定版本并禁用自动更新;要升级:安装较新版本或重建镜像,然后重启 runner;插件:插件市场也不自动更新,在 runner 的环境里设 FORCE_AUTOUPDATE_PLUGINS=1,可让插件自动更新而二进制保持固定。
伸缩机队
由你的编排器决定何时增减 runner。由于每个 runner 只锁定一个所有者,最小副本数就是你预期并发活跃的用户和 Claude Tag 智能体的数量;--capacity 控制的是同一个所有者的会话之间的并行度,不是跨所有者的。有两种伸缩方式:固定机队——运行一组静态的 runner 副本,按每个 runner 提供的 Prometheus 指标伸缩;按需 runner——运行 claude self-hosted-runner orchestrator 子命令,它轮询 Anthropic 获取排队且没有可用 runner 的会话,并调用你的 spawn-runner hook 为每个会话启动一个(见「自定义会话」里的按需 runner)。
已知问题与限制
以下是此版本的限制,有变通办法的附上。
连接器流量离开你的网络
Anthropic 从它自己的基础设施而不是你的 runner 调用连接器工具(连接器工具是 claude.ai 连接器,如 GitHub、Slack 和 Linear)。Claude 在自托管会话里使用连接器时,该流量经 api.anthropic.com,而不是从你的网络边界内部发出。要让某个连接器不进入自托管会话,用 allowedMcpServers 和 deniedMcpServers 策略设置过滤它。Claude Code 把这些设置同样应用于 Anthropic 交付的连接器,以及你从 runner 主机播种的服务器和用户添加的服务器,所以如果你为其他服务器部署了允许列表,Claude Code 也会阻止交付的连接器。要在基于 URL 的允许列表之外保留连接器,加入匹配交付连接器的 Anthropic 代理路径的条目:https://api.anthropic.com/v2/ccr-sessions/*、https://api.anthropic.com/v1/code/sessions/*、https://api.anthropic.com/v1/code/mcp/*。如果工具流量必须留在你的网络内,改在 runner 镜像上把等价的工具作为本地 MCP 服务器运行(见「自定义会话」的 MCP 服务器一节)。
有些会话不算空闲
持有永不结束的后台任务的会话不算空闲,所以 --release-idle-session-min 不会释放该会话的槽位;在运行中的工具调用内等待审批的会话也不算空闲。始终配合设 --kill-session-after-min 作为硬性兜底,这样没有会话能无限期占着槽位。--kill-session-after-min 是失控会话的兜底。在 v2.1.260 及以上的 runner 上,到达限制的会话不会被直接终止,runner 给它一个宽限窗口(默认 15 分钟,可用 SELF_HOSTED_RUNNER_MAX_LIFETIME_GRACE_MS 更改):会话在等用户时,runner 释放它;轮次已结束且只持有后台任务时,runner 最多等 60 秒让这些任务结束然后释放它,会话在其用户发下一条消息时恢复;仍有轮次在运行时,runner 等轮次结束或会话下一次等待其用户,然后释放它;宽限窗口结束时会话仍在 runner 上,runner 终止它,任何运行中轮次的工作都会丢失(在运行中的工具调用内等待审批的轮次就是会话超出窗口的一种方式)。被释放的会话从全新克隆恢复,所以它没推送的工作两种情形下都会丢,见「恢复的会话丢失未推送的工作」。v2.1.260 之前,runner 在限制处终止每个会话,最多等宽限窗口让运行中的轮次结束。把标志设得高于你预期的最长会话,如 --kill-session-after-min 480 表示 8 小时;要让对话变空闲时腾出槽位,改用 --release-idle-session-min。
其他限制
- 恢复的会话丢失未推送的工作:会话被释放或其 runner 被重启后用户再发消息,会话在全新的 runner 上恢复,它从起始分支重新克隆仓库,所以会话没推送的工作就没了。设
--push-outcome-on-release让 runner 在释放之前尽力推送会话的结果分支,这样恢复的会话从这些提交开始;这保留的是已提交的工作,不是脏的工作树。启用前,要限制谁能在源远端推送到claude/*引用(例如用分支规则集):恢复时 runner 获取先前推送的分支而不验证是谁推送的,所以任何有这些引用推送权限的人都能把内容放进恢复的工作区。runner 在恢复时还会丢弃每会话配置。 - 会话中途不能添加私有仓库:会话开始后添加的仓库在自托管 runner 上不会带凭据克隆,所以添加失败;创建会话时就选好会话需要的每个仓库。
- 有些连接器不出现在自托管会话里:你还没在 claude.ai 设置里连接的连接器不会列在自托管会话里,会话也不会提示你去连接;先在设置里连接,再启动新会话。给已在运行的会话添加连接器也不会让它的工具对 Claude 可用,要启动新会话才能获取新添加的连接器。
- 报告问题:自托管环境的问题,联系你的 Anthropic 客户团队。
排障
要引导式诊断,在 runner 主机上运行 doctor 子命令。它启动一个附带 runner 日志和状态的交互式 Claude Code 会话;先在该主机上用 claude auth login 登录,会话才能查询你的环境、其 runner 和排队的会话。没有该登录(例如主机用 API key 认证)时,它只限于本地健康端点、指标和 runner 日志,且只有在你用 --log-file 启动 runner 时才读日志。
claude self-hosted-runner doctor常见问题:
- runner 没出现在环境里:确认主机能经 HTTPS 访问
api.anthropic.com、环境密钥是当前的、主机时钟与真实时间相差在五分钟内(偏差更大会导致认证失败)。认证失败时,runner 会记录带拒绝原因的[runner:fatal]。 - runner 启动时以
cannot create or write to base directory退出:runner 无法创建或写入--base-dir(默认/workspace);修正目录所有权,或把--base-dir指向可写路径。如果 runner 改为记录[runner:fatal]说基础目录检查超时,则是目录在挂起的 NFS 或 CSI 挂载上,要检查挂载健康而不是权限。runner 在打开--log-file之前就把这两种启动失败打印到 stderr,所以要在终端或平台的容器日志里找,而不是日志文件。v2.1.225 之前,runner 在启动时不检查基础目录,这种配置错误会在会话被接手后使会话失败。 - 会话一直排队:每个在线 runner 可能都被锁定到不同的所有者。检查每个 runner 的
claude_code_self_hosted_runner_locked_account指标,或其[runner:health]日志行的locked_account字段,看谁持有它;两者只有在 runner 被签发了带act.email声明的会话令牌之后才显示所有者邮箱,Claude Tag 智能体的会话永远没有它。没有该声明时,runner 不发出locked_account序列并记录locked_account=yes,这告诉你 runner 已被锁定但不知道锁给了谁。增加副本,或等待现有 runner 排空并重启。环境使用按需 runner 时,改查编排器。 - 会话在被接手后立即失败:在 claude.ai/code 里打开会话查看错误。最常见的原因是 runner 镜像里缺 git 凭据,以及没装构建工具。不可写的基础目录会让 runner 在启动时停止,而不是让会话失败。
- 会话无法经需要认证的出口代理访问网络:你用
--proxy-authorization-command或--proxy-authorization-file设置的来源失败、30 秒后超时或产生空值时,runner 对该连接回答502 Bad Gateway并记录原因;runner 在该日志里对命令的 stderr 做了脱敏,也从不记录头的值。用--proxy-authorization-command时,自己在主机上运行该命令,确认它在 stdout 上打印完整的头值。如果 runner 改为以could not start the proxy-authorization listener在启动时退出,说明它无法打开环回监听器。 - runner 记录含
rejecting the malformed poll response的Poll failed行:runner 收到的工作轮询响应的正文不是队列预期的 JSON,最常见的原因是 runner 与api.anthropic.com之间的东西(如拦截代理或强制门户)用自己的页面作了应答。runner 拒绝该响应,把它计入claude_code_self_hosted_runner_poll_errors_total指标的transport类,并按会话生命周期里描述的失败轮询计划重试;runner 继续为其存活会话服务。要配置代理不加改动地透传来自api.anthropic.com的响应。v2.1.246 之前,runner 把这种响应读作空工作队列,这可能结束其存活的会话或让它退出。 - 会话的分支在远端已不存在:对会话只读的 git 源,runner 跳过该源并继续用其余的。对会话向其推送结果的源,被删除的分支(通常是因为已合并并自动删除)会让会话失败,错误点名仓库和分支并要求你恢复分支后重试;如果跳过会让会话完全没有仓库,runner 也以同样的错误让会话失败。v2.1.228 之前,这样的会话在空目录里启动。
- 会话启动时少了它的某个仓库:在没有
checkouthook 的 runner 上,git 主机可能拒绝 runner 对会话只读的某个仓库的访问检查。runner 于是跳过该仓库,记录点名拒绝原因的[runner:warn] could not access context source行,并用其余仓库启动会话。runner 只跳过明确的拒绝:主机回答仓库未找到、git 找不到该主机的凭据,或认证失败;网络失败、超时或 HTTP403仍让会话启动失败,对会话向其推送结果的仓库的拒绝也是;跳过会让会话完全没有仓库时,runner 仍让会话失败。用--use-anthropic-git-proxy时,runner 只跳过 git 代理自己拒绝的仓库。访问检查在会话每次在 runner 上启动时都再次运行,所以一旦 runner 的 git 身份有了读取权限,下次启动就会克隆该仓库。v2.1.274 之前,这些拒绝每一个都会让会话启动失败。 - 会话需要几分钟才启动:通常是初始克隆占大头。观察
claude_code_self_hosted_runner_session_init_duration_seconds指标来确认,并用预热检出或更小的CLAUDE_RUNNER_FETCH_DEPTH减少克隆。 - 轮次以 401 失败:每个会话用 runner 从 Anthropic 获取并经会话 stdin 轮换的短期
CLAUDE_CODE_OAUTH_TOKEN认证模型调用。当轮次以来自模型 API 的 401 或 403 结束时,runner 获取新令牌并交给会话,失败的轮次不会重试;获取失败时 runner 记录一行说明何时重试的inference_token refresh failed,并在会话运行期间一直重试。如果每个调用在会话约 30 分钟时开始失败,很可能是包装脚本切断了会话的 stdin,令牌轮换无法到达它(见「自定义会话」的保持 stdin 和文件描述符 3 连接)。v2.1.274 之前,runner 在几次尝试后就停止重试失败的获取,等下一次计划的获取;失败的轮次不触发获取,所以每个轮次都以 401 失败,直到下一次计划的获取。 - pod 在排空途中被杀:把
terminationGracePeriodSeconds提高到至少 runner 在启动时记录的值(见「关闭时序」)。
日志初始化之后,runner 把生命周期日志(包括 [runner:fatal] 行)写到 stdout,把调试输出写到 stderr,都是纯文本行而不是 JSON;上面排障条目里描述的启动失败在此之前打印到 stderr。用 --log-file(它还让 self-hosted-runner doctor 能跟踪它们)或你平台的日志收集来捕获两个流。每个会话的子进程会写一个单独的调试日志,失败时 runner 在 claude.ai/code 里把日志末尾与会话一起展示;除非你用 --remove-session-state 启动 runner,它还会把失败会话的日志保留在磁盘上,并在 runner 日志里打印其路径。
runner 退出时
不要重启按需 runner,因为它的工单是一次性的。刚启动就退出的 runner,需要不同于因其他原因退出的 runner 的处理。正常退出:runner 完成了它的会话并排空、到达退休时间,或被告知停止;重启它,让环境重新有容量(这些退出见「runner 生命周期」)。启动失败:runner 无法用被给定的配置或主机启动,所以在启动几秒后退出,并且每次重启都以同样方式退出;更快地重启没有用,需要有人读它的输出并修复原因。要配置你的监督器在 runner 每次退出时重启它、在 runner 一直刚启动就退出时在重启之间等得更久,并在这种情况持续时通知某人。
识别启动失败:runner 无法启动时,它打印一行说明原因的日志然后退出。多数原因下该行含 [runner:fatal];有些原因下该行改以 error: 开头,包括 runner 无法解析标志、无法读取环境密钥或无法创建或写入基础目录时,下一行会指向 --help。大多数日志行以时间戳和 [self-hosted-runner] 开头(下面的示例省略了它们)。例如,用 Anthropic git 代理且容量大于一启动的 runner 会打印类似这样的一行:
[runner:fatal] --use-anthropic-git-proxy requires --capacity 1 (the proxy URL is per-session and linked worktrees share origin). Omit --use-anthropic-git-proxy or set --capacity 1.在 runner 的标准输出和标准错误、平台的容器日志,或你用 --log-file 设置的文件里找这一行;runner 在打开日志文件之前就打印 error: 行,所以要在终端或容器日志里找它。读失败启动时这些也有帮助:根本没有行——被主机杀掉的 runner 两者都不打印,输出结束时既没有 [runner:fatal] 行也没有 error: 行,就检查是不是主机或你的编排器停止了进程,例如因超出内存限制;退出码——runner 不为每次启动都重复的错误保留退出码,对配置错误(如不支持的标志组合)和可能自行消除的失败(如 API 在 runner 自己的重试之后仍不可达)退出码相同,所以要根据 runner 多快退出来决定是否等更久,并读 runner 的输出来了解原因;看起来健康的环境——有些启动步骤在 runner 向你的环境注册之后才运行,如 --configure-git 和 Anthropic git 代理的凭据设置,其中一步失败时,环境可能在进程退出后的几分钟里仍列出该 runner,Cloud environments 页可能显示 Healthy 而没有 runner 在接工作;如果会话在看起来健康的环境里一直排队,检查你的监督器是否在重启 runner。
以递增的等待重启:怎么得到递增的等待取决于你的监督器。Kubernetes:本页的 Deployment 无需改动,容器退出后 kubelet 默认会等一会儿再重启容器,每次重启等待递增直到上限,容器运行了一阵没退出后等待重新开始;kubelet 在容器只短暂运行的正常退出后也应用同样的等待,所以经常排空的 runner 也可能显示 CrashLoopBackOff 状态,因此在断定 runner 无法启动之前要读输出。下面的命令读取 Deployment 的某个 pod 上一次运行的输出:
kubectl logs --previous -n claude-runners deploy/claude-runner上一次运行是启动失败时,[runner:fatal] 或 error: 行就在输出的最后几行里;要读另一个 pod 上一次的运行,用该 pod 的名字代替 deploy/claude-runner。Docker 和 Docker Compose:本页的 Compose 配方无需改动,用 restart: always 时 Docker 在每次重启不断退出的容器前等得更久(有上限)。把下面命令里的 <container> 换成容器名,它读取 Docker 重启该容器的次数:
docker inspect --format '{{.RestartCount}}' <container>命令打印一个数字,数字不断上升说明 Docker 一直在重启 runner。systemd 单元:默认 systemd 在每次重启前等同样的 RestartSec 且不会变长,所以带 Restart=always 的单元会以同一间隔每次重启无法启动的 runner;当启动快到触及单元的启动速率限制(默认 10 秒内五次启动)时,systemd 停止重启该单元,单元保持停止直到有人再次启动它(速率限制的间隔过去后或 systemctl reset-failed 之后 systemd 允许);因为 RestartSec 适用于每次重启,更长的值也会延迟正常退出之后的重启,所以要选一个平衡两者的值,并对单元的重启计数告警。shell 循环或你自己的监督器:自己应用同样的规则,从五秒的等待开始,每次在一分钟内结束的运行之后把下次重启的等待翻倍,上限五分钟;一次持续一分钟或更久的运行之后,回到五秒。
检查 runner 为什么一直退出:当 runner 连续几次刚启动就退出时,在再次重启之前停下来检查这些:最后一条 [runner:fatal] 或 error: 行——它说明 runner 为什么停止(常见原因见「排障」);标志组合——Anthropic git 代理需要 --capacity 1,本页的配方用更高的容量,所以加代理时要把它调低;服务的环境能到达什么——如果 runner 手动启动正常、在你的监督器下失败,比较用户、home 目录、PATH 和内存限制,--configure-git 和 Anthropic git 代理需要 PATH 里有 git 和可写的 ~/.gitconfig;环境密钥——如果你撤销了密钥或输错了,runner 会打印含 RegisterRunner auth failed 的一行;环境的 Activity 标签页——打开环境并选 Activity,如果新 runner 不断出现在那里而没有一个接到工作,说明你的监督器在重启 runner。要在 runner 主机上做引导式诊断,运行 doctor 子命令。
下一步
- 自定义会话:包装脚本、生命周期 hooks、按需 runner、MCP 服务器和权限
- 端到端测试:晋升前从 CI 验证新的 runner 镜像
- 参考:每个 CLI 标志、环境变量和指标