본문 바로가기

컨테이너 오케스트레이션과 실무

Kubernetes가 원하는 상태를 선언받아 현재 상태를 맞추는 조정 루프를 모델로 확인하고, Pod·Deployment·Service와 검사(probe), 자원 요청과 한도가 cgroup으로 이어지는 방식, HPA의 확장 계산을 살펴봅니다. 컨테이너 네트워크, 서버리스와 마이크로 VM, 인프라 선택과 CI/CD도 정리합니다.

컨테이너 하나를 실행하는 것은 docker run 한 줄이면 됩니다. 하지만 수백, 수천 개의 컨테이너를 여러 서버에 나누어 배치하고, 서버가 고장 나면 다른 서버에서 다시 띄우고, 트래픽에 따라 개수를 늘리고 줄이는 일을 사람이 손으로 할 수는 없습니다. 이 일을 자동으로 해 주는 것이 컨테이너 오케스트레이션(orchestration)이며, Kubernetes가 사실상의 표준입니다.

이 글에서는 Kubernetes가 어떤 원리로 동작하는지, 운영체제의 개념이 그 안에서 어떻게 쓰이는지 살펴보고, 컨테이너 네트워크와 서버리스, 인프라를 고르는 기준까지 정리합니다.

Kubernetes는 원하는 상태를 맞춘다

Kubernetes는 구글이 내부에서 쓰던 클러스터 관리 시스템 Borg의 경험을 바탕으로 만들어 2014년에 공개한 시스템입니다. 사용자는 "이 이미지로 만든 웹 서버를 세 개 실행하라"처럼 원하는 상태(desired state)를 선언하고, Kubernetes는 실제 상태를 끊임없이 관찰하며 원하는 상태와 다르면 맞추는 일을 반복합니다. 이 반복을 조정 루프(reconciliation loop)라고 합니다.

import itertools

desired = 3                                 # Deployment에 선언한 replicas
pods = []                                   # 지금 실행 중인 Pod
names = (f"web-{i}" for i in itertools.count(1))

def reconcile():
    """원하는 상태와 지금 상태의 차이만큼 Pod를 만들거나 지운다."""
    while len(pods) < desired:
        pods.append(next(names))
    while len(pods) > desired:
        pods.pop()

reconcile()
print("처음:", pods)
pods.remove("web-2")                        # 노드 장애로 Pod 하나가 사라졌다
reconcile()
print("장애 뒤:", pods)
desired = 5                                 # kubectl scale --replicas=5
reconcile()
print("확장 뒤:", pods)
처음: ['web-1', 'web-2', 'web-3']
장애 뒤: ['web-1', 'web-3', 'web-4']
확장 뒤: ['web-1', 'web-3', 'web-4', 'web-5', 'web-6']

Pod 하나가 사라지자 루프는 그것을 되살리는 대신 web-4라는 새 Pod를 만들어 개수를 맞추었습니다. 사용자는 무엇을 할지가 아니라 어떤 상태여야 하는지만 말하므로, 장애가 나든 설정을 바꾸든 같은 루프가 차이를 메웁니다. 실제 Kubernetes에서는 여러 컨트롤러가 각자 맡은 종류의 자원에 대해 이런 루프를 돌립니다.

Kubernetes 클러스터는 이 루프를 돌리는 제어 영역(control plane)과, 실제로 컨테이너를 실행하는 노드(node)로 나뉩니다.

구성 요소하는 일
API 서버모든 요청을 받는 창구. kubectl도 여기에 요청한다
etcd클러스터의 원하는 상태와 현재 상태를 저장하는 분산 키-값 저장소
스케줄러새 Pod를 어느 노드에 둘지 정한다
컨트롤러 매니저여러 조정 루프를 돌려 현재 상태를 원하는 상태로 맞춘다
kubelet노드마다 하나씩 돌며, 그 노드에 배정된 Pod의 컨테이너를 실행하고 감시한다
kube-proxyService의 가상 주소로 온 트래픽을 Pod로 전달하는 규칙을 노드에 설정한다

Pod와 Deployment

Pod는 Kubernetes가 배치하는 가장 작은 단위로, 하나 이상의 컨테이너를 묶은 것입니다. 같은 Pod의 컨테이너들은 네트워크 네임스페이스를 함께 써서 localhost로 서로 통신하고, 볼륨을 함께 마운트할 수 있습니다. 애플리케이션 컨테이너 옆에 로그 수집기나 프록시 컨테이너를 붙이는 사이드카(sidecar) 패턴이 이를 이용합니다.

보통은 Pod를 직접 만들지 않고, Deployment로 "이런 Pod를 몇 개 유지하라"고 선언합니다.

deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: web
spec:
  replicas: 3
  selector:
    matchLabels:
      app: web
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 1          # 교체하는 동안 replicas보다 최대 하나 더 띄울 수 있다
      maxUnavailable: 0    # 교체하는 동안 준비된 Pod가 replicas보다 줄지 않게 한다
  template:
    metadata:
      labels:
        app: web
    spec:
      containers:
      - name: nginx
        image: nginx:1.28-alpine
        ports:
        - containerPort: 80
        resources:
          requests:
            cpu: "250m"
            memory: "64Mi"
          limits:
            cpu: "500m"
            memory: "128Mi"
        readinessProbe:
          httpGet:
            path: /
            port: 80
          periodSeconds: 5
        livenessProbe:
          httpGet:
            path: /
            port: 80
          initialDelaySeconds: 10

