Kubernetes는 컨테이너를 실행하지 않는다

2014년 즈음, 한 팀이 매일 새벽 서버 50대에 SSH로 접속해 같은 설정을 복사하고 있었다. 컨테이너는 이미 있었다. Docker가 "한 번 빌드하면 어디서든 돌아간다"고 약속했고, 실제로 그랬다. 그런데 팀은 여전히 밤을 새웠다. 컨테이너를 실행하는 일은 해결됐는데, "어디에, 몇 개를, 어떻게 계속 띄워 둘까"를 결정하는 일은 사람이 하고 있었다.

이 글이 푸는 질문은 하나다. Kubernetes는 정확히 이 "결정"의 어느 부분을 가져갔는가? 그리고 왜 그 방식(선언적 API + 조정 루프)이 컨테이너를 더 빨리 띄우는 것보다 훨씬 근본적인 변화였는가?

Kubernetes가 "실행"하지 않는다는 것의 의미

흔한 오해부터 꺼내자. Kubernetes는 컨테이너를 실행하지 않는다. 컨테이너를 실제로 띄우고 격리하는 건 노드에 깔린 컨테이너 런타임(containerd, CRI-O)이다. Kubernetes가 제공하는 건 그 런타임을 지휘하는 컨트롤 플레인(control plane)이다.

  컨테이너 런타임 Kubernetes
하는 일 컨테이너 생성·격리·실행 "무엇을 원하는가"를 받아 계속 그 상태로 맞춤
관심사 단일 노드 안의 프로세스 여러 노드에 걸친 의도된 상태(desired state)
비유 악사 한 명 지휘자 — 직접 연주 않고 악단을 조율

이 구분을 못 하면 Kubernetes를 "컨테이너 실행 도구"로 오해하고, 문제가 생겼을 때 "런타임 로그"만 뒤지다가 진짜 원인(컨트롤 플레인의 결정)을 놓친다. Kubernetes는 의사결정 계층이지 실행 계층이 아니다.

그렇다면 진짜 질문은 이것이다: "의도된 상태로 계속 맞춘다"는 게 구체적으로 어떤 일이며, 왜 그게 혁명인가?

"원하는 상태"를 말하면, "현재 상태"를 떠난다 — 선언적 API

운영자가 예전에 하던 방식은 명령형(imperative)이었다. "서버 A에 컨테이너를 띄워라", "서버 B의 컨테이너를 죽여라". 각 명령은 한 번 실행되면 끝이다. 결과는 운영자의 머릿속(또는 스크립트 로그)에만 남는다.

Kubernetes는 선언적(declarative)으로 뒤바꿨다. "나는 nginx 컨테이너 3개를 원한다"고 한다. 어떻게 3개가 되는지는 Kubernetes가 결정한다.

# Kubernetes 1.36
apiVersion: apps/v1
kind: Deployment
metadata:
  name: nginx
spec:
  replicas: 3
  selector:
    matchLabels:
      app: nginx
  template:
    metadata:
      labels:
        app: nginx
    spec:
      containers:
      - name: nginx
        image: nginx:1.27

이 매니페스트의 핵심은 replicas: 3이라는 한 줄이다. 이것은 명령이 아니라 사실의 선언이다. "3개여야 한다"는 원하는 상태를 기록했을 뿐, "3개로 만들어라"고 지시한 게 아니다.

선언과 명령의 차이는 장애 순간에 드러난다. 노드 하나가 죽어서 컨테이너가 1개 줄었다고 치자.

  • 명령형: 아무도 모른다. 누군가가 알림을 보고, SSH로 접속해, 빠진 만큼 다시 띄워야 한다.
  • 선언형: Kubernetes가 스스로 "지금 2개인데 원하는 건 3개네?"를 감지하고, 살아 있는 노드에 1개를 더 띄운다.
# Kubernetes 1.36, kind 단일 노드
kubectl apply -f - <<'EOF'
apiVersion: apps/v1
kind: Deployment
metadata: {name: nginx}
spec:
  replicas: 3
  selector: {matchLabels: {app: nginx}}
  template:
    metadata: {labels: {app: nginx}}
    spec:
      containers: [{name: nginx, image: nginx:1.27}]
