본문으로 건너뛰기 Kubernetes 완벽 가이드: 컨테이너 오케스트레이션

Kubernetes 완벽 가이드: 컨테이너 오케스트레이션

Kubernetes 완벽 가이드: 컨테이너 오케스트레이션

이 글의 핵심

Kubernetes(K8s)는 컨테이너화된 애플리케이션의 배포, 확장, 관리를 자동화하는 오픈소스 플랫폼입니다. Google이 개발하고 CNCF가 관리하며, 선언적 설정, 자가 치유, 수평 확장, 서비스 디스커버리, 롤링 업데이트를 제공합니다.

Kubernetes란 무엇인가

Kubernetes(K8s)는 컨테이너화된 애플리케이션을 여러 서버에 배포하고, 운영하고, 확장하는 과정을 자동화해 주는 오픈소스 플랫폼입니다. Google이 사내에서 사용하던 컨테이너 관리 시스템 Borg의 경험을 바탕으로 만들었고, 지금은 CNCF(Cloud Native Computing Foundation)가 관리하고 있습니다. 문서를 처음 접하면 “선언적이며 자가 치유가 가능하다”는 말이 자주 등장하는데, 정작 운영에 들어가면 CRD, 네트워크 플러그인, StorageClass, RBAC 같은 개념까지 한꺼번에 다뤄야 해서 머릿속이 복잡해지기 쉽습니다. 그래서 이 가이드는 “정말 필요할 때 제대로 쓸 수 있는 수준”을 목표로, 핵심 오브젝트와 실전에서 자주 마주치는 문제를 함께 정리했습니다.

VM 한 대에 Docker Compose로도 충분히 돌아가는 서비스에 굳이 클러스터를 띄우면, 배보다 배꼽이 커지는 경우가 많습니다. 반면 여러 팀이 여러 서비스를 운영하면서 롤백, 스케일링, 네트워크 정책이 동시에 얽히기 시작하면 Kubernetes의 가치가 분명하게 드러납니다. 이 글에서는 그 경계를 실제 코드와 함께 살펴보겠습니다.

Docker와의 관계

Docker는 컨테이너를 만들고 실행하는 도구이고, Kubernetes는 그 컨테이너들을 여러 서버(노드)에 걸쳐 스케줄링하고 관리하는 오케스트레이터입니다. 즉 서로 경쟁하는 기술이 아니라 역할이 다른 계층입니다. Kubernetes는 Docker뿐 아니라 containerd, CRI-O 등 CRI(Container Runtime Interface)를 구현한 런타임이라면 무엇이든 사용할 수 있습니다. 단일 서버에서 컨테이너 몇 개만 돌리는 수준이라면 Docker Compose로 충분하지만, 여러 서버에 걸쳐 자동 확장, 로드 밸런싱, 무중단 배포가 필요해지는 순간부터는 Kubernetes가 필요한 경우가 많습니다.

클러스터 아키텍처

Kubernetes 클러스터는 크게 Control Plane과 Worker Node로 나뉩니다. Control Plane은 API Server, Scheduler, Controller Manager, etcd로 구성되어 클러스터 전체의 상태를 관리하고 결정을 내리는 두뇌 역할을 합니다. API Server는 모든 요청이 거쳐 가는 관문이고, etcd는 클러스터의 상태를 저장하는 분산 키-값 저장소이며, Scheduler는 새로 생성된 Pod를 어느 노드에 배치할지 결정하고, Controller Manager는 실제 상태를 원하는 상태로 맞추기 위해 계속 조정 작업을 수행합니다. Worker Node에는 kubelet, kube-proxy, Container Runtime이 올라가 있으며, 이들이 실제로 컨테이너를 실행하고 네트워크를 연결합니다. 이 구조를 알아두면 나중에 장애 상황에서 “어디를 먼저 봐야 하는지” 감을 잡기 훨씬 쉬워집니다.

핵심 개념: Pod

Pod는 Kubernetes에서 배포할 수 있는 가장 작은 단위입니다. 하나 이상의 컨테이너를 묶어서 같은 네트워크 네임스페이스와 스토리지를 공유하게 만드는 개념으로, 대부분의 경우 Pod 하나에 컨테이너 하나를 담지만, 로그 수집기나 프록시처럼 밀접하게 연관된 컨테이너를 사이드카 형태로 함께 배포하기도 합니다. 아래는 리소스 요청량과 제한량을 지정한 간단한 Pod 예시입니다.

# pod.yaml
apiVersion: v1
kind: Pod
metadata:
  name: nginx-pod
  labels:
    app: nginx