이미지를 바꾸면 Deployment는 롤링 업데이트(rolling update)로 새 Pod를 하나씩 띄우고, 준비되면 옛 Pod를 하나씩 내립니다. 위 설정은 교체하는 동안에도 준비된 Pod가 세 개 아래로 내려가지 않게 합니다. 컨테이너가 죽으면 kubelet이 같은 Pod 안에서 다시 시작하고, 노드가 고장 나 Pod가 사라지면 앞의 모델처럼 컨트롤러가 다른 노드에 새 Pod를 만듭니다. Pod가 다른 노드로 옮겨 가는 것이 아니라 새로 만들어지므로, Pod 안에 저장한 데이터는 사라진다는 점에 주의해야 합니다.

kubectl apply -f deployment.yaml                          # 원하는 상태를 선언한다
kubectl get pods -o wide                                  # Pod와 배치된 노드
kubectl set image deployment/web nginx=nginx:1.29-alpine  # 이미지를 바꿔 롤링 업데이트
kubectl rollout status deployment/web                     # 교체가 끝났는지
kubectl rollout undo deployment/web                       # 직전 버전으로 되돌린다

set image의 nginx=nginx:1.29-alpine에서 = 앞의 nginx는 이미지가 아니라 YAML의 containers에 적은 컨테이너의 이름입니다. 이 명령은 클러스터에 저장된 Deployment만 고치고 deployment.yaml의 nginx:1.28-alpine은 그대로 두므로, 나중에 같은 파일을 다시 apply하면 파일에 적힌 1.28로 되돌아갈 수 있습니다. 그래서 명령으로 이미지를 바꿨다면 파일도 함께 고쳐 두어야 파일과 클러스터의 상태가 어긋나지 않습니다.

자원 요청과 한도는 cgroup이 된다

resources의 두 값은 쓰임이 다릅니다. requests는 이 컨테이너가 최소한 이만큼은 필요하다는 선언으로, 스케줄러가 남은 자원이 충분한 노드를 고를 때 씁니다. limits는 넘어서는 안 되는 상한으로, kubelet과 컨테이너 런타임이 앞 글에서 본 cgroup의 값으로 바꾸어 적용합니다. CPU 단위 m은 1,000분의 1 코어이므로, cpu: "500m"은 cpu.max에 100ms마다 50ms를 주는 설정이 되고, memory: "128Mi"는 memory.max가 됩니다.

그래서 한도를 넘었을 때의 결과도 cgroup의 동작을 따릅니다. CPU 한도를 넘으면 다음 주기까지 기다리게 되어 느려질 뿐이지만, 메모리 한도를 넘으면 커널의 OOM 킬러가 컨테이너의 프로세스를 종료하고, Kubernetes는 그 컨테이너의 상태를 OOMKilled로 보여 줍니다.

더 깊이: requests와 limits가 정하는 OOM 점수

requests와 limits를 어떻게 적었는지에 따라 Pod는 서비스 품질(quality of service, QoS) 등급 셋 가운데 하나를 받습니다. Pod QoS 문서에 따르면 모든 컨테이너가 CPU와 메모리의 요청과 한도를 모두 갖고 두 값이 같으면 Guaranteed, 그렇지 않지만 요청이나 한도가 하나라도 있으면 Burstable, 하나도 없으면 BestEffort입니다. 노드의 자원이 모자라 kubelet이 Pod를 내보낼 때는 사용량이 요청을 넘는지와 Pod의 우선순위로 순서를 정하므로, 등급을 직접 보지는 않지만 대개 BestEffort, Burstable, Guaranteed의 차례가 됩니다.

이 등급은 9장에서 본 oom_score_adj로도 이어집니다. 노드 압박에 따른 축출 문서에 따르면 kubelet은 Guaranteed 컨테이너에 -997을, BestEffort 컨테이너에 1000을 주고, Burstable 컨테이너에는 메모리 요청이 노드 메모리에서 차지하는 비율로 계산한 2부터 999 사이의 값을 줍니다. kubelet이 메모리를 회수하기 전에 노드 전체가 메모리 부족에 빠지면 커널의 OOM 킬러가 메모리 사용 비율에 이 값을 더한 점수가 가장 높은 컨테이너를 끝냅니다. 그래서 요청한 양에 비해 메모리를 많이 쓰는 낮은 등급의 컨테이너가 먼저 희생됩니다.

검사로 상태 판단하기

Pod 검사 실패가 바꾸는 상태

startupProbe를 정의한 정상 경로이다. readiness 실패 기준을 충족한다. 각 단계의 값과 전환 이유를 비교합니다.

Kubernetes · 검사와 상태

Readiness 실패는 트래픽 준비 상태를 바꾸며 컨테이너를 재시작하지 않습니다

