Claude 샌드박스 세 번의 반복: Docker, mitm, Kubernetes pod
이 블로그 시리즈 열 편 전체는 샌드박스 pod 안에서 실행되는 Claude 에이전트가 초안을 작성했고, 그 작업을 안전하게 만들기 위해 4월에 걸쳐 샌드박스를 세 번 다시 만들었습니다. 매 반복마다 한 축씩 — 격리, 그다음 관찰 가능성, 그다음 이식성 순으로 — 끌어올렸고, 이전 축을 약화시키지는 않았습니다. 키보드를 잡은 에이전트는 실제 GitHub PAT와 실제 kubectl 토큰을 보유하고 있으므로, 이는 LLM 기반 개발 플로우의 결정판입니다: 샌드박스는 포스트를 작성하는 바로 그 에이전트를 위한 안전 장치입니다.
TL;DR
- Phase 01 (4월 21일): 로컬 Docker, read-only rootfs, 떨어뜨린 capability,
viewClusterRole — 격리 축. - Phase 02 (4월 22일):
mitmproxy사이드카와 호스트 측 Bun 자격 증명 프록시(:9090) — 관찰 가능성 축. - Phase 03 (4월 23일):
claude-sandbox네임스페이스의 K8s pod, PVC, SOPS 암호화 Secret,NetworkPolicy— 이식성 축. - 사이드바:
notifier-api-go가 단일 POST를 Discord와 Telegram으로 fan-out시켜, 긴 세션에서 자리를 비울 수 있게 합니다. - 단계당 위험 하나, 축당 재구축 한 번. 두 단계를 하나로 합치지 않습니다.
단계가 대응한 위협 모델
세 가지 구체적인 두려움이 재구축을 이끌었습니다: 평범해 보이는 API 호출을 통한 토큰 유출, 우발적 파괴(rm -rf, kubectl delete, git push --force), 그리고 관찰 불가능한 네트워크 활동. 각 단계는 정확히 하나만 다뤘습니다. “단계당 위험 하나” 매핑을 강제한 덕분에 “격리”에서 “내친 김에 secret 워크플로우도 다시 짜자”로 미끄러지는 일이 막혔습니다.
Phase 01 — Docker 격리로 가장 쉬운 축부터 정리
claude, gh, kubectl, bun, tmux가 미리 구워진 Debian 12 slim 이미지. 두 개의 호스트 bind mount가 상태를 유지합니다: 레포 클론용 workspace/, Claude OAuth 토큰용 home/. 기록해 둘 만한 버그 두 가지: /home/agent/.bun 아래의 도구가 비어 있는 home bind mount에 의해 가려졌고(해결: /opt에 설치), 미리 시드한 anthropic.key는 raw API 키가 아니라 Claude Code의 OAuth JSON이었습니다(해결: pre-seed를 없애고 컨테이너 안에서 claude login을 한 번 실행).
컨테이너 플래그가 핵심입니다:
docker run -d --name cn-claude-default \
--user 1000:1000 --read-only --tmpfs /tmp:size=512m \
--cap-drop=ALL --security-opt=no-new-privileges \
--memory=4g --cpus=2 --pids-limit=512 \
cn-claude-sandbox:phase01
PID 1은 tail -f /dev/null을 호출하는 tini이고, tmux는 엔트리포인트에서 detached 상태로 시작됩니다. 첫 버전은 docker run --rm -it ... tmux new였는데, Ctrl-b d를 누르는 순간 tmux 클라이언트가 종료되고 PID 1도 따라 죽으면서 컨테이너가 사라졌습니다. K8s 쪽: 내장 view ClusterRole에 바인딩된 claude-readonly ServiceAccount — Secret은 제외되는데, RBAC에는 .data 없이 Secret 메타데이터를 반환하는 verb가 없기 때문입니다.
Phase 02 — mitmproxy와 자격 증명 프록시로 네트워크를 가독 가능하게
Phase 01을 일주일 운용한 뒤에도 에이전트가 어떤 URL을 두드리는지 알 길이 없었습니다. mitmproxy 사이드카가 같은 Docker 네트워크에 합류하고, 샌드박스는 HTTP_PROXY=http://mitm:8080을 설정하며, mitm CA는 update-ca-certificates로 trust store에 구워 넣고, NODE_EXTRA_CA_CERTS가 Node 기반 호출자를 커버합니다. Anthropic API, GitHub, K8s — 모든 plaintext flow가 csb traffic <name>으로 실시간 관측됩니다.
함정 하나: kubectl은 시스템 trust store가 아니라 클러스터 CA에 대해 검증하므로, K8s 서버 IP를 NO_PROXY에 추가하기 전까지 호출이 TLS 오류로 실패했습니다. 두 번째 수는 호스트 측 자격 증명 프록시 — localhost:9090에서 동작하는 Bun HTTP 서버가 GitHub PAT와 kubeconfig를 쥐고, 좁은 표면(git/clone, gh/:path, k8s/:path)만 노출합니다. secrets mount를 제거한 뒤로는 cat /run/secrets/github.token이 “no such file”을 돌려주고 env | grep GH_TOKEN도 비어 있습니다. 검증 스위트: 23개 점검, 23개 통과.
Phase 03 — Kubernetes pod로 노트북 의존을 끊다
Phase 02는 여전히 제 노트북에서 돌았습니다. 세션은 재부팅과 “지금 집을 나선다”를 견뎌야 합니다. pod는 한 spec에서 샌드박스 컨테이너와 mitmproxy/mitmproxy 사이드카를 함께 돌리고, 두 개의 PVC가 /workspace와 /home/agent를 백업해 pod 삭제 후에도 데이터가 유지되며 새 pod가 직전 지점에서 이어 받습니다. 자격 증명은 ~/claude-sandbox/secrets/에서 FluxCD가 reconcile하는 SOPS 암호화 csb-secrets Secret으로 옮겨 갔습니다. csb CLI의 모든 docker 호출은 kubectl 호출이 되었고 — csb attach <name>은 이제 kubectl exec -it csb-<name> -c sandbox -- tmux attach -t claude입니다.
NetworkPolicy는 샌드박스 컨테이너가 직접 egress하지 못하도록 강제합니다 — 같은 pod 내 mitm 사이드카(8080 포트)와 호스트 자격 증명 프록시(9090 포트)를 통해서만 가능합니다:
spec:
podSelector: { matchLabels: { app: csb-session } }
policyTypes: [Egress]
egress:
- ports: [{ port: 8080 }]
- to: [{ namespaceSelector: {} }]
ports: [{ port: 9090 }]
Pod spec 초안 작성, manifest 스테이징, 마이그레이션 진행 중.
사이드바 — 긴 세션이 호출할 수 있는 notifier-api
notifier-api-go는 cloudnest-functions의 Go 서비스이며 ClusterIP 전용으로 http://notifier-api-go-stag.cloudnest-functions.svc.cluster.local/api/v1/notify에 위치합니다. SOPS 암호화 notifier-credentials Secret 기반의 Bearer 토큰 인증, title/message/level/source를 담은 단일 POST 한 번이면 Discord와 Telegram으로 fan-out됩니다. -race로 20개 테스트 통과; 샌드박스 pod에는 NOTIFIER_URL과 NOTIFIER_AUTH_TOKEN이 secretKeyRef로 주입됩니다.
앞으로 달라지는 것
진짜 코드를 쓰는 장수명 Claude 세션이, 첫날 주니어 계약직에게도 줄 만하지 않은 자격 증명은 보유하지 않으며, 보내는 모든 바이트를 제가 볼 수 있고, 노트북에 묶여 있지도 않습니다. 이 시리즈 — 이 포스트 포함 — 는 그런 샌드박스 안의 에이전트가 초안을 작성한 뒤, 콘텐츠 퍼블리싱 워크플로우에 정리해 둔 발행 파이프라인으로 넘어갔습니다. 다음을 위한 규칙: capability를 추가할 때는 어느 단계의 invariant를 강화하는지 명시할 것, 그 어떤 것도 조용히 약화시키지 말 것.
AI 워크플로우 메모
이 설계는 Claude가 자기 자신의 샌드박스를 계획한 결과물이며, 위협 모델 우선 프롬프트 패턴을 사용했습니다: Dockerfile이나 YAML보다 먼저 세 가지 위험을 평범한 산문으로 적고, 단계마다 위험 하나씩 매핑하는 식입니다. 그 매핑이 범위 붕괴를 막아 줬습니다 — 에이전트가 “격리”와 “자격 증명 격리”를 합치자고 제안할 때마다 평문 위협 목록이 그 제안을 밀어냈습니다. 실패 양상은: 축의 이름을 명시하지 않은 채 Claude에게 “샌드박스를 강화해 달라”고 시키면 검증 가능한 invariant 없는 일반론적 체크리스트가 나옵니다. 각 단계의 에이전트에게 반복해서 한 말: 첫날 주니어 계약직에게 건네지 못할 자격 증명은 절대 에이전트에게도 건네지 말 것.
