본문 바로가기

Infra/Kubernetes

[Kubernetes] 쿠버네티스 환경 배포를 위한 오브젝트 개념

반응형

실무에서 쿠버네티스 환경에 배포해야 할 상황이 왔다. 배포 가이드 문서를 읽는데 Pod, Deployment, Service 같은 용어가 쏟아진다. 명령어를 그대로 따라 칠 수는 있지만, 문제가 생겼을 때 대응하려면 구조를 알아야 한다.

 

그래서 세 편으로 정리한다. 1편(이 글)은 쿠버네티스를 구성하는 개념들, 이후는 실제 배포와 그 후 흐름이다. docker와 docker-compose까지는 써 봤다는 전제로 쓴다.

0. 세 개의 축

쿠버네티스 오브젝트는 종류가 많아 보이지만, 세 축으로 나누면 자리가 잡힌다.

 

Cluster
│
├─ [인프라]    Node 1, Node 2, …   ← 컨테이너가 실제로 도는 서버들
│
├─ [워크로드]  Deployment ─▶ ReplicaSet ─▶ Pod ─▶ Container
│                                         │
│                          PVC · ConfigMap · Secret (Pod에 꽂아 쓰는 부속)
│
└─ [네트워크]  Ingress ─▶ Service ─(label 매칭)─▶ Pod들

 

  • 인프라 축: 어디서 실행되나
  • 워크로드 축: 무엇을 실행하나
  • 네트워크 축: 어떻게 접근하나

세 개의 축을 하나씩 정리해본다.

1. 인프라 축

docker-compose 환경에서는 서버 한 대에 SSH로 들어가 docker compose up을 치면 끝이었다. 쿠버네티스에서 가장 먼저 버려야 할 감각이 이거다. 서버에 들어가지 않는다.

 

개발자 ──kubectl──▶ Control Plane (API 서버) ──▶ Node들이 컨테이너 실행
                                                   ▲
                      container registry ── pull ──┘

Cluster: 여러 서버를 묶어 하나의 큰 컴퓨터처럼 다루는 단위. "이 컨테이너를 어느 서버에 놓을지"는 개발자가 아니라 클러스터가 정한다.

Node: 클러스터에 소속된 서버 한 대. 컨테이너가 실제로 실행되는 곳이다.

Control Plane: kubectl 요청을 받아 클러스터를 조율하는 관리 영역. EKS·AKS·GKE 같은 managed 서비스에서는 클라우드가 대신 운영해서 직접 볼 일이 거의 없다.

kubectl: 클러스터에 명령을 보내는 CLI. SSH가 아니라 API 서버에 "이렇게 해 줘"라고 요청하는 클라이언트다. 실무에서는 k로 alias를 걸어 쓴다.

 

여기서 헷갈리기 쉬운 것 하나. 이미지를 registry에서 pull 하는 주체는 노드다. kubectl을 실행하는 내 머신이 아니다. 내 머신에서 docker images를 쳐 봐야 그건 로컬 cache일 뿐, 클러스터에서 도는 이미지와 무관하다. "지금 무엇이 돌고 있는가"는 kubectl get deploy -o wide로 클러스터에 물어봐야 한다.

 

container registry: 이미지를 push/pull 하는 원격 이미지 창고. Docker Hub가 공개 registry이고, 회사는 보통 사설 registry(ECR, ACR, Harbor 등)를 쓴다. 이 글에서는 registry.example.com으로 쓴다.

2. 워크로드 축

$ kubectl get pods
NAME                          READY   STATUS    RESTARTS   AGE
myapp-web-bbcbb9688-gq6xp     1/1     Running   0          2d

Pod: 컨테이너를 실행하는 최소 단위. 대부분 Pod 하나 = 컨테이너 하나로 생각해도 된다. 자기만의 IP를 받는다.

 

