0. Contents


1. Overview

방문자앱이름.apps.durokukie.comEC2 노드 한 대 (Spot) · 여러 팀이 함께팀 A 앱 PodgVisor시스템 콜을 대신 처리팀 B 앱 PodgVisor시스템 콜을 대신 처리Cilium (호스트 커널 eBPF)Pod 출입구마다 정책 집행 · Hubble 기록호스트 커널 · 노드 IAM 권한허용같은 팀 · DNS · 인터넷차단다른 팀 · 내부망 · 메타데이터
초록은 Pod 안쪽의 gVisor, 보라는 Pod 바깥쪽의 Cilium. 하나가 뚫려도 다른 하나가 버틴다

호스팅 클러스터의 자리

  • Kukie 는 바이브코더가 레포를 연결하면 앱을 배포해 주는 서비스로 방향을 바꿨다
  • 사용자 앱은 서비스 클러스터(kukie-dev)와 분리한 호스팅 클러스터(kukie-hosting)에서 돈다
  • 이 클러스터의 노드에는 여러 팀의 앱이 함께 올라간다
  • 첫 과제: 남의 코드끼리 서로, 그리고 노드를 건드리지 못하게 막기

두 겹의 격리

  • Pod 안쪽: gVisor
  • Pod 바깥쪽: Cilium
  • 둘이 무엇인지는 위키로 넘기고, 여기서는 우리 클러스터에서 어떻게 골랐고 무엇이 걸렸는지만 적는다

장별 내용

  • 3·4장: 왜 그 둘인지
  • 5·6장: 노드에 실제로 넣다가 막힌 곳
  • 7장: 사용자 앱이 대시보드 로그인을 건드리지 못하게 한 도메인과 쿠키

2. Background — 남의 코드를 한 노드에서

호스팅 클러스터에서 도는 코드

  • 우리가 쓰지 않은 코드다
    • 사용자가 AI 로 만든 앱
    • 그 앱이 npm install 로 끌어오는 수백 개의 패키지
  • 이 코드가 같은 EC2 노드에 여러 팀 몫으로 함께 올라간다

Namespace 와 runc 의 한계

  • 쿠버네티스의 Namespace 는 이름표를 나눌 뿐 벽이 아니다
  • 일반 컨테이너(runc)는 호스트 커널을 그대로 함께 쓴다
  • 커널 취약점 하나로 컨테이너를 빠져나가면 → 그 노드의 모든 것이 노출된다

탈출했을 때 노출되는 것

노출되는 것결과
옆 팀 Pod 의 파일·환경변수다른 사용자의 비밀값 유출
노드 IAM 역할이미지 저장소(ECR) 등 AWS 리소스 접근
노드의 네트워크 위치내부망 탐색, 다른 팀 앱 호출

막아야 할 두 길

  • Pod 안에서 커널로 내려가는 길: 시스템 콜
  • Pod 밖으로 나가는 길: 네트워크

3. Sandbox — gVisor 선택

시스템 콜 격리 후보

방식격리우리 노드(t3·m5a)에서비용·운영
runc + Pod Security restricted호스트 커널 공유됨없음. 커널 취약점은 그대로
gVisor가짜 커널이 시스템 콜을 대신 처리됨 (KVM 없이 systrap 모드)노드에 직접 설치. I/O 많은 앱은 느려질 수 있음
Kata Containers · FirecrackerVM 수준보통 bare metal 필요인스턴스가 비쌈
EKS FargatePod 마다 microVM됨DaemonSet 불가(Cilium 못 씀), Spot 불가, 기동 느림
팀마다 노드 따로노드 단위됨무료 사용자에게는 너무 비쌈
여러 팀이 노드를 함께 쓰며 감당할 수 있는 범위비용 · 운영 부담 →격리 강도 →runc + restricted커널 공유gVisor가짜 커널 · 직접 설치EKS FargateDaemonSet · Spot 불가Kata · Firecrackerbare metal 필요팀마다 노드무료 사용자에 과함
감당할 수 있는 범위(초록 점선) 안에서 가장 위에 있는 것이 gVisor — 위치는 상대 비교

선택: gVisor

  • 세 조건을 함께 맞추는 가장 강한 선택지였다
    • 일반 EC2 위에서
    • 여러 팀이 노드를 함께 쓰면서
    • 비용을 맞출 수 있게
  • GKE Sandbox 와 Cloud Run 도 같은 이유로 gVisor 를 쓴다
