LabHub

블로그

집에 있는 서버로 실습형 학습 플랫폼 만들기 — 특권 없는 파드 안에 쿠버네티스를 넣는 방법

한국어English日本語中文

집에 쿠버네티스 클러스터를 세워 두고 나면 다음 질문은 대개 같습니다. 이걸로 뭘 하지?

저는 학습 플랫폼을 만들기로 했습니다. 수강생이 코스를 열고 실습 시작을 누르면 그 사람 전용 컨테이너가 즉시 뜨고, 브라우저 안의 터미널로 진짜 리눅스 셸에 붙고, 단계마다 채점 버튼을 누르면 서버가 파드 안의 실제 상태를 검사해 통과 여부를 알려 주는 구조입니다. 실습을 마치거나 제한 시간이 지나면 컨테이너는 사라집니다.

만들고 나서 돌아보니, 어려운 부분은 기능이 아니었습니다. 격리였습니다.

문제는 root 를 줘야 한다는 것

실습이므로 수강생은 컨테이너 안에서 root 여야 합니다. useradd 도 해 보고 chmod 도 해 보고 파일시스템도 헤집어 봐야 배웁니다. 그건 협상할 수 없습니다.

그런데 그 root 가 컨테이너 밖으로 나가면 곤란합니다. 이 클러스터는 제 집 네트워크에 있고, 같은 네트워크에 공유기와 NAS 와 레지스트리와 다른 노드들이 있습니다.

처음 만들었을 때 실습 파드의 파드 스펙은 이랬습니다.

spec:
  containers:
    - name: lab
      image: registry.internal/labhub/lab-linux:v1
      command: ["sleep", "infinity"]
      resources:
        requests: { cpu: 100m, memory: 256Mi }
        limits:   { cpu: "1",  memory: 1Gi }

securityContext 가 없습니다. 네트워크 정책도 없었습니다. 실제로 뚫리는지 확인해 봤습니다.

# 실습 파드 안에서
cat < /dev/null > /dev/tcp/192.168.219.1/80    # 공유기 관리 페이지
cat < /dev/null > /dev/tcp/10.96.0.1/443       # 쿠버네티스 API 서버

둘 다 열렸습니다. 게다가 automountServiceAccountToken 을 끄지 않았으므로 기본 서비스어카운트 토큰이 파드 안에 마운트돼 있었습니다. 수강생이 마음만 먹으면 클러스터 API 를 호출할 수 있다는 뜻입니다.

1층 — 네트워크를 실습별로 연다

Cilium 은 IP 가 아니라 아이덴티티로 정책을 판단합니다. 그래서 toEndpoints 를 kube-dns 하나만 열면 클러스터 안 다른 파드와 전부 단절되고, toEntities 를 하나도 안 열면 노드와 API 서버가 단절됩니다. toCIDRSet 은 클러스터 밖 목적지에만 적용되므로 거기서 사설망을 도려내면 됩니다.

처음에는 모든 실습에 인터넷 80/443 을 열어 줬습니다. 그런데 생각해 보니 리눅스·Git·데이터베이스 실습은 필요한 게 전부 이미지 안에 있어서 인터넷이 한 바이트도 필요 없습니다. 열어 둘 이유가 없는 문은 닫는 편이 낫습니다.

그래서 프로파일을 셋으로 나눴습니다.

프로파일개방 범위쓰는 실습
기본 (라벨 없음)DNS 만리눅스·Git·FDE·DB·쿠버네티스
net=internet+ 공인 인터넷 80/443apt·pip 가 필요한 실습
net=registry+ 레지스트리 도메인만컨테이너 실습

기본값이 가장 좁습니다. 파드에 라벨이 없으면 자동으로 DNS 만 열립니다.

apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
  name: lab-egress-base