EOF

확인할 것: apply는 "이 상태를 원한다"고 선언하는 동작. 생성·수정·유지를 한 명령어로 처리한다.

deployment.apps/nginx created
kubectl get deploy nginx
NAME    READY   UP-TO-DATE   AVAILABLE   AGE
nginx   3/3     3            3           8s

이 선언적 모델의 뿌리는 Google의 내부 시스템 Borg로 거슬러 올라간다. Kubernetes의 설계자들은 Borg와 그 후속 Omega를 만든 사람들이다. 이 계보는 (Borg, Omega, and Kubernetes, ACM Queue 2016)에 설계자들 자신이 정리했다 — "왜 선언적이어야 하는가"에 대한 1차 근거.

조정 루프 — "현재 상태"를 "원하는 상태"로 끌어당기는 영구 엔진

선언만 하면 끝날 것 같지만, 그렇지 않다. "3개여야 한다"는 말 자체는 아무것도 움직이지 않는다. 이 선언을 읽고 행동하는 무언가가 필요하다. 그것이 컨트롤러(controller)이고, 컨트롤러가 반복하는 일이 조정 루프(reconciliation loop)다.

조정 루프는 끊임없이 한 질문을 되묻는다: *"지금 현재 상태(current state)가 원하는 상태(desired state)와 같은가?"* 다르면, 같아질 때까지 행동한다.

flowchart LR
    A["원하는 상태<br/>replicas: 3"] --> C{"조정 루프<br/>컨트롤러"}
    B["현재 상태<br/>실행 중 2개"] --> C
    C -->|다르다| D["행동: 1개 더 생성"]
    D --> B
    C -->|같다| E["대기 (아무것도 안 함)"]
    E --> C

이 루프의 위대함은 두 가지 속성에서 나온다.

첫째, 멱등성(idempotency). 루프는 몇 번이고 돌아도 결과가 같다. "3개인가?"를 100번 물어도, 3개면 아무 일도 안 한다. 그래서 네트워크가 끊겼다 다시 연결돼도, 컨트롤러가 재시작돼도, 결국 같은 상태로 수렴한다. 명령형 스크립트는 "실행됐는지 안 됐는지"를 추적해야 하지만, 조정 루프는 추적 자체가 필요 없다 — 그냥 다시 물어보면 된다.

둘째, self-healing. 노드가 죽으면 현재 상태가 2개로 줄고, 루프가 이를 감지해 3개로 복구한다. 누군가가 수동으로 Pod를 삭제해도 같다.

# 수동으로 Pod 하나 삭제 — 자가 복구 확인
kubectl delete pod -l app=nginx --kubeconfig= 2>/dev/null || kubectl delete pod -l app=nginx

확인할 것: Pod를 지우면 즉시 새 Pod가 만들어져 replicas가 3으로 유지된다.

pod "nginx-xxxx-yyyy" deleted
# 잠시 후 다시 확인
kubectl get deploy nginx
NAME    READY   UP-TO-DATE   AVAILABLE   AGE
nginx   3/3     3            3           40s

이 동작이 가능한 이유는 replicas: 3이라는 원하는 상태가 시스템에 남아 있고, 그것을 향해 끌어당기는 루프가 영원히 돌기 때문이다. 명령형 세계에서는 "방금 Pod 1개 만들라"고 해놓고, 그것이 죽으면 아무도 다시 만들어주지 않는다.

level-triggered, edge-triggered가 아니다

조정 루프의 동작 방식을 정확히 짚는 용어가 있다. Kubernetes 컨트롤러는 level-triggered(상태 기반)로 동작한다. "지금 3개인가?"라는 상태를 본다. 반대로 edge-triggered(이벤트 기반)는 "방금 1개가 죽었다"는 이벤트에 반응한다.