runc — 일반 컨테이너앱 (남의 코드)시스템 콜 그대로호스트 커널모든 Pod 가 함께 쓴다커널 취약점 하나 → 노드 전체 노출gVisor — runsc앱 (남의 코드)Sentry — 사용자 공간 커널Pod 마다 하나 · 시스템 콜을 대신 처리줄인 시스템 콜만호스트 커널앱은 호스트 커널을 직접 만지지 못한다
runc 는 앱과 호스트 커널 사이에 아무것도 없고, gVisor 는 Pod 마다 가짜 커널(Sentry)을 하나 끼운다

gVisor 의 대가

  • EKS 에는 관리형 gVisor 옵션이 없다 → 노드 부팅 때 우리가 설치한다(5장)
  • 파일과 네트워크를 많이 쓰는 앱은 느려질 수 있다 → 아직 재지 않았다(8장)

로컬 kind 실험 결과

  • gVisor Pod 는 Pod Security restricted 로 정상 기동했다
  • 안에서는 seccomp 가 꺼져 있다(Seccomp: 0). gVisor 가 그 역할을 대신한다
  • 설정 자체를 빼면 Pod Security 가 Pod 를 거부한다 → 설정은 남겨 둔다

4. CNI — vpc-cni 대신 Cilium

출발점: EKS 기본 CNI

  • Pod 바깥쪽, 즉 패킷이 어디로 갈 수 있는지는 CNI 가 정한다
  • 호스팅 클러스터는 EKS 기본값인 vpc-cni 로 만들어져 있었다
  • 후보는 둘이었다
vpc-cni + Envoy GatewayCilium
팀 간·내부망·메타데이터 차단표준 NetworkPolicy 로 됨됨
앱마다 주소 나누기(Gateway)Envoy Gateway 를 따로 설치내장
통신 기록없음Hubble
도메인 기반 차단, 클러스터 전체 정책없음있음
운영AWS 관리형직접 설치·업그레이드

결정 기준: 악용을 보는 눈

  • 핵심 격리만 보면 vpc-cni 로도 충분하다
  • 결정을 가른 것은 남의 코드를 돌리는 서비스라 악용을 봐야 한다는 점이었다
  • 로컬 실험에서 악용 흉내 다섯 가지를 gVisor Pod 로 돌려 Hubble 기록을 봤다
흉내 낸 행동Hubble 에 남은 흔적
채굴채굴 풀 도메인 조회, 3333번 포트
원격 조종(C2) 신호같은 낯선 도메인으로 6.0초 간격 반복
포트 스캔짧은 시간에 서로 다른 포트 201개로 SYN
스팸외부 25번 포트 연결
내부 탐색메타데이터·다른 팀·내부 주소로 Policy denied

Hubble 기록의 쓰임

  • 팀별 지표로 모을 수 있다
  • 운영 Agent 가 “왜 수상한지” 설명하는 근거가 된다
  • 25번·3333번 포트는 정책으로 미리 막을 수도 있었다
  • vpc-cni 에는 이 눈이 없다

전환 시점: 클러스터가 빈 지금

  • CNI 는 사용자 앱이 올라간 뒤에는 바꾸기가 매우 어렵다
  • 지금 클러스터는 비어 있어서 바꿔도 깨질 것이 없다

실험에서 나온 Cilium 쪽 대가

  • gVisor Pod 는 socketLB.hostNamespaceOnly=true 가 필요하다
    • 기본값에서 gVisor Pod 의 DNS 가 실패한 클러스터가 있었다
    • gVisor 안의 앱은 호스트 커널 소켓을 쓰지 않는다 → Cilium 이 소켓 단계에서 하는 Service 주소 변환을 받지 못한다
  • Helm 값 하나가 노드 네트워크를 끊는다
    • 필터 값을 잘못 넣자 cilium-agent 가 재시작을 반복했다
    • CNI 가 멈추면 그 노드의 Pod 네트워크가 전부 끊긴다
    • 렌더링을 먼저 확인하고 노드 한 대부터 올린다
일반 Pod (runc)앱호스트 커널 소켓connect() 그대로소켓 단계 변환Service → Pod 주소DNS 성공gVisor Pod기본값앱gVisor 네트워크 스택호스트 소켓 안 씀변환 없음Service 주소 그대로DNS 실패gVisor PodhostNamespaceOnly=true앱gVisor 네트워크 스택호스트 소켓 안 씀Pod 출입구 변환패킷 단위 (eBPF)DNS 성공
gVisor 안의 앱은 호스트 커널 소켓을 거치지 않는다 — 주소 변환을 Pod 출입구로 내려야 Service 주소가 풀린다

