Certified Kubernetes Security Specialist (CKS) #11 격리: gVisor, Kata Containers, RuntimeClass

CKS 시리즈는 도메인 Minimize Microservice Vulnerabilities를 다루는 중입니다. 앞선 #9 Pod Security Admission#10 Secrets 관리가 Pod의 권한과 비밀 데이터를 다뤘다면, 이번 글은 한 걸음 더 들어가서 컨테이너 자체의 격리가 왜 약한지와 그것을 보완하는 샌드박스 런타임을 정리하겠습니다.

컨테이너는 가볍습니다. 그런데 그 가벼움의 대가가 곧 보안의 약점입니다. 컨테이너는 호스트의 커널을 그대로 공유하기 때문에, 컨테이너 안에서 커널 취약점을 찌르는 공격이 성공하면 그 피해가 호스트 전체로 번질 수 있습니다. 이 약점을 메우는 것이 gVisor와 Kata Containers 같은 샌드박스 런타임이고, 쿠버네티스에서 이를 선택하는 장치가 RuntimeClass입니다.

왜 컨테이너 격리는 약한가 #

가상 머신과 컨테이너의 차이를 한 줄로 정리하면 커널을 공유하느냐 아니냐입니다. 가상 머신은 게스트마다 자기 커널을 따로 돌리고 그 아래 하이퍼바이저가 하드웨어를 가상화합니다. 반면 컨테이너는 호스트의 커널을 그대로 빌려 쓰면서 네임스페이스와 cgroup으로 보이는 범위만 나눕니다.

이 구조가 컨테이너를 가볍고 빠르게 만들지만, 보안 경계는 얇아집니다. 컨테이너 안에서 도는 모든 시스템 콜은 결국 호스트의 같은 커널로 전달됩니다. 그래서 다음 같은 상황이 위험합니다.

  • 컨테이너 안 프로세스가 커널의 취약점을 찌르면, 그 취약점은 호스트 커널의 취약점입니다. 컨테이너 탈출(container escape)로 호스트를 장악할 수 있습니다.
  • 신뢰할 수 없는 코드(외부에서 받은 이미지, 멀티테넌트 환경의 사용자 워크로드)를 같은 노드에서 돌리면, 한 컨테이너의 침해가 옆 컨테이너와 노드로 번질 수 있습니다.

AppArmor와 seccomp로 시스템 콜을 제한하는 것은 이 공격 표면을 줄이는 좋은 방법이지만, 결국 같은 커널을 공유한다는 전제는 바뀌지 않습니다. 공유 커널 자체를 더 두껍게 감싸거나 아예 분리하자는 것이 샌드박스 런타임의 발상입니다.

실제로 과거에 보고된 여러 컨테이너 탈출 사례는 커널이나 컨테이너 런타임의 결함을 이용했습니다. 권한 있는 컨테이너의 설정 실수, 커널의 메모리 처리 버그, 런타임 바이너리를 덮어쓰는 공격 등이 거기에 속합니다. 이런 공격의 공통점은 컨테이너와 호스트가 같은 커널을 보고 있다는 사실을 지렛대로 삼는다는 데 있습니다. 따라서 신뢰할 수 없는 워크로드를 돌릴 때는 커널 경계 자체를 한 겹 더 두는 방어가 의미를 갖습니다.

샌드박스 런타임 두 가지 #

쿠버네티스에서 자주 쓰는 샌드박스 런타임은 두 갈래입니다. 접근 방식이 서로 다르므로 원리를 구분해 두겠습니다.

gVisor (runsc) #

gVisor는 구글이 만든 샌드박스 런타임입니다. 핵심은 호스트 커널과 컨테이너 사이에 유저 공간에서 도는 또 하나의 커널을 끼워 넣는 것입니다. 이 유저 공간 커널이 runsc이고, 컨테이너가 부르는 시스템 콜을 호스트 커널로 바로 넘기지 않고 가로채서 자기가 대신 처리합니다.

컨테이너가 시스템 콜을 부르면 그 콜은 먼저 runsc로 갑니다. runsc는 리눅스 시스템 콜의 상당수를 유저 공간에서 자체 구현해 두었으므로, 많은 경우 호스트 커널을 건드리지 않고 응답합니다. 호스트 커널에 실제로 닿아야 하는 콜은 극히 제한된 좁은 통로로만 전달됩니다. 결과적으로 컨테이너가 호스트 커널과 직접 맞닿는 면적이 크게 줄어듭니다.

대가는 성능입니다. 시스템 콜마다 한 겹을 더 거치므로 입출력이 잦은 워크로드나 시스템 콜이 많은 워크로드에서는 성능이 떨어집니다. 또한 runsc가 구현하지 않은 일부 시스템 콜이나 기능을 쓰는 애플리케이션은 호환성 문제가 날 수 있습니다.

Kata Containers #

Kata Containers는 다른 방향을 택합니다. 컨테이너를 경량 가상 머신 안에서 돌립니다. 각 Pod(또는 컨테이너)가 자기만의 가벼운 VM과 그 안의 게스트 커널을 갖게 되므로, 격리 경계가 컨테이너 수준이 아니라 VM 수준이 됩니다.