spec:
  containers:
  - name: nginx
    image: nginx:1.24
    ports:
    - containerPort: 80
    resources:
      requests:
        memory: "64Mi"
        cpu: "250m"
      limits:
        memory: "128Mi"
        cpu: "500m"
kubectl apply -f pod.yaml
kubectl get pods
kubectl logs nginx-pod
kubectl exec -it nginx-pod -- /bin/bash

requests는 스케줄러가 이 Pod를 어느 노드에 배치할지 결정할 때 참고하는 최소 요구량이고, limits는 컨테이너가 초과해서 사용할 수 없는 상한선입니다. 실제 서비스를 운영할 때는 이 값을 반드시 지정하는 것을 권장합니다. 값을 지정하지 않으면 한 Pod가 자원을 과도하게 점유해 같은 노드의 다른 Pod에 영향을 줄 수 있고, 나중에 메모리 부족(OOM)이 발생했을 때 원인을 추적하기도 더 어려워집니다.

Pod는 직접 만들 수도 있지만, 실제 운영에서는 Pod를 단독으로 생성하는 경우가 거의 없습니다. Pod는 한 번 스케줄링된 노드에서 문제가 생기면 그대로 사라지고 재생성되지 않기 때문에, 개수를 유지하고 배포 전략을 관리해 주는 상위 오브젝트인 Deployment를 통해 배포하는 것이 일반적입니다.

Deployment: 원하는 상태를 선언하기

Deployment는 “이 이미지를, 이만큼의 개수로, 이런 방식으로 배포하고 싶다”는 의도를 선언적으로 정의하는 오브젝트입니다. Deployment를 사용하면 replica 수를 늘리거나 줄이는 작업, kubectl set image로 새 버전의 이미지를 롤링 업데이트하는 작업, 문제가 생겼을 때 kubectl rollout undo로 이전 버전으로 되돌리는 작업까지 모두 선언적으로 처리할 수 있습니다. 이 흐름에 익숙해지면 Pod가 재시작되는 상황을 봤을 때 그것이 장애로 인한 것인지, 아니면 의도된 배포나 롤백 과정인지 훨씬 빠르게 판단할 수 있습니다.

# deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: nginx-deployment
spec:
  replicas: 3
  selector:
    matchLabels:
      app: nginx
  template:
    metadata:
      labels:
        app: nginx
    spec:
      containers:
      - name: nginx
        image: nginx:1.24
        ports:
        - containerPort: 80
kubectl apply -f deployment.yaml
kubectl get deployments
kubectl get pods
kubectl scale deployment nginx-deployment --replicas=5
kubectl set image deployment/nginx-deployment nginx=nginx:1.25
kubectl rollout undo deployment/nginx-deployment

Deployment 내부에는 실제로 ReplicaSet이라는 오브젝트가 생성되어 지정된 개수만큼 Pod를 유지하는 역할을 합니다. 이미지를 새 버전으로 바꾸면 Deployment는 새 ReplicaSet을 만들고, 기존 Pod를 하나씩 새 Pod로 교체하는 롤링 업데이트를 수행합니다. 이 과정에서 문제가 발생하면 kubectl rollout status deployment/nginx-deployment로 진행 상황을 확인하고, 필요하면 즉시 이전 버전으로 롤백할 수 있습니다.

Service: 클러스터 안에서 트래픽 연결하기

Pod는 재생성될 때마다 IP 주소가 바뀝니다. 그래서 애플리케이션이 다른 Pod의 IP를 직접 알고 접속하는 방식은 쓸 수 없습니다. Service는 라벨 셀렉터를 기준으로 여러 Pod를 하나의 고정된 엔드포인트로 묶어 주는 오브젝트로, “클러스터 안에서 어디로 트래픽을 보내야 하는가”라는 문제를 해결해 줍니다.

# service.yaml
apiVersion: v1
kind: Service
metadata:
  name: nginx-service
spec:
  type: LoadBalancer
  selector:
    app: nginx
  ports:
  - protocol: TCP
    port: 80
    targetPort: 80

Service의 type은 용도에 따라 다르게 지정합니다. ClusterIP는 클러스터 내부에서만 접근 가능한 기본 타입이고, NodePort는 각 노드의 특정 포트를 열어 외부에서 접근할 수 있게 하며, LoadBalancer는 클라우드 제공업체의 로드 밸런서를 프로비저닝해서 외부 트래픽을 받습니다. 로컬 환경에서 Minikube를 사용한다면 minikube service nginx-service 명령으로 브라우저에서 바로 접속해 동작을 확인해 볼 수 있습니다. Service는 selector에 지정된 라벨과 일치하는 Pod들을 자동으로 찾아 트래픽을 분산하기 때문에, Deployment가 Pod를 새로 만들거나 없애더라도 Service의 엔드포인트는 항상 최신 상태로 유지됩니다.

