Skip to content
목록으로

쿠버네티스는 왜 나왔고 무엇을 하는가

Kubernetes2025년 5월 4일
쿠버네티스는 왜 나왔고 무엇을 하는가

이번 포스팅부터는 쿠버네티스(Kubernetes) 를 처음부터 정리해보려 합니다. 쿠버네티스는 이름은 익숙한데 막상 설명하려 하면 막히는 기술이었습니다. Spark 를 쿠버네티스 위에 올린다는 말은 자주 들었지만 그 아래에서 무슨 일이 벌어지는지는 설명할 수 없는 상태였습니다.

그래서 이번 시리즈에서는 왜 쿠버네티스가 나왔는지부터 시작해, 실제 운영에서 마주치는 지점까지 차례로 정리해보려 합니다. 이번 편은 그 첫 번째로, 등장 배경과 쿠버네티스를 관통하는 핵심 사고방식을 다룹니다.

첫 편이 개념 이야기라 다소 추상적일 수 있는데 뒤에 나올 모든 것이 여기서 나오는 선언형(Declarative) 과 컨트롤 루프(Control Loop) 두 가지로 설명됩니다. 이 둘을 잡아두면 나머지는 그 위에 얹히는 이야기입니다.


1. 컨테이너까지 오는 길


1.1. 물리 서버에서 가상 머신으로

처음에는 애플리케이션 하나에 서버 한 대를 썼습니다. 이 방식에는 두 가지 문제가 있었습니다. 서버 자원의 대부분이 놀고 있는데도 다른 일에 쓸 수 없었고 새 애플리케이션을 올리려면 서버를 새로 들여와야 했습니다. 그래서 가상 머신(VM) 이 나왔습니다. 하이퍼바이저가 물리 서버 하나를 여러 대의 가상 서버로 쪼개니, 한 대에 여러 애플리케이션을 격리해 올릴 수 있게 되었습니다.

다만 VM 은 게스트 OS 를 통째로 들고 있습니다. 애플리케이션 하나를 띄우자고 커널부터 시스템 데몬까지 한 벌을 더 올리는 셈이라, 부팅에 수십 초가 걸리고 이미지도 기가바이트 단위입니다.

1.2. 컨테이너

컨테이너는 여기서 한 발 더 들어갑니다. OS 커널은 호스트 것을 함께 쓰고 프로세스가 보는 세계만 격리합니다. 이것을 가능하게 하는 것이 리눅스 커널의 두 기능입니다.

  1. 네임스페이스(Namespace) : 프로세스가 볼 수 있는 범위를 갈라놓습니다. PID 네임스페이스를 나누면 컨테이너 안에서는 자기 프로세스만 보이고 네트워크 네임스페이스를 나누면 자기만의 네트워크 인터페이스와 라우팅 테이블을 갖습니다. "무엇이 보이는가" 를 통제합니다.
  2. cgroups(Control Groups) : 프로세스가 쓸 수 있는 자원의 양을 제한합니다. CPU 를 몇 퍼센트까지, 메모리를 몇 기가까지 쓸 수 있는지를 정합니다. "얼마나 쓸 수 있는가" 를 통제합니다.

컨테이너는 결국 이 두 가지가 걸린 그냥 프로세스입니다. VM 처럼 별도의 머신이 아니라, 호스트에서 ps 로 보면 그대로 보이는 프로세스입니다. 그래서 시작이 밀리초 단위이고 이미지도 수십 메가로 끝납니다.

여기에 Docker 가 이미지 라는 포장 방식을 더했습니다. 애플리케이션과 그 실행에 필요한 라이브러리, 설정을 한 덩어리로 묶어 어디서든 똑같이 실행되게 만든 것입니다. "내 노트북에서는 되는데요" 라는 말이 줄어든 것이 이 지점의 가장 큰 성과입니다.

1.3. 그런데 컨테이너가 많아지면

컨테이너 하나를 띄우는 것은 쉽습니다. 문제는 그것이 수백, 수천 개가 되었을 때입니다. 실제로 이런 질문들이 따라옵니다.

  1. 이 컨테이너를 어느 서버에 띄울 것인가? 그 서버에 자원이 남아 있는가?
  2. 컨테이너가 죽으면 누가 다시 띄우는가? 서버 한 대가 통째로 죽으면?
  3. 컨테이너의 IP 는 재시작할 때마다 바뀌는데, 다른 컨테이너는 어떻게 찾아가는가?
  4. 새 버전을 배포할 때 서비스를 끊지 않고 하나씩 교체하려면?
  5. 트래픽이 몰릴 때 자동으로 개수를 늘리려면?