이렇게 하면 컨테이너 안에서 커널을 장악해도 그것은 호스트 커널이 아니라 게스트 커널이므로, 호스트로 번지려면 하이퍼바이저 경계를 한 번 더 넘어야 합니다. 가상 머신에 준하는 강한 격리를 얻는 셈입니다. 대신 VM을 띄우는 만큼 시작 시간과 메모리 사용이 늘고, 노드에 가상화 지원(중첩 가상화 등)이 필요합니다.

두 방식의 비교 #

항목gVisor (runsc)Kata Containers
격리 방식유저 공간 커널로 시스템 콜 가로채기경량 VM + 게스트 커널
격리 강도강함(공격 표면 축소)더 강함(VM 경계)
성능 부담시스템 콜,입출력에서 저하VM 기동,메모리 부담
노드 요구사항비교적 가벼움가상화 지원 필요
적합한 곳신뢰 낮은 일반 워크로드강한 멀티테넌시 격리

RuntimeClass #

샌드박스 런타임을 노드에 설치했다고 해서 모든 Pod가 자동으로 그 런타임을 쓰는 것은 아닙니다. 쿠버네티스에는 어떤 Pod를 어떤 런타임으로 돌릴지 고르는 장치가 필요한데, 그것이 RuntimeClass입니다.

RuntimeClass는 컨테이너 런타임 설정을 가리키는 클러스터 범위 리소스입니다. 핵심 필드는 handler이고, 이 값은 노드의 컨테이너 런타임(containerd 등) 설정에 정의된 핸들러 이름과 일치해야 합니다. 예를 들어 containerd에 runsc 핸들러를 등록해 두었다면, RuntimeClass의 handlerrunsc로 지정합니다.

여기서 전제가 하나 있습니다. 노드에 해당 런타임이 이미 설치되어 있고, 컨테이너 런타임 설정에 핸들러가 등록되어 있어야 합니다. RuntimeClass는 그 핸들러를 가리키는 이름표일 뿐, 런타임 자체를 설치하지는 않습니다. 시험에서는 보통 핸들러가 이미 노드에 준비된 상태로 주어지고, 응시자는 RuntimeClass를 만들고 Pod에 연결하는 부분을 맡습니다.

YAML로 만들어 보기 #

먼저 gVisor를 가리키는 RuntimeClass를 만듭니다.

apiVersion: node.k8s.io/v1
kind: RuntimeClass
metadata:
  name: gvisor
handler: runsc

metadata.name은 Pod에서 참조할 이름이고, handler는 노드 런타임에 등록된 핸들러 이름입니다. 이 둘은 헷갈리기 쉽습니다. Pod는 name(여기서는 gvisor)을 참조하고, 노드 런타임은 handler(여기서는 runsc)를 찾습니다.

만든 RuntimeClass를 Pod에 적용할 때는 Pod spec의 runtimeClassName에 RuntimeClass의 이름을 적습니다.

apiVersion: v1
kind: Pod
metadata:
  name: sandboxed-nginx
spec:
  runtimeClassName: gvisor
  containers:
    - name: nginx
      image: nginx:1.27

runtimeClassName: gvisor로 지정한 순간, 이 Pod의 컨테이너는 노드에서 runsc 핸들러를 통해, 곧 gVisor 샌드박스 안에서 기동합니다. 다른 Pod가 runtimeClassName을 비워 두면 노드의 기본 런타임으로 돌므로, 샌드박스가 필요한 워크로드에만 선택적으로 적용할 수 있습니다.

Kata Containers를 쓰는 경우도 형태는 같습니다. 노드에 Kata 핸들러(예를 들어 kata)가 등록되어 있다면, RuntimeClass의 handler를 그 이름으로 바꾸기만 하면 됩니다.

apiVersion: node.k8s.io/v1
kind: RuntimeClass
metadata:
  name: kata
handler: kata

이 RuntimeClass를 만든 뒤 Pod에서 runtimeClassName: kata로 지정하면 해당 Pod는 경량 VM 안에서 돕니다. RuntimeClass의 모양은 동일하고, 가리키는 핸들러만 달라지는 구조입니다.

적용 확인 #

Pod가 정말 샌드박스 런타임 안에서 도는지는 Pod 안에서 커널 정보를 보면 드러납니다. 일반 컨테이너는 호스트 커널을 공유하므로 호스트와 같은 커널 정보를 보지만, gVisor 안에서는 runsc가 보고하는 다른 정보가 나옵니다.

# 노드의 호스트 커널 정보
uname -r

# Pod 안에서 커널 정보 확인
kubectl exec sandboxed-nginx -- uname -r

gVisor 안에서 도는 Pod라면 uname -r이 호스트와 다른, gVisor가 흉내 내는 커널 버전을 보여 줍니다. 또한 dmesg로 커널 로그를 보면 일반 컨테이너와 다른 출력이 나옵니다. gVisor는 호스트의 실제 커널 링 버퍼를 그대로 보여 주지 않고 자체 메시지를 내보내므로, 두 환경의 dmesg 출력이 다릅니다.