ConfigMap과 Secret: 설정과 민감 정보 분리하기

애플리케이션 설정값과 비밀번호 같은 민감 정보를 컨테이너 이미지에 하드코딩하면 이미지를 바꿀 때마다 다시 빌드해야 하고, 보안 사고로도 이어지기 쉽습니다. ConfigMap은 환경변수나 설정 파일처럼 민감하지 않은 설정값을 담고, Secret은 비밀번호나 API 키처럼 민감한 정보를 담는 용도로 사용합니다.

apiVersion: v1
kind: ConfigMap
metadata:
  name: app-config
data:
  DATABASE_HOST: postgres
  DATABASE_PORT: "5432"
---
apiVersion: v1
kind: Secret
metadata:
  name: app-secret
type: Opaque
data:
  DATABASE_PASSWORD: cGFzc3dvcmQ=

여기서 주의할 점은 Secret의 값이 암호화가 아니라 base64로 인코딩만 되어 있다는 사실입니다. 누구든 디코딩하면 원본 값을 바로 확인할 수 있기 때문에, Secret 매니페스트를 그대로 Git 저장소에 커밋해서는 안 됩니다. 실무에서는 Sealed Secrets, External Secrets Operator, 클라우드의 시크릿 매니저(AWS Secrets Manager, GCP Secret Manager 등)와 연동하는 방식을 많이 사용합니다. Pod에서는 이렇게 정의한 ConfigMap과 Secret을 환경변수나 볼륨으로 마운트해서 사용할 수 있습니다.

    envFrom:
    - configMapRef:
        name: app-config
    - secretRef:
        name: app-secret

Ingress: 외부 HTTP 트래픽 라우팅

Service의 LoadBalancer 타입은 서비스마다 별도의 로드 밸런서를 만들기 때문에 서비스가 늘어날수록 비용과 관리 부담이 커집니다. Ingress는 하나의 진입점에서 도메인이나 경로에 따라 여러 서비스로 트래픽을 라우팅할 수 있게 해 주는 오브젝트입니다. 실제로 Ingress가 동작하려면 NGINX Ingress Controller, Traefik 같은 Ingress Controller를 클러스터에 별도로 설치해야 한다는 점을 기억해 두어야 합니다. Ingress 리소스 자체는 라우팅 규칙을 선언하는 역할만 하고, 실제 트래픽 처리는 컨트롤러가 담당합니다.

# ingress.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: nginx-ingress
  annotations:
    nginx.ingress.kubernetes.io/rewrite-target: /
spec:
  rules:
  - host: example.com
    http:
      paths:
      - path: /
        pathType: Prefix
        backend:
          service:
            name: nginx-service
            port:
              number: 80

이 설정은 example.com으로 들어오는 요청을 nginx-service로 전달합니다. 실제 프로덕션 환경에서는 TLS 인증서 발급을 위해 cert-manager를 함께 사용하고, tls 필드를 추가해 HTTPS를 적용하는 경우가 대부분입니다.

헬스체크: Liveness Probe와 Readiness Probe

Kubernetes는 컨테이너가 살아 있는지, 그리고 트래픽을 받을 준비가 되었는지를 스스로 판단하지 못합니다. 이를 판단할 수 있게 해 주는 것이 Probe입니다. Liveness Probe는 컨테이너가 정상적으로 동작하고 있는지를 확인하고, 실패하면 kubelet이 해당 컨테이너를 재시작합니다. Readiness Probe는 컨테이너가 트래픽을 받을 준비가 되었는지를 확인하고, 실패하면 Service가 해당 Pod로 트래픽을 보내지 않습니다.

    livenessProbe:
      httpGet:
        path: /healthz
        port: 80
      initialDelaySeconds: 10
      periodSeconds: 10
    readinessProbe:
      httpGet:
        path: /ready
        port: 80
      initialDelaySeconds: 5
      periodSeconds: 5

initialDelaySeconds는 컨테이너가 시작된 뒤 첫 검사를 시작하기까지 기다리는 시간입니다. 이 값을 너무 짧게 잡으면 애플리케이션이 아직 초기화 중인데도 Probe가 실패로 판단해 불필요한 재시작이 반복되는 상황이 발생할 수 있습니다. 반대로 너무 길게 잡으면 실제로 문제가 생긴 컨테이너를 늦게 발견하게 됩니다. 애플리케이션의 부팅 시간과 헬스체크 엔드포인트의 응답 특성을 확인한 뒤 값을 조정하는 것이 중요합니다.