이 질문들에 답하는 것이 컨테이너 오케스트레이션(Container Orchestration) 이고 그 자리에서 사실상 표준이 된 것이 쿠버네티스입니다.


2. 쿠버네티스의 뿌리


쿠버네티스는 구글이 2014년에 공개했습니다. 갑자기 나온 것은 아니고 구글이 십수 년간 내부에서 운영하던 Borg 라는 클러스터 관리 시스템의 경험을 바탕으로 만들어졌습니다.

Borg 논문에서 쿠버네티스로 이어진 개념들이 꽤 많습니다.

  1. 여러 컨테이너를 하나로 묶어 배치한다 : 쿠버네티스의 Pod 에 해당합니다.
  2. 라벨로 대상을 고른다 : 쿠버네티스의 Label 과 Selector 입니다.
  3. 원하는 상태를 선언하면 시스템이 맞춘다 : 쿠버네티스 전체를 관통하는 방식입니다.

이름도 여기서 왔습니다. 쿠버네티스는 그리스어로 키잡이, 조타수를 뜻합니다. 흔히 쓰는 k8s 라는 줄임말은 K 와 s 사이에 여덟 글자가 있다는 뜻입니다. 2015년에는 구글이 쿠버네티스를 CNCF(Cloud Native Computing Foundation) 에 기부했습니다. 특정 회사의 제품이 아니라 재단이 관리하는 프로젝트가 되었다는 점이, 클라우드 제공사들이 다 함께 채택하게 된 배경이기도 합니다.


3. 선언형이라는 사고방식


쿠버네티스를 이해하는 데 가장 중요한 개념입니다. 여기를 넘어가면 나머지가 다 쉬워지고 여기가 흐릿하면 계속 헷갈립니다.

3.1. 명령형과 선언형

두 방식의 차이를 예로 봅시다. "웹 서버를 3대 띄우고 싶다" 는 요구가 있다고 합시다. 명령형(Imperative) 은 절차를 지시합니다.

bash
서버에 접속한다
컨테이너를 띄운다
또 띄운다
또 띄운다

선언형(Declarative) 은 결과를 기술합니다.

yaml
replicas: 3

차이는 단순히 문법이 아닙니다. 한 대가 죽었을 때 무슨 일이 벌어지는지가 다릅니다. 명령형에서는 누군가 죽은 것을 알아채고 다시 띄우는 명령을 내려야 합니다. 선언형에서는 시스템이 "3대여야 하는데 2대네" 를 스스로 발견하고 한 대를 더 띄웁니다. 사람이 개입하지 않습니다.

3.2. 원하는 상태와 현재 상태

쿠버네티스는 모든 것을 이 두 가지로 봅니다.

  1. Desired State(원하는 상태) : 사용자가 YAML 로 적어 제출한 것입니다. "이 이미지로 3개" 같은 것입니다.
  2. Current State(현재 상태) : 실제 클러스터에서 지금 벌어지고 있는 일입니다. "2개가 떠 있고 1개는 시작 중" 같은 것입니다.

그리고 쿠버네티스가 하는 일은 딱 하나입니다. 이 둘의 차이를 계속 줄이는 것입니다. 이 과정을 Reconciliation(조정) 이라고 부릅니다.

3.3. 컨트롤 루프

조정을 수행하는 주체가 컨트롤러(Controller) 이고 컨트롤러는 아래 루프를 끝없이 돕니다.

반복 {
  현재 상태를 관찰한다
  원하는 상태와 비교한다
  차이가 있으면 좁히는 쪽으로 조치한다
}

이 루프는 에어컨의 온도 조절과 같은 구조입니다. 26도로 맞춰두면(원하는 상태) 에어컨은 현재 온도를 계속 재면서(관찰) 그보다 높으면 냉방을 돌립니다(조치). 사람이 "지금 껐다 켜라" 를 지시하지 않습니다.

여기서 중요한 성질 두 가지가 나옵니다.

  1. 멱등성(Idempotency) : 같은 YAML 을 몇 번을 적용해도 결과가 같습니다. 컨트롤러는 "명령을 실행" 하는 것이 아니라 "상태를 맞추는" 것이기 때문입니다.
  2. 자기 치유(Self-healing) : 누군가 Pod 를 지워도, 노드가 죽어도, 컨트롤러가 차이를 발견하고 되돌립니다. 장애 복구가 별도의 기능이 아니라 평상시 동작과 같은 동작입니다.