# gVisor Pod 안에서 커널 로그 확인
kubectl exec sandboxed-nginx -- dmesg

uname -rdmesg의 출력이 호스트와 다르게 나온다면, 그 Pod가 샌드박스 런타임 안에서 돌고 있다는 신호입니다. RuntimeClass가 제대로 적용됐는지 빠르게 검증하는 방법으로 손에 익혀 두면 좋습니다.

트레이드오프 #

샌드박스 런타임은 격리를 강화하는 만큼 성능과 호환성을 그만큼 포기해야 합니다.

  • 보안 대 성능. gVisor는 시스템 콜마다 한 겹을 거치므로 입출력이 잦은 워크로드에서 느려지고, Kata는 VM 기동과 메모리 비용이 듭니다. 격리가 강할수록 일반적으로 부담도 큽니다.
  • 보안 대 호환성. gVisor는 일부 시스템 콜을 구현하지 않으므로 특정 애플리케이션이 동작하지 않을 수 있습니다. 노드 디바이스에 직접 접근하거나 특수한 커널 기능을 쓰는 워크로드는 샌드박스와 맞지 않을 수 있습니다.
  • 선택적 적용. 그래서 실무에서는 모든 Pod를 샌드박스로 돌리지 않습니다. 신뢰 수준이 낮은 워크로드나 멀티테넌트 환경의 외부 코드에만 RuntimeClass로 선택 적용하고, 나머지는 기본 런타임으로 두는 방식이 일반적입니다.

시험 포인트 #

  • RuntimeClass 생성과 Pod 지정이 단골 작업입니다. “노드에 이미 등록된 runsc 핸들러를 쓰는 RuntimeClass를 만들고, 주어진 Pod가 그것을 쓰도록 하라” 유형이 나옵니다. RuntimeClass를 만들고 Pod spec에 runtimeClassName을 넣는 두 단계를 빠르게 끝낼 수 있어야 합니다.
  • namehandler를 구분합니다. RuntimeClass의 metadata.name은 Pod가 참조하는 이름이고, handler는 노드 런타임에 등록된 핸들러 이름입니다. Pod의 runtimeClassName에는 handler가 아니라 RuntimeClass의 name을 적어야 합니다.
  • apiVersion을 기억합니다. RuntimeClass는 node.k8s.io/v1입니다. 문서에서 빠르게 복사해 오는 편이 안전합니다.
  • 런타임은 이미 설치돼 있다고 전제합니다. 시험에서 응시자가 gVisor나 Kata를 노드에 설치하는 경우는 드뭅니다. 핸들러는 준비돼 있고, RuntimeClass와 Pod 연결이 채점 대상입니다.
  • 검증 명령을 압니다. 적용 여부가 의심되면 kubectl exec로 Pod에 들어가 uname -r이나 dmesg로 호스트와 다른지 확인합니다.
  • gVisor 문서 열람이 허용됩니다. gVisor 공식 문서는 시험 중 열람 가능한 지정 문서이므로, RuntimeClass 예제 위치를 미리 익혀 두면 시간을 아낍니다.

정리 #

이번 글에서 잡은 것:

  • 컨테이너 격리가 약한 이유는 호스트 커널 공유입니다. 컨테이너 안 시스템 콜이 결국 호스트 커널로 가므로, 커널 취약점은 곧 컨테이너 탈출의 통로가 됩니다.
  • gVisor(runsc)는 유저 공간 커널로 시스템 콜을 가로채 호스트 커널과 맞닿는 면적을 줄입니다. 가볍지만 성능과 호환성 비용이 있습니다.
  • Kata Containers는 경량 VM 안에서 컨테이너를 돌려 VM 수준의 강한 격리를 얻습니다. 격리는 더 강하지만 기동,메모리 부담과 가상화 요구사항이 있습니다.
  • RuntimeClass는 handler로 노드의 런타임을 가리키는 리소스이고, Pod의 runtimeClassName으로 적용합니다. 런타임은 노드에 미리 설치돼 있어야 합니다.
  • 트레이드오프는 보안 대 성능,호환성입니다. 신뢰 낮은 워크로드에만 선택적으로 적용하는 것이 실무 패턴입니다.
  • 검증은 uname -rdmesg로 호스트와 다른지 확인합니다.

다음: Pod-to-Pod mTLS #

격리로 한 노드 안에서 워크로드를 가뒀다면, 다음은 노드 사이를 오가는 통신을 지킬 차례입니다. 같은 도메인 Minimize Microservice Vulnerabilities의 마지막 주제로, Pod 사이의 트래픽을 암호화하고 상호 인증하는 mTLS를 다룹니다.

#12 Pod-to-Pod mTLS: Cilium에서는 Cilium이 어떻게 Pod 간 통신에 mTLS를 입히는지, NetworkPolicy와 어떻게 맞물리는지, 그리고 시험에서 통신 암호화를 요구하는 작업을 어떻게 풀어내는지 직접 만들어 보며 정리하겠습니다.

X