오토스케일링: HorizontalPodAutoscaler

트래픽이 몰릴 때마다 수동으로 kubectl scale을 실행하는 것은 현실적이지 않습니다. HorizontalPodAutoscaler(HPA)는 CPU 사용률이나 메모리 사용량, 또는 커스텀 메트릭을 기준으로 Pod 개수를 자동으로 늘리거나 줄여 줍니다.

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

이 설정은 평균 CPU 사용률이 70%를 넘으면 Pod 개수를 늘리고, 사용률이 낮아지면 다시 줄이되 최소 2개, 최대 10개 범위를 유지하도록 지시합니다. HPA가 제대로 동작하려면 Deployment의 컨테이너에 resources.requests가 설정되어 있어야 한다는 점에 유의해야 합니다. 사용률은 요청량 대비 실제 사용량으로 계산되기 때문에, requests 값이 없으면 HPA가 스케일링 여부를 판단할 기준 자체가 없어집니다.

트러블슈팅: Pod가 계속 재시작될 때

실무에서 Kubernetes를 처음 다룰 때 가장 자주 마주치는 상황이 바로 Pod가 계속 재시작되는 문제입니다. 대시보드에는 CrashLoopBackOff만 표시되고, 방금 정상적으로 뜬 것 같았던 Pod가 kubectl get pods를 다시 실행해 보면 RESTARTS 칼럼만 계속 올라가 있는 상황입니다. 이럴 때 가장 먼저 확인해야 할 명령은 kubectl describe pod <이름>입니다.

kubectl describe pod <pod-name>
kubectl logs <pod-name>
kubectl logs <pod-name> --previous

describe 명령의 Events 섹션에는 OOMKilled, ImagePullBackOff, Probe 실패 같은 힌트가 시간 순서대로 기록되어 있습니다. 그래서 로그를 먼저 보기보다 Events를 먼저 확인하는 경우가 많습니다. 원인은 대체로 다음 세 가지 중 하나로 좁혀집니다.

첫째, 메모리 limits가 실제 사용량보다 타이트하게 잡혀 있으면 컨테이너가 조용히 종료되고 OOMKilled 상태가 됩니다. 이 경우 애플리케이션의 실제 메모리 사용 패턴을 측정한 뒤 limits 값을 현실적으로 조정해야 합니다. 둘째, Liveness Probe가 너무 공격적으로 설정되어 있으면 애플리케이션이 아직 준비되지 않은 상태에서 실패로 판단되어 재시작되고, 재시작 후에도 같은 이유로 다시 실패하는 루프에 빠질 수 있습니다. initialDelaySecondsfailureThreshold 값을 애플리케이션의 부팅 시간에 맞게 늘려주는 것이 해결책입니다. 셋째, 이미지 태그가 잘못되었거나 프라이빗 레지스트리 인증 정보가 누락된 경우에는 ImagePullBackOff 상태로 pull 단계에서 멈추게 되므로, 이미지 이름과 태그, imagePullSecrets 설정을 다시 확인해야 합니다.

이 세 가지를 순서대로 점검하는 습관을 들이면, 재시작 루프를 마주쳤을 때 당황하지 않고 원인을 빠르게 좁혀갈 수 있습니다.

정리

Kubernetes는 모든 팀이 당장 도입해야 할 만큼 간단한 도구는 아닙니다. 학습 곡선이 가파르고, 운영하려면 네트워크와 스토리지, 보안까지 함께 이해해야 하는 경우가 많습니다. 하지만 여러 팀이 여러 서비스를 운영하면서 롤백, 스케일링, 서비스 디스커버리가 동시에 필요해지는 시점부터는 그 복잡성을 감수할 만한 가치가 분명하게 드러납니다.

이 글에서 다룬 Pod, Deployment, Service, ConfigMap과 Secret, Ingress, Probe, HPA는 실제 운영 환경에서 거의 항상 함께 쓰이는 조합입니다. 각 오브젝트를 따로 외우기보다는, 예시로 등장한 YAML을 직접 열어 놓고 averageUtilization이나 initialDelaySeconds 같은 필드가 왜 그 자리에 있는지 하나씩 확인해 보는 방식으로 학습하는 것을 권장합니다. 그리고 실제 장애 상황에서 Pod가 계속 재시작된다면, 당황하지 말고 describe로 Events를 먼저 확인하고, logs로 애플리케이션 상태를 확인한 뒤, 리소스 설정과 Probe, 이미지 순서로 의심해 보시기 바랍니다. 이 순서만 익혀 두어도 체감 난이도는 확실히 낮아집니다.

같이 보면 좋은 글 (내부 링크)

이 주제와 연결되는 다른 글입니다.