3.4. 레벨 트리거와 에지 트리거

면접에서 한 단계 더 들어가면 나오는 이야기입니다.

  1. 에지 트리거(Edge-triggered) : "이벤트가 발생했다" 는 신호에 반응합니다. 신호를 한 번 놓치면 그 일은 영영 처리되지 않습니다.
  2. 레벨 트리거(Level-triggered) : "지금 상태가 이렇다" 를 보고 반응합니다. 신호를 놓쳐도 다음 주기에 상태를 다시 보기 때문에 결국 맞춰집니다.

쿠버네티스 컨트롤러는 레벨 트리거 방식입니다. 이벤트를 구독해서 쓰기는 하지만 그것은 어디까지나 빨리 알아채기 위한 힌트이고 판단은 항상 "지금 실제 상태" 를 기준으로 합니다. 그래서 컨트롤러가 잠시 죽었다 살아나도, 네트워크가 끊겼다 붙어도 결국 원하는 상태로 수렴합니다.

분산 시스템에서 메시지는 유실되고 중복되고 순서가 바뀝니다. 그 전제 위에서 안정적으로 동작하기 위해 고른 설계입니다.


4. 쿠버네티스를 이루는 것들


뒤 편에서 하나씩 자세히 다루겠지만 전체 그림을 먼저 잡아두면 읽기가 수월합니다.

4.1. 모든 것은 오브젝트다

쿠버네티스에서 다루는 것은 전부 오브젝트(Object) 라는 형태의 데이터입니다. Pod 도, 서비스도, 설정값도, 접근 권한도 전부 같은 모양을 하고 있습니다.

yaml
apiVersion: apps/v1      # 어떤 API 그룹과 버전인가
kind: Deployment         # 무엇인가
metadata:                # 이름, 네임스페이스, 라벨
  name: web
spec:                    # 원하는 상태, 사용자가 적는다
  replicas: 3
status:                  # 현재 상태, 시스템이 채운다
  readyReplicas: 2

여기서 spec 과 status 의 구분이 핵심입니다. spec 은 사람이 쓰고 시스템이 읽습니다. status 는 시스템이 쓰고 사람이 읽습니다. 3.2 에서 본 원하는 상태와 현재 상태가 그대로 한 오브젝트 안에 들어 있는 것입니다.

4.2. 자주 쓰는 오브젝트들

종류 하는 일 다루는 편
Pod 컨테이너를 담는 가장 작은 배포 단위 3편
Deployment Pod 를 원하는 개수만큼 유지하고 새 버전으로 교체 3편
StatefulSet 상태를 가진 애플리케이션을 순서와 고정 이름으로 관리 3편
DaemonSet 모든 노드에 하나씩 Pod 를 배치 3편
Job · CronJob 끝나는 작업, 주기적으로 도는 작업 3편
Service 바뀌는 Pod 들 앞에 고정된 접근 지점을 제공 4편
Ingress 클러스터 바깥에서 들어오는 HTTP 트래픽을 라우팅 4편
PV · PVC 저장 공간을 요청하고 연결 5편
ConfigMap · Secret 설정값과 민감 정보를 컨테이너에 주입 7편
Namespace 오브젝트를 논리적으로 갈라 담는 공간 7편

4.3. 라벨과 셀렉터

쿠버네티스는 오브젝트끼리 직접 참조하지 않습니다. Deployment 가 "이 Pod 세 개" 를 이름으로 들고 있는 것이 아닙니다. 대신 라벨(Label) 을 붙이고 셀렉터(Selector) 로 고릅니다.

yaml
# Pod 쪽
metadata:
  labels:
    app: web
    tier: frontend

# 이 Pod 들을 고르는 쪽
selector:
  matchLabels:
    app: web

이렇게 느슨하게 묶어두면 여러 가지가 편해집니다. Pod 가 죽고 새로 떠서 이름과 IP 가 바뀌어도 라벨만 같으면 계속 선택됩니다. 결합을 느슨하게 만드는 장치라고 보면 됩니다.

4.4. 네임스페이스

하나의 클러스터를 논리적으로 나누는 단위입니다. 팀별로, 환경별로 오브젝트를 갈라 담는 데 씁니다.