이름 뒤의 랜덤 문자열(예시에서의 -gq6xp)이 Pod의 본질을 말해 준다. Pod는 일회용이다. 죽으면 같은 Pod가 되살아나는 게 아니라 새 이름, 새 IP의 새 Pod가 만들어진다. compose에서 컨테이너를 재시작하면 같은 컨테이너가 다시 뜨는 것과 감각이 다르다.

 

따라오는 원칙 두 가지:

  • Pod 이름을 적어 두고 쓰지 마라. 다음 배포면 이미 다른 이름이다.
  • 컨테이너 안에서 수정한 파일은 Pod와 함께 사라진다. 컨테이너 파일시스템은 임시다.

그럼 죽은 Pod는 누가 다시 만드나. 여기서 쿠버네티스의 핵심 사고방식이 나온다.

Deployment: "이 이미지로 Pod를 N개 유지하라"는 선언. Pod가 죽으면 Deployment가 새로 만든다. Pod 이름은 매번 바뀌지만 Deployment 이름(myapp-web)은 고정이다.

 

명령형("컨테이너를 띄워라")이 아니라 선언형("이 상태를 유지하라")이다. 프로세스가 죽으면 내가 다시 띄우는 게 아니라, 선언과 실제의 차이를 클러스터가 감지해 스스로 메꾼다. 그래서 로그를 볼 때도 Pod가 아니라 Deployment 이름으로 본다.

 

kubectl logs deploy/myapp-web --tail=100   # Pod 이름이 바뀌어도 항상 동작

 

Pod 이름의 구조도 여기서 나온다.

 

Deployment: myapp-web                     ← 내가 만드는 것. 이름 고정
└─ ReplicaSet: myapp-web-bbcbb9688        ← 버전마다 자동 생성
   └─ Pod: myapp-web-bbcbb9688-gq6xp      ← 랜덤 접미사, 일회용

ReplicaSet: "Pod N개 유지"를 실제로 수행하는 중간 오브젝트. Deployment가 버전(이미지·설정 조합)마다 하나씩 자동으로 만든다. 직접 다룰 일은 없지만, 옛 버전의 ReplicaSet이 남아 있는 덕분에 롤백이 가능하다.

 

정리하면: Deployment가 ReplicaSet을 만들고, ReplicaSet이 Pod를 만들고, Pod가 Node 위에서 컨테이너를 돌린다. 개발자가 만지는 건 양 끝이다. (선언은 Deployment에, 디버깅은 Pod에)

3. 매니페스트와 apply

Deployment는 compose처럼 YAML로 작성한다. 매니페스트(manifest)라고 부른다.

 

# myapp-web.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: myapp-web
spec:
  replicas: 2                # Pod 몇 개 유지할지
  selector:
    matchLabels: { app: myapp-web }
  template:                  # 이 아래가 Pod의 정의
    metadata:
      labels: { app: myapp-web }   # Pod에 붙는 꼬리표 — 네트워크 축에서 중요해진다
    spec:
      containers:
        - name: myapp-web
          image: registry.example.com/myapp:latest

 

적용은 kubectl apply -f myapp-web.yaml 명령어를 사용해서 한다. 여기까지는 compose와 비슷한데, 결정적 차이가 있다.

 

compose는 파일이 유일한 정의다. 하지만 쿠버네티스는 apply 하는 순간 클러스터 안에 오브젝트로 저장되고, 그쪽이 진실이 된다.

etcd: control plane이 쓰는 key-value 저장 시스템. apply로 만든 모든 오브젝트의 현재 정의가 여기 영속적으로 저장된다. 메모리가 아니라 디스크라서 클러스터가 재부팅돼도 남는다.

 

그래서 파일 없이도 현재 정의를 통째로 꺼내볼 수 있고

 

kubectl get deploy myapp-web -o yaml

 

파일을 안 거치고 클러스터 안의 오브젝트를 직접 고치는 명령들도 있다.

 

kubectl set image deploy/myapp-web myapp-web=registry.example.com/myapp:v2
kubectl scale deploy/myapp-web --replicas=4

 

그렇다면 apply 후에 set image로 고친 내용은 Pod가 죽으면 사라질까? 그건 아니다. 수정한 대상이 무엇이냐에 따라 운명이 갈린다.

 