spec:
  endpointSelector:
    matchLabels:
      app: lab-session
  egress:
    # 이름 해석만. L7 DNS 파서를 물려 두면 누가 어떤 도메인을 물었는지 Hubble 에 남는다.
    - toEndpoints:
        - matchLabels:
            io.kubernetes.pod.namespace: kube-system
            k8s-app: kube-dns
      toPorts:
        - ports: [{ port: "53", protocol: ANY }]
          rules:
            dns: [{ matchPattern: "*" }]

인터넷을 여는 프로파일에서는 사설 대역을 전부 도려냅니다.

    - toCIDRSet:
        - cidr: 0.0.0.0/0
          except:
            - 10.0.0.0/8          # 사설 A + 파드 CIDR + 서비스 CIDR
            - 172.16.0.0/12       # 사설 B
            - 192.168.0.0/16      # 집 네트워크·공유기·NAS
            - 169.254.0.0/16      # 링크로컬 + 클라우드 메타데이터
            - 100.64.0.0/10       # 통신사 CGNAT
      toPorts:
        - ports:
            - { port: "80",  protocol: TCP }
            - { port: "443", protocol: TCP }

포트를 80/443 으로 묶은 것도 의도적입니다. aptpipgit clone 은 되지만 아웃바운드 SSH 브루트포스나 SMTP 스팸이나 포트스캔은 안 됩니다. 실습 환경이 남을 공격하는 발판이 되면 곤란하니까요.

인그레스는 전면 차단했습니다. 브라우저 터미널은 API 서버와 kubelet 을 거치는 exec 경로라 파드 네트워크를 쓰지 않기 때문에 아무것도 열 필요가 없습니다. 유일한 예외는 웹 프리뷰입니다. 수강생이 실습 파드 안에서 띄운 앱을 브라우저로 보려면 백엔드가 그 포트로 들어가야 하므로, 백엔드 파드에서 오는 지정 포트만 허용합니다. 이 규칙 덕분에 수강생끼리 서로의 파드를 공격하는 것도 함께 막힙니다.

적용한 뒤 다시 두드려 봤습니다.

공유기 192.168.219.1:80차단 (타임아웃)
NAS 192.168.219.109:5001   → 차단
레지스트리 :80              → 차단
쿠버네티스 API 10.96.0.1:443 → 차단
옆 실습 파드                → 차단
DNS                        → 허용

차단이 거부(Connection refused)가 아니라 타임아웃이라는 점이 중요합니다. 패킷이 조용히 버려지고 있다는 뜻이고, 이게 방화벽이 제대로 동작할 때의 모습입니다.

2층 — 커널 권한을 실습별로 준다

네트워크를 막아도 컨테이너 안의 root 는 여전히 root 입니다. 여기서 리눅스 케이퍼빌리티가 등장합니다.

처음엔 모든 실습에 같은 아홉 개를 줬습니다. 리눅스 실습이 useradd 때문에 SETUID 가 필요하다고 해서 쿠버네티스 실습까지 같은 권한을 받을 이유는 없는데도요. 그래서 기본값을 "아무것도 없음"으로 바꾸고 실습이 실제로 요구하는 만큼만 얹도록 프로파일로 나눴습니다.

CAP_PROFILES = {
    # 기본값. 케이퍼빌리티가 하나도 없다.
    # 쿠버네티스·DB·FDE·Git 실습은 전부 이걸로 충분하다 — 자기 소유 파일을
    # 만들고 프로세스를 띄우는 데는 특별한 권한이 필요 없다.
    "none": [],
    # 남의 소유 파일을 다루는 실습
    "files": ["CHOWN", "DAC_OVERRIDE", "FOWNER", "FSETID"],
    # 사용자를 만들고 전환하는 실습
    "users": ["CHOWN", "DAC_OVERRIDE", "FOWNER", "FSETID",
              "SETUID", "SETGID", "KILL"],
    # rootless 컨테이너를 돌리는 실습
    "container": ["CHOWN", "DAC_OVERRIDE", "FOWNER", "FSETID", "KILL",
                  "SETUID", "SETGID", "SETPCAP", "SYS_CHROOT"],
}

어떤 프로파일에도 들어가지 않는 것들이 있습니다.