차이는 이벤트를 놓쳤을 때 드러난다. edge-triggered 방식은 "Pod가 죽었다"는 이벤트를 놓치면 영영 복구를 안 한다. level-triggered는 "3개인가?"를 계속 묻기에, 이벤트를 놓쳐도 다음 루프에서 상태 불일치를 발견하고 고친다. Kubernetes가 장애에 강한 근본 이유는 이 설계 선택에 있다. 이 원칙은 Kubernetes 설계 문서들과 (Kubernetes design proposals)에서 "level-triggered logic"으로 반복적으로 언급된다.

모든 것은 API 객체다 — 리소스와 컨트롤러의 짝

조정 루프라는 아이디어를 강력하게 만든 건, 이 루프가 하나가 아니라는 점이다. Kubernetes는 수십 종류의 리소스(resource)를 정의하고, 각 리소스마다 전용 컨트롤러를 짝으로 둔다.

flowchart TD
    API["kube-apiserver<br/>(모든 상태의 단일 진실 원천)"] --> D[Deployment 컨트롤러]
    API --> S[StatefulSet 컨트롤러]
    API --> J[Job 컨트롤러]
    API --> N[Node 컨트롤러]
    API --> SV[Service 컨트롤러]
    D -->|"Deployment를 본다"| D
    S -->|"StatefulSet을 본다"| S
  • Deployment 리소스 → Deployment 컨트롤러가 관심
  • Job 리소스 → Job 컨트롤러가 관심
  • Node 리소스 → Node 컨트롤러가 관심

각 컨트롤러는 남의 리소스에는 관심 없이, 자기 리소스만 watch(감시)한다. 그리고 자기 리소스의 원하는 상태와 현재 상태를 비교해 행동한다.

이 구조가 가져온 변화는 확장성이다. Kubernetes에 새 기능을 추가한다는 건, 보통 "새 리소스 + 새 컨트롤러" 쌍을 하나 더 얹는 것이다. Custom Resource Definition(CRD)으로 자기만의 리소스를 만들고, 그것을 감시하는 컨트롤러를 짜면 Kubernetes의 일부가 된다. KubeEdge, cert-manager, Istio가 모두 이 패턴으로 Kubernetes 위에 얹힌다. 이 서브프로젝트의 뒷글(KubeEdge, EdgeMesh)이 결국 같은 패턴의 변주임을 알게 될 것이다.

# Kubernetes 1.36 — 시스템에 정의된 리소스 종류 일부
kubectl api-resources | head -20

확인할 것: 수십~수백 종의 리소스가 각자 자기 컨트롤러와 짝을 이룬다.

NAME                              SHORTNAMES   APIVERSION                             NAMESPACED   KIND
bindings                                       v1                                     true         Binding
componentstatuses                 cs           v1                                     false        ComponentStatus
configmaps                        cm           v1                                     true         ConfigMap
endpoints                         ep           v1                                     true         Endpoints
events                            ev           v1                                     true         Event
...
deployments                       deploy       apps/v1                                true         Deployment
...

컨트롤 플레인과 노드 — 결정과 실행의 분리

여기까지 보면 Kubernetes가 두 개의 분명한 계층으로 나뉘는 게 보인다.

flowchart TD
    CP["컨트롤 플레인 (control plane)<br/>결정하는 층"] --> APIS["kube-apiserver<br/>모든 API 요청의 관문"]
    CP --> ETCD["etcd<br/>모든 상태의 저장소"]
    CP --> SCHED["kube-scheduler<br/>Pod를 어느 노드에 넣을지 결정"]
    CP --> CM["kube-controller-manager<br/>대부분의 내장 컨트롤러 묶음"]
    N["노드 (node)<br/>실행하는 층"] --> KL["kubelet<br/>이 노드의 Pod를 런타임에 지시"]
    N --> KP["kube-proxy<br/>네트워크(Service) 규칙"]
    N --> RT["컨테이너 런타임<br/>containerd/CRI-O (실제 실행)"]
    APIS -. watch .-> KL
  • 컨트롤 플레인: "무엇을 원하는가"를 받고, "현재 무엇인가"를 저장(etcd)하며, "어떻게 바꿀까"를 결정한다(scheduler + controllers). 핵심은 kube-apiserver 하나로 모든 읽기·쓰기가 지나간다는 것 — 단일 진실의 원천(single source of truth).
  • 노드: 결정을 실행한다. kubelet이 컨트롤 플레인을 watch하며 "이 노드에 배정된 Pod가 있네?"를 감지하고, 컨테이너 런타임에 "이거 띄워라"고 지시한다.