적용 상태

  • Cilium 전환은 PR 까지 만들었다
  • 적용은 gVisor 확인이 끝난 뒤

5. Node Bootstrap — 고정 버전과 체크섬

설치 경로: launch template 의 user_data

  • gVisor 는 노드 부팅 때 설치한다
  • EKS 관리형 노드 그룹의 launch template 에 user_data 를 넣으면, EKS 가 자기 부팅 설정과 합쳐 노드에 넘긴다
  • 두 조각을 MIME 문서 하나에 담았다
# 1) nodeadm 이 containerd 설정에 합친다 — runsc 런타임 핸들러 등록
apiVersion: node.eks.aws/v1alpha1
kind: NodeConfig
spec:
  containerd:
    config: |
      [plugins."io.containerd.cri.v1.runtime".containerd.runtimes.runsc]
        runtime_type = "io.containerd.runsc.v1"
# 2) cloud-init 이 실행하는 설치 스크립트 (요약)
set -euo pipefail
url=https://storage.googleapis.com/gvisor/releases/release/20261012.0/x86_64/gvisor.tar.bz2
curl -fsSL --retry 5 -o "$work/gvisor.tar.bz2" "$url"
echo "<코드에 고정한 SHA-512>  $work/gvisor.tar.bz2" | sha512sum -c -
tar -xjf "$work/gvisor.tar.bz2" -C "$work" runsc containerd-shim-runsc-v1 gvisor-bin
install -m 0755 "$work/runsc" "$work/containerd-shim-runsc-v1" /usr/local/bin/

설계 원칙 세 가지

  • 버전 고정 — latest 를 쓰지 않는다. 릴리스 이름과 그 묶음의 SHA-512 를 Terraform 코드에 같이 적고, 테스트가 latest 가 없는지 확인한다
  • 부팅 때 대조 — 받은 묶음이 고정한 해시와 다르면 설치를 멈춘다
  • 실패하면 막힘(fail closed) — 설치가 실패해도 containerd 에는 runsc 핸들러만 등록돼 있다
    • gVisor 를 요청한 Pod 는 아예 뜨지 않는다
    • 몰래 runc 로 도는 일은 없다
launch templateuser_data — MIME 문서 한 장NodeConfig + 설치 스크립트nodeadm → containerd 에 runsc 핸들러 등록cloud-init 설치 스크립트내려받기릴리스 20261012.0SHA-512 대조코드에 고정한 값설치runsc · shim · gvisor-bingvisor Pod 기동실패설치 중단못 받거나 해시가 다르면fail closed핸들러만 남은 노드 → runtimeClassName: gvisor Pod 는 뜨지 않음runc 로 몰래 도는 일 없음참고user_data 는 처음 켤 때 한 번→ 적용하면 노드를 한 대씩 교체RuntimeClass 없는 시스템 Pod(CoreDNS 등)는 runc 그대로새로 뜨는 노드도 같은 설명서→ gVisor 없는 노드가 안 섞임
설치가 어디서 실패해도 결과는 "gvisor Pod 가 안 뜬다" 하나 — runc 로 조용히 넘어가는 길이 없다

런타임 선택: RuntimeClass

  • 사용자 앱은 runtimeClassName: gvisor 로 gVisor 를 고른다
  • RuntimeClass 가 없는 시스템 Pod(CoreDNS 등)는 그대로 runc 다
apiVersion: node.k8s.io/v1
kind: RuntimeClass
metadata:
  name: gvisor
handler: runsc

적용 방식: 노드 교체

  • user_data 는 노드가 처음 켜질 때 한 번만 실행된다
  • 이미 떠 있던 노드에는 gVisor 가 생기지 않는다
  • 그래서 적용하면 노드 그룹이 새 노드를 띄우고 옛 노드를 빼는 방식으로 한 대씩 교체된다
  • 이번에는 Spot 재고 부족(UnfulfillableCapacity)으로 새 노드 띄우기가 여러 번 재시도됐다 → 교체에 10분 가까이 걸렸다

앞으로 생길 노드

  • Spot 회수나 자동 교체로 새 노드가 떠도 같은 설명서로 부팅한다
  • gVisor 가 없는 노드가 섞이지 않는다

6. First Apply — gvisor_sentry 누락