그리고 allowPrivilegeEscalation 을 끕니다. 이게 켜져 있으면 위에서 아무리 케이퍼빌리티를 깎아도 setuid 바이너리 하나로 되돌아옵니다. 이 설정은 no_new_privs 를 걸어 setuid 자체를 무력화합니다.

부수 효과가 하나 있습니다. sudo 가 동작하지 않습니다. 그런데 컨테이너 안에서 이미 root 이므로 실습에는 지장이 없고, visudo -c 같은 문법 검사는 그대로 되므로 자격증 실습에도 문제가 없습니다.

3층 — 자격증명을 주지 않는다

serviceAccountName: lab-nobody      # RoleBinding 이 하나도 없는 계정
automountServiceAccountToken: false # 토큰 자체를 마운트하지 않는다
enableServiceLinks: false           # 다른 서비스 주소를 환경변수로 흘리지 않는다

마지막 줄이 잘 잊히는 부분입니다. 쿠버네티스는 기본적으로 같은 네임스페이스의 모든 서비스 주소를 환경변수로 주입합니다. 실습 파드에는 아무 쓸모가 없으면서 내부 지형만 알려 주는 셈입니다.

그런데 쿠버네티스 실습은 어떻게 하지

여기서 막혔습니다. 쿠버네티스를 가르치려면 수강생에게 클러스터를 줘야 하는데, kind 는 노드를 컨테이너로 띄우고 k3s 는 kubelet 을 돌려야 해서 둘 다 특권이 필요합니다. 특권을 주는 순간 지금까지 쌓은 세 층이 전부 무의미해집니다. 특권 컨테이너에서 탈출하는 건 어렵지 않고, 탈출하면 집 네트워크입니다.

답은 kwok 이었습니다.

kwok 은 etcd 와 API 서버와 컨트롤러 매니저와 스케줄러를 평범한 프로세스로 띄웁니다. 그리고 kwok 컨트롤러가 가짜 노드를 흉내 내서 파드를 Running 까지 보냅니다. 진짜 컨테이너 런타임이 없으므로 특권이 한 톨도 필요 없습니다.

kwokctl create cluster --name lab --runtime binary --wait 180s

--runtime binary 가 핵심입니다. kwokctl 의 기본은 docker 나 podman 으로 컴포넌트를 컨테이너로 띄우는 방식인데, 이 파드에는 컨테이너 런타임이 없습니다. binary 런타임은 바이너리를 그냥 프로세스로 실행합니다.

케이퍼빌리티를 전부 제거한 파드에서 실제로 돌려 봤습니다.

노드 3Ready
CRD 39개 등록
기동 시간 11.4

이 환경에서 되는 것과 안 되는 것을 정직하게 구분해야 합니다.

되는 것 — 모든 리소스의 CRUD, 디플로이먼트 스케일, 롤아웃과 되돌리기, 스케줄링(노드셀렉터·어피니티·테인트·드레인), RBAC 검증, ResourceQuota 와 NetworkPolicy 오브젝트, kubectl explain, kustomize, helm, etcd 스냅샷 저장과 복구, 서드파티 CRD 적용.

안 되는 것 — 워크로드 파드에 kubectl exec, 진짜 로그 출력, 포트포워드, 실제 CNI 트래픽, 볼륨에 담긴 데이터. 진짜 컨테이너가 없으니 당연합니다.

공교롭게도 CKA 와 CKAD 시험이 지필로 묻는 영역은 대부분 앞쪽입니다. 뒤쪽은 실습 대신 이론과 퀴즈로 다뤘습니다.

밟은 함정들

레디니스 프로브를 업무 API 에 걸었다

배포가 멈췄습니다. 새 파드는 계속 뜨는데 Ready 가 되지 않고, 롤아웃이 타임아웃 나고, 파이프라인이 실패합니다. 로그에는 아무 오류도 없습니다.

원인은 프로브였습니다.

readinessProbe:
  httpGet: { path: /api/courses, port: 8000 }   # 업무 API

