배포 패키지 계약
이미지와 chart와 manifest의 필드 의미를 확인한다
계약
배포 입력은 움직이는 태그 대신 digest로 고정한다. 게시 이미지는 linux/amd64와 linux/arm64를 담은 index다. 태그는 commit을 찾는 데 사용하고, 설치에서는 같은 소스 commit과 세 이미지 digest를 한 세트로 사용한다.
| 패키지 | 주소 | main 머지 | v* 태그 |
|---|---|---|---|
| 이미지 | ghcr.io/jeongjaesoon/werft-control-host, werft-worker, werft-worker-codex, werft-egress-proxy | 태그 main-<12> | 태그 v<버전> |
| Helm chart | oci://ghcr.io/jeongjaesoon/werft | 버전 <Chart.yaml version>-main-<12> | 버전 <버전>(v 없음) |
| SDK | npm @werft-ai/sdk | main 이미지 게시와 별도. SDK 설치에서 배포 버전 확인 | SDK 배포 버전을 별도로 확인 |
이미지는 linux/amd64와 linux/arm64를 담은 index이고, 설치에는 digest로 고정한다. chart는 세 이미지의 digest와 worker runtime을 담는다. 패키지는 로그인 없이 받는다. <12>는 commit SHA의 앞 12자이고, main 버전끼리는 순서에 뜻이 없으므로 Images workflow에서 초록색으로 끝난 run의 commit을 골라 정확히 적는다.
| 이미지 | 내용 | 실행 주체 |
|---|---|---|
werft-control-host | apps/control-host 실행물 하나로 api·scheduler·reconciler role을 모두 돌린다. --filter로 그 앱의 closure만 설치하며 Agent SDK·Claude Code executable을 담지 않는다(빌드가 node_modules/@anthropic-ai 부재를 확인). 기본 uid 1000, scheduler role만 compose user: "0:0"로 root가 되어 Docker socket을 쥔다 | bun run apps/control-host/src/main.ts <role> (CMD 기본은 api) |
werft-worker | --filter ./apps/worker로 worker와 그것이 의존하는 workspace의 closure만 설치한다(pg·drizzle·웹 콘솔 패키지는 없다). SDK 0.3.270과 번들 Claude Code 2.1.270, git, non-root(uid 1000), /workspace를 1000 소유로 미리 생성(LocalDockerBackend의 volume 계약). 빌드 시 resolvePinnedClaudeExecutable()로 executable 경로를 확정해 /usr/local/bin/claude로 걸고 claude --version을 실행한다 | bun run apps/worker/src/main.ts — scheduler가 env로 넘긴 bootstrap identity로 세션 하나를 claim하고 WorkerHost 루프를 돈다 |
| (scheduler role) | Docker socket을 mount하는 유일한 서비스이며 root로 실행한다(socket 소유자는 어차피 daemon host의 root와 같고, socket gid는 daemon마다 달라 고정 uid가 이식성을 깎기만 한다) | compose에서는 감독 루프(main.ts scheduler)가 SCHEDULER_INTERVAL_SEC(기본 5초)마다 자식 프로세스(--serve)에 pass 하나를 요청한다. 실패하거나 kill된 pass는 자식과 함께 끝나고 다음 pass는 새 자식에서 돈다(실행). 실패 pass가 SCHEDULER_MAX_CONSECUTIVE_FAILURES(3)번 이어지면(degraded exit 76은 세지 않는다) 루프가 exit 1 해 restart: unless-stopped가 재시작하고(compose ps에 드러남), SCHEDULER_HEALTH_STALE_SEC(200초) 동안 완료 pass(성공 또는 degraded)가 없으면 healthcheck(main.ts scheduler --health)가 unhealthy가 된다. DB가 멈추면 pass가 스스로 exit 1로 끝난다: scheduler·reconciler pool은 API와 같은 timeout(connect 5초·statement 10초·read 20초)을 쓰고, 연결을 한 번 잃은 뒤의 store 호출은 기다리지 않고 바로 실패하므로 DB 대기는 실패한 statement(5+20초)와 pass lock 해제(20초)를 합친 약 45초가 상한이다. SCHEDULER_PASS_TIMEOUT_SEC(180초)는 이 45초와 worker 정지 한 번(stop timeout 120초 + request timeout 30초)보다 크게 두는 바깥 watchdog이며, 그 밖의 hang을 kill해 실패로 센다(unhealthy만으로는 Docker가 재시작하지 않는다). pool timeout을 늘리면 이 값도 connect + 2 × read보다 크게 올린다 |
werft-egress-proxy | apps/egress-proxy의 소스만(의존성 없음, 한 stage). 코드는 root 소유로 두고 uid 1000으로 실행해 proxy가 다음 기동의 코드를 고치지 못한다 | bun run src/main.ts — compose egress-proxy, 모든 worker 네트워크에 붙는 유일한 바깥 경로 |
릴리스 manifest 읽기
v* 태그마다 GitHub Release가 하나 생기고, 파일 release-manifest.json이 붙는다. 운영자는 run이나 artifact를 찾지 않는다. 저장소 checkout도 필요 없고 gh와 jq면 된다. 아래 TAG에 읽으려는 Release의 태그를 넣는다.
TAG=v0.1.2 # the release tag to read
gh release download "$TAG" -R JeongJaeSoon/werft -p release-manifest.json -D "$TAG" # one directory per tag
jq -r '.images[] | "\(.image)@\(.digest)"' "$TAG/release-manifest.json" # image digests (deployment uses control-host, worker and egress-proxy)
jq -c .workerRuntime "$TAG/release-manifest.json" # worker runtime
jq -r '"\(.chart.ref) \(.chart.version)"' "$TAG/release-manifest.json" # chart location and version
helm show chart "$(jq -r .chart.ref "$TAG/release-manifest.json")" --version "$(jq -r .chart.version "$TAG/release-manifest.json")"저장소가 공개이므로 gh 없이 mkdir -p "$TAG" && curl -fsSL -o "$TAG/release-manifest.json" "https://github.com/JeongJaeSoon/werft/releases/download/$TAG/release-manifest.json"으로도 받는다. 레지스트리와 대조하려면 digest 하나를 docker buildx imagetools inspect <이미지>@<digest>에 넣는다.
| 필드 | 값 |
|---|---|
schema | manifest 형식 버전. 지금은 1 |
tag, commit | 릴리스 태그와 그 commit(40자 hex) |
images.<app>.image | ghcr.io/<owner>/werft-<app>. <app>은 control-host, worker, worker-codex, egress-proxy. worker-codex는 Codex 엔진 worker 이미지이고 chart의 배포 세트에는 들어가지 않는다 |
images.<app>.digest | 두 아키텍처를 담는 index의 digest. 배포는 이 값으로 고정한다 |
images.<app>.platforms.amd64, .arm64 | 그 아키텍처의 manifest digest |
workerRuntime | worker 이미지가 담은 Claude 실행 환경(engine, sdkVersion, cliVersion, profileDigestScheme) |
workerCodexRuntime | worker-codex 이미지가 담은 Codex 실행 환경(workerRuntime과 같은 필드, engine은 codex) |
chart.name, chart.version, chart.appVersion, chart.ref | 같은 run이 게시한 chart. chart.ref는 oci:// 위치 |
값은 promote가 레지스트리에서 다시 읽어 확인한 digest와, chart-publish가 로그인 없이 읽어 확인한 chart에서 온다. manifest를 만들 때 chart 패키지의 Chart.yaml·values.yaml이 같은 digest와 버전을 담는지도 대조한다. 한번 붙은 manifest는 바뀌지 않는다. 같은 태그를 다시 돌려 다른 내용이 나오면 job이 실패한다.
설치하는 운영자는 이 manifest에서 단일 서버 설치에 넣을 이미지 줄을 얻는다.
main 패키지와 릴리스
main-<12>는 릴리스가 아니다. 해당 commit의 Images 실행에서 세 이미지·chart가 모두 게시됐는지 확인하고 정확한 버전을 선택한다. SHA 문자열은 시간순 버전이 아니므로 Helm의 --devel로 최신을 고르지 않는다. main 게시의 unverifiedSources가 빈 배열이면 모든 소스 보관을 확인한 것이고, 필드가 없으면 확인 기록이 없는 것이다. 공식 릴리스는 소스 보관을 모두 확인해야 게시된다.
게시 chart는 세 이미지 digest와 worker runtime을 담는다. 설치별 values에 이 값을 다시 적거나 --reuse-values를 쓰면 다음 chart의 새 값을 덮는다. worker runtime은 engine·SDK·CLI·profile digest 방식으로 구성되며 이미지 digest 자체와는 다르다.
unverifiedSources가 남아 있으면 해당 Debian 소스가 실제로 보관되지 않았을 가능성도 있다. 공식 릴리스 수준의 소스 보관 보증으로 간주하지 않는다. multi-arch 게시 도입 전의 오래된 main-<12>는 amd64만 담을 수 있으므로 ARM 서버에서는 index의 platform 목록도 확인한다. 게시가 일부만 끝났거나 조회가 실패한 세 digest를 한 배포 세트로 사용하지 않는다.
checkout 없이 설치하기: 릴리스 번들
v* 릴리스의 GitHub Release에는 compose 파일, scripts/test-ops.sh, 예시 설정을 담은 werft-test-ops-<tag>.tar.gz가 붙는다. 이미지는 담지 않고, 릴리스 release-manifest.json의 digest로 이미 게시된 이미지를 받는다. 번들을 쓰면 단일 서버 설치의 checkout 준비와 호스트 bun이 필요 없다. 다운로드부터 deploy까지의 명령은 저장소의 docs/operator/test-ops.md("릴리스 번들로 설치")에 있다.
| checkout 설치 | 번들 설치 | |
|---|---|---|
| 필요한 것 | git, jq, curl, bun 1.3.14, 배포 commit의 checkout | docker, jq, curl |
preflight deploy start stop status key reset manifest | 지원 | 지원 |
upgrade backup reseed restore-drill | 지원 | 거부. checkout에서 한다 |
직접 빌드한 이미지의 runtime
API와 slack role은 WORKER_IMAGE_RUNTIME과 WORKER_IMAGE_PREVIOUS_RUNTIME으로 현재·직전 worker 이미지의 런타임 조합을 읽는다. 값은 손으로 적지 않고, images.yml이 남긴 worker digest JSON의 runtime 필드를 그대로 넣는다({"engine":…,"sdkVersion":…,"cliVersion":…,"profileDigestScheme":…}). engine은 지금 claude뿐이고, chart는 다른 이름을 렌더링 때 images.worker.runtime.engine must be one of: claude로 거절한다. 직접 빌드한 이미지에는 그 JSON이 없다. images.yml과 같은 명령으로 이미지에서 읽는다.
docker run --rm -i --network none --entrypoint bun <worker-image> \
run apps/worker/src/image-runtime.ts <<<'{}' | jq -c 'del(.profiles)'사용자 registry 빌드는 사용자 이미지의 digest를 끝까지 유지한다. 게시·승격 절차는 기여자 문서를 따른다.