probe를 정의한 경우에 적용됩니다. startup과 liveness는 실패 임계값에서 컨테이너 종료와 재시작 정책에 연결되고, readiness는 Service 트래픽 준비 상태를 바꿉니다.

startup가 바꾸는 상태와 조건startupProbe를 정의한 경우의 세 분기다. 성공 전에는 readiness/liveness를 시작하지 않는다. 실패 임계값 미도달에서는 시작 검사를 계속하며, 도달 시 컨테이너를 종료하고 재시작 정책에 따른다. 종료가 Pod 전체 삭제나 무조건 재시작을 뜻하지 않는다.startupProbe결과·실패 횟수성공준비·생존 검사시작실패 · 임계 미도달시작 검사 계속다른 검사 대기실패 · 임계 도달컨테이너 종료재시작 정책 적용성공실패·미도달실패·도달
startup가 바꾸는 상태와 조건startupProbe를 정의한 경우의 세 분기다. 성공 전에는 readiness/liveness를 시작하지 않는다. 실패 임계값 미도달에서는 시작 검사를 계속하며, 도달 시 컨테이너를 종료하고 재시작 정책에 따른다. 종료가 Pod 전체 삭제나 무조건 재시작을 뜻하지 않는다.startupProbe결과·실패 횟수성공준비·생존 검사시작실패 · 임계 미도달시작 검사 계속다른 검사 대기실패 · 임계 도달컨테이너 종료재시작 정책 적용성공실패·미도달실패·도달
readiness가 바꾸는 상태와 조건readinessProbe를 정의한 경우다. 성공 응답만으로 여러 컨테이너·readiness gates·Service 선택자 등 다른 조건을 무시하지 않는다. 준비 실패 기준을 충족하면 Ready=false로 일반 Service 트래픽 준비 대상에서 제외하며 이 검사 실패 자체는 컨테이너 재시작 명령이 아니다.readinessProbe유효한 판정준비 응답 성공다른 Ready 조건 확인실패 기준 충족Pod Ready=false일반 Service트래픽 대상 제외성공실패
readiness가 바꾸는 상태와 조건readinessProbe를 정의한 경우다. 성공 응답만으로 여러 컨테이너·readiness gates·Service 선택자 등 다른 조건을 무시하지 않는다. 준비 실패 기준을 충족하면 Ready=false로 일반 Service 트래픽 준비 대상에서 제외하며 이 검사 실패 자체는 컨테이너 재시작 명령이 아니다.readinessProbe유효한 판정준비 응답 성공다른 Ready 조건 확인실패 기준 충족Pod Ready=false일반 Service트래픽 대상 제외성공실패
liveness가 바꾸는 상태와 조건livenessProbe를 정의한 경우다. 성공하거나 실패 임계값에 아직 도달하지 않은 정상 실행 경로와, 실패 임계값 도달 뒤 컨테이너 종료·재시작 정책 적용 경로를 구분한다. 일시 부하와 timeout/failureThreshold 설계는 기존 지원 설명의 조건을 보존한다.livenessProbe실패 임계값?임계값 미도달컨테이너 계속 실행다음 검사실패 임계값 도달컨테이너 종료재시작 정책 적용미도달도달
liveness가 바꾸는 상태와 조건livenessProbe를 정의한 경우다. 성공하거나 실패 임계값에 아직 도달하지 않은 정상 실행 경로와, 실패 임계값 도달 뒤 컨테이너 종료·재시작 정책 적용 경로를 구분한다. 일시 부하와 timeout/failureThreshold 설계는 기존 지원 설명의 조건을 보존한다.livenessProbe실패 임계값?임계값 미도달컨테이너 계속 실행다음 검사실패 임계값 도달컨테이너 종료재시작 정책 적용미도달도달
Pod 검사 실패가 바꾸는 상태
검사실패했을 때다른 검사와의 관계
시작 검사설정된 실패 임계값에 이르면 컨테이너 종료·재시작 정책 적용startupProbe가 성공하기 전에는 readiness와 liveness 검사를 시작하지 않음
준비 검사Pod의 Ready 상태가 false가 되어 일반 Service 트래픽 대상에서 제외검사 실패 자체로 컨테이너를 재시작하지 않음
생존 검사실패 임계값에 이르면 컨테이너 종료·재시작 정책 적용일시적 부하를 실패로 오판하지 않도록 임계값 설계
시작 검사
실패했을 때: 설정된 실패 임계값에 이르면 컨테이너 종료·재시작 정책 적용
다른 검사와의 관계: startupProbe가 성공하기 전에는 readiness와 liveness 검사를 시작하지 않음
준비 검사
실패했을 때: Pod의 Ready 상태가 false가 되어 일반 Service 트래픽 대상에서 제외
다른 검사와의 관계: 검사 실패 자체로 컨테이너를 재시작하지 않음
생존 검사
실패했을 때: 실패 임계값에 이르면 컨테이너 종료·재시작 정책 적용
다른 검사와의 관계: 일시적 부하를 실패로 오판하지 않도록 임계값 설계