리팩터링하면서 /api/courses/api/paths 로 바꿨습니다. 프로브는 404 를 받았고, 파드는 영영 Ready 가 되지 못했습니다. 애플리케이션은 멀쩡히 동작하고 있는데도요.

교훈은 간단합니다. 프로브는 전용 엔드포인트로만 걸어야 합니다.

startupProbe:                       # 첫 기동은 넉넉하게
  httpGet: { path: /healthz, port: 8000 }
  periodSeconds: 5
  failureThreshold: 36              # 최대 3분
readinessProbe:
  httpGet: { path: /healthz, port: 8000 }
livenessProbe:
  httpGet: { path: /healthz, port: 8000 }
  failureThreshold: 5

/healthz 는 프로세스만 확인하고 /readyz 는 DB 연결까지 확인합니다. 라이브니스에 DB 검사를 걸지 않은 이유는, DB 가 잠깐 흔들렸다고 애플리케이션을 재시작하면 상황이 더 나빠지기 때문입니다.

이 사고에는 그나마 좋은 면이 있었습니다. 운영계가 옛 버전을 유지했다는 것. 파이프라인이 제 역할을 한 셈입니다.

움직이는 태그와 imagePullPolicy

실습 이미지를 다시 구웠는데 반영이 안 됐습니다. 몇 번을 다시 빌드해도 파드 안의 파일은 예전 그대로였습니다. 빌드 로그에는 "Pushed" 가 찍혀 있었습니다.

쿠버네티스의 기본 imagePullPolicy 는 태그가 latest 일 때만 Always 이고, 그 외에는 IfNotPresent 입니다. 저는 v2 라는 움직이는 태그를 쓰고 있었으므로, 노드가 한 번 받은 이미지를 영원히 재사용했습니다. 아무 오류도 남지 않습니다.

image: registry.internal/labhub/lab-k8s:v2
imagePullPolicy: Always     # 움직이는 태그에는 반드시

정공법은 불변 태그를 쓰는 것입니다. 다만 커리큘럼이 계속 늘어나 이미지를 자주 다시 굽는 상황이라, 레지스트리가 같은 네트워크에 있는 점을 고려해 Always 로 갔습니다.

kwok 이 캐시를 무시했다

실습 파드는 DNS 말고 아무 데도 못 나갑니다. 그런데 kwokctl 은 기동할 때 컴포넌트 바이너리를 인터넷에서 받습니다. 캐시가 비어 있으면 클러스터 생성이 그냥 실패합니다.

그래서 이미지 빌드 때 미리 받아 뒀는데, 소용이 없었습니다.

{"level":"INFO","msg":"Download","uri":"https://github.com/etcd-io/etcd/.../etcd-v3.5.11-linux-amd64.tar.gz"}
{"level":"ERROR","msg":"Failed to setup config","err":"... i/o timeout"}

kwok 은 자기가 정한 기본 버전을 쓰고(제가 받아 둔 건 다른 버전이었습니다), 캐시 경로도 URL 구조를 그대로 미러링합니다.

/root/.kwok/cache/https/dl.k8s.io/release/v1.30.4/bin/linux/amd64/kube-apiserver

버전이든 경로든 하나만 어긋나면 캐시를 못 찾고 다시 받습니다. URL 을 추측하는 대신, 빌드 중에 클러스터를 한 번 만들었다 지우는 쪽으로 바꿨습니다.

RUN kwokctl create cluster --name warmup --runtime binary --wait 180s && \
    kwokctl delete cluster --name warmup && \
    rm -rf /root/.kwok/clusters

kwok 이 필요한 것을 자기 방식으로 받아 자기 경로에 넣으므로 어긋날 여지가 없습니다. 클러스터 상태(PKI 와 etcd 데이터)는 지우고 캐시만 남깁니다. 기동 시간이 1분 52초에서 11.4초로 줄었습니다.

파드가 죽은 세션이 자리를 영원히 차지했다