확인 Pod 와 증상

  • 노드 교체가 끝난 뒤 확인 Pod 를 띄웠다
    • Pod Security restricted Namespace 에 runtimeClassName: gvisor 로 올린 busybox
    • 커널 버전, seccomp 상태, 메타데이터 접근을 찍는다
  • Pod 는 ContainerCreating 에서 넘어가지 않았다
OCI runtime create failed: creating container: cannot create sandbox:
cannot create sandbox process: sidecar "gvisor_sentry" not usable:
stat /usr/local/bin/gvisor-bin/gvisor_sentry: no such file or directory

오류가 알려 준 두 가지

  • 된 것: containerd 가 runsc shim 까지 불렀다 → 핸들러 등록(5장의 NodeConfig)은 의도대로 됐다
  • 안 된 것: 설치 스크립트

원인: 바뀐 릴리스 묶음 구성

  • 로컬 실험 때 쓴 gVisor 는 runsc 실행 파일 하나로 동작했다
  • 지금 릴리스 묶음은 runsc 가 옆의 gvisor-bin/ 디렉터리에서 sentry 와 보조 프로그램을 띄운다
gvisor.tar.bz2
├── runsc
├── containerd-shim-runsc-v1
└── gvisor-bin/
    ├── gvisor_sentry
    ├── gvisor-sentry-prewarmer
    ├── checkpointgofer
    ├── runsc-fd-parking
    └── runsc-metric-server

수정

  • 처음 스크립트는 위 두 파일만 꺼냈다
  • gvisor-bin/ 까지 /usr/local/bin/gvisor-bin/ 에 설치하도록 고쳤다
  • 테스트에 그 경로가 user_data 에 있는지 확인하는 단언을 더했다
처음 스크립트/usr/local/bin/runsccontainerd-shim-runsc-v1gvisor-bin/ ← 없음ContainerCreating 에서 멈춤sidecar "gvisor_sentry" not usable핸들러 등록은 됐다 — runsc shim 까지 불림fail closed 덕에 바로 드러남고친 스크립트/usr/local/bin/runsccontainerd-shim-runsc-v1gvisor-bin/gvisor_sentry 외 4개4.19.0-gvisor두 노드 모두 · Seccomp 0 · imds: blocked테스트에 gvisor-bin 경로 단언 추가노드를 한 번 더 교체
릴리스 묶음이 runsc 한 파일에서 디렉터리 구조로 바뀐 것을 로컬 실험이 알려 주지 못했다

교훈 두 가지

  • 로컬 실험은 원리를 확인해 줄 뿐, 실제 배포 경로(내려받는 묶음의 구성까지)를 대신하지 않는다
  • “실패하면 막힌다” 설계 덕분에 잘못된 설치가 runc 로 조용히 넘어가지 않고 바로 드러났다

재적용 결과

  • 수정 PR 을 적용해 노드를 한 번 더 교체했다
  • 같은 확인 Pod 를 두 노드에 각각 띄웠고, 두 노드 모두 같은 결과였다
4.19.0-gvisor
Seccomp:	0
imds: blocked
확인결과의미
uname -r4.19.0-gvisor호스트 커널이 아니라 gVisor 안에서 돈다
/proc/self/status 의 Seccomp0gVisor 가 seccomp 를 대신한다
메타데이터 주소 접속막힘IMDS hop limit 1 이 Pod 를 막는다

7. App Domain — apps.durokukie.com 과 __Host- 쿠키

사용자 앱 주소

  • 앱이름.apps.durokukie.com 으로 정했다
  • 원래 설계는 대시보드와 아예 다른 도메인(.app)이었다
  • 같은 도메인 아래에 남의 앱을 두면 두 가지 위험이 생긴다
위험내용막는 법
쿠키 덮어쓰기앱이 Domain=durokukie.com 쿠키를 심어 대시보드 로그인 쿠키를 가린다쿠키 이름에 __Host- 접두어
Safe Browsing 차단피싱 앱 하나 때문에 도메인 전체가 위험 사이트로 막힐 수 있다별도 도메인뿐

하위 도메인으로 시작한 이유

  • 지금은 초대한 소수만 쓰는 단계 → 두 번째 위험이 낮다
  • 도메인 구매도 막혀 있었다
    • AWS 계정이 속한 조직의 정책(SCP)이 Route 53 도메인 기능을 통째로 막는다
  • 그래서 이미 가진 도메인의 하위 이름을 쓴다
  • 조건: 공개 가입을 열기 전에 별도 도메인으로 옮긴다

