0. Contents
1. Overview
초록은 Pod 안쪽의 gVisor, 보라는 Pod 바깥쪽의 Cilium. 하나가 뚫려도 다른 하나가 버틴다
호스팅 클러스터의 자리
- Kukie 는 바이브코더가 레포를 연결하면 앱을 배포해 주는 서비스로 방향을 바꿨다
- 사용자 앱은 서비스 클러스터(
kukie-dev)와 분리한 호스팅 클러스터(kukie-hosting)에서 돈다 - 이 클러스터의 노드에는 여러 팀의 앱이 함께 올라간다
- 첫 과제: 남의 코드끼리 서로, 그리고 노드를 건드리지 못하게 막기
두 겹의 격리
장별 내용
- 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 · Firecracker | VM 수준 | 보통 bare metal 필요 | 인스턴스가 비쌈 |
| EKS Fargate | Pod 마다 microVM | 됨 | DaemonSet 불가(Cilium 못 씀), Spot 불가, 기동 느림 |
| 팀마다 노드 따로 | 노드 단위 | 됨 | 무료 사용자에게는 너무 비쌈 |
감당할 수 있는 범위(초록 점선) 안에서 가장 위에 있는 것이 gVisor — 위치는 상대 비교
선택: gVisor
- 세 조건을 함께 맞추는 가장 강한 선택지였다
- 일반 EC2 위에서
- 여러 팀이 노드를 함께 쓰면서
- 비용을 맞출 수 있게
- GKE Sandbox 와 Cloud Run 도 같은 이유로 gVisor 를 쓴다
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 Gateway | Cilium | |
|---|---|---|
| 팀 간·내부망·메타데이터 차단 | 표준 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 네트워크가 전부 끊긴다
- 렌더링을 먼저 확인하고 노드 한 대부터 올린다
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 로 도는 일은 없다
설치가 어디서 실패해도 결과는 "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
restrictedNamespace 에runtimeClassName: gvisor로 올린 busybox - 커널 버전, seccomp 상태, 메타데이터 접근을 찍는다
- Pod Security
- 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 가
runscshim 까지 불렀다 → 핸들러 등록(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에 있는지 확인하는 단언을 더했다
릴리스 묶음이 runsc 한 파일에서 디렉터리 구조로 바뀐 것을 로컬 실험이 알려 주지 못했다
교훈 두 가지
- 로컬 실험은 원리를 확인해 줄 뿐, 실제 배포 경로(내려받는 묶음의 구성까지)를 대신하지 않는다
- “실패하면 막힌다” 설계 덕분에 잘못된 설치가 runc 로 조용히 넘어가지 않고 바로 드러났다
재적용 결과
- 수정 PR 을 적용해 노드를 한 번 더 교체했다
- 같은 확인 Pod 를 두 노드에 각각 띄웠고, 두 노드 모두 같은 결과였다
4.19.0-gvisor
Seccomp: 0
imds: blocked
| 확인 | 결과 | 의미 |
|---|---|---|
uname -r | 4.19.0-gvisor | 호스트 커널이 아니라 gVisor 안에서 돈다 |
/proc/self/status 의 Seccomp | 0 | gVisor 가 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
__Host- 이름은 Domain 을 달고 올 수 없다 — 하위 도메인의 앱이 같은 이름을 심을 길이 막힌다
배포 순서: 서버 먼저, 에이전트 다음
- 쿠키를 직접 읽는 에이전트도 같은 이름으로 바꿔야 한다
- 서버와 에이전트 설정을 정해진 순서로 함께 배포한다
- 서버가 먼저 새 이름으로 굽는다
- 그 직후 에이전트가 새 이름을 읽는다
- 순서가 뒤집히면 → 그 사이 채팅이 모두 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 근거다.
AL2023 EKS 노드의 nodeadm 과 NodeConfig. containerd 설정을 부팅 설정에 합치는 방식이다.
RuntimeClass 로 Pod 마다 런타임을 고르는 방법.
Cilium 의 소켓 단계 로드 밸런싱과 hostNamespaceOnly. 4장의 gVisor 주의점이다.
__Host- 접두어가 붙은 쿠키의 조건(Secure, Path=/, Domain 없음). 7장의 근거다.