수강생 한 명이 동시에 열 수 있는 실습을 하나로 제한했습니다. 창을 닫고 잊어버리는 순간 파드가 낭비되기 때문입니다.

그런데 데이터베이스가 "실행 중"이라고 해서 파드가 살아 있는 건 아닙니다. 노드가 재시작했거나 OOM 으로 죽었거나 누가 손으로 지우면, 파드는 없고 기록만 남습니다. 그러면 그 수강생은 새 실습을 영영 열 수 없습니다.

세션을 재사용하기 전에 파드 생존을 확인하고, 죽었으면 기록을 마감한 뒤 새로 만들도록 고쳤습니다. 회수기에도 방향을 하나 더했습니다.

셋째가 이번에 추가한 것입니다. 다만 만든 지 1분 이내는 제외해야 합니다. 방금 만든 파드가 아직 API 에 안 보일 수 있으니까요.

자원이 새지 않게 하는 세 겹

실습 파드는 비싼 자원입니다. 수강생이 창을 그냥 닫아도 자원이 남지 않아야 합니다.

  1. 세션마다 만료 시각을 못 박습니다. 만들 때 결정되고 화면에 남은 시간이 표시됩니다. 5분 남으면 알림이 뜨고, 연장은 가능하지만 총 3배까지만입니다.
  2. 1인 1세션입니다. 새 실습을 열려면 기존 것을 먼저 끝내야 합니다. 열려 있는 실습이 무엇인지 알려 주고 바로 이동하거나 종료할 수 있게 합니다.
  3. 60초마다 회수기가 돕니다. 위의 세 방향으로 정리하고, 운영 화면이 데이터베이스와 실제 파드의 일치 여부를 상시 보여 줍니다.

네임스페이스에는 ResourceQuota 와 LimitRange 를 걸어 두었습니다. 파드 수와 CPU·메모리·임시 저장소 총량에 상한이 있고, 실습 파드가 서비스나 PVC 를 만들 수 없게 count/services: "0" 도 넣었습니다. 임시 저장소 상한이 특히 중요한데, 이게 없으면 디스크를 채워 노드를 죽일 수 있습니다.

보안 회귀를 테스트로 묶는다

이 격리는 여러 파일에 흩어져 있습니다. 파드 스펙, 네트워크 정책, 네임스페이스 라벨. 그중 하나만 되돌아가도 격리가 뚫리는데, 뚫린 상태로도 서비스는 멀쩡히 돌아갑니다. 그래서 테스트가 없으면 아무도 모릅니다.

푸시할 때마다 도는 검사를 만들었습니다.

✅ 기본 프로파일이 비어 있다(권한 0)
NET_RAW 을 어느 프로파일에도 주지 않는다
SYS_ADMIN 을 어느 프로파일에도 주지 않는다
✅ 권한 상승을 막는다
SA 토큰을 마운트하지 않는다
✅ 인터넷 프로파일이 192.168.0.0/16 를 제외한다
✅ 인그레스를 차단한다
✅ 인그레스 예외가 백엔드로만 한정된다
PSA 가 privileged 로 낮아지지 않았다
✅ 소스에 API 키가 하드코딩돼 있지 않다
...
통과 — 38개 항목 모두 정상

한 가지 배운 게 있습니다. 처음 만든 검사는 소스에서 NET_RAW 라는 문자열을 찾았는데, 주석에 "NET_RAW 를 주지 않는다"라고 써 놓은 것 때문에 오탐이 났습니다. 지금은 파이썬 AST 로 실제 프로파일 딕셔너리를 파싱해서 값을 봅니다. 검사기가 검사하려는 대상을 정확히 겨눠야 한다는 당연한 이야기인데, 실제로 겪기 전엔 잘 안 보입니다.

배포는 git 으로

처음엔 CI 가 kubectl set image 로 클러스터를 직접 밀었습니다. 그러면 지금 무엇이 떠 있는지 알려면 클러스터에 물어봐야 하고, 롤백은 예전 태그를 기억하는 사람에게 달려 있고, 누가 손으로 고친 것과 배포된 것이 구분되지 않습니다.