쿠키 덮어쓰기 방어: __Host- 접두어

  • 이미 있던 방어
    • 서버의 로그인 쿠키에는 Domain 속성이 없다(host-only)
    • 다른 출처에서 온 요청은 쿠키로 인증하지 않는다(Sec-Fetch-Site 검사)
  • 남은 구멍: 앱이 같은 이름의 쿠키를 상위 도메인에 심는 것
  • 막는 법: 이름에 __Host- 를 붙인다
    • 브라우저가 그 이름의 쿠키를 Domain 속성과 함께 받지 않는다
    • 하위 도메인이 덮어쓸 수 없다
kukie_access          → __Host-kukie_access
kukie_refresh         → __Host-kukie_refresh
접두어 없음 — kukie_access남의 앱x.apps.durokukie.com브라우저 — 저장함같은 이름 쿠키 둘로그인 쿠키가 가려짐둘이 함께 감Set-Cookie: kukie_access=…; Domain=durokukie.com→ 대시보드 요청__Host- 접두어 — __Host-kukie_access남의 앱x.apps.durokukie.com브라우저 — 거부함Domain 달린 __Host- 쿠키서버가 구운 쿠키만덮어쓸 수 없음Set-Cookie: __Host-kukie_access=…; Domain=durokukie.com→ 대시보드 요청
__Host- 이름은 Domain 을 달고 올 수 없다 — 하위 도메인의 앱이 같은 이름을 심을 길이 막힌다

배포 순서: 서버 먼저, 에이전트 다음

  • 쿠키를 직접 읽는 에이전트도 같은 이름으로 바꿔야 한다
  • 서버와 에이전트 설정을 정해진 순서로 함께 배포한다
    1. 서버가 먼저 새 이름으로 굽는다
    2. 그 직후 에이전트가 새 이름을 읽는다
  • 순서가 뒤집히면 → 그 사이 채팅이 모두 401 이 된다

8. Discussion

남은 과제

  • gVisor 성능 — I/O 가 많은 대표 앱으로 runc 와 비교. 차이가 크면 유료 플랜 전용 노드를 검토한다
  • 빌드 위치 — 사용자 코드의 빌드(npm install 포함)도 gVisor 안에서 돌릴지. BuildKit 이 gVisor 안에서 도는지 확인하지 않았다
  • Cilium 적용 — vpc-cni 와 kube-proxy 를 빼고 Cilium ENI 모드로. 같은 AWS 계정의 서비스 클러스터 자원을 건드리지 않도록 operator 범위를 호스팅 태그로 묶었다
  • AMI 에 굽기 — 지금은 부팅 때마다 바깥에서 묶음을 받는다. 받는 곳이 막히면 새 노드가 gVisor 없이 뜬다(막히는 쪽으로)
  • 별도 앱 도메인 — 공개 가입 전

핵심 메시지

  • Namespace 는 이름표다. 남의 코드를 같은 노드에 올리면 Pod 안쪽(시스템 콜)과 바깥쪽(네트워크)을 따로 막아야 한다
  • CNI 는 격리만이 아니라 “무슨 일이 있었나"를 보는 눈이다. 남의 코드를 돌리는 서비스에서는 그 눈이 결정 기준이 된다
  • 노드에 넣는 런타임은 버전을 고정하고, 실패하면 막히게 만든다. 이번 첫 적용의 실수도 그 덕에 조용히 넘어가지 않았다
  • 로컬 실험은 원리를 확인할 뿐, 실제로 내려받는 묶음의 구성까지 대신 확인해 주지 않는다

9. References

gVisor 를 containerd 에 붙이는 방법과 runtime handler 설정. 5장의 NodeConfig 근거다.
→ gVisor — Containerd Quick Start
AL2023 EKS 노드의 nodeadm 과 NodeConfig. containerd 설정을 부팅 설정에 합치는 방식이다.
→ Amazon EKS AMI — nodeadm
RuntimeClass 로 Pod 마다 런타임을 고르는 방법.
→ Kubernetes — Runtime Class
Cilium 의 소켓 단계 로드 밸런싱과 hostNamespaceOnly. 4장의 gVisor 주의점이다.
→ Cilium — Kubernetes Without kube-proxy
__Host- 접두어가 붙은 쿠키의 조건(Secure, Path=/, Domain 없음). 7장의 근거다.
→ MDN — Set-Cookie: Cookie prefixes