앞의 YAML에는 probe가 없습니다. 애플리케이션 응답 상태를 검사하려면 대상에 맞는 probe를 별도로 정의해야 합니다.

이 예의 조건

  • 본문과 전체 도표의 예를 따라 시작 검사 대기부터 준비 실패까지 진행합니다.
관계도의 보충 설명
전체 도표의 표기·관계·조건
구분전체 도표의 내용
제목startup이 바꾸는 상태와 조건
표의 값시작 검사. 실패했을 때: 설정된 실패 임계값에 이르면 컨테이너 종료·재시작 정책 적용. 다른 검사와의 관계: startupProbe가 성공하기 전에는 readiness와 liveness 검사를 시작하지 않음. 준비 검사. 실패했을 때: Pod의 Ready 상태가 false가 되어 일반 Service 트래픽 대상에서 제외. 다른 검사와의 관계: 검사 실패 자체로 컨테이너를 재시작하지 않음. 생존 검사. 실패했을 때: 실패 임계값에 이르면 컨테이너 종료·재시작 정책 적용. 다른 검사와의 관계: 일시적 부하를 실패로 오판하지 않도록 임계값 설계.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
시작 검사 대기

startupProbe를 정의한 정상 경로이다.

  • 검사·Pod 상태 1: startup 아직 성공 전
  • 검사·Pod 상태 2: readiness/liveness 대기
  • 시작 검사를 계속
startup 성공

다른 검사 시작 조건이 충족된다.

  • 검사·Pod 상태 1: startup 성공
  • 검사·Pod 상태 2: readiness/liveness 시작
  • 준비 판단 가능
준비 실패

readiness 실패 기준을 충족한다.

  • 검사·Pod 상태 1: Pod Ready=false
  • 검사·Pod 상태 2: 일반 Service 트래픽 대상 제외
  • 컨테이너는 이 실패만으로 재시작 안 함; liveness 종료 조건은 별도

Pod 검사 실패가 바꾸는 상태

startupProbe를 정의한 정상 경로이다. readiness 실패 기준을 충족한다. 각 단계의 값과 전환 이유를 비교합니다.

Kubernetes · 검사와 상태

Readiness 실패는 트래픽 준비 상태를 바꾸며 컨테이너를 재시작하지 않습니다

probe를 정의한 경우에 적용됩니다. startup과 liveness는 실패 임계값에서 컨테이너 종료와 재시작 정책에 연결되고, readiness는 Service 트래픽 준비 상태를 바꿉니다.

startup가 바꾸는 상태와 조건startupProbe를 정의한 경우의 세 분기다. 성공 전에는 readiness/liveness를 시작하지 않는다. 실패 임계값 미도달에서는 시작 검사를 계속하며, 도달 시 컨테이너를 종료하고 재시작 정책에 따른다. 종료가 Pod 전체 삭제나 무조건 재시작을 뜻하지 않는다.startupProbe결과·실패 횟수성공준비·생존 검사시작실패 · 임계 미도달시작 검사 계속다른 검사 대기실패 · 임계 도달컨테이너 종료재시작 정책 적용성공실패·미도달실패·도달
startup가 바꾸는 상태와 조건startupProbe를 정의한 경우의 세 분기다. 성공 전에는 readiness/liveness를 시작하지 않는다. 실패 임계값 미도달에서는 시작 검사를 계속하며, 도달 시 컨테이너를 종료하고 재시작 정책에 따른다. 종료가 Pod 전체 삭제나 무조건 재시작을 뜻하지 않는다.startupProbe결과·실패 횟수성공준비·생존 검사시작실패 · 임계 미도달시작 검사 계속다른 검사 대기실패 · 임계 도달컨테이너 종료재시작 정책 적용성공실패·미도달실패·도달
readiness가 바꾸는 상태와 조건readinessProbe를 정의한 경우다. 성공 응답만으로 여러 컨테이너·readiness gates·Service 선택자 등 다른 조건을 무시하지 않는다. 준비 실패 기준을 충족하면 Ready=false로 일반 Service 트래픽 준비 대상에서 제외하며 이 검사 실패 자체는 컨테이너 재시작 명령이 아니다.readinessProbe유효한 판정준비 응답 성공다른 Ready 조건 확인실패 기준 충족Pod Ready=false일반 Service트래픽 대상 제외성공실패
readiness가 바꾸는 상태와 조건readinessProbe를 정의한 경우다. 성공 응답만으로 여러 컨테이너·readiness gates·Service 선택자 등 다른 조건을 무시하지 않는다. 준비 실패 기준을 충족하면 Ready=false로 일반 Service 트래픽 준비 대상에서 제외하며 이 검사 실패 자체는 컨테이너 재시작 명령이 아니다.readinessProbe유효한 판정준비 응답 성공다른 Ready 조건 확인실패 기준 충족Pod Ready=false일반 Service트래픽 대상 제외성공실패
liveness가 바꾸는 상태와 조건livenessProbe를 정의한 경우다. 성공하거나 실패 임계값에 아직 도달하지 않은 정상 실행 경로와, 실패 임계값 도달 뒤 컨테이너 종료·재시작 정책 적용 경로를 구분한다. 일시 부하와 timeout/failureThreshold 설계는 기존 지원 설명의 조건을 보존한다.livenessProbe실패 임계값?임계값 미도달컨테이너 계속 실행다음 검사실패 임계값 도달컨테이너 종료재시작 정책 적용미도달도달
liveness가 바꾸는 상태와 조건livenessProbe를 정의한 경우다. 성공하거나 실패 임계값에 아직 도달하지 않은 정상 실행 경로와, 실패 임계값 도달 뒤 컨테이너 종료·재시작 정책 적용 경로를 구분한다. 일시 부하와 timeout/failureThreshold 설계는 기존 지원 설명의 조건을 보존한다.livenessProbe실패 임계값?임계값 미도달컨테이너 계속 실행다음 검사실패 임계값 도달컨테이너 종료재시작 정책 적용미도달도달
Pod 검사 실패가 바꾸는 상태
검사실패했을 때다른 검사와의 관계
시작 검사설정된 실패 임계값에 이르면 컨테이너 종료·재시작 정책 적용startupProbe가 성공하기 전에는 readiness와 liveness 검사를 시작하지 않음
준비 검사Pod의 Ready 상태가 false가 되어 일반 Service 트래픽 대상에서 제외검사 실패 자체로 컨테이너를 재시작하지 않음
생존 검사실패 임계값에 이르면 컨테이너 종료·재시작 정책 적용일시적 부하를 실패로 오판하지 않도록 임계값 설계
시작 검사
실패했을 때: 설정된 실패 임계값에 이르면 컨테이너 종료·재시작 정책 적용
다른 검사와의 관계: startupProbe가 성공하기 전에는 readiness와 liveness 검사를 시작하지 않음
준비 검사
실패했을 때: Pod의 Ready 상태가 false가 되어 일반 Service 트래픽 대상에서 제외
다른 검사와의 관계: 검사 실패 자체로 컨테이너를 재시작하지 않음
생존 검사
실패했을 때: 실패 임계값에 이르면 컨테이너 종료·재시작 정책 적용
다른 검사와의 관계: 일시적 부하를 실패로 오판하지 않도록 임계값 설계