지금은 저장소의 gitops/ 가 진실입니다.

gitops/
  base/    환경 공통 — 이미지 태그도 네임스페이스도 없다
  dev/     namespace, 세션 TTL, LoadBalancer 노출
  prod/    namespace, replicas 2, SSO 시크릿, 공개 URL

이미지 태그는 오버레이의 images.newTag 한 줄입니다. CI 는 그 줄만 고쳐 커밋하고, ArgoCD 가 알아서 동기화합니다. 롤백은 그 줄을 되돌리는 커밋 하나입니다. kubectl 로 손대면 selfHeal 이 되돌리므로 드리프트가 남지 않습니다.

한 가지 함정이 있었습니다. 기준 매니페스트에 Service 를 ClusterIP 로만 정의했더니, ArgoCD 가 개발계의 LoadBalancer 를 ClusterIP 로 바꿔 버려서 접속이 끊겼습니다. GitOps 로 옮길 때는 클러스터에 이미 있는 것을 먼저 정확히 옮겨 적어야 합니다. 옮겨 적지 않은 것은 사라진다고 생각하는 편이 안전합니다.

지금 상태

실습 파드가 뜨는 데 걸리는 시간을 재 봤습니다.

리눅스 실습    세션 생성 15초 → 바로 사용 가능
쿠버네티스     세션 생성 6초 → 환경 준비 15 (kwok 클러스터 기동)

쿠버네티스 실습은 파드가 Running 이 된 뒤에도 클러스터가 뜨는 시간이 더 필요합니다. 이걸 화면에 알리지 않으면 수강생이 그 사이에 채점을 누르고 자기가 틀린 줄 압니다. 그래서 준비 스크립트가 끝나면서 표식 파일을 남기고, 백엔드가 그걸 읽어 준비 중인지 알려 주고, 화면은 준비될 때까지 채점 버튼을 잠급니다.

한계와 남은 것

정직하게 적어 둘 것들이 있습니다.

컨테이너 실습은 rootless podman 으로 진짜 컨테이너를 돌리지만, 스토리지 드라이버가 vfs 라 느립니다. overlayfs 는 마운트 권한이 필요하고 fuse-overlayfs 는 장치 파일이 필요한데 둘 다 줄 수 없습니다. 실습용 작은 이미지에서는 체감 차이가 크지 않지만, 큰 이미지를 다루는 실습은 만들기 어렵습니다.

가상화 실습은 QEMU 를 소프트웨어 에뮬레이션으로 돌립니다. /dev/kvm 이 없으니 선택지가 없습니다. 작은 알파인 VM 은 뜨지만 느립니다. KVM 이 왜 빠른지는 이론으로 다루고, 실습은 디스크 이미지 조작과 스냅샷 쪽으로 설계했습니다.

tcpdump 는 동작하지 않습니다. NET_RAW 를 주지 않기 때문입니다. 패킷 캡처를 가르치려면 다른 방법을 찾아야 하는데, 아직 좋은 답이 없어서 이론과 퀴즈로 다루고 있습니다. 네트워크 진단 실습은 ssdiggetent/proc/net 으로 설계했습니다.

정리

집에 있는 서버로 실습 환경을 만들 때 가장 큰 제약은 성능이 아니라 격리였습니다. 그리고 그 제약이 오히려 설계를 좋게 만들었습니다. 특권을 줄 수 없으니 kwok 을 찾게 됐고, 결과적으로 클러스터가 11초에 뜨는 환경이 됐습니다. 인터넷을 열 수 없으니 필요한 것을 전부 이미지에 굽게 됐고, 결과적으로 오프라인에서도 완전히 동작합니다.

권한을 넓히는 쪽으로 문제를 푸는 습관이 있다면, 한 번쯤 반대로 가 보는 것도 나쁘지 않습니다.

댓글

아직 댓글이 없습니다.

로그인하면 댓글을 쓸 수 있습니다