수정한 곳 저장 위치 Pod가 죽으면 사라지는 조건
오브젝트 (set image, edit) etcd (영속) 유지 — 새 Pod가 수정된 정의로 뜬다 누군가 옛 yaml로 다시 apply 할 때
컨테이너 안 파일 (exec로 수정) 컨테이너 파일시스템 (임시) 즉시 사라짐 Pod 재생성
디스크의 yaml 파일 내 디스크 무관 — (클러스터에 반영 안 된 채 존재)

 

오브젝트 수정은 오히려 인과가 반대다. Pod가 죽으면 etcd의 (수정된) Deployment 정의를 읽어서 새 Pod를 만드니까, 수정이 사라지기는커녕 계속 적용된다.

 

진짜 위험은 세 번째 줄이다. set image로 클러스터를 고쳐도 디스크의 yaml은 옛날 그대로다. 어긋난 채 지내다가 누군가 옛 파일로 apply 하는 순간 수동 수정이 덮여 사라진다. 그래서 수동 kubectl 수정을 했으면 yaml에도 반영해 두는 게 원칙이고, 이 동기화를 사람 손에 안 맡기려는 해법이 GitOps다.

GitOps / ArgoCD: git repo의 매니페스트를 진실로 삼고, 클러스터 상태가 어긋나면 자동으로 되돌리는 운영 방식과 그 대표 도구. ArgoCD가 붙은 클러스터에서 수동 set image를 하면 잠시 후 소리 없이 원상복구된다. 배포 전에 클러스터에 GitOps가 붙어 있는지 확인해야 하는 이유다.

 

compose와 또 하나의 차이는 compose 한 파일이 쿠버네티스에서는 여러 오브젝트로 쪼개진다는 것이다. 컨테이너 실행은 Deployment, 접근 경로는 Service와 Ingress, 디스크는 PVC, 환경변수는 ConfigMap/Secret. ---로 한 파일에 몰아넣거나 디렉토리째 apply 할 수 있다.

4. 네트워크 축

Pod는 IP가 계속 바뀐다. 그래서 고정 진입점이 계층으로 놓인다.

 

브라우저 (api.example.com)
   │
   ▼
Ingress             ← 도메인·경로 기반 라우팅 규칙
   │
   ▼
Service (myapp-web)  ← 고정 주소. label이 일치하는 Pod들로 부하 분산
   │
   ├──▶ Pod (…-gq6xp)
   └──▶ Pod (…-x7z2q)

Service: label selector로 Pod들을 묶어 고정 주소 하나를 주는 오브젝트. 클러스터 안에서는 Service 이름이 곧 DNS 이름이라 http://myapp-web으로 호출한다.

Ingress: 클러스터 밖 트래픽을 도메인/경로 규칙으로 Service에 나눠 주는 관문. HTTPS 인증서 처리도 보통 여기서 한다.

 

Service는 Pod를 띄운다고 자동으로 생기지 않는다. Deployment와 완전히 별개의 오브젝트라 직접 만들어야 한다.

 

kind: Service
metadata:
  name: myapp-web
spec:
  selector:
    app: myapp-web        # ← Deployment template의 label과 문자열이 일치해야 연결된다
  ports:
    - port: 80
      targetPort: 8000    # 컨테이너가 listen 하는 포트

 

Deployment와 Service 사이에 직접 참조는 없다. app: myapp-web이라는 문자열이 일치하는 것이 연결의 전부다. 이들의 관계는 아래 표와 같다.

 

상황 결과
Service를 안 만듦 Pod는 잘 돈다. 단, 고정 주소가 없다
Service는 있는데 label 불일치 (오타 등) Service는 존재하지만 연결 대상이 빈다 → 배포는 성공했는데 503
label과 selector 일치 트래픽 분산 정상 동작

 