kubelet이 apiserver를 watch한다는 절차가 중요하다. 컨트롤 플레인이 노드에 "명령을 내리는" 게 아니라, 노드가 스스로 "내 일거리가 있나?"를 물어보는 구조다. 이것도 조정 루프의 한 형태다. 그래서 노드가 일시적으로 컨트롤 플레인과 연결이 끊겨도, 이미 배정된 Pod는 계속 돈다 — 결정은 캐시돼 있고 실행은 로컬이기 때문이다. (이 성질이 KubeEdge의 "엣지 자율"으로 이어진다.)

컨테이너만 있었는데 왜 "오케스트레이션"이 필요했나

처음의 모순으로 돌아가자. Docker 하나로 충분하지 않았나? 충분하지 않았다. 컨테이너는 "어떤 환경에서든 같게 돈다"를 해결했지만, 운영의 질문은 그게 전부가 아니었다:

  • 이 컨테이너를 몇 개 띄울까? (스케일)
  • 트래픽이 늘면 자동으로 늘릴 수 있나? (autoscaling)
  • 노드가 죽으면 누가 다른 노드로 옮기나? (self-healing)
  • 새 버전을 배포할 때 무중단으로 어떻게? (rolling update)
  • 서로 다른 컨테이너가 서로를 어떻게 찾나? (service discovery)
  • 한 노드에 너무 몰리지 않게 어떻게 분산시키나? (scheduling)

이 질문들의 공통점은 "컨테이너 하나의 문제"가 아니라 "여러 컨테이너와 여러 노드의 관계"의 문제라는 것이다. 컨테이너 런타임은 이 관계를 다루지 않는다. Kubernetes가 다루는 건 정확히 이 관계 계층이다. 그래서 이름이 컨테이너가 아니라 오케스트레이션(orchestration, 조율)이다.

운영 질문 컨테이너 런타임 Kubernetes
컨테이너 하나를 격리해 돌린다 O 관여 안 함 (런타임에 위임)
몇 개를 띄울지 결정 X replicas
노드가 죽으면 다른 곳으로 X self-healing (조정 루프)
트래픽으로 스케일 X HPA(12장)
서로를 이름으로 찾기 X Service + DNS(네트워킹 영역)
무중단 교체 X Deployment rolling update(05장)

"현실"과 버전 — 이 글이 1.36을 기준으로 하는 이유

Kubernetes는 분기마다 새 마이너 버전이 나오고, 매 버전마다 기능이 GA/beta/alpha를 오간다. 구형 자료(1.20 이전)를 보면 이미 제거된 기능을 "현재 기능"처럼 가르친다. 가장 흔한 함정:

  • dockershim: 1.24에서 제거. 이제 런타임은 containerd/CRI-O. "docker"를 런타임으로 쓰는 예시는 역사다.
  • PodSecurityPolicy: 1.25에서 제거. 후속은 Pod Security Admission(03-k8s-security 영역).

이 서브프로젝트 전체의 기준은 Kubernetes 1.36 "Haru"(2026-04 GA)이다. 1.36에서 눈에 띄게 GA된 것들 — fine-grained kubelet API authorization, User Namespaces in pods, DRA 핵심 — 은 보안·엣지 글에서 다시 만난다. "최신 버전"이라는 추상 표현은 쓰지 않는다. 항상 구체 버전과 GA/beta/alpha 상태를 함께 적는다(CHANGELOG-1.36).

버전이 중요한 진짜 이유는 단순히 "최신"이라서가 아니다. Kubernetes에서 GA/beta/alpha 상태가 바뀌면 기본값·활성화 여부·안정성이 바뀐다. alpha는 기본 꺼짐, beta는 기본 켜짐, GA는 영구. 이 상태를 모르면 "문서대로 했는데 안 된다"는 사태를 겪는다(03-k8s-security에서 ValidatingAdmissionPolicy로 직접 겪는다).

흔히 묻는 것, 흔히 틀리는 것