주의할 것은 네임스페이스가 보안 경계는 아니라는 점입니다. 이름 충돌을 막고 권한과 자원 할당량을 걸 수 있는 단위일 뿐, 기본 설정에서는 다른 네임스페이스의 Pod 와 네트워크로 통신이 됩니다. 막으려면 NetworkPolicy 를 따로 걸어야 합니다. 이 부분은 4편과 7편에서 다루겠습니다.

5. 조금 더 들어가보기 : 커널은 컨테이너를 어떻게 만들어내는가


앞에서 컨테이너를 "네임스페이스와 cgroups 가 걸린 프로세스" 라고 정리했는데 그 두 가지가 커널에서 실제로 어떻게 동작하는지까지 내려가 봅시다. 쿠버네티스를 쓰다 보면 결국 이 층에서 원인을 찾아야 하는 순간이 오기 때문입니다.

5.1. 네임스페이스는 일곱 가지다

리눅스 커널이 나누는 네임스페이스는 종류별로 따로 존재합니다. 프로세스마다 어느 네임스페이스에 속해 있는지가 정해집니다.

  1. mnt : 마운트된 파일 시스템 목록. 컨테이너가 자기만의 루트를 갖는 근거입니다.
  2. pid : 프로세스 ID 공간. 컨테이너 안의 PID 1 은 호스트에서는 다른 번호입니다.
  3. net : 네트워크 인터페이스, 라우팅 테이블, 방화벽 규칙, 포트 공간.
  4. ipc : 공유 메모리나 세마포어 같은 프로세스 간 통신 수단.
  5. uts : 호스트 이름과 도메인 이름.
  6. user : 사용자와 그룹 ID 의 매핑.
  7. cgroup : cgroup 계층에서 보이는 루트 위치.

프로세스가 이 중 무엇에 속해 있는지는 /proc/<pid>/ns/ 아래의 심볼릭 링크로 드러납니다. 링크가 가리키는 값이 같으면 같은 네임스페이스를 공유하는 것입니다.

$ ls -l /proc/1234/ns/
net -> 'net:[4026532567]'
pid -> 'pid:[4026532568]'

Pod 안의 컨테이너들이 네트워크를 공유한다는 말의 실체가 이것입니다. 두 컨테이너 프로세스의 ns/net 링크가 같은 번호를 가리킵니다. 그래서 localhost 로 서로를 부를 수 있고 같은 포트를 두 번 열 수 없습니다.

앞에서 본 pause 컨테이너도 여기서 설명됩니다. pause 가 네트워크 네임스페이스를 만들어 붙들고 있고 나머지 컨테이너는 새로 만드는 대신 그 네임스페이스에 합류합니다. 그래서 본 컨테이너가 재시작되어도 네임스페이스는 그대로 남고 IP 가 유지됩니다.

5.2. cgroup v2 의 실제 인터페이스

cgroups 는 파일 시스템으로 노출됩니다. 값을 파일에 써서 제한을 겁니다. v1 은 자원 종류마다 계층이 따로 있어 복잡했는데 v2 는 하나의 계층으로 통합되었습니다. 요즘 배포판은 대부분 v2 입니다. 컨테이너 하나에 해당하는 디렉토리에 들어가 보면 이런 파일들이 있습니다.

cpu.max        "200000 100000"   → 100ms 마다 200ms 어치, 즉 2코어
cpu.weight     100               → 경합할 때의 상대 비중
memory.max     536870912         → 512MB 상한
memory.high    483183820         → 이 위로는 회수 압력을 강하게 건다
memory.current 412876800         → 지금 쓰고 있는 양

여기서 쿠버네티스의 설정이 어디로 가는지가 보입니다.

  1. limits.cpu 는 cpu.max 가 됩니다.
  2. requests.cpu 는 cpu.weight 가 됩니다. 즉 요청량은 경합이 있을 때의 배분 비율입니다. 노드가 한가하면 요청량보다 더 써도 됩니다.
  3. limits.memory 는 memory.max 가 됩니다.

requests 와 limits 가 성격이 다르다고 한 이유가 여기서 분명해집니다. 하나는 비중이고 하나는 상한입니다.

5.3. 컨테이너의 파일 시스템은 겹쳐진 것이다