다만 생성은 수동, 그 이후의 멤버 관리는 자동이다. Service를 한 번 만들어 두면 selector와 일치하는 Pod 목록을 클러스터가 실시간 갱신한다 — Pod가 죽으면 빼고, 새로 뜨면 넣고, 아직 기동 중이면 제외한다. 배포 중에 Pod가 전부 교체돼도 트래픽이 안 끊기는 이유다.

 

kubectl get svc                      # Service 목록
kubectl describe svc myapp-web       # Endpoints가 비어 있으면 label 불일치 신호

 

worker처럼 호출받을 일 없는 앱(큐에서 작업을 가져가기만 하는)은 Service를 안 만드는 게 정상이다. Service는 "호출받는 쪽"에만 필요하다.

Service가 없으면 Pod에 어떻게 접근하나

클러스터 에서는 Pod IP(kubectl get pods -o wide)로 직접 통신할 수 있지만, 재생성되면 IP가 바뀌므로 운영 수단이 아니다. 클러스터 에서는 Pod IP가 내부 전용 대역이라 아예 닿지 않는다. 외부 노출은 Service type이 정한다.

 

Service type 노출 범위
ClusterIP (기본값) 클러스터 안에서만
NodePort 모든 Node의 특정 포트로 외부 접근
LoadBalancer 클라우드 로드밸런서로 외부 IP 부여
+ Ingress 그 위에 도메인·경로 라우팅을 얹음

 

port-forward를 통해 연결할 수도 있다. 디버깅할 때 정말 자주 쓴다:

 

kubectl port-forward deploy/myapp-web 8000:8000
curl http://localhost:8000/health    # 내 PC에서 바로 접근

port-forward: 내 PC의 포트를 kubectl의 인증된 연결을 타고 Pod 포트로 터널링하는 기능. 네트워크 경로가 안 닿아도 동작한다. 터미널을 끄면 끊기는 임시 통로라 서비스 트래픽용은 아니다.

5. 부속 오브젝트

Pod
├─ volumeMounts ──▶ PVC        ← Pod가 죽어도 남는 디스크
├─ env ──────────▶ ConfigMap   ← 일반 설정값
└─ env ──────────▶ Secret      ← API 키 등 민감정보

PVC(PersistentVolumeClaim): "이만한 디스크가 필요하다"는 요청서. 컨테이너 파일시스템은 임시지만, PVC로 마운트한 경로(/uploads 같은)만은 Pod 재생성 후에도 데이터가 남는다.

ConfigMap / Secret: 이미지에 넣으면 안 되는 설정값·비밀값을 담는 오브젝트. 값을 바꿔도 이미 뜬 Pod에는 반영되지 않는다. 환경변수는 프로세스 시작 시점에 읽히므로 Pod 재생성이 필요하다.

Namespace: 위의 모든 오브젝트를 담는 논리적 폴더. 한 클러스터에서 dev/prod를 나눌 때 쓴다. kubectl 명령에 -n이 없으면 default 또는 kubeconfig에 지정된 namespace를 본다. Node는 물리 자원이라 namespace에 속하지 않는 예외다.

6. 정리

오브젝트 비유
Cluster 데이터센터
Node 서버 한 대
Deployment systemd 서비스 정의
Pod 프로세스 (일회용)
Service 사내 DNS + 로드밸런서
Ingress 최전방 nginx 설정
PVC NFS 마운트
ConfigMap / Secret .env 파일
etcd 오브젝트 vs yaml 파일 DB의 현재 상태 vs 마지막으로 실행한 SQL 스크립트

 

  1. Pod는 일회용이다. 고정된 것은 Deployment 이름과 볼륨뿐이고, 컨테이너 안 수정은 재생성과 함께 사라진다.
  2. 진실은 파일이 아니라 클러스터 안(etcd)에 있다. yaml은 apply 시점의 입력일 뿐이며, 오브젝트 수정은 영속이고 파일과의 어긋남이 사고의 씨앗이다.
  3. 연결은 label 문자열 매칭이다. Service는 자동으로 생기지 않고, 만든 뒤의 멤버 갱신만 자동이다.
반응형