앞의 YAML에는 probe가 없습니다. 애플리케이션 응답 상태를 검사하려면 대상에 맞는 probe를 별도로 정의해야 합니다.

이 예의 조건

  • 본문과 전체 도표의 예를 따라 시작 검사 대기부터 준비 실패까지 진행합니다.

관계도의 보충 설명

전체 도표의 표기·관계·조건
구분전체 도표의 내용
제목startup이 바꾸는 상태와 조건
표의 값시작 검사. 실패했을 때: 설정된 실패 임계값에 이르면 컨테이너 종료·재시작 정책 적용. 다른 검사와의 관계: startupProbe가 성공하기 전에는 readiness와 liveness 검사를 시작하지 않음. 준비 검사. 실패했을 때: Pod의 Ready 상태가 false가 되어 일반 Service 트래픽 대상에서 제외. 다른 검사와의 관계: 검사 실패 자체로 컨테이너를 재시작하지 않음. 생존 검사. 실패했을 때: 실패 임계값에 이르면 컨테이너 종료·재시작 정책 적용. 다른 검사와의 관계: 일시적 부하를 실패로 오판하지 않도록 임계값 설계.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
시작 검사 대기

startupProbe를 정의한 정상 경로이다.

  • 검사·Pod 상태 1: startup 아직 성공 전
  • 검사·Pod 상태 2: readiness/liveness 대기
  • 시작 검사를 계속
startup 성공

다른 검사 시작 조건이 충족된다.

  • 검사·Pod 상태 1: startup 성공
  • 검사·Pod 상태 2: readiness/liveness 시작
  • 준비 판단 가능
준비 실패

readiness 실패 기준을 충족한다.

  • 검사·Pod 상태 1: Pod Ready=false
  • 검사·Pod 상태 2: 일반 Service 트래픽 대상 제외
  • 컨테이너는 이 실패만으로 재시작 안 함; liveness 종료 조건은 별도

그림 아래에는 앞의 YAML에 probe가 없다는 메모가 있지만, 위 deployment.yaml에는 준비 검사와 생존 검사가 들어 있고 시작 검사만 없습니다. kubelet은 설정된 검사(probe)로 컨테이너의 상태를 확인합니다. 시작 검사(startupProbe)는 느리게 시작하는 애플리케이션이 준비될 때까지 다른 검사를 미룹니다. 준비 검사(readinessProbe)가 실패하면 그 Pod는 Service의 트래픽 대상에서 빠지지만 재시작되지는 않습니다. 생존 검사(livenessProbe)가 정해진 횟수만큼 실패하면 kubelet이 컨테이너를 종료하고 재시작 정책에 따라 다시 시작합니다. 잠시 바빠서 요청을 받을 수 없는 상태와, 멈춰서 다시 시작해야 하는 상태를 나누어 다루는 것입니다.

Service와 HPA

Service: 바뀌지 않는 주소