이미지가 레이어로 쌓인다고 했는데 그것을 실제로 합쳐 보여주는 것은 overlayfs 라는 커널 파일 시스템입니다.

  1. lowerdir : 이미지의 읽기 전용 레이어들. 여러 개를 겹칠 수 있습니다.
  2. upperdir : 컨테이너가 쓰는 읽기쓰기 레이어.
  3. merged : 이 둘을 합쳐 하나처럼 보여주는 결과. 컨테이너가 보는 파일 시스템입니다.

읽을 때는 위 레이어부터 찾아 내려가고 쓸 때는 아래 레이어의 파일을 위로 통째로 복사한 뒤 고칩니다. 이것을 기록 중 복사(Copy-on-Write)라고 합니다. 여기서 성능 특성이 나옵니다. 큰 파일을 조금만 고쳐도 파일 전체가 복사됩니다. 데이터베이스처럼 큰 파일에 잦은 쓰기가 일어나는 것을 컨테이너 레이어에 두면 안 되는 이유입니다. 볼륨을 붙여 overlayfs 를 우회해야 합니다.

5.4. 컨테이너 안의 root 는 진짜 root 가 아니다

컨테이너 안에서 whoami 를 하면 root 라고 나옵니다. 그런데 호스트를 마음대로 할 수는 없습니다. capability 때문입니다. 리눅스는 예전에 "root 냐 아니냐" 만 구분했는데 지금은 root 의 권한을 수십 개의 조각으로 나눠두었습니다. CAP_NET_BIND_SERVICE(1024 미만 포트 열기), CAP_SYS_ADMIN(거의 모든 것), CAP_NET_RAW(원시 소켓) 같은 것들입니다.

컨테이너 런타임은 기본적으로 이 중 일부만 주고 나머지는 떼어냅니다. 그래서 컨테이너 안의 root 는 "제한된 root" 입니다. privileged: true 가 위험한 이유가 여기서 분명해집니다. 모든 capability 를 되돌려주고 장치 접근까지 열어주므로, 사실상 호스트의 root 와 같아집니다.

여기에 seccomp 가 한 겹 더 있습니다. 프로세스가 호출할 수 있는 시스템 콜 자체를 목록으로 제한합니다. 컨테이너 탈출에 쓰이는 시스템 콜을 미리 막아두는 장치입니다.

5.5. 그래서 컨테이너 격리는 어디까지인가

정리하면 컨테이너는 커널을 호스트와 공유합니다. 네임스페이스로 보이는 것을 가리고, cgroups 로 쓸 수 있는 양을 제한하고, capability 와 seccomp 로 할 수 있는 일을 줄인 것입니다. 그래서 커널 자체의 취약점은 격리를 그대로 통과합니다. 가상 머신과 결정적으로 다른 지점이고 신뢰할 수 없는 코드를 돌려야 할 때 gVisor 나 Kata Containers 처럼 커널을 한 겹 더 두는 방식을 쓰는 이유이기도 합니다.


결론


첫 편을 정리하면서 가장 크게 와닿은 것은, 쿠버네티스가 결국 하나의 아이디어 위에 쌓아 올린 시스템이라는 점이었습니다. "원하는 상태를 적어두면 시스템이 계속 그쪽으로 맞춘다" 는 것 하나입니다. Deployment 도, 오토스케일러도, 인증서를 갱신해주는 오퍼레이터도 전부 같은 루프를 돌고 있을 뿐이었습니다.

처음에는 오브젝트 종류가 수십 개나 되는 것이 부담스러웠는데 spec 과 status 라는 같은 모양을 하고 있다는 것을 알고 나니 훨씬 덜 무서워졌습니다. 새로운 오브젝트를 만나면 "이것의 spec 은 무엇이고, 누가 그 status 를 채우는가" 를 물으면 된다는 감각이 생겼습니다.

레벨 트리거 이야기도 인상 깊었습니다. 분산 시스템에서 메시지가 유실되는 것을 막으려 애쓰는 대신, 유실되어도 결국 맞춰지는 구조를 고른 것이기 때문입니다. 하둡 시리즈에서 공부한 분산 시스템의 전제들이 여기서 다시 나오는 것을 보면서 결국 같은 문제를 다른 방식으로 푸는 것이라는 생각이 들었습니다.


공부 참고 자료


📑 1. Kubernetes 공식 문서 - Overview

📑 2. Kubernetes 공식 문서 - Objects

📑 3. Large-scale cluster management at Google with Borg

📑 4. Borg, Omega, and Kubernetes

📑 5. Linux namespaces - man page

📑 6. Linux cgroups - man page

📑 7. CNCF 공식 사이트

Comments