Wintersalmon | Blog

Claude 샌드박스 세 번의 반복: Docker, mitm, Kubernetes pod

5 min read

이 블로그 시리즈 열 편 전체는 샌드박스 pod 안에서 실행되는 Claude 에이전트가 초안을 작성했고, 그 작업을 안전하게 만들기 위해 4월에 걸쳐 샌드박스를 세 번 다시 만들었습니다. 매 반복마다 한 축씩 — 격리, 그다음 관찰 가능성, 그다음 이식성 순으로 — 끌어올렸고, 이전 축을 약화시키지는 않았습니다. 키보드를 잡은 에이전트는 실제 GitHub PAT와 실제 kubectl 토큰을 보유하고 있으므로, 이는 LLM 기반 개발 플로우의 결정판입니다: 샌드박스는 포스트를 작성하는 바로 그 에이전트를 위한 안전 장치입니다.

TL;DR

  • Phase 01 (4월 21일): 로컬 Docker, read-only rootfs, 떨어뜨린 capability, view ClusterRole — 격리 축.
  • 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-gocloudnest-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_URLNOTIFIER_AUTH_TOKENsecretKeyRef로 주입됩니다.

앞으로 달라지는 것

진짜 코드를 쓰는 장수명 Claude 세션이, 첫날 주니어 계약직에게도 줄 만하지 않은 자격 증명은 보유하지 않으며, 보내는 모든 바이트를 제가 볼 수 있고, 노트북에 묶여 있지도 않습니다. 이 시리즈 — 이 포스트 포함 — 는 그런 샌드박스 안의 에이전트가 초안을 작성한 뒤, 콘텐츠 퍼블리싱 워크플로우에 정리해 둔 발행 파이프라인으로 넘어갔습니다. 다음을 위한 규칙: capability를 추가할 때는 어느 단계의 invariant를 강화하는지 명시할 것, 그 어떤 것도 조용히 약화시키지 말 것.

AI 워크플로우 메모

이 설계는 Claude가 자기 자신의 샌드박스를 계획한 결과물이며, 위협 모델 우선 프롬프트 패턴을 사용했습니다: Dockerfile이나 YAML보다 먼저 세 가지 위험을 평범한 산문으로 적고, 단계마다 위험 하나씩 매핑하는 식입니다. 그 매핑이 범위 붕괴를 막아 줬습니다 — 에이전트가 “격리”와 “자격 증명 격리”를 합치자고 제안할 때마다 평문 위협 목록이 그 제안을 밀어냈습니다. 실패 양상은: 축의 이름을 명시하지 않은 채 Claude에게 “샌드박스를 강화해 달라”고 시키면 검증 가능한 invariant 없는 일반론적 체크리스트가 나옵니다. 각 단계의 에이전트에게 반복해서 한 말: 첫날 주니어 계약직에게 건네지 못할 자격 증명은 절대 에이전트에게도 건네지 말 것.

#claude #sandbox


Hungjoon

I'm Hungjoon, a software engineer based in South Korea. This is my long-form notebook — homelab, Kubernetes, AI infra, and whatever else keeps me up at night.