Pod는 만들어지고 사라지기를 반복하므로 IP 주소도 계속 바뀝니다. Service는 레이블로 고른 Pod들 앞에 바뀌지 않는 가상 주소와 이름을 두고, 그 주소로 온 요청을 준비된 Pod들에 나누어 보냅니다.

service.yaml
apiVersion: v1
kind: Service
metadata:
  name: web
spec:
  type: ClusterIP
  selector:
    app: web
  ports:
  - port: 80
    targetPort: 80

ClusterIP는 클러스터 안에서만 쓰는 주소를 주고, NodePort는 모든 노드의 같은 포트로 들어온 요청을 전달하며, LoadBalancer는 클라우드의 로드 밸런서를 만들어 바깥에 노출합니다. HTTP 경로나 도메인에 따라 서로 다른 Service로 보내는 규칙은 Ingress나 Gateway API로 정의합니다. Service는 레이블만 보고 Pod를 고르므로, 같은 레이블을 단 Pod라면 어느 Deployment에 속했는지와 상관없이 트래픽을 받습니다.

HPA: 부하에 따라 개수 조절하기

HPA(horizontal pod autoscaler)는 측정한 부하에 따라 Deployment의 replicas를 늘리고 줄입니다. 아래 설정은 scaleTargetRef로 앞에서 만든 Deployment web을 가리키고, Pod 수를 minReplicas의 2개와 maxReplicas의 10개 사이에 두면서 평균 CPU 사용률이 70%에 가깝도록 맞추라고 선언합니다.

hpa.yaml
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: web
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: web
  minReplicas: 2
  maxReplicas: 10
  metrics:
  - type: Resource
    resource:
      name: cpu
      target:
        type: Utilization
        averageUtilization: 70

목표 70%는 Pod들의 CPU 사용량을 CPU requests에 대한 비율로 평균한 값입니다. HPA 문서에 따르면 기본 계산식은 ceil(현재 replicas × 현재 지표 ÷ 목표 지표)입니다.

import math

def hpa_desired(usages_m, request_m, target_pct):
    utilization = sum(usages_m) * 100 // (request_m * len(usages_m))   # request 대비 평균 사용률(%)
    return utilization, math.ceil(len(usages_m) * utilization / target_pct)

for usages in ([350, 175, 175], [100, 100, 100]):
    util, want = hpa_desired(usages, request_m=250, target_pct=70)
    print(f"Pod {len(usages)}개, 사용 {usages}m -> 평균 {util}%, 권장 {want}개")
Pod 3개, 사용 [350, 175, 175]m -> 평균 93%, 권장 4개
Pod 3개, 사용 [100, 100, 100]m -> 평균 40%, 권장 2개

평균 93%는 목표 70%보다 높으므로 4개로 늘리고, 평균 40%라면 2개로 줄이라는 계산이 나옵니다. 실제 컨트롤러는 여기에 몇 가지를 더 따집니다. 목표와의 차이가 기본 10%의 허용 오차 안이면 아무것도 하지 않고, 줄일 때는 기본 5분 동안의 권장값 가운데 가장 큰 것을 따라 너무 자주 오르내리지 않게 합니다. 또 CPU requests가 없는 Pod는 비율을 계산할 수 없으므로, HPA를 쓰려면 requests를 반드시 설정해야 합니다.

컨테이너 네트워크

Docker는 기본으로 호스트에 가상 브리지를 하나 만들고, 컨테이너마다 veth라는 가상 이더넷 선의 한쪽 끝을 컨테이너의 네트워크 네임스페이스에, 다른 쪽 끝을 브리지에 연결합니다. 컨테이너가 바깥과 통신할 때는 호스트가 NAT로 주소를 바꾸어 주고, -p 8080:80은 호스트의 8080번 포트로 온 요청을 컨테이너의 80번 포트로 넘기는 규칙을 만듭니다. Docker의 네트워크 모드와 Kubernetes의 Pod 네트워크는 네트워크 교재의 클라우드와 컨테이너 네트워크에서 더 자세히 다룹니다.

Kubernetes 네트워크 모델은 Pod마다 클러스터 전체에서 고유한 IP를 주고, 두 가지를 요구합니다. 모든 Pod는 같은 노드에 있든 다른 노드에 있든 프록시나 NAT 없이 다른 모든 Pod와 직접 통신할 수 있어야 하고, 시스템 데몬이나 kubelet 같은 노드의 에이전트는 그 노드의 모든 Pod와 통신할 수 있어야 합니다. 이 요구를 어떻게 이룰지는 CNI(container network interface) 플러그인이 정합니다. Flannel처럼 노드 사이에 VXLAN 터널을 뚫는 오버레이 방식, Calico처럼 BGP로 노드 사이에 경로를 알리는 방식, Cilium처럼 eBPF로 커널 안에서 패킷을 처리하는 방식이 있습니다. Pod 사이의 통신을 제한하려면 NetworkPolicy를 쓰는데, 이를 실제로 적용하는 것도 CNI 플러그인의 몫입니다.

서버리스와 마이크로 VM

