실행 기반과 격리
backend별 신뢰 경계와 실패 시 동작을 이해한다
개요
Docker backend와 Kubernetes backend가 worker를 격리하고 실행을 회수하는 이유를 설명한다. 운영 설정은 설정 계약, 복구 결정은 멈춘 세션 복구가 정본이다.
제어 역할과 실행
reconciler pass(main.ts reconciler --once, package script reconciler)는 한 batch만 처리한 뒤 종료한다. lease 기한은 스스로 해석하지 않는다. API가 heartbeat를 받을 때 workers.lease_expires_at에 마감 시각을 적고 reconciler는 그 시각과 DB 시계를 비교한다. HEARTBEAT_TTL_SEC는 API만 읽으며(기본 30, 20 이하이거나 86400을 넘으면 기동 거부, API 설정 참고), reconciler는 이 값이 설정돼 있으면 기동하지 않는다. 실제 변경 전에 대상만 확인하려면 dry-run을 명시한다. 미처리 row 또는 queued turn만 자동 재전달하며, 실행 중이거나 상태를 증명할 수 없는 row는 session을 failed로 전환하고 명시적 복구 대상으로 남긴다.
compose의 apps profile에서는 reconciler 서비스가 이 pass를 기본으로 반복 실행한다. role 이름만 준 main.ts reconciler가 감독 루프이고, pass는 루프가 띄운 자식 프로세스(main.ts reconciler --serve)에서 돈다. 자식은 루프가 IPC로 요청할 때마다 pass를 하나 돌리고 다음 요청을 기다린다. pass마다 bun을 새로 띄우고 모듈을 다시 읽으면 pass 자체보다 몇 배의 CPU가 들어, 놀고 있는 스택에서도 role 하나가 코어의 수십 %를 썼기 때문이다. pool은 지금도 pass마다 새로 열고 닫는다. 실패한 pass, 제한 시간을 넘겨 kill된 pass, 종료 신호를 받은 pass는 자식과 함께 끝나므로 다음 pass는 새 프로세스에서 돈다. pass 사이에 자식이 죽으면 실패 pass로 센다. pass는 겹치지 않고 직렬로 돈다. healthcheck는 main.ts reconciler --health다. 루프가 SIGTERM을 받으면 새 pass를 시작하지 않고 진행 중 pass에 SIGTERM을 보낸 뒤 10초 뒤 SIGKILL한다. 쓰기마다 트랜잭션이라 어디서 끊겨도 잃는 것이 없다. compose stop_grace_period는 20s다. compose는 감독 루프를 쓰는 세 role(reconciler·scheduler·slack)에 BUN_OPTIONS=--smol을 준다. 루프, 자식, healthcheck가 모두 이 값을 물려받아 GC를 더 자주 돌리고 힙을 작게 유지한다.
pass의 마지막 단계는 도구 호출 투영이다. 지난 pass 뒤에 쌓인 tool_use·tool_result 이벤트를 짝지어 tool_calls와 일별 합계 tool_call_days에 적는다. 사용량 API(GET /v1/usage/tools)는 이벤트를 다시 파싱하지 않고 이 두 표만 읽는다. 한 트랜잭션에 이벤트 5,000개씩, pass마다 10초 동안 처리하고 남은 것은 다음 pass가 잇는다. 배치가 문장 timeout(10초)에 걸리면 배치를 10분의 1로 줄여 다시 처리하고, 줄인 크기를 tool_call_progress.batch_size에 바로 커밋한다. pass timeout(60초)이 pass를 끊어도 다음 pass는 그 크기에서 시작하므로 결국 한 배치는 지나간다. timeout 없이 끝난 pass는 크기를 10배씩 되돌린다(설정값에 닿으면 NULL). timeout마다 Tool call batch timed out 로그(batch_size, retry_size)를 바로 남기고, pass 끝의 Tool calls projected에는 timed_out_batches로 횟수를 남긴다. 진행 위치는 tool_call_progress.applied_through(이벤트 id)다. 이 커서는 60초보다 오래된 이벤트까지만 넘어가므로, 늦게 커밋된 낮은 id의 이벤트도 빠뜨리지 않는다. 그보다 새 이벤트는 다음 pass가 다시 읽으며, 다시 적용해도 결과는 같다. 그래서 새 호출이 API에 보이기까지는 보통 pass 하나만큼(10초 남짓) 걸린다. migration 뒤 첫 pass들은 커서 0에서 기존 이벤트를 모두 채운다(backfill). backfill 동안 API의 도구 집계는 아직 처리한 범위만 반영한다. dry-run은 투영하지 않는다.
scheduler pass도 one-shot이다(main.ts scheduler --once, package script scheduler). compose scheduler 서비스는 같은 감독 루프로 이 pass를 반복 실행한다(main.ts scheduler, healthcheck main.ts scheduler --health). 설정은 SCHEDULER_INTERVAL_SEC(5)·SCHEDULER_PASS_TIMEOUT_SEC(180, EXECUTION_DOCKER_STOP_TIMEOUT_SEC + EXECUTION_DOCKER_REQUEST_TIMEOUT_SEC보다 커야 기동. kubernetes backend는 Kubernetes 실행 backend)·SCHEDULER_MAX_CONSECUTIVE_FAILURES(3)·SCHEDULER_HEALTH_STALE_SEC(200, interval + timeout보다 커야 기동)·SCHEDULER_STATUS_FILE(/tmp/scheduler-status.json, compose는 넘기지 않는다)이고 의미는 위 reconciler 표와 같다. 다른 pass가 lock을 쥐고 있어 건너뛴 pass는 exit 75로 끝나며 성공도 실패도 아니다. 건너뛰기만 이어지면 성공이 없으므로 stale 시간 뒤 unhealthy가 된다. 할 일은 다 했지만 이번 pass에서 도울 수 없는 세션을 남긴 pass(launch가 backoff 중이거나(launch_backoff_count), 방금 quarantine됐거나(launch_quarantined_count), 교체 한도를 다 쓴(replacement_exhausted_count) 세션)는 exit 76(degraded)으로 끝난다. 그 상태는 DB에 있어 재시작으로 풀리지 않으므로 degraded는 연속 실패로 세지 않고 실패 streak를 끊는다. health는 최근 degraded pass를 healthy로 보되 reason에 completed degraded를 적고, status에는 lastDegradedAt으로 남는다(lastSuccessAt은 깨끗한 pass만). 같은 pass에 다른 실패가 하나라도 있으면 지금처럼 exit 1이다. 루프가 SIGTERM을 받으면 진행 중 pass는 다음 안전 지점(pass lock 상실 때 멈추는 자리와 같다)에서 멈춰 더 예약하지 않고, 30초 안에 끝나지 않으면 kill된다. pass가 기다리던 worker 정지는 kill돼도 daemon에서 계속된다(Engine 25부터 요청이 끊겨도 stop을 취소하지 않는다). compose stop_grace_period는 45s다. kill 의도(terminate, lease 만료, quarantine)를 수행할 때 pass는 worker가 turn을 drain하기를 기다리지 않는다. stop을 요청하고 5초 안에 끝나지 않으면 그 컨테이너를 kills_stopping_count로 세고 넘어가며, 실패로 세지 않는다. daemon은 SIGTERM 뒤 EXECUTION_DOCKER_STOP_TIMEOUT_SEC가 지나면 SIGKILL하고, 다음 pass가 컨테이너가 사라진 것을 확인해 slot을 돌려준다. drain 기한을 넘긴 claimed worker의 교체와 launch intent 없는 orphan 컨테이너의 정지도 같다(orphan은 orphan_stopping_count로 세고 사라질 때까지 slot을 차지한다). unclaimed worker의 교체는 기다린다. turn이 없어 곧 끝나고, 교체를 시도할 때마다 교체 한도를 쓰기 때문이다. 그래도 unclaimed 컨테이너를 지우고 다시 만드는 경로에서는 pass가 정지 하나를 끝까지 기다릴 수 있다. 그래서 pass timeout이 stop timeout + request timeout보다 작으면 scheduler가 기동을 거부한다. 컨테이너 create·start 요청이 EXECUTION_DOCKER_REQUEST_TIMEOUT_SEC 안에 답을 받지 못하면 컨테이너가 생겼는지 알 수 없으므로 launch 실패로 세지 않는다. nonce를 폐기하지 않고 backoff도 걸지 않는다. 그 pass는 실패(exit 1)로 끝난다. 다음 pass가 inspect해서 컨테이너가 있으면 채택한다(생성만 됐으면 시작한다). 없으면 그제야 launch 실패로 센다. 이때 nonce를 폐기하고 backoff를 걸어, 끝내 생기지 않는 create가 slot을 계속 붙잡지 않고 실패 한도에서 quarantine된다. 폐기 뒤에 늦게 생긴 컨테이너는 실패한 시도로 보고 지운다. daemon이 거절로 답한 경우(4xx·5xx)는 지금처럼 launch 실패로 센다. scheduler를 멈춰도 worker는 끝나지 않는다. slot은 pass가 worker 부재를 확인한 뒤에만 반환된다. 한 pass는 ① 살아 있는 executions row를 Docker와 대조(컨테이너가 없으면 같은 intent로 재생성, exit했으면 terminated 기록 후 제거) ② launch intent 없는 관리 컨테이너를 로그 후 정지하고, 컨테이너가 사라진 worker 네트워크를 지우거나 proxy가 떨어진 네트워크에 다시 붙임 ③ EXECUTION_SLOT_LIMIT 안에서 unassigned session마다 intent 커밋 → 컨테이너 생성 ④ 끝난 session의 workspace volume 회수 순서로 진행한다. worker 컨테이너는 Docker socket·host HOME을 받지 않고 env는 bootstrap claim에 필요한 WORKER_EXECUTION_ID·WORKER_EXECUTION_GENERATION·WORKER_BOOTSTRAP_NONCE·WORKER_GATEWAY_URL, tmpfs를 가리키는 HOME, egress proxy를 가리키는 HTTP_PROXY·HTTPS_PROXY·NO_PROXY(대소문자 두 표기. NO_PROXY에는 proxy 자신의 이름이 들어간다), proxy의 credential route를 가리키는 WORKER_EGRESS_CREDENTIAL_URL(API 서버의 credential route 절), 그리고 object store의 이름, 곧 S3_BUCKET·AWS_REGION과 세션 prefix WORKER_OBJECT_PREFIX(sessions/<sessionId>/)를 받는다. scheduler는 bucket과 region이 없으면 기동하지 않는다. object store 자격 증명과 endpoint는 받지 않는다. 워커는 proxy의 object store route로만 object store에 닿으며, 경계는 그 route다(같은 절). 워커 쪽 scopedCheckpointObjectStore는 prefix 밖 key를 요청 전에 거절하는 빠른 실패일 뿐이다. 워커 안에서 @werft-ai/storage를 import하는 파일은 하나뿐이며 아키텍처 시험이 이를 강제한다. Docker daemon 응답이 create 요청 본문을 되돌려 주는 경우에 대비해 backend는 오류 메시지에서 nonce를 지운다. /tmp·HOME tmpfs는 worker uid/gid 소유로 마운트된다. /workspace named volume은 Docker가 이미지의 같은 경로에서 초기화하므로 worker 이미지가 /workspace를 worker uid 소유로 미리 만들어 두어야 한다(이미지 계약). worker 이미지는 WORKER_IMAGE로 받는다. pass 전체는 Postgres session advisory lock(scheduler:pass)으로 직렬화되어 겹친 실행은 로그만 남기고 건너뛴다. 같은 Docker daemon을 여러 설치가 공유하면 EXECUTION_INSTALLATION_ID를 설치마다 다르게 준다. scheduler는 이 값이 없으면 기동하지 않는다(adapter만 테스트용 기본값 local을 가짐). 같은 값을 쓰는 두 설치가 daemon을 공유하면 서로의 컨테이너를 orphan으로 회수한다.
Docker 네트워크와 credential 경계
worker 컨테이너는 scheduler가 execution마다 만드는 전용 네트워크 werft-net-<installationId>-<executionId>-g<generation> 하나에만 붙는다. 이 네트워크는 bridge driver, internal: true, EnableIPv6: false, com.docker.network.bridge.gateway_mode_ipv4=isolated로 만들어진다. Docker가 이 네트워크에서 바깥으로 나가는 경로를 만들지 않고 host 쪽 bridge 주소(IPAM gateway)도 두지 않는다. 그래서 worker는 host·host에서 도는 프로세스·LAN·instance metadata(169.254.169.254)·다른 compose 서비스·다른 worker에 직접 닿지 못한다. 이 네트워크의 구성원은 worker 자신과 egress proxy 둘뿐이다. scheduler는 werft.egress-proxy=<installationId> label이 붙은 실행 중인 컨테이너 정확히 하나를 그 설치의 proxy로 보고, 네트워크마다 EXECUTION_EGRESS_PROXY_URL의 host 이름을 alias로 붙여 connect한다. worker는 HTTP_PROXY/HTTPS_PROXY로 그 이름을 가리킨다. proxy 주소가 네트워크마다 다르므로 EXECUTION_EGRESS_PROXY_URL의 host는 이름이어야 하고, IP literal과 localhost는 거부한다. compose의 egress-proxy 서비스는 이 label을 달고 있다. worker에는 host.docker.internal:host-gateway 매핑이 없다. gateway도 proxy를 거친다.
차단 정책은 proxy의 두 목록으로 버전 관리한다. EGRESS_ALLOWLIST는 공인 목적지(host:port)이고 해석된 주소가 전부 public unicast여야 통과한다. EGRESS_PRIVATE_ALLOWLIST는 사설 대역에 있다고 알고 허용하는 목적지(gateway)다. 기본값과 입력 형식은 egress 설정표를 따른다. provider·저장소·object store는 두 목록 어디에도 두지 않는다. forward proxy로 닿으면 각자의 credential route를 우회하기 때문이다. provider에는 worker가 가진 임의 key로 비용 계측 밖에서 닿고, public 저장소는 token 없이 읽히며, object store에는 API가 각 요청을 검증하고 서명하는 route를 거쳐야 한다. 로컬 SeaweedFS도 SigV4를 검사하지만 저장소 인증만으로 세션별 prefix 권한을 대신할 수 없다. 이들은 credential route만 닿는 목록 EGRESS_CREDENTIAL_ALLOWLIST·EGRESS_CREDENTIAL_PRIVATE_ALLOWLIST에 둔다. credential 목록의 목적지가 forward 목록에도 있으면 proxy는 기동을 거부한다. 그래서 환경 파일의 forward 목록에 그런 값이 남아 있으면 지워야 proxy가 뜬다. 비교는 적힌 host:port 그대로이므로, 같은 upstream을 다른 이름이나 IP로 적으면 잡지 못한다. .env.example도 같은 값이다(시험이 둘을 묶는다). 두 목록 모두 link-local(169.254.0.0/16·fe80::/10)·multicast·reserved로 해석되면 거부하므로 allowlist에 오른 이름이 metadata 주소로 해석되는 rebinding도 막힌다. 목록에 없는 host·port는 CONNECT·absolute-form 모두 403이고, absolute-form이 아닌 요청은 /healthz 외에는 400이다. 목록에 있는 이름이 조회되지 않으면(재시작 중인 api 컨테이너가 Docker DNS에서 잠시 빠질 때) 정책 거부가 아니라 502이고, 조회가 시간 안에 끝나지 않으면 504다. worker는 둘 다 재시도한다.
공개 패키지 레지스트리(npm·RubyGems·Go module proxy와 Go checksum DB)는 forward 목록이 아니라 credential route의 packages route로 연다. SNI만 보는 forward 허용은 CDN 뒤의 다른 목적지로 새는 것을 막지 못하기 때문이다. worker는 GET·HEAD /packages/<registry>/<경로>를 WORKER_EGRESS_CREDENTIAL_URL로 보낸다. <registry>는 npm(https://registry.npmjs.org)·rubygems(https://rubygems.org)·go(https://proxy.golang.org)·gosum(https://sum.golang.org) 넷이다. proxy.golang.org은 /sumdb/ 프록시 요청에 404로 답하므로 checksum DB는 따로 둔다. 요청에는 attempt마다 발급하는 packages 전용 egress token(접두사 wek_)을 Authorization: Bearer로, URL userinfo의 비밀번호(Basic)로, 또는 경로 세그먼트 /packages/<registry>/_token/<token>/<경로>로 싣는다. Go는 http 프록시 URL의 userinfo로는 자격 증명을 보내지 않으므로(refusing to pass credentials to insecure URL) 경로 세그먼트를 쓴다. proxy는 이 세그먼트를 떼어 낸 경로만 upstream에 보내고 로그에도 남기지 않는다. 헤더와 경로에 token이 함께 오면 401이다. 세션 안에서 이 token이 보이는 범위는 다른 매니저의 설정 파일과 같고, Go 오류 메시지에 URL이 찍히면 token도 함께 찍힐 수 있다(worker의 출력 scrubber가 가린다). Bundler와 Go는 bearer를 보낼 수 없어서 Basic도 받는다. authorizer는 token의 attempt가 아직 세션을 가지고 있는지만 보고 레지스트리 주소를 돌려준다. 자격 증명은 붙이지 않는다. 다른 method(PUT·POST·DELETE 등, 즉 publish·unpublish)는 route가 없어 404이고 authorizer에 묻지도 않는다. 경로에 .·..로 풀리는 segment가 있어도 404다.
어느 레지스트리를 열지는 proxy 전역 설정으로 정한다. 네 host를 EGRESS_CREDENTIAL_ALLOWLIST에 두면 열리고 빼면 모든 세션에서 닫힌다. 기본 host 목록은 설정표에 있다. 목록에 없는 레지스트리 요청은 403 egress denied다. 지금까지 레지스트리를 열려고 이 host들을 EGRESS_ALLOWLIST에 넣어 두었다면 업그레이드할 때 그 값을 지운다. 새 기본값과 겹치면 proxy가 기동을 거부한다. profile이나 에이전트마다 다르게 여는 기능은 아직 없다. 레지스트리가 다른 host로 redirect하면 packages route만 EGRESS_PACKAGES_REDIRECT_ALLOWLIST(host:port, 쉼표 구분, public 주소만)에 있는 host에 한해 proxy가 서버 쪽에서 따라간다(한 요청에 최대 5번). 따라갈 때는 worker가 보낸 Authorization도, authorizer가 레지스트리 요청에 붙인 헤더도 싣지 않는다. 목록 밖 host, http·https가 아닌 위치, userinfo가 든 위치, 다섯 번을 넘는 redirect는 502다. 같은 레지스트리 host 안에서 경로만 바꾸는 redirect도 그 host가 redirect 목록에 있어야 따라간다. 캐시 재검증에 대한 304는 redirect가 아니므로 그대로 worker에 돌려준다. 다른 route의 redirect는 지금처럼 따라가지 않고 502다. 저장소 route의 Git LFS 객체도 이 목록의 host에서만 자격 없이 받는다. GitHub 저장소의 LFS를 쓰려면 github-cloud.githubusercontent.com:443을 더한다(API 서버의 LFS 항목). redirect 목록의 host가 forward 목록에도 있으면 proxy는 기동을 거부한다.
proxy 로그의 Credential route 줄에 purpose: packages, registry, session_id, attempt_id, upstream host와 status가 남는다. 따라간 redirect는 Credential route redirect followed(to_host·to_port), 거절한 redirect는 Credential route redirect refused 또는 Credential route denied로 남는다.
GitHub Packages(사설 npm·gem)
조직의 사설 npm·gem은 <registry> github-npm(https://npm.pkg.github.com)과 github-rubygems(https://rubygems.pkg.github.com)로 받는다. 공개 레지스트리와 달리 authorizer가 워크스페이스 관리자가 등록한 bot classic PAT를 Authorization: Bearer로 붙여 준다. 이 값은 worker에 가지 않는다. 등록·허용 목록·감사의 설계는 결정: GitHub Packages bot token에 있다. 설정 작업은 사설 패키지 registry 허용을 따른다.
authorizer는 GitHub 요청을 경로로 판정한다. 허용 목록에 없는 패키지, 알아볼 수 없는 경로, token이 없거나 만료된 경우는 403이고 fetch_github_package receipt가 failed와 reason_code로 남는다. 허용한 요청도 같은 receipt가 succeeded로 남는다. receipt를 쓸 수 없으면 요청이 실패한다. 이 경로의 proxy 로그는 다른 packages 요청과 같다.
npm 11은 GitHub tarball 위치를 기본 registry 기준으로 바꾸므로(replace-registry-host=always) tarball 요청은 npm route로 온다. authorizer는 /download/@owner/<name>/<version>/<digest> 모양의 npm 경로를 GitHub 요청으로 판정한다. 공개 npm에는 이 모양의 경로가 없다.
worker는 claim을 받을 때마다 engine의 HOME(tmpfs)에 user 수준 설정만 쓴다. 저장소 파일(.npmrc·Gemfile·.bundle/config·go.mod)은 바꾸지 않는다.
| 도구 | 파일(HOME 기준) | 내용 |
|---|---|---|
| npm(pnpm·Bun도 읽음) | .npmrc | registry=<route>/packages/npm/, //<route host>/packages/npm/:_authToken=<token> |
| Bundler | .bundle/config | BUNDLE_MIRROR__HTTPS://RUBYGEMS__ORG/를 <route, userinfo에 token>/packages/rubygems로 |
| Go | .config/go/env | GOPROXY=<route>/packages/go/_token/<token>(direct 없음), GOSUMDB=sum.golang.org <route>/packages/gosum/_token/<token>, GOFLAGS=-mod=readonly |
허용 목록에 GitHub owner가 있으면 claim의 packages.github에 owner 이름만 실려 오고(token은 없다), worker는 위 파일에 다음을 더한다. .npmrc에는 replace-registry-host=always, owner마다 @<owner>:registry=<route>/packages/github-npm/, 그리고 //<route host>/packages/github-npm/:_authToken=<token>이 들어간다. .bundle/config에는 owner마다 https://rubygems.pkg.github.com/<owner>/를 <route>/packages/github-rubygems/<owner>로 돌리는 mirror가 들어간다. Bundler는 mirror 키를 소문자로 읽으므로 Gemfile의 source도 owner를 소문자로 적어야 mirror를 탄다. 저장소의 .npmrc가 @<owner>:registry=https://npm.pkg.github.com을 고정하면 그 설정이 HOME 설정을 이겨 요청이 credential route가 아니라 forward proxy로 가고, 거기서 거절된다. 그 줄은 저장소에서 빼야 한다.
GOPROXY에 direct를 두지 않으므로 Go가 VCS host로 직접 나가지 않는다. GONOSUMDB·GOPRIVATE·GONOSUMCHECK는 두지 않아 모든 모듈이 checksum DB 검증을 받는다. 이 파일은 token을 포함하므로 0600이고, claim에 packages가 없는 옛 API를 만나면 지운다. 세션 안에서 사용자가 go env -w나 npm config set으로 바꿀 수는 있지만 경계는 proxy이므로 바꿔도 다른 곳에 닿지 않는다.
lockfile 밖 버전을 막는 일은 proxy가 하지 않는다. proxy는 lockfile을 모른다. 세션에서는 frozen 설치(npm ci, bundle install --deployment 또는 BUNDLE_FROZEN=true, go mod download와 -mod=readonly)를 쓴다. 저장소의 npm 설정이 registry를 고정할 때의 동작과 E2E 결과는 이미지와 공급망 § worker 안의 패키지 도구에 있다.
CONNECT 터널은 TLS만 나른다. proxy는 200 Connection Established를 쓴 뒤 클라이언트의 첫 바이트를 upstream에 흘리기 전에 TLS ClientHello로 읽어, DNS 이름 authority면 server_name이 그 authority와(대소문자만 무시하고) 같아야 하고, allowlist에 명시된 IP literal authority면 server_name이 없어야 한다. encrypted_client_hello(0xfe0d)는 GREASE 여부와 관계없이 거부한다. proxy는 둘을 구별할 수 없고, 진짜 ECH는 검사 대상인 이름을 숨긴다. handshake가 아닌 첫 레코드, host_name이 둘인 hello, 최초 16KiB(record header 포함) 또는 15초(handshakeTimeoutMs) 안에 완성되지 않는 hello는 전부 연결을 끊는다. 거부는 200 뒤에 일어나므로 클라이언트에는 handshake 도중 연결 종료로 보인다.
이 관문의 한계:
- 검사 대상은 평문으로 보이는 바깥 ClientHello의 SNI다. TLS를 종단하지 않으므로 그 안의 HTTP
Host·경로·본문, 허용된 서비스가 다시 중계하는 곳은 보지 못한다. domain fronting을 CDN 쪽에서 막는 것은 별개의 통제다. - IP literal allowlist는
IP:port접근 권한이지 hostname 보장이 아니다. 공유 CDN edge 주소를 IP로 allowlist에 올리지 않는다. - Bun 1.3.10–1.3.13의
fetch·node:https는 GREASE ECH를 보내므로 이 proxy로 CONNECT하면 거부된다. 1.3.14는 보내지 않는다. 통합 테스트의 "Bun's own fetch handshakes through the tunnel"이 이를 고정한다. Bunnode:tls·Bun.connect({tls}), Node, curl, 그리고 worker의 SDK가 spawn하는 Claude Code 바이너리도 보내지 않는다. worker의 object store는 CONNECT를 쓰지 않는다. worker 네트워크 안의 proxy object store route에 평문 http로 보내고, upstream TLS는 proxy가 맺는다. AWS에서는 route가 virtual-hosted 이름으로 부르므로<bucket>.s3.<region>.amazonaws.com:443이EGRESS_CREDENTIAL_ALLOWLIST에 있어야 한다(점이 든 bucket 이름은 path-styles3.<region>.amazonaws.com:443). worker의 Bun HTTP 클라이언트로 https upstream을 CONNECT로 부르는 코드는 지원하지 않는다. 런타임·클라이언트 버전을 올리면 다시 측정한다.
worker끼리는 서로 닿지 않는다. 다른 worker와 같은 네트워크에 있지 않으므로 그 주소로 가는 경로가 없고, 컨테이너 이름도 풀리지 않는다. proxy를 거쳐 가려 해도 사설 주소는 EGRESS_PRIVATE_ALLOWLIST에 없으면 거부된다. 다른 설치의 worker와 proxy에도 닿지 않는다. 네트워크와 proxy 선택이 모두 설치별이다. 통합 테스트("workers do not reach one another")가 이를 확인한다. 같은 네트워크의 형제 컨테이너라면 열린 포트에 닿는다는 양성 대조와 함께 확인한다. 남은 한계:
- proxy는 모든 worker 네트워크에 붙는 신뢰 구성요소다. proxy가 침해되면 그 설치의 모든 worker에 닿는다.
EGRESS_PRIVATE_ALLOWLIST에는 worker로 해석될 수 있는 이름을 넣지 않는다.
host 프로세스에도 닿지 않는다. 기본 gateway mode(nat)의 internal bridge는 host에 subnet의 첫 주소를 준다. 그러면 daemon host가 그 주소나 wildcard로 listen하는 host 프로세스(publish된 포트가 아니라 host에서 직접 띄운 프로세스)에 worker가 proxy 없이 닿는다. 그래서 worker 네트워크는 gateway_mode_ipv4=isolated로 만들어 host가 그 네트워크에 주소를 갖지 않게 한다. 이 모드는 Docker 28(API 1.48) 이상에만 있다. scheduler는 기동 preflight(verifyNetworkIsolation)에서 daemon의 API 버전이 1.48 미만이면 경고로 넘기지 않고 거부한다(GatewayModeUnsupportedError). 끄는 설정은 없다. 통합 테스트("a host process on a wildcard address")는 두 가지를 함께 확인한다. 모드가 없는 internal 네트워크에서는 host listener에 닿는다(양성 대조). worker 네트워크에서는 닿지 않는다. native Linux(CI)에서는 테스트 프로세스 자신이 0.0.0.0 listener다. Docker Desktop은 daemon이 VM 안에 있으므로 VM의 network namespace를 쓰는 --network host 컨테이너가 listener를 대신한다. IPv6는 worker 네트워크에서 꺼져 있어 적용되지 않는다.
scheduler는 pass 전에 그 설치의 proxy가 정확히 하나 떠 있는지 확인한다(verifyNetworkIsolation). 없거나 둘 이상이면 pass lock을 잡고 worker 네트워크 reconcile만 돌린다(orphan 회수, 둘 이상이면 proxy 분리). 그다음 아무것도 띄우지 않은 채 non-zero로 종료한다. worker 네트워크는 launch 때마다 검사한다. 이미 같은 이름의 네트워크가 있으면 새로 만들지 않고 다음을 확인한다. 하나라도 어긋나면 NetworkIsolationError로 launch를 거부한다(fail closed).
- bridge·internal·IPv6 꺼짐·소유 label(설치·execution·generation)이 맞는가
- host가 네트워크에 주소를 갖지 않는가:
Options의gateway_mode_ipv4가isolated이고 IPAM에 gateway가 없어야 한다. 모르는 옵션을 기록만 하고 gateway를 주는 옛 daemon이 있어서 두 조건을 모두 본다 - worker와 proxy 외의 구성원이 없는가
proxy attach 결과는 응답 코드가 아니라 proxy 컨테이너가 보고하는 attach·alias로 판정한다. 이미 붙어 있으면 403이 오고, alias 없이 붙어 있으면 DNS가 풀리지 않기 때문이다. 이미 있는 컨테이너를 adopt할 때는 그 컨테이너가 자기 네트워크(같은 id) 하나에만 붙어 있어야 한다. 컨테이너는 네트워크 이름이 아니라 id로 만든다. 그래서 같은 이름으로 다시 만들어진 네트워크가 검사받은 네트워크를 대신할 수 없다.
예전 설정 EXECUTION_DOCKER_NETWORK·EXECUTION_DOCKER_NETWORK_ALLOWLIST는 더 이상 읽지 않는다. 값이 남아 있으면 조용히 무시하지 않고 기동을 거부한다. 환경 파일에서 지우고 proxy 컨테이너에 label을 단다.
컨테이너에는 만들어질 때의 격리 계약이 werft.isolation label로 <버전>:<지문> 형태로 찍힌다. 지문은 proxy URL·credential route 포트·NO_PROXY 값·user·workspace/HOME 경로·tmpfs 크기·workspace quota 설정과 inode 상한·stop timeout·worker 상한(turn 시간, provider 재시도), 그리고 object store의 bucket·region의 해시라서, 코드를 바꾸지 않고 EXECUTION_EGRESS_PROXY_URL이나 S3_BUCKET만 바꿔도 값이 달라진다. object store endpoint와 자격 증명은 지문에 없다. worker는 둘 다 받지 않고 proxy의 object store route를 거치며, 그 요청은 API가 서명한다. 그래서 AWS_ENDPOINT_URL이나 key를 바꿔도 worker는 교체되지 않고, API를 재기동하면 다음 요청부터 새 값이 쓰인다. CPU·memory·pids 한도도 일부러 지문 밖에 둔다. 한도는 launch의 것이라 reserve 때 worker_launches.resources에 고정되고 werft.launch-spec label로 대조된다. 그래서 한도를 낮추면 실행 중인 worker를 한꺼번에 교체하지 않고 다음 launch부터 적용된다. 단, werft.launch-spec label이 없는 컨테이너는 대조 없이 adopt된다. 실행 중인 컨테이너의 격리는 제어 호스트를 올려도 바뀌지 않으므로, scheduler는 label이 현재 값과 다른 컨테이너를 stale로 보고 정지·제거한 뒤 저장된 intent로 다시 만든다(ensureExecution도 그런 컨테이너는 adopt하지 않는다). 버전이 더 높은 컨테이너는 롤백 중인 새 제어 호스트가 만든 것이다. 그 경계가 지금 요구하는 것과 같은지 알 수 없으므로 adopt도 교체도 하지 않고 IsolationContractError로 거절한다. row는 살아 있고 pass는 non-zero로 끝나므로 운영자가 롤포워드하거나 직접 제거해야 한다. 격리의 모양 자체가 바뀌면 ISOLATION_CONTRACT를 올린다. 계약 5부터 worker 네트워크가 execution별이다. 그래서 계약 4 이하에서 업그레이드하면 공유 네트워크 위의 기존 컨테이너가 전부 stale로 교체된다. 교체 전 확인(assertReplaceable: 이미지·workspace·proxy·새 네트워크)이 실패하면, 보통은 옛 컨테이너를 그대로 두고 다음 pass에서 다시 시도한다. 하지만 계약 5 미만 컨테이너는 공유 네트워크에서 이웃 worker에 닿을 수 있다. 그래서 아직 claim되지 않은 그 컨테이너는 모든 네트워크에서 떼어 낸 채로 둔다. claim 전이라 진행 중인 turn은 없다. 확인이 통과하는 pass에서 정상 교체된다. 결과는 교체 실패 로그의 오류 메시지 끝에 붙는다. claim된 계약 5 미만 worker는 이 확인 없이 teardown되므로 여기서 건드리지 않는다. 확인과 disconnect를 한 번에 묶는 fence는 없다. 그래서 disconnect 뒤에 claim 여부를 한 번 더 확인하고, 그사이 claim됐으면 네트워크를 다시 붙여 scheduler의 teardown(SIGTERM으로 drain)에 맡긴다. disconnect가 이어지는 그 짧은 동안 claim 요청이 오가던 worker는 요청이 끊길 수 있다.
계약 6부터 worker 네트워크에 host 주소가 없다. 계약 5 컨테이너의 네트워크는 이름이 같지만 gateway mode는 제자리에서 바꿀 수 없으므로 교체는 다음 순서를 탄다.
- 교체 전 확인(
assertReplaceable): 같은 launch의 계약 5 worker가 그 네트워크에 실제로 붙어 있고 결함이 host 주소 하나뿐이면 옛 네트워크를 통과시킨다. 이어지는 teardown이 컨테이너와 함께 네트워크를 지운다. 그다음ensureExecution이isolated로 새로 만든다. - 일반 launch 경로: worker 없이 남은 옛 네트워크(teardown의 네트워크 제거가 실패한 경우)는 proxy만 붙어 있으면 지우고 다시 만든다. 다른 구성원이 있으면 거부한다. 옛 worker가 아직 붙어 있는 옛 네트워크에 새 컨테이너를 올리는 일은 없다.
reconcileNetworks: 계약 5 worker가 붙은 옛 네트워크에서는 proxy를 떼지 않는다. claim된 worker는 확인 없이 drain·teardown되므로 그 전에 egress를 끊지 않기 위해서다. 계약 6 worker가 host 주소 있는 네트워크에 있으면 결함으로 보고 proxy를 뗀다.
이 교체도 claim된 worker는 아래의 turn 경계 drain 뒤에 닫는 규칙을 따른다. 적용 순서는 격리 계약 변경 작업을 따른다.
이미 claim된 worker는 다시 만들 수 없으므로 turn 경계까지 drain한 뒤 teardown된다. 아래 절을 따른다.
계약이 오를 때 claim된 worker의 drain
claim된 컨테이너가 stale이면 scheduler는 바로 부수지 않는다.
- 첫 pass가
worker_launches.drain_requested_at을 DB 시계로 기록한다. 그때부터 그 worker는 새 turn을 받지 않는다. 이미 받은 turn은 끝까지 돌고, 뒤에 온 입력은 queued로 남는다. - 도는 turn(
running·needs_input)이 있거나 worker가 아직 첫 입력을 청하지 않았으면(workspace 준비·checkpoint 복원 중. 이때 끊으면 실패한 기동으로 센다) 컨테이너를 그대로 두고 pass summary의draining(로그draining_count,Claimed execution draining before its replacement)에 올린다. - turn이 끝난 뒤 첫 pass가 컨테이너를 teardown한다. turn의 checkpoint는 그 finalize에 실려 이미 확정됐다. SIGTERM을 받은 worker는 idle drain으로 release하고, queued 입력은 새 launch(현재 계약)가 받아 checkpoint에서 이어간다.
- drain 기한이 지나도 turn이 열려 있거나 기동이 끝나지 않았으면 그대로 teardown한다. 이때 그 turn은 전처럼
outcome_unknown이 된다. 끝나지 않은 기동은 worker가 SIGTERM에 drain으로 release하면 세지 않고, release 없이 죽으면 실패 1회로 센다. pass summary의drainsOverdue(로그drain_overdue_count)와 경고Drain deadline passed with the worker still busy; replacing anyway한 줄이 남는다. 기한은MAX_TURN_SECONDS+ 5분이다. 어떤 turn도MAX_TURN_SECONDS를 넘지 못하고, 5분은 그 turn을 끝내는 finalize·checkpoint 몫이다.
claim되지 않은 stale 컨테이너는 전처럼 곧바로 교체된다.
운영자의 rollout·긴급 terminate·rollback 결정은 격리 계약 변경 작업을 따른다.
Kubernetes 격리
Kubernetes는 Docker의 네트워크 대신 chart의 NetworkPolicy와 namespace 권한으로 worker 경계를 만든다.
scheduler는 replica 1, Recreate
scheduler pass는 PostgreSQL advisory lock(pg_try_advisory_lock) 하나로 직렬화된다. 두 번째 scheduler는 lock을 얻지 못해 pass를 건너뛰고, 성공한 pass가 없으니 --health가 stale이 되어 liveness가 그 Pod를 계속 재시작한다. 늘려도 처리량은 늘지 않는다. 그래서 chart는 replicas: 1을 고정하고(values에 키가 없다), rollout도 RollingUpdate가 아니라 Recreate로 옛 Pod를 먼저 내린다. 업그레이드 동안 잠깐 pass가 멈춘다. reconciler는 pass를 행 lock 아래에서 다시 판단하므로 leader election 없이 reconciler.replicas를 늘릴 수 있고, 기본은 1이다.
worker NetworkPolicy
chart의 NetworkPolicy가 Docker backend의 execution별 internal 네트워크를 대신한다. worker Pod(app.kubernetes.io/part-of=werft, app.kubernetes.io/component=worker, werft.installation=<installationId>)는 아무 연결도 받지 않고, 바깥으로는 egress-proxy의 3128·3129로만 연결한다. DB, api, 다른 worker, node·cloud metadata 주소에 직접 닿지 않는다. api로 가는 gateway 요청도 proxy를 거친다. egress-proxy는 worker에게서만 연결을 받고, api의 authorizer(3100)는 egress-proxy에게서만 받는다. 클러스터 CNI가 NetworkPolicy를 강제하지 않으면 이 격리가 없다(kind의 kindnet은 강제한다. EKS는 VPC CNI의 network policy agent를 켠다).
API server 인증
API server 인증은 KUBECONFIG가 있으면 그것을 쓰고, 없으면 Pod의 ServiceAccount를 쓴다. ServiceAccount 경로는 KUBERNETES_SERVICE_HOST·KUBERNETES_SERVICE_PORT와 token·CA 파일을 읽는다. scheduler의 Role 동사와 pidsLimit 처리는 저장소의 Kubernetes 실행 어댑터 README에 있다.
이미지 안의 도구와 신원
worker 안에서 만든 commit의 작성자
worker 이미지는 git system 설정(/etc/gitconfig)에 user.name=Werft, user.email=noreply@werft.invalid를 둔다. 그래서 Claude가 workspace에서 만든 commit은 저장소나 세션이 따로 정하지 않는 한 author와 committer가 모두 Werft <noreply@werft.invalid>다. 이 값이 없으면 git이 commit을 거부하고, Claude는 사용자에게 이름과 주소를 묻는다. .invalid는 어디에도 배달되지 않는 예약 도메인이라 실제 사람의 주소와 겹치지 않는다. checkpoint의 내부 commit은 이 값을 쓰지 않는다(checkpoint@werft.invalid). 같은 system 설정은 그 뒤에 include.path=~/.werft/git-identity를 둔다. 세션 저장소가 콘솔의 작성용 GitHub App을 쓰면 worker가 그 파일에 App bot 계정(<slug>[bot])을 써서 기본값을 덮는다(GitHub 계정 연결의 "commit 작성자"). 파일이 없으면 include는 건너뛴다.
system 설정은 git 설정 중 우선순위가 가장 낮아서, 세션 안에서 정한 값이 있으면 그쪽이 이긴다. 세션의 작성자를 바꾸려면 workspace 저장소에 git config user.name·git config user.email을 쓴다. git commit --author는 author만 바꾸고 committer는 그대로 둔다. git config --global은 tmpfs HOME에 쓰이므로 worker가 바뀌면 사라진다. 저장소 설정도 checkpoint에서 복원한 worker에서는 사라진다. 복원이 workspace를 비우고 저장소를 새로 만들기 때문이다. 그때부터는 다시 기본 작성자로 commit된다. 카탈로그나 설치 설정으로 기본값을 바꾸는 기능은 두지 않았다. 사용자별 작성자를 기본값으로 쓰려면 사용자 주소를 worker에 넘겨야 하므로 제품 결정이 먼저다. 이미지 smoke(.github/scripts/image-smoke.sh)가 빈 저장소에서 commit해 이 작성자를 확인하고, e2e(tests/e2e/alpha-path.e2e.ts)가 실제 Claude Code의 Bash 도구로 같은 것을 확인한다.
worker 안의 GitHub CLI
worker 이미지에는 GitHub CLI(gh, /usr/local/bin/gh)가 있다. 세션 안에서 gh 명령이 없어 헤매지 않게 하려는 것이고, GitHub에 닿는 경로를 늘리지 않는다. 이미지에 GitHub 로그인이나 token이 없고, api.github.com은 egress proxy의 기본 목록 어디에도 없다. 그래서 출하 이미지와 기본 egress 설정에서는 gh --version·gh help처럼 네트워크를 쓰지 않는 명령만 되고, GitHub를 부르는 명령은 읽기든 쓰기든 실패한다. 로그인이 없으면 gh가 요청 전에 gh auth login 안내와 함께 exit 4로 끝나고, 세션이 GH_TOKEN을 넣어도 proxy가 api.github.com:443 CONNECT를 destination is not allowlisted로 거부해 Forbidden으로 끝난다. 운영자가 EGRESS_ALLOWLIST에 api.github.com:443을 더하면 이 차단은 사라진다. 그때는 세션이 스스로 가진 token으로 gh가 GitHub API를 부를 수 있고, 플랫폼의 push 승인·브랜치 제한은 그 호출에 걸리지 않는다. GitHub 쓰기를 내장 github MCP 서버로만 보내려면 이 host를 목록에 넣지 않는다. 버전은 worker Dockerfile의 GH_VERSION과 arch별 sha256(릴리스의 checksums 파일 값)으로 고정하고, 매일의 Debian 보안 갱신으로 따라 올라가지 않으므로 손으로 올린다. 바이너리 하나가 이미지를 약 38MB 늘린다.
worker 이미지는 Docker socket이나 host HOME을 받지 않는다. 도구가 사용할 패키지 runtime은 사용자 이미지 빌드에서 준비한다. 저장소에만 남긴 git 설정은 checkpoint 복원 시 새 저장소를 만들면서 사라질 수 있다.