오해 정정
"Kubernetes가 컨테이너를 실행한다" 실행은 컨테이너 런타임(containerd/CRI-O). Kubernetes는 의사결정·조정 계층
"Docker 대신 Kubernetes다" 다른 계층. Docker(또는 containerd)로 컨테이너를 만들고, Kubernetes로 여러 노드를 조율
"Kubernetes는 컨테이너 오케스트레이터라 자동으로 다 해준다" "원하는 상태"를 선언해야 자동이 작동. 선언 없이는 아무것도 안 함
"replicas: 3이면 항상 3개가 돈다" 3개로 수렴하려 계속 시도. 일시적으론 2개일 수 있지만 자가 복구로 3개로
"명령형(kubectl run)과 선언형(apply)은 같다" run은 일회성 명령. apply는 원하는 상태 기록 — 사라지지 않고 self-healing의 기준이 됨
"etcd가 죽어도 Pod는 돈다" 실행 중 Pod는 돈다(로컬). 단 복구·재조정은 멈춤. etcd는 단일 진실 원천이라 백업이 생명

요약 — 이 글의 결론

  • Kubernetes는 컨테이너를 실행하지 않는다. 실행은 런타임이 하고, Kubernetes는 그 런타임을 지휘하는 컨트롤 플레인이다. 헷갈리면 장애 원인을 런타임에서만 찾게 된다.
  • 선언적 API가 핵심 전환이다. "어떻게"가 아니라 "무엇을 원하는가"(replicas: 3)를 말한다. 장애 순간에 시스템이 스스로 원하는 상태로 복구하는 근거가 된다.
  • 조정 루프가 그 선언을 영구적으로 시행한다. level-triggered로 동작해 이벤트를 놓쳐도 상태 불일치를 다음 루프에서 잡는다. 이게 self-healing·멱등성의 원천.
  • 모든 기능은 "리소스 + 컨트롤러" 쌍으로 구성된다. 그래서 확장은 "새 리소스 + 새 컨트롤러" 추가이고, CRD로 Kubernetes 위에 KubeEdge·cert-manager·Istio가 얹힌다.
  • 컨트롤 플레인(결정)과 노드(실행)의 분리가 Kubernetes의 아키텍처 골격. kubelet이 apiserver를 watch하는 구조가 잠시 단절돼도 Pod가 사라지지 않는 이유.
  • 기준은 1.36 "Haru". GA/beta/alpha 상태가 매 버전 바뀌므로, "최신"이 아니라 구체 버전과 상태를 함께 봐야 "문서대로 했는데 안 된다"를 피한다.

생각해 볼 문제

  1. replicas: 3인 Deployment에서 노드 하나가 죽었다. Kubernetes는 정확히 어떤 과정으로 3개를 복구하는가? 조정 루프의 언어로 한 문단씩 써 보라.
  2. edge-triggered로 동작하는 시스템이 level-triggered보다 나은 경우는 어떤 상황일까? (힌트: 이벤트를 절대 놓치지 않는다고 보장할 수 있을 때)
  3. "Kubernetes는 데이터베이스다"라는 비유가 있다 — etcd에 상태를 저장하고 watch로 "쿼리"한다는 관점. 이 비유가 도움을 주는 지점과 무너지는 지점을 각각 말하라.
  4. CRD로 자기 리소스를 만들면 Kubernetes의 일부가 된다고 했다. 그렇다면 "Kubernetes의 경계"는 어디인가? kubelet 없이도 Kubernetes인가?
  5. 컨테이너 런타임이 dockershim 제거(1.24)로 바뀐 것이 운영자에게 어떤 실무적 영향을 미쳤을까?
  6. 선언적 시스템에서 "현재 상태가 원하는 상태로 수렴하지 못하는" 상황(예: 리소스 부족으로 Pod가 못 뜬다)을 시스템은 어떻게 표현하는가? (힌트: 다음 글의 Pod phase, conditions)

참고

'Tech Artifacts > Kubernetes' 카테고리의 다른 글

K8s - 03. api machinery  (0) 2026.07.13
K8s - 02. architecture  (1) 2026.07.13