서버리스(serverless)는 서버를 직접 관리하지 않고 함수 단위로 코드를 올려 두면, 요청이 올 때 플랫폼이 실행 환경을 마련해 실행해 주는 모델입니다. AWS Lambda, Google Cloud Run functions, Azure Functions가 대표적입니다. 서버가 없는 것이 아니라 서버의 관리를 플랫폼이 맡을 뿐이며, 쓴 만큼만 비용을 내고 요청이 없으면 실행 환경도 없습니다.

AWS Lambda는 고객마다 커널을 나누려고, 앞 글에서 본 Firecracker 마이크로 VM 안에서 함수를 실행합니다. Firecracker는 KVM 위에서 virtio 네트워크와 블록 장치처럼 꼭 필요한 장치만 흉내 내어, 보통의 VM보다 훨씬 빨리 시작하고 메모리도 적게 씁니다. 그래도 한동안 요청이 없던 함수를 부르면 실행 환경을 새로 준비하느라 첫 요청이 늦어지는데, 이를 콜드 스타트(cold start)라고 합니다.

인프라 고르기

인프라 선택 · 운영 책임

필요한 제어와 직접 관리할 범위로 실행 환경을 비교합니다

장치·게스트 OS·이미지·함수 중 어디까지 제어해야 하는지 확인합니다. 아래 질문은 선택 기준이며 특정 환경의 성능이나 비용 우위를 보장하지 않습니다.

인프라 선택에서 직접 관리하는 범위
환경직접 확인할 범위선택 질문
베어메탈물리 장비·OS·애플리케이션GPU·HPC의 장치 접근과 하드웨어 제어가 필요한가?
가상 머신게스트 OS·가상 자원·애플리케이션게스트 커널 경계와 운영체제 제어가 필요한가?
컨테이너이미지·실행 정책·오케스트레이션공유 커널 전제에서 배포 단위를 표준화할 수 있는가?
서버리스함수 코드·권한·서비스 제약이벤트 모델과 실행·동시성 제한에 작업이 맞는가?
베어메탈
직접 확인할 범위: 물리 장비·OS·애플리케이션
선택 질문: GPU·HPC의 장치 접근과 하드웨어 제어가 필요한가?
가상 머신
직접 확인할 범위: 게스트 OS·가상 자원·애플리케이션
선택 질문: 게스트 커널 경계와 운영체제 제어가 필요한가?
컨테이너
직접 확인할 범위: 이미지·실행 정책·오케스트레이션
선택 질문: 공유 커널 전제에서 배포 단위를 표준화할 수 있는가?
서버리스
직접 확인할 범위: 함수 코드·권한·서비스 제약
선택 질문: 이벤트 모델과 실행·동시성 제한에 작업이 맞는가?

관리형 서비스 사용 여부에 따라 운영 책임이 달라집니다. 한 시스템 안에서도 서로 다른 환경을 함께 사용할 수 있습니다.

그림처럼 베어메탈, VM, 컨테이너, 서버리스로 갈수록 직접 관리할 범위가 줄어드는 대신, 제어할 수 있는 범위도 줄어듭니다. GPU나 특수 장치를 직접 다뤄야 하면 베어메탈이나 장치를 직접 할당한 VM이, 커널 설정을 바꾸거나 서로 믿을 수 없는 고객을 격리해야 하면 VM이 알맞습니다. 여러 서비스를 표준화된 방식으로 배포하고 확장하려면 컨테이너와 Kubernetes가, 짧은 작업이 드문드문 들어오는 이벤트 처리라면 서버리스가 알맞습니다. 실제로는 데이터베이스는 관리형 서비스나 VM에, 애플리케이션 서버는 Kubernetes에, 이벤트 처리는 서버리스에 두는 식으로 섞어 쓰는 경우가 많습니다.

CI/CD와 컨테이너 이미지

컨테이너 이미지는 빌드한 결과를 그대로 어디서나 실행할 수 있으므로, 코드를 고칠 때마다 자동으로 빌드하고 시험하고 배포하는 CI/CD(지속적 통합과 배포) 파이프라인과 잘 맞습니다. 아래 다섯 줄은 그런 파이프라인이 한 번 돌 때의 순서입니다. 커밋 해시를 태그로 붙여 이미지를 만들고, 알려진 취약점을 검사한 뒤 레지스트리에 올리고, Deployment의 이미지를 바꾼 다음 교체가 끝났는지 확인합니다. ${GIT_SHA}는 CI 도구가 커밋 해시를 넣어 둔 환경 변수라고 가정합니다.

docker build -t registry.example.com/myapp:${GIT_SHA} .   # 커밋 해시를 태그로
trivy image registry.example.com/myapp:${GIT_SHA}         # 알려진 취약점 검사
docker push registry.example.com/myapp:${GIT_SHA}
kubectl set image deployment/myapp myapp=registry.example.com/myapp:${GIT_SHA}
kubectl rollout status deployment/myapp

이미지 태그에 Git 커밋 해시를 쓰면 어떤 코드가 배포되었는지 바로 알 수 있습니다. 다만 태그는 같은 이름으로 다른 이미지를 다시 올려 덮어쓸 수 있으므로, 정확히 같은 내용을 보장하려면 이미지 내용의 해시인 다이제스트(image@sha256:...)로 지정합니다. 롤백하려면 이전 이미지가 레지스트리에 남아 있어야 하고, 그사이 데이터베이스 구조가 바뀌었다면 옛 버전이 새 구조와 호환되는지도 확인해야 합니다.

정리

  • Kubernetes는 원하는 상태를 선언받아, 조정 루프로 현재 상태를 계속 맞춥니다. 사라진 Pod는 옮겨지는 것이 아니라 새로 만들어집니다.
  • Deployment는 Pod의 개수와 롤링 업데이트를 관리하고, 준비 검사는 트래픽 대상 여부를, 생존 검사는 재시작 여부를 정합니다.
  • requests는 스케줄링에, limits는 cgroup의 한도로 쓰이며, CPU 한도는 느려지게, 메모리 한도는 OOM 종료로 이어집니다.
  • Service는 레이블로 고른 Pod 앞에 바뀌지 않는 주소를 두고, HPA는 requests 대비 사용률로 replicas를 계산합니다.
  • 컨테이너 네트워크는 veth, 브리지, NAT와 CNI 플러그인으로 이루어지며, 서버리스는 마이크로 VM 위에서 함수를 실행합니다. 인프라는 필요한 제어 범위와 격리 수준에 따라 고릅니다.

연습 문제

1. HPA 계산과 Service의 선택

CPU requests가 250m인 Pod 네 개가 각각 350m를 쓰고 있고, HPA의 목표는 70%입니다. 허용 오차와 안정화를 무시한 기본 계산식의 권장 개수를 구하세요. 또 같은 네임스페이스에 Deployment와 상관없이 app: web 레이블을 단 Pod를 하나 따로 만들었다면, 본문의 Service는 그 Pod에도 트래픽을 보내나요?

해설 보기

평균 사용률은 350 ÷ 250 = 140%이므로, 권장 개수는 ceil(4 × 140 ÷ 70) = 8개입니다. 이 값은 HPA의 최대 10개 안에 있습니다. Service는 레이블 선택자만 보고 Pod를 고르므로, 따로 만든 Pod도 app: web 레이블이 있고 준비 상태라면 트래픽을 받습니다. 어느 Deployment가 Pod를 만들었는지와 Service가 Pod를 고르는지는 서로 다른 문제이므로, 레이블은 겹치지 않게 설계해야 합니다.

2. 데이터베이스를 확인하는 생존 검사

어떤 팀이 생존 검사가 데이터베이스 연결까지 확인하도록 만들었습니다. 데이터베이스가 1분 동안 멈추면 웹 서버 Pod들에 어떤 일이 일어나나요? 검사를 어떻게 나누는 것이 좋을까요?

해설 보기

데이터베이스가 멈추면 모든 웹 서버 Pod의 생존 검사가 함께 실패하고, kubelet은 정해진 실패 횟수가 지나면 모든 컨테이너를 재시작합니다. 웹 서버에는 아무 문제가 없는데도 재시작이 되풀이되고, 다시 시작한 Pod들이 한꺼번에 데이터베이스에 연결하려 해서 회복이 더 늦어질 수 있습니다. 생존 검사는 프로세스가 스스로 회복할 수 없는 상태인지만, 곧 이 프로세스가 응답하는지만 확인해야 합니다. 외부 의존성 때문에 요청을 처리할 수 없는 상태는 준비 검사로 알려 트래픽만 받지 않게 하는 편이 낫고, 그마저도 모든 Pod가 함께 빠지는 일이 없도록 신중히 설계해야 합니다.

3. requests와 limits가 만드는 결과

컨테이너의 requests.cpu가 250m, limits.cpu가 500m, limits.memory가 128Mi입니다. 이 컨테이너가 CPU를 한 코어만큼 쓰려 할 때와, 메모리를 200Mi까지 쓰려 할 때 각각 어떤 일이 일어나나요? requests.cpu는 어디에 쓰이나요?

해설 보기

limits.cpu 500m은 cgroup의 cpu.max가 되어 100ms마다 50ms까지만 CPU를 쓰게 하므로, 한 코어만큼 쓰려 하면 주기마다 할당량을 다 쓰고 기다리게 되어 느려지지만 종료되지는 않습니다. limits.memory 128Mi는 memory.max가 되므로, 200Mi까지 쓰려 하면 커널이 회수를 시도하다 실패하고 OOM 킬러가 프로세스를 종료하며, Pod의 상태에 OOMKilled가 나타납니다. requests.cpu 250m은 스케줄러가 이 Pod를 둘 노드를 고를 때 남은 CPU를 계산하는 데 쓰이고, 노드의 CPU가 부족할 때 컨테이너들이 CPU를 나누어 받는 비율에도 쓰입니다. HPA의 사용률 계산에서 분모가 되는 것도 이 값입니다.

다음 장의 리눅스 필수 명령어에서는 지금까지 배운 프로세스, 메모리, 파일 시스템, 네트워크의 개념을 실제 시스템에서 확인하고 다루는 명령들을 정리합니다.