| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | 5 | ||
| 6 | 7 | 8 | 9 | 10 | 11 | 12 |
| 13 | 14 | 15 | 16 | 17 | 18 | 19 |
| 20 | 21 | 22 | 23 | 24 | 25 | 26 |
| 27 | 28 | 29 | 30 |
- Rag
- blockchain
- Android
- java
- architecture
- GCP
- SQL
- TypeScript
- Algorithm
- 배포
- CS
- http
- Python
- LLM
- Design
- springboot
- DL
- DB
- Database
- spring boot
- Kotlin
- OS
- inflearn
- frontend
- Studying
- AI
- Network
- Spring
- docker
- cloud
- Today
- Total
소소한 지식 저장소
[STUDYING] 35. 쿠버네티스 이해 및 애플리케이션 배포_Day1_핵심 정리 본문
1. 왜 쿠버네티스인가
배포 방식의 변화 · 선언형 API · 매니페스트와 리소스 · 실습 환경
이 과정에서 만들어 볼 것
Spring Boot 앱 하나를 컨테이너로 만들어 EKS에 올리고, 무중단으로 고쳐 배포하는 데까지 가는 것이 이 과정의 목표임. 직전 과정에서 만든 REST API를 그대로 가져다 컨테이너로 감싸며, 2일 안에 내 앱이 도메인으로 접속된다까지 도달하는 것이 목표임.

배포 방식은 세 번 바뀌었다
물리 서버 → 가상 머신 → 컨테이너 순으로 배포 방식이 세 번 바뀌었고, 매번 한 대에 몇 개를 안전하게 올릴 수 있는가가 달라짐. 격리 단위가 작아질수록 부팅이 빨라지고 밀도가 올라가며, 컨테이너는 커널을 공유하므로 OS 부팅 시간이 통째로 사라짐.
- 격리 단위가 작아질수록 부팅이 빨라지고 밀도가 올라감
- 컨테이너는 커널을 공유하므로 OS 부팅 시간이 통째로 사라짐
| 시대 | 격리 단위 | 기동 시간 | 한 대당 개수 | 약점 |
| 물리 서버 | 서버 1대 = 앱 1개 | 수 분 ~ 수십 분 | 1 ~ 2개 | 자원 낭비, 증설에 수 주 |
| 가상 머신 | 게스트 OS 통째로 | 30초 ~ 2분 | 10 ~ 20개 | OS 중복, 이미지 수 GB |
| 컨테이너 | 프로세스 · 네임스페이스 | 0.1 ~ 2초 | 50 ~ 200개 | 커널 공유, 격리가 약함 |
참고: 컨테이너는 VM보다 안전한 것은 아님. 커널을 공유하므로 커널 취약점 하나가 전체에 영향을 줌
컨테이너만으로는 부족하다
docker run은 컨테이너 하나를 띄우는 것뿐이며, 서비스를 운영한다는 것은 그다음 스무 가지를 하는 일임. 컨테이너 3개까지는 사람이 손으로 관리할 수 있지만 30개부터는 사람이 못 함. 손으로 하던 그 일들이 곧 오케스트레이터의 기능 목록이 됨.
| 운영에서 실제로 필요한 것 | docker run 만 있을 때 | 쿠버네티스 |
| 프로세스가 멈추면 재시작 | restart 옵션, 노드가 멈추면 끝 | 다른 노드에 다시 스케줄 |
| 트래픽을 여러 복제본에 분배 | 앞에 nginx를 직접 세우고 설정 수정 | Service가 자동 분배 |
| 복제본 주소가 바뀌면 반영 | 설정 파일을 사람이 고치고 reload | Endpoint가 자동 갱신 |
| 새 버전으로 무중단 교체 | 직접 순서를 짜서 하나씩 교체 | 롤링 업데이트 내장 |
| 문제 생기면 이전 버전 복귀 | 이전 이미지 태그를 기억해야 함 | rollout undo 한 줄 |
| 부하에 따라 개수 조절 | 모니터링 보고 사람이 실행 | HPA가 지표 보고 자동 |
| 설정 · 비밀값 주입 | -e 나열, 비밀값이 명령 이력에 남음 | ConfigMap · Secret |
| 어느 노드에 올릴지 결정 | 사람이 서버를 골라 SSH | 스케줄러가 자원 보고 배치 |
정리: 쿠버네티스는 새 개념이 아니라 운영자가 손으로 하던 판단을 코드로 옮긴 것임
선언형 API와 조정 루프
무엇을 하라가 아니라 어떤 상태여야 하는가를 적으면, 컨트롤러가 계속 그 상태로 되돌림. 매니페스트는 명령이 아니라 원하는 상태(desired state) 선언이며, 컨트롤러는 현재 상태와 비교해 차이가 사라질 때까지 반복함 — 이것이 조정 루프임.
apiVersion: apps/v1
kind: Deployment
metadata:
name: shop-api
spec:
replicas: 3 # "3개여야 한다" — 만들라는 명령이 아니다
selector:
matchLabels:
app: shop-api # 이 라벨을 가진 Pod 를 내 것으로 센다
template: # 부족하면 이 틀로 새로 만든다
metadata:
labels: { app: shop-api }
spec:
containers:
- name: app
image: shop-api:1.0.0
정리: Pod를 사람이 지워도 3개로 되돌아옴. 지워지지 않는 게 아니라 다시 만들어지는 것임
명령형과 선언형, 무엇이 다른가
같은 결과를 만들지만, 두 번째 실행했을 때와 사고가 났을 때 완전히 달라짐. 명령형은 지금 무엇을 할지, 선언형은 최종 상태가 무엇인지를 적음. 실습·디버깅은 명령형이 빠르고, 운영에 남기는 것은 반드시 선언형이어야 함.
| 명령형 (kubectl run / create, kubectl scale --replicas=5) | 선언형 (kubectl apply -f) |
| 동작을 지시한다 | 원하는 상태를 파일에 적고 적용한다 |
| 두 번 실행하면 에러(이미 있음) | 몇 번을 실행해도 결과가 같다 (멱등) |
| 누가 언제 무엇을 바꿨는지 기록이 안 남는다 | Git 이력이 곧 변경 이력이 된다 |
| 클러스터를 새로 만들면 복원 불가 | 클러스터가 사라져도 apply로 재현 |
| 쓸 곳: 임시 확인, 디버깅용 Pod 띄우기 | 쓸 곳: 운영에 올라가는 모든 것 |
주의: 운영 클러스터에서 kubectl edit으로 고치면 Git과 실제가 어긋남. 급하면 고치되 반드시 파일에 반영해야 함
Manifest 와 Resource
둘은 다른 것임. 명세서를 내면 클러스터가 실물을 만들어 줌. Manifest는 그릇, Resource는 내용물이며, 파일을 지운다고 리소스가 사라지지 않음.
| 구분 | Manifest (명세서) | Resource (실물) |
| 무엇 | YAML 파일 · 요청서 | 클러스터 안에 만들어진 객체 |
| 어디에 | 내 노트북 · Git | etcd |
| 누가 만드나 | 사람 | 컨트롤러 |
| 확인 | cat deploy.yaml | kubectl get deploy |
| 지우면 | 리소스는 그대로 남는다 | 클러스터에서 사라진다 |
| 예 | kind: Deployment 를 적은 파일 | 이름 · 상태 · 이벤트를 가진 Deployment |
정리: 파일을 고쳤다고 클러스터가 바뀌지 않음. apply로 전달해야 비로소 리소스가 바뀜
spec 과 status
내가 쓰는 것은 spec뿐이며, status는 클러스터가 채워 넣는 답장임. 조정 루프는 status를 spec에 맞추는 일을 끝없이 반복하는 것임.
| spec 은 내가 작성한다 | status 는 클러스터가 채운다 |
| replicas: 3 | replicas: 3 (실제 생성됨) |
| image: nginx:1.21.6 | readyReplicas: 2 (하나가 아직) |
| containerPort: 80 | availableReplicas: 2 |
| 원하는 상태를 적는다 | 지금 상태가 적힌다 |
| 손으로 편집하는 유일한 곳 | 고쳐도 곧 덮어써진다 |
체크: kubectl get deploy 의 READY 열이 2/3 이면 status가 spec을 아직 못 따라잡은 것임
YAML 로 쓰고 JSON 으로 읽는다
API 서버는 JSON으로 말하며, YAML은 사람이 쓰기 좋으라고 있는 껍데기임. -o json으로 보면 API가 실제로 주고받는 모양이 보이고 jq로 다루기 좋음.
kubectl get pod my-pod -o yaml # 사람이 읽기 좋다
kubectl get pod my-pod -o json # 도구가 다루기 좋다
# 같은 데이터다. jq 를 쓰려면 json 이 편하다
kubectl get pod -o json | jq -r '.items[] | .metadata.name'
# YAML 을 쓸 때 자주 하는 실수
# ① 탭 문자 — YAML 은 공백만 쓴다
# ② 들여쓰기 2칸/4칸 혼용
# ③ 문자열인데 따옴표를 안 씀 — port: "8080" 과 8080 은 다르다
# ④ 목록 하이픈 뒤 공백 누락
kubectl apply --dry-run=server -f deploy.yaml # 보내 보되 저장은 안 한다
주의: "100"과 100은 다른 타입임. 문자열 자리에 숫자를 넣으면 cannot unmarshal로 거부됨
개발자가 알아야 할 8가지
리소스는 수십 가지지만, 앱을 올리는 데 실제로 사용하는 것은 여덟 개임. 앞의 넷이 돌아가게 하고, 뒤의 넷이 제대로 돌아가게 함.
| 리소스 | 한 줄 정의 | 없으면 | 관련 장 |
| Pod | 컨테이너를 담는 최소 실행 단위 | 아무것도 실행하지 못한다 | 7장 |
| Deployment | Pod 를 몇 개, 어떻게 바꿀지 | 멈추면 안 살아난다 | 8장 |
| Service | 안 바뀌는 주소 · 부하 분산 | Pod IP 를 쫓아다녀야 | 9장 |
| Ingress | 도메인 · 경로로 외부에서 들어오는 문 | 밖에서 못 붙는다 | 11장 |
| ConfigMap | 설정을 이미지 밖으로 | 환경마다 재빌드 | 12장 |
| Secret | 비밀값을 따로 인코딩 | Git 에 비밀번호가 남는다 | 12장 |
| PVC | 사라지지 않는 저장소 요청 | 재시작하면 데이터가 증발 | 12장 |
| StatefulSet | 순서 · 이름이 고정된 Pod 집합 | DB 같은 것을 못 올린다 | 심화 |
- Pod (파드)
- 설명: 쿠버네티스에서 배포할 수 있는 가장 작은 단위입니다. 하나 이상의 도커 컨테이너를 감싸고 있는 껍데기입니다.
- 없으면? 쿠버네티스 위에 실행할 수 있는 프로그램(앱) 자체가 존재할 수 없습니다.
- Deployment (디플로이먼트)
- 설명: Pod의 개수를 유지하고 업데이트/배포 상태를 관리합니다. (예: "Pod 3개 유지해 줘", "새 버전으로 바꿔 줘")
- 없으면? Pod가 다운되었을 때 자동으로 재생성되지 않으며, 무중단 배포나 롤백이 불가능해집니다.
- Service (서비스)
- 설명: Pod는 재시작될 때마다 IP가 계속 바뀝니다. Service는 고정된 내부 IP를 제공하고, 트래픽을 여러 Pod로 균등하게 나누어 줍니다(부하 분산).
- 없으면? 내부 서비스끼리 통신할 때 계속 바뀌는 Pod의 IP를 매번 일일이 추적하고 찾으러 다녀야 합니다.
- Ingress (인그레스)
- 설명: 클러스터 외부에서 들어오는 요청(HTTP/HTTPS)을 특정 서비스로 연결해 주는 '문' 역할을 합니다. (도메인 주소나 URL 경로 기반 라우팅)
- 없으면? 외부 사용자가 웹 브라우저나 앱을 통해 클러스터 내부의 서비스에 접근하기 어렵습니다.
- ConfigMap (컨피그맵)
- 설명: 데이터베이스 주소, 포트 번호 같은 설정값을 컨테이너 이미지와 분리하여 저장하는 공간입니다.
- 없으면? 설정 하나 바꿀 때마다 소스 코드를 수정하고 도커 이미지를 매번 다시 빌드해야 합니다.
- Secret (시크릿)
- 설명: DB 비밀번호, API 키, SSH 키 같은 보안이 필요한 민감한 정보를 암호화(Base64 인코딩)하여 관리하는 공간입니다.
- 없으면? 비밀번호를 코드에 직접 하드코딩해서 Git 같은 소스 관리 시스템에 노출될 위험이 큽니다.
- PVC (PersistentVolumeClaim, 영구 부피 요청)
- 설명: Pod가 꺼지거나 삭제되어도 데이터가 사라지지 않고 유지되도록 영구 저장소(디스크)를 할당해 달라고 요청하는 것입니다.
- 없으면? Pod가 재시작될 때 그 안에 저장해 둔 파일이나 사용자 데이터가 전부 증발합니다.
- StatefulSet (스테이트풀셋)
- 설명: DB(MySQL, Redis 등)처럼 고정된 이름과 순서, 전용 저장소가 필요한 애플리케이션을 관리하는 리소스입니다.
- 없으면? 데이터 일관성 및 상태 보존이 필수적인 데이터베이스류 시스템을 쿠버네티스에 올리기 매우 힘듭니다.
정리: 앞의 넷이 돌아가게 하고, 뒤의 넷이 제대로 돌아가게 함. 순서대로 배움
핵심 리소스의 전체 그림
여덟 개가 따로 있는 게 아니라 하나의 요청

경로 위에 순서대로 놓여 있음. 네트워크가 두 겹임 — Service 네트워크(클러스터)와 Pod 네트워크(오버레이).
참고: Service는 물리적 실체가 없음. 각 노드의 iptables 규칙일 뿐이며, 9장에서 직접 확인함
노드 · 파드 · 컨테이너
세 겹임. 각각이 무엇을 공유하고 무엇을 격리하는지가 다름. 같은 Pod 안의 컨테이너는 localhost로 통신하며, 이것이 사이드카가 가능한 이유임.
| 구분 | 노드 | 파드 | 컨테이너 |
| 정체 | 물리 · 가상 머신 (EC2) | 컨테이너 묶음 | 프로세스 격리 단위 |
| IP | 노드 IP (VPC) | Pod IP (하나) | Pod IP 를 공유 |
| 네트워크 | VPC 서브넷 | Pod 마다 별도 | localhost 로 서로 통신 |
| 저장소 | 노드 디스크 | 볼륨을 공유 가능 | 각자 파일시스템 |
| 수명 | 길다 (교체 가능) | 짧다 (언제든 재생성) | Pod 와 함께 |
| 개수 | 노드당 58 Pod 상한 | 보통 컨테이너 1개 | 사이드카 시 2 ~ 3개 |
체크: kubectl get pod -o wide 한 줄에 Pod 이름 · Pod IP · 어느 NODE 인지가 다 나옴
→ 컨테이너들의 실행 단위가 파드이기 때문에, 파드와 컨테이너는 생명 주기가 같음
쿠버네티스의 경계선
어디까지가 내 책임이고 어디부터가 클러스터의 책임인지 선을 그어 둠. 경계를 모르면 남의 영역을 고치려다 시간을 버림.
| 경계 | 내쪽 | 클러스터 쪽 |
| 이미지 | 무엇을 담을지 · Dockerfile | 받아 오고 실행하기 |
| 개수 | 몇 개 원하는지 (spec) | 그 개수를 유지하기 |
| 배치 | 제약만 준다 (requests) | 어느 노드에 둘지 |
| IP | 관여하지 않는다 | Pod IP · Service IP 할당 |
| 재시작 | 프로브로 판단 기준만 | 실제 재시작 실행 |
| 설정값 | ConfigMap 내용 | 주입 시점과 방법 |
| 저장소 | 얼마나 · 어떤 모드 | 실제 볼륨 생성 · 연결 |
| 장애 복구 | 설계로 대비 | 노드 단위 재배치 |
실무: 오른쪽 열을 손으로 하려 들면 반드시 충돌함. 컨트롤러가 되돌리므로, 원하는 게 있으면 spec을 고쳐야 함
관리형 쿠버네티스와 EKS
컨트롤 플레인을 직접 운영하는 것과 맡기는 것의 차이이며, 이 과정은 EKS 기준으로 진행함. 직접 구축(kubeadm)은 etcd 백업 · 인증서 갱신 · HA 구성까지 전부 우리 책임이지만, EKS는 컨트롤 플레인을 AWS가 운영하고 우리는 노드와 워크로드만 봄.
| 항목 | 직접 구축 (kubeadm) | AWS EKS |
| 컨트롤 플레인 서버 | 직접 3대 이상 구성 · 감시 | AWS가 다중 AZ로 운영 |
| etcd 백업 | cron 으로 직접 스냅샷 | AWS가 자동 백업 |
| 인증서 갱신 | 1년마다 수동 갱신, 놓치면 클러스터 정지 | 자동 |
| 버전 업그레이드 | 컨트롤 플레인 · 노드 전부 직접 | 컨트롤 플레인은 콘솔에서, 노드는 우리가 |
| 비용 | 서버 값 + 인건비가 진짜 비용 | 클러스터당 시간당 $0.10 (월 약 $73) |
| 장애 시 지원 | 우리 팀이 새벽에 대응 | AWS 지원 티켓 |
| AWS 연동 | 직접 컨트롤러 설치 | IRSA · ALB · EBS CSI 를 표준으로 제공 |
정리: 컨트롤 플레인은 맡기고 워크로드에 집중함. 이 과정에서 우리가 배우는 것도 대부분 워크로드 쪽임
이 과정의 실습 환경
EKS 클러스터 하나를 함께 씀. 네임스페이스는 반별로 하나이고, 반 안에서는 공유함. 네임스페이스가 1인 1개가 아니라 1반 1개이며, 이름 규칙이 곧 사고 방지 장치가 됨.
| 항목 | 항목 | 메모 |
| 클러스터 | skala-2026 (ap-northeast-2) | Kubernetes 1.36 |
| 노드 | m6i.xlarge (4vCPU · 16GiB) | 훈련생용 0 ~ 6대, 노드당 58 Pod |
| 네임스페이스 | class-6 ~ class-10 | 반별 하나, 같은 반은 공유 |
| 레지스트리 | Harbor (skala-registry.skala-ai.com) | 프로젝트 = 네임스페이스 이름 |
| Ingress | ingress-nginx 공용 LB 하나 | ALB 컨트롤러는 없음 |
| 스토리지 | ebs-sc 기본 · efs-sc RWX | 단일 AZ (ap-northeast-2b) |
| 로컬 도구 | kubectl · awscli v2 · docker | 5장에서 설치 확인 |
주의: 모든 리소스 이름 뒤에 자기 식별자를 붙임 (예: shop-P000). 안 붙이면 옆 사람 것을 지우게 됨
내 앱이 놓이는 자리
클러스터 안에서 만드는 것과 밖에서 가져다 사용하는 것이 갈림. 요청은 도메인 하나로 들어와 공용 LB를 거쳐 각 반 네임스페이스로 갈라지며, 이미지와 볼륨은 클러스터 밖에서 옴.

- 흐름: 사용자 브라우저 → *.skala-ai.com → ingress-nginx 공용 LB 하나 → 네임스페이스 class-6(반별 하나) 안의 Service → Pod(ConfigMap · Secret 부착, PVC 연결)
- 클러스터 밖에서 오는 것: 이미지 레지스트리(Harbor, skala-registry.skala-ai.com), 스토리지 클래스(ebs-sc: 블록 · RWO · 기본, efs-sc: 파일 · RWX)
- 같은 반이 하나의 네임스페이스를 공유하므로 리소스 이름 뒤에 자기 식별자를 붙여 구분함
- 다이어그램 표기: 빨간 화살표는 요청 경로, 점선은 이미지와 볼륨을 가져오는 길, 회색 상자는 클러스터 밖을 의미
→ 네임스페이스(class-6), Ingress(ingress-ngix)는 반 단위로 공유하고, Service, Pod, ConfigMap, Secret, PVC는 개인별로 독립적으로 가짐
정리: 매니페스트로 만드는 것은 네임스페이스 안쪽뿐임. 레지스트리와 스토리지는 밖에 있고 이름으로만 참조함
[핵심 요약] 왜 쿠버네티스인가
1장에서 남길 것은 개념 하나와 판단 기준 하나임.
| 항목 | 한 줄 정리 | 실무 포인트 |
| 배포의 변화 | 물리 → VM → 컨테이너, 격리 단위가 작아졌다 | 컨테이너가 VM보다 안전한 건 아니다 |
| 오케스트레이션 | 손으로 하던 운영 판단을 코드로 옮긴 것 | 재시작 · 분배 · 롤아웃 · 확장이 기본 기능 |
| 선언형 API | 원하는 상태를 적으면 컨트롤러가 맞춘다 | Pod 를 지워도 되살아나는 이유 |
| 조정 루프 | 현재 ↔ 목표를 계속 비교해 차이를 메운다 | 멈추려면 replicas 0 또는 삭제 |
| 명령형 vs 선언형 | 디버깅은 명령형, 운영은 선언형 | edit 로 고쳤으면 그날 파일에 반영 |
| 범위 밖 | 로그 · 모니터링 · CI 는 따로 붙여야 한다 | 도입 계획에 수집기가 없으면 첫 장애에서 막힌다 |
| 도입 판단 | 서비스 5개 이상 · 잦은 배포부터 이득 | 팀에 아는 사람 0명이면 PaaS 먼저 |
| EKS | 컨트롤 플레인은 AWS, 노드부터는 우리 | 버전 지원 14개월, 연 1회 업그레이드 계획 |
체크: 매니페스트에 replicas: 3 이라고 적으면 그다음 무슨 일이 일어나는지 한 문장으로 말할 수 있는가?
→ k8의 deployment controller가 원하는 상태(3개)를 맞추기 위해 클러스트 내에 동일한(Pod) 3개를 자동으로 생성하고 유지함.
2. 컨테이너와 이미지
이미지와 레이어 · Dockerfile · JVM 메모리 · 다이제스트와 레지스트리
컨테이너는 무엇으로 만들어지나
컨테이너는 특별한 기술이 아니라 리눅스 커널 기능 세 가지를 조합한, 잘 격리된 프로세스임. 호스트에서 ps를 치면 컨테이너 프로세스가 그대로 보이며, 가상 머신이 아니므로 부팅도 게스트 OS도 없음.
| 커널 기능 | 무엇을 하나 | 이것이 없으면 |
| namespace | PID · 네트워크 · 마운트 · 사용자를 따로 보이게 | 옆 컨테이너의 프로세스가 다 보인다 |
| cgroup | CPU · 메모리 사용량에 상한을 건다 | 한 컨테이너가 노드 메모리를 다 소모한다 |
| union filesystem | 읽기 전용 레이어를 겹쳐 하나로 보이게 | 이미지마다 OS 파일을 통째로 복사 |
| capabilities | root 권한을 잘게 쪼개 일부만 준다 | 컨테이너 root = 호스트 root 위험 |
| seccomp | 쓸 수 있는 시스템 콜을 제한 | 커널 공격면이 그대로 열린다 |
정리: 컨테이너 = namespace(시야 격리) + cgroup(자원 제한) + 레이어 파일시스템. 나머지는 편의 도구임
이미지와 컨테이너의 관계
이미지는 읽기 전용 설계도, 컨테이너는 그 위에 쓰기 층을 하나 얹은 실행 인스턴스임. 같은 이미지로 컨테이너를 100개 띄워도 디스크는 이미지 한 벌만 쓰며, 컨테이너 안에서 만든 파일은 컨테이너가 사라지면 같이 사라짐.
- 이미지(base: eclipse-temurin + 앱 JAR, 읽기 전용) 위에 컨테이너 A · 컨테이너 B가 각자의 쓰기 층을 얹어 이미지 레이어를 공유하는 구조
| 이미지 | 컨테이너 | 남기려면 |
| 빌드 시점에 확정 | 실행 시점에 생성 | 볼륨을 붙인다 |
| 읽기 전용 | 쓰기 층은 임시 | 12장 PV · PVC |
| 레지스트리에 저장 | 지우면 데이터 소멸 | 로그는 stdout 으로 |
주의: 컨테이너 안에 파일로 저장한 것은 재시작하면 사라짐. 로그를 파일로 쓰면 장애 원인이 같이 사라짐
이미지 레이어와 빌드 캐시
Dockerfile 한 줄이 레이어 하나이며, 바뀐 줄부터 아래는 전부 다시 빌드됨. 자주 바뀌는 것을 아래로 내리면 캐시가 살아 빌드가 몇 배 빨라지며, 의존성 다운로드를 소스 복사보다 먼저 하는 것이 핵심임.
# 나쁜 예 — 소스가 한 글자만 바뀌어도 의존성을 다시 받는다
COPY . /app # 레이어 3 (소스가 자주 바뀜)
RUN ./gradlew build # 레이어 4 → 매번 의존성 재다운로드 (5~10분)
# 좋은 예 — 빌드 파일이 안 바뀌면 의존성 레이어가 캐시에서 나온다
COPY gradle/ gradle/ # 레이어 3 (거의 안 바뀜)
COPY build.gradle settings.gradle ./
RUN ./gradlew dependencies # 레이어 5 → 캐시 적중 (0초)
COPY src/ src/ # 레이어 6 (자주 바뀜)
RUN ./gradlew bootJar # 레이어 7 → 여기부터만 재빌드 (30초)
정리: 레이어 순서 하나로 CI 빌드가 8분에서 40초가 됨. 커밋마다 도는 파이프라인에서는 큰 차이임
Dockerfile 핵심 지시어
열 개 남짓이면 충분함. 다만 CMD와 ENTRYPOINT, COPY와 ADD의 차이는 반드시 알아야 함. ENTRYPOINT는 exec 형식으로 써야 시그널이 앱에 전달되며, ADD는 URL 다운로드 · 자동 압축해제까지 하므로 COPY를 기본으로 씀.
| 지시어 | 하는 일 | 실무 주의점 |
| FROM | 베이스 이미지 지정 | 태그를 고정한다. :latest 는 재현이 안 된다 |
| WORKDIR | 이후 명령의 작업 디렉터리 | RUN cd 는 다음 줄에 안 남는다 |
| COPY | 빌드 컨텍스트에서 파일 복사 | .dockerignore 로 불필요 파일 제외 |
| ADD | COPY + URL · 자동 압축해제 | 의도치 않은 해제가 생긴다. 쓰지 않는다 |
| RUN | 빌드 시점에 명령 실행 | && 로 묶어 레이어 수를 줄인다 |
| ENV | 환경변수 설정 | 비밀값을 넣지 않는다. 이미지에 그대로 남는다 |
| EXPOSE | 문서용 포트 선언 | 실제로 열리지 않는다. 문서일 뿐 |
| USER | 실행 사용자 지정 | non-root 지정이 기본 |
| ENTRYPOINT | 컨테이너의 주 명령 | ["java","-jar",...] exec 형식으로 |
| CMD | ENTRYPOINT 의 기본 인자 | docker run 뒤 인자로 덮인다 |
주의: ENTRYPOINT java -jar app.jar(셸 형식)로 쓰면 앱이 PID 1이 아니라 SIGTERM 을 못 받고 강제 종료됨
Spring Boot 앱의 Dockerfile
멀티스테이지로 빌드 환경과 실행 환경을 분리함. 이미지가 700MB에서 200MB로 줄어들며, 빌드 단계의 JDK · Gradle · 소스는 최종 이미지에 남지 않음.
# ── 1단계: 빌드 (JDK 필요) ──────────────────────────
FROM eclipse-temurin:21-jdk AS builder
WORKDIR /build
COPY gradlew settings.gradle build.gradle ./
COPY gradle/ gradle/
RUN ./gradlew dependencies --no-daemon # 의존성만 먼저 (캐시 대상)
COPY src/ src/
RUN ./gradlew bootJar --no-daemon
# ── 2단계: 실행 (JRE 만 있으면 된다) ────────────────
FROM eclipse-temurin:21-jre AS runtime
WORKDIR /app
RUN addgroup --system app && adduser --system --ingroup app app
COPY --from=builder /build/build/libs/*.jar app.jar
USER app # non-root 로 실행
EXPOSE 8080
ENTRYPOINT ["java","-XX:MaxRAMPercentage=75","-jar","/app/app.jar"]
정리: builder 단계의 결과물만 복사함. 소스 · 빌드 캐시 · JDK 가 최종 이미지에 남지 않아 공격면도 줄어듦
컨테이너 안의 JVM 메모리
JVM은 컨테이너의 메모리 제한을 인식하지만, 힙 외에도 메모리를 쓴다는 게 함정임. -Xmx를 고정하면 limit을 바꿀 때마다 이미지를 다시 빌드해야 하지만, MaxRAMPercentage는 컨테이너 limit 기준 비율이라 limit만 바꾸면 됨.
| 메모리 영역 | 무엇인가? | 대략 크기 | 메모 |
| 힙(Heap) | 객체가 사는 곳 | limit 의 75% | MaxRAMPercentage 로 지정 |
| 메타스페이스 | 클래스 메타데이터 | 50 ~ 150MB | Spring 은 클래스가 많아 큰 편 |
| 스레드 스택 | 스레드당 1MB | 200 스레드 기준 200MB | 톰캣 기본 스레드 수 확인 |
| 코드 캐시 | JIT 컴파일 결과 | 50 ~ 100MB | 장시간 구동 시 증가 |
| 직접 버퍼 | NIO · Netty | 수십 MB | 누수가 나면 여기서 샌다 |
| 합계 여유 | 힙 외 오버헤드 | limit 의 20 ~ 25% | 그래서 75% 가 권장값 |
주의: 힙을 limit 의 90%로 잡으면 힙은 안 찼는데 컨테이너가 OOMKilled 됨. 가장 헷갈리는 장애 유형임
이미지 태그 전략
latest는 배포 도구가 아님. 무엇이 돌고 있는지 모르게 만들고 롤백을 불가능하게 함. 태그는 불변(immutable)이어야 하며, 롤백은 결국 이전 태그로 되돌리는 일이므로 태그가 없으면 롤백도 없음.
| 태그 방식 | 예 | 쓸 곳 | 판단 |
| latest | shop-api:latest | 로컬 실습만 | 운영 금지. 무엇이 실행 중인지 모른다 |
| 시맨틱 버전 | shop-api:1.4.2 | 릴리스 | 사람이 읽기 좋다. 수동 관리 부담 |
| Git 커밋 SHA | shop-api:a3f9c21 | CI 자동 배포 | 가장 안전. 코드와 1:1 |
| 빌드 번호 | shop-api:build-482 | CI | 순서를 알기 쉽다 |
| 버전+SHA | shop-api:1.4.2-a3f9c21 | 운영 권장 | 읽기 좋고 추적도 된다 |
| 환경 태그 | shop-api:prod | 지양 | 가변 태그. 같은 문제 반복 |
정리: 커밋 SHA 를 태그에 넣을 것. 장애 났을 때 지금 도는 게 어느 커밋인가가 한 번에 나옴
태그는 움직이고 다이제스트는 안 움직인다
같은 태그가 어제와 오늘 다른 이미지일 수 있지만, 다이제스트는 그럴 수 없음. sha256:...는 이미지 매니페스트의 해시로, 내용이 1비트만 달라도 값이 바뀜. 이미지 태그는 사람이 붙이는 이름이고, 이미지 다이제스트는 이미지 내용으로 계산된 고유한 지문(fingerprint)임.
# 태그로 받기 — 편하지만 같은 태그가 바뀔 수 있다
docker pull myapp:1.0.0
# 다이제스트로 받기 — 정확히 그 이미지
docker pull myapp@sha256:d59a7821cb7e3bb8b1b346a57a3d74d3...
# 지금 이미지의 다이제스트 확인
docker images --digests myapp
docker inspect myapp:1.0.0 --format '{{index .RepoDigests 0}}'
# 클러스터에서 실제로 무엇이 돌고 있나 — 태그가 아니라 이것을 본다
kubectl get pod my-pod -o jsonpath='{.status.containerStatuses[0].imageID}'
실무: latest 가 위험한 진짜 이유가 이것임. 롤백을 해도 같은 태그가 이미 다른 내용을 가리킬 수 있음
이미지 ID · 태그 · 다이제스트
셋 다 이미지를 가리키지만 서로 다른 문서의 해시임. 재현을 보장하는 것은 하나뿐이며, docker images의 IMAGE ID와 레지스트리의 다이제스트는 다른 값임.
| 구분 | 무엇의 값 | 언제 쓰나 | 예 |
| 태그 | 사람이 붙인 이름 | 일상적으로 | 1.0.0 · latest |
| IMAGE ID | 로컬 config 문서의 해시 | 로컬에서 구분 | d7c0cdd200be |
| RepoDigest | 레지스트리 매니페스트의 해시 | 재현 · 검증 | sha256:d59a78... |
| imageID (Pod) | 실제 돌고 있는 것 | 배포 검증 | ...@sha256:d59a78 |
- 다이제스트는 이미지 파일이 아니라 매니페스트 문서의 해시이며, 매니페스트에는 config와 레이어 목록이 들어 있어 레이어가 같아도 값이 갈릴 수 있음
- 그래서 같은 소스를 다시 빌드해도 값이 달라질 수 있고, 레지스트리를 옮겨도 매니페스트가 같으면 값은 같으며, 태그를 지워도 다이제스트로는 계속 받을 수 있음
주의: 로컬 IMAGE ID 는 push 전후로 달라질 수 있음. 재현성 확인은 반드시 RepoDigest 로 해야 함
다이제스트로 고정한 베이스
FROM 에 태그 대신 다이제스트(컨테이너 이미지의 내용을 기준으로 계산한 고유한 해시값)를 쓰면 빌드가 언제 돌아도 같은 결과가 나옴. 재현 가능한 빌드의 첫걸음이며, 베이스 이미지가 바뀌면 내 앱도 바뀜.
# 태그 방식 — 베이스(이미지 파일)가 갱신되면 결과가 달라진다
FROM eclipse-temurin:21-jre
# 다이제스트 방식 — 언제 빌드해도 같은 베이스
FROM eclipse-temurin:21-jre@sha256:d59a7821cb7e3bb8b1b346a5...
COPY --from=build /app/build/libs/app.jar app.jar
ENTRYPOINT ["java", "-jar", "/app.jar"]
# 다이제스트 알아내기
# docker pull eclipse-temurin:21-jre
# docker inspect eclipse-temurin:21-jre \
# --format '{{index .RepoDigests 0}}'
주의: 다이제스트로 고정하면 보안 패치도 자동으로 안 들어옴. 주기적으로 올려 주는 절차가 함께 있어야 함
레지스트리는 어떻게 인증하나
docker는 설정 파일에, 클러스터는 Secret에 자격증명을 둠. 둘은 별개이며, 내 노트북과 클러스터는 자격증명을 따로 가짐(이 클러스터는 그 설정이 이미 되어 있음).
| 구분 | 내 노트북 | 클러스터(Pod) |
| 누가 받나 | docker 데몬 | 노드의 kubelet |
| 자격증명 위치 | ~/.docker/config.json | Secret (dockerconfigjson) |
| 설정 방법 | docker login | kubectl create secret docker-registry |
| 연결 | 자동 | 매니페스트에 imagePullSecrets |
| 범위 | 내 계정 전체 | 네임스페이스 하나 |
| 안 되면 | unauthorized | ImagePullBackOff |
체크: 이 클러스터는 풀 시크릿이 필요 없음. 노드가 레지스트리에서 바로 받도록 구성되어 있음
로컬에서 먼저 돌려 본다
클러스터에 올리기 전에 로컬에서 컨테이너로 한 번 돌려 보면 문제의 절반이 여기서 걸러짐. 컨테이너 안에서 localhost는 그 컨테이너 자신이며, DB 연결이 여기서 자주 실패함.
# 빌드하고 바로 실행
docker build --platform linux/amd64 -t shop-api:dev .
docker run --rm -p 8080:8080 \
-e SPRING_PROFILES_ACTIVE=local \
-e JAVA_TOOL_OPTIONS="-XX:MaxRAMPercentage=75" \
--memory=1g --cpus=1 shop-api:dev # limit 을 미리 흉내 낸다
# 헬스체크가 200 을 주는지 — K8s 프로브가 이걸 본다
curl -s localhost:8080/actuator/health | jq .
# 안 뜨면 안으로 들어가 본다
docker run --rm -it --entrypoint sh shop-api:dev
docker logs <컨테이너ID> --tail 100
체크: --memory=1g 를 걸고도 뜨는가? 로컬에서 무제한으로만 돌려 보면 클러스터에서 OOM 으로 처음 만남
[핵심 요약] 컨테이너와 Docker
2장에서 남길 것은 이미지를 만드는 사람의 판단 기준임.
| 항목 | 한 줄 정리 | 실무 포인트 |
| 컨테이너의 정체 | namespace + cgroup + 레이어 FS 인 프로세스 | VM 이 아니다. 커널을 공유한다 |
| 쓰기 층 | 컨테이너가 사라지면 같이 사라진다 | 로그는 파일이 아니라 stdout 으로 |
| 레이어 캐시 | 바뀐 줄부터 아래가 다시 빌드된다 | 의존성 → 소스 순서. 빌드 8분 → 40초 |
| 멀티스테이지 | 빌드 환경을 최종 이미지에서 뺀다 | 700MB → 200MB, 공격면도 축소 |
| ENTRYPOINT | exec 형식으로 써야 SIGTERM 이 간다 | 셸 형식이면 graceful shutdown 실패 |
| JVM 메모리 | 힙 외 오버헤드가 limit 의 20 ~ 25% | MaxRAMPercentage 75 가 기본값 |
| 플랫폼 | 맥 M시리즈는 arm64 로 빌드된다 | --platform linux/amd64 를 습관으로 |
| 태그 | latest 는 배포도 롤백도 못 한다 | 커밋 SHA 를 태그에 넣는다 |
| 보안 | 이미지에 넣은 것은 공개된 것 | non-root · 스캔 · 비밀값 금지 |
체크: 지금 우리 팀 Dockerfile 은 non-root 인가, 태그가 불변인가, 의존성 캐시가 살아 있는가?
3. 쿠버네티스 아키텍처
컨트롤 플레인 · 워커 노드 · 오브젝트 구조 · 라벨과 네임스페이스
클러스터의 전체 그림
컨트롤 플레인은 결정하고, 워커 노드는 실행함. 모든 대화는 API 서버를 거치며, 컴포넌트끼리 직접 대화하지 않고 전부 API 서버를 통함. 그래서 API 서버가 멈추면 새 배포는 막히지만 실행 중이던 Pod는 계속 동작함.
- 컨트롤 플레인: API 서버 · etcd · 스케줄러 · 컨트롤러 매니저
- 워커 노드 1 / 워커 노드 2: kubelet · kube-proxy · containerd · Pod
| 결정하는 쪽 | 실행하는 쪽 | 연결하는 것 |
| 무엇이 있어야 하는가 | 컨테이너를 실행한다 | 모든 통신은 API 서버 |
| 어느 노드에 둘 것인가 | 상태를 보고한다 | etcd 에만 상태 저장 |
| 상태를 저장한다 | 트래픽을 넘긴다 | kubelet 이 주기적으로 조회 |
정리: API 서버가 유일한 관문임. kubectl 도, kubelet 도, 컨트롤러도 전부 여기에만 말을 걺
→ 워커 노드 간에는 통신을 하지만, 쿠버네티스 관리망의 컴포넌트 간 통신의 경우에는 무조건 API 서버를 경유함.
• 쿠버네티스 관리망 (컴포넌트 간 통신):
◦ kubelet → API 서버 → etcd
◦ 인프라를 '관리'하기 위한 제어 신호들은 무조건 API 서버를 경유합니다.
• 사용자 서비스 데이터망 (워커 노드 / Pod 간 통신):
◦ [노드 1] 프론트엔드 Pod → [노드 2] 백엔드 Pod
◦ 실제 사용자가 웹사이트를 이용할 때 발생하는 프론트-백엔드 간의 데이터 통신은 쿠버네티스 관리 컴포넌트(API 서버 등)를 전혀 거치지 않고 워커 노드끼리 랜선/네트워크로 직접 주고받습니다.
컨트롤 플레인 구성요소
네 개임. 각각이 무엇을 결정하는지 알면 장애 시 어디를 볼지가 정해짐. EKS에서는 이 넷이 AWS 관리 영역이라 직접 접근할 수 없고, 대신 컨트롤 플레인 로그를 CloudWatch로 내보내 간접 확인함.
| 컴포넌트 | 하는 일 | 이것이 멈추면 |
| kube-apiserver | 모든 요청의 관문. 인증 · 인가 · 검증 후 etcd 에 기록 | kubectl 이 전부 실패. 돌던 Pod 는 유지 |
| etcd | 클러스터의 유일한 상태 저장소 (키-값 DB) | 클러스터 전체 정지. 백업이 곧 생명줄 |
| kube-scheduler | 새 Pod 를 어느 노드에 둘지 결정 | 새 Pod 가 Pending 에서 멈춘다 |
| kube-controller-manager | Deployment · ReplicaSet 등 컨트롤러 묶음. 조정 루프를 실행 | 자동 복구 · 스케일이 멈춘다 |
| cloud-controller-manager | LoadBalancer · 노드 등 클라우드 연동 | Service 의 LB 가 안 만들어진다 |
주의: 직접 구축한 클러스터라면 etcd 백업이 최우선임. etcd 를 잃으면 클러스터를 잃음
워커 노드 구성요소
kubelet이 컨테이너를 관리하고, kube-proxy가 트래픽 규칙을 만들고, 런타임이 실제로 실행함. kubelet은 API 서버를 폴링하며 자기 노드에 배정된 Pod를 맞추고, kube-proxy는 트래픽을 직접 나르지 않고 커널 규칙만 설치함.
| 컴포넌트 | 하는 일 | 실무에서 만나는 지점 |
| kubelet | Pod 스펙대로 컨테이너를 띄우고 프로브를 실행 | 프로브 실패 · 이미지 풀 실패가 여기 로그에 |
| kube-proxy | Service 의 가상 IP → Pod IP 규칙을 커널에 설치 | iptables/IPVS 모드. 규칙이 많으면 지연 |
| 컨테이너 런타임 | containerd. 이미지 받고 컨테이너 실행 | crictl ps 로 노드에서 직접 확인 |
| CNI 플러그인 | Pod 에 IP 를 할당하고 네트워크 연결 | EKS 는 VPC CNI. Pod 가 VPC IP 를 받는다 |
| CSI 드라이버 | 볼륨을 노드에 붙이고 마운트 | EBS CSI. PVC 로 볼륨 자동 생성 |
| 노드 에이전트 | 로그 수집기 · 모니터링 등 DaemonSet | Fluent Bit · CloudWatch Agent |
참고: EKS 의 VPC CNI 는 Pod 에 실제 VPC IP 를 줌. 편리하지만 노드 타입마다 Pod 개수 상한이 생김
kubectl apply 한 줄의 여정
명령 하나가 컨테이너가 되기까지 여섯 단계를 거침. 각 단계가 곧 장애 지점이며, 각 단계에서 멈추면 Pod 상태가 다르게 나타남.
| 단계 | 누가 무엇을 | 여기서 | 멈추면 |
| ① | kubectl | YAML 을 API 서버로 POST | 인증 실패 · RBAC 거부 |
| ② | API 서버 | 인증 · 인가 · 검증 후 etcd 에 저장 | 필드 오타 → 검증 에러 |
| ③ | Deployment 컨트롤러 | ReplicaSet 을 만든다 | 거의 안 멈춤 |
| ④ | ReplicaSet 컨트롤러 | 부족한 수만큼 Pod 오브젝트 생성 | 쿼터 초과 → 생성 거부 |
| ⑤ | 스케줄러 | 노드를 골라 Pod 에 기록 | Pending (자원 · taint · AZ) |
| ⑥ | kubelet | 이미지 받고 컨테이너 실행 | ImagePullBackOff (경로 · 태그) |
| ⑦ | kubelet | 프로브 확인 후 Ready 표시 | CrashLoopBackOff (앱 오류) |
| ⑧ | Endpoint 컨트롤러 | Ready 인 Pod 를 Service 에 등록 | Endpoints 비어 있음 → 503 |
→ 워커 노드는 컴퓨터 1대임. 그 안에 여러 개의 Pod(백엔드 서버, 프론트엔드 서버 등)가 존재하고 그걸 관리하는 주체가 kubelet임.
각 Pod마다 재생성 시 IP가 변경되는데, CNI가 이 Pod들에게 개별 가상 IP를 할당하고 노드/Pod 간 기본 네트워크를 연결함.
IP 변경 문제를 해결하기 위해 Service가 변하지 않는 대표 고정 IP(단일 창구)를 제공하고, kube-proxy가 네트워크 규칙을 통해 Service로 들어온 트래픽을 실제 Pod IP로 전달함. 볼륨은 CSI에서 저장소를 연결함
정리: Pending 은 스케줄러, ImagePull 은 레지스트리, CrashLoop 은 앱. 상태 이름이 곧 단계를 알려 줌
Pod 하나가 뜨기까지
kubectl run 한 줄에 다섯 컴포넌트가 순서대로 관여함. 멈춘 지점이 곧 원인이며, 각 단계에서 멈췄을 때 보이는 상태 이름이 다름 — 그것이 진단의 출발점임.
| # | 누가 | 무엇을 | 여기서 멈추면 |
| 1 | kubectl | API 서버로 요청 | 연결 · 인증 오류 |
| 2 | API 서버 | 검증 후 etcd 저장 | Forbidden · 검증 오류 |
| 3 | 스케줄러 | 노드 배정, watch 로 감지 | Pending |
| 4 | kubelet | 런타임에 생성 요청 | ContainerCreating |
| 5 | 런타임 | 이미지 받고 컨테이너 실행 | ImagePullBackOff |
| 6 | kubelet | 프로브 확인 후 Ready 보고 | Running 인데 0/1 |
| 7 | Endpoint 컨트롤러 | Service 목록에 추가 | 503 |
체크: kubectl get pod 의 STATUS 하나로 3~5 단계가 갈림. 그 다음 describe 의 Events 를 아래에서 위로 읽음
모든 오브젝트의 공통 구조
Pod든 Service든 Ingress든 네 부분으로 되어 있음. 이 틀을 알면 처음 보는 리소스도 읽히며, 우리가 쓰는 것은 spec 이고 쿠버네티스가 채우는 것은 status 임.
apiVersion: apps/v1 # 어느 API 그룹·버전인가
kind: Deployment # 무슨 종류인가
metadata: # 이름표 — 누구인가
name: shop-api
namespace: class-6
labels: { app: shop-api, tier: backend }
spec: # 원하는 상태 — 우리가 쓴다
replicas: 3
selector:
matchLabels: { app: shop-api }
template:
metadata:
labels: { app: shop-api }
spec:
containers:
- name: app
image: shop-api:1.0.0
# status: # 현재 상태 — 쿠버네티스가 채운다 (직접 쓰지 않는다)
정리: spec 은 소원, status 는 현실임. 컨트롤러가 하는 일은 이 둘의 차이를 없애는 것임
라벨과 셀렉터
쿠버네티스는 이름이 아니라 라벨로 대상을 찾음. 느슨한 연결이 유연함을 만들며, Service는 shop-api Pod가 아니라 app=shop-api 라벨이 붙은 것을 찾음. 그래서 Pod 이름이 매번 바뀌어도 연결이 유지됨.
# Deployment — 어떤 Pod 를 내 것으로 셀 것인가
spec:
selector:
matchLabels: { app: shop-api } # ← 이 라벨을 가진 Pod 를 관리
template:
metadata:
labels: { app: shop-api, ver: v1 } # ← 만들 Pod 에 붙일 라벨
---
# Service — 어떤 Pod 로 트래픽을 보낼 것인가
spec:
selector: { app: shop-api } # ← 같은 라벨을 보고 찾아간다
# 명령줄에서도 같은 문법을 쓴다
kubectl get pod -l app=shop-api
kubectl get pod -l 'ver in (v1,v2)'
kubectl delete pod -l app=shop-api,tier=backend
주의: Deployment 의 selector 는 만든 뒤 바꿀 수 없음. 바꾸려면 지우고 다시 만들어야 함
네임스페이스로 나누기
한 클러스터를 여러 논리 공간으로 나눔. 이름 충돌을 막고 권한 · 쿼터의 경계가 되지만, 네임스페이스는 논리적 경계이지 보안 경계가 아니며 네트워크는 기본적으로 뚫려 있음. 노드 · PV · StorageClass 처럼 네임스페이스에 속하지 않는 리소스도 있음.
| 용도 | 어떻게 나누나 | 주의 |
| 환경 분리 | dev / staging / prod | 운영은 별도 클러스터가 더 안전하다 |
| 팀 분리 | team-a / team-b | RBAC 으로 접근 권한을 함께 걸어야 의미가 있다 |
| 교육 · 실습 | class-6 ~ class-10 (반별) | 이름 규칙으로 반 안의 충돌을 막는다 |
| 시스템 | kube-system | 직접 만들지 않는다. 건드리면 클러스터가 흔들린다 |
| 기본 | default | 실무에서는 쓰지 않는 편이 좋다 |
| 속하지 않는 것 | Node · PV · StorageClass · ClusterRole | kubectl api-resources --namespaced=false |
주의: 네임스페이스를 지우면 그 안의 리소스가 전부 함께 지워짐. 되돌릴 수 없음
기본에는 없어서 따로 넣는 것들
쿠버네티스를 설치했다고 다 되는 게 아님. 몇 가지는 따로 넣어야 하며, 우리 실습 클러스터에 무엇이 설치되어 있는지 알아야 무엇이 되는지 앎.
| 애드온 | 없으면 | 우리 클러스터 |
| CNI (VPC CNI) | Pod 가 아예 안 뜬다 | 설치됨 (정책 집행은 꺼짐) |
| CoreDNS | 이름으로 통신 불가 | 설치됨 |
| metrics-server | kubectl top · HPA 불가 | 설치됨 |
| Ingress Controller | Ingress 가 무시된다 | ingress-nginx |
| CSI 드라이버 | PVC 가 Pending | EBS · EFS 설치됨 |
| cert-manager | TLS 인증서 수동 | 설치됨 (letsencrypt) |
| 로그 수집기 | Loki 가 비어 있다 | 없음 |
체크: kubectl get pods -n kube-system 으로 무엇이 실행 중인지 확인함. 없는 기능을 사용하려다 시간을 버리지 않게 됨
[핵심 요약] 쿠버네티스 아키텍처
3장에서 남길 것은 장애가 났을 때 어디를 볼지 정하는 지도임.
| 항목 | 한 줄 정리 | 실무 포인트 |
| 통신 구조 | 모든 대화는 API 서버를 거친다 | API 서버가 죽어도 돌던 Pod 는 산다 |
| etcd | 클러스터의 유일한 상태 저장소 | 잃으면 클러스터를 잃는다. 매니페스트를 Git 에 |
| 스케줄러 | 필터로 거르고 스코어로 고른다 | Pending 이면 describe 의 Events 부터 |
| kubelet | Pod 스펙대로 맞추고 프로브를 실행한다 | ImagePull · CrashLoop 은 여기 로그 |
| kube-proxy | 규칙만 설치하고 패킷은 커널이 | 프로세스가 죽어도 기존 연결은 유지 |
| apply 여정 | 8단계, 상태 이름이 곧 단계 | Pending/ImagePull/CrashLoop 분기 |
| spec vs status | 소원과 현실. 컨트롤러가 좁힌다 | readyReplicas 를 replicas 와 비교 |
| 라벨 | 이름이 아니라 라벨로 찾는다 | Deployment selector 는 불변 |
| EKS 인증 | IAM 이 인증, RBAC 이 인가 | Unauthorized 와 forbidden 은 다른 문제 |
체크: Pod 가 Pending 이다. 가장 먼저 칠 명령 한 줄은 무엇인가?
4. 클러스터 내부 동작
요청 처리 단계 · 컨트롤러와 스케줄러 · kubelet 과 런타임 · 장애 영향
AWS EKS 아키텍처
AWS 공식 EKS 참조 아키텍처를 5개 레이어로 정리한 다이어그램임.

참고: 프로덕션 권장 구성은 Multi-AZ · Multi-Node Group · IRSA · GitOps(ArgoCD/Flux)임
쿠버네티스 아키텍처
일반적인 Kubernetes Cluster Architecture를 좌 · 중앙 · 우 3개 영역으로 정리한 다이어그램임.

정리: Self-managed vs Managed — 프로덕션은 보통 EKS/GKE/AKS 같은 관리형 서비스로 컨트롤 플레인 HA 를 확보함
API 서버가 요청을 받는 순서
apply 한 줄이 etcd에 닿기까지 여섯 관문을 지남. 어디서 막혔는지가 곧 원인이며, 거부 메시지의 말투로 어느 관문인지 알 수 있어 진단이 빨라짐.
| # | 관문 | 하는 일 | 여기서 막히면 |
| 1 | HTTP Handler | 요청을 API 객체로 변환 | 400 Bad Request |
| 2 | 인증 (authn) | 누구인가 — 토큰 · 인증서 확인 | Unauthorized |
| 3 | 인가 (authz) | 할 수 있는가 — RBAC | Forbidden |
| 4 | Mutating Admission | 값을 채워 넣음 (sidecar 주입 등) | webhook 오류 |
| 5 | Schema 검증 | 필드 · 타입이 스펙에 맞는가 | cannot unmarshal |
| 6 | Validating Admission | 정책 검사 (Quota · 보안) | 정책 이름과 함께 거부 |
| 7 | etcd 저장 | 여기까지 오면 성공 | - |
정리: 3번(RBAC)과 6번(정책)을 헷갈리면 안 됨. 권한은 있는데 거부되는 것은 6번임
컨트롤러 매니저 안의 컨트롤러들
하나의 프로세스 안에 수십 개의 컨트롤러가 각자 자기 리소스를 지켜봄. 전부 같은 일을 함 — 지금 상태를 보고 원하는 상태와 다르면 움직임.
| 컨트롤러 | 무엇을 지켜보나 | 무슨 일을 하나 |
| Deployment | Deployment 의 spec | ReplicaSet 을 만들고 교체한다 |
| ReplicaSet | Pod 개수 | 모자라면 만들고 남으면 지운다 |
| Node | 노드의 하트비트 | 응답 없으면 NotReady · Pod 축출 |
| Endpoint | Pod 의 Ready 상태 | Service 뒤 목록을 갱신 |
| Namespace | 네임스페이스 삭제 | 안의 리소스를 전부 정리 |
| PersistentVolume | PVC 요청 | 볼륨을 붙이고 뗀다 |
| Job · CronJob | 완료 여부 · 시각 | 실행하고 정리한다 |
체크: Endpoint 컨트롤러가 이 과정에서 가장 자주 만나는 것임. readiness 가 내려가면 이 컨트롤러가 목록에서 뺌
스케줄러가 노드를 고르는 법
거르고, 점수를 매기고, 가장 높은 노드에 둠. 두 단계이며, 하드 제약은 거르고 소프트 제약은 점수만 줌 — 이 차이가 Pending 진단을 가름.
| 단계 | 무엇을 보나 | 성격 |
| ① 거르기 | requests 가 들어갈 자리가 있나 | 하드 (못 넣으면 탈락) |
| ① 거르기 | nodeSelector 라벨이 맞나 | 하드 |
| ① 거르기 | taint 를 toleration 하는지 | 하드 |
| ① 거르기 | 노드당 Pod 상한(58)에 여유가 있나 | 하드 |
| ① 거르기 | PV 의 AZ 와 맞나 | 하드 |
| ② 점수 | 자원이 고르게 퍼지는가 | 소프트 |
| ② 점수 | affinity 선호도 | 소프트 |
| ② 점수 | topologySpread 균형 | 소프트 |
주의: 모든 노드가 ①에서 탈락하면 Pending 임. describe pod 의 0/N nodes are available 뒤에 이유가 붙음
상태는 etcd 에만 있다
모든 상태가 여기 있으며, 다른 컴포넌트는 전부 이것을 보고 움직임. etcd가 멈추면 클러스터의 기억이 사라지며, 실행 중이던 Pod는 살아 있지만 변경이 불가능해짐.
| 질문 | 답 |
| 무엇인가 | 분산 키-값 저장소 (Raft 합의) |
| 무엇이 들어 있나 | 모든 리소스의 spec 과 status |
| 누가 쓰나 | 오직 kube-apiserver 만. 다른 컴포넌트는 API 서버를 거친다 |
| 다른 컴포넌트는 | API 서버에 watch 를 걸고 변경 알림을 받는다 |
| EKS 에서는 | AWS 가 관리한다. 접근도 백업도 우리 몫이 아니다 |
| 직접 만든 클러스터면 | 백업이 곧 클러스터 백업이다. 최우선 운영 항목 |
참고: watch 구조라서 폴링이 없음. 변경이 생기면 API 서버가 알려 주므로 반응이 빠름
클라우드 컨트롤러 매니저
쿠버네티스가 AWS 자원을 만들어 주는 통로임. LoadBalancer Service가 여기서 나오며, 매니페스트 한 장이 실제 AWS 요금이 되는 지점임.
| 내가 만든 것 | CCM 이 AWS 에 하는 일 | 요금 |
| type: LoadBalancer Service | ELB/NLB 생성 | 시간당 + 트래픽 |
| 노드 추가 | EC2 인스턴스를 노드로 등록 | - |
| 노드 삭제 | 클러스터에서 제거 | - |
| PVC (EBS) | EBS 볼륨 생성 · 연결 | GB 당 |
| Pod 네트워크 | VPC IP 할당 (VPC CNI) | - |
| Route | 노드 간 경로 설정 | - |
주의: Service 를 지워야 ELB 가 지워짐. 클러스터만 지우고 Service 를 안 지우면 로드밸런서가 남아 요금이 계속 나감
kubelet 이 실제로 하는 일
노드마다 하나씩 있는 대리인임. 클러스터의 결정을 이 노드에서 실행하며, kubelet은 etcd를 안 보고 API 서버에 watch를 걸어 내 노드 것만 받음.
| 하는 일 | 구체적으로 | 실패하면 |
| Pod 실행 | 런타임(containerd)에 컨테이너 생성 요청 | ContainerCreating 에서 멈춤 |
| 프로브 실행 | liveness · readiness · startup 호출 | 재시작 또는 트래픽 차단 |
| 상태 보고 | Pod 와 노드 상태를 API 서버로 | 노드가 NotReady |
| 볼륨 연결 | PV 를 노드에 마운트 | Pod 가 Pending · 마운트 오류 |
| 이미지 관리 | pull 과 캐시 · 정리 | ImagePullBackOff |
| 자원 회수 | 디스크 · 메모리 부족 시 Pod 축출 | Evicted |
정리: 프로브를 kubelet 이 직접 호출함. 그래서 Service 나 Ingress 와 무관하게 동작하고, 네트워크 문제와 구분됨
컨테이너 런타임과 CRI
쿠버네티스는 Docker 를 쓰지 않으며, CRI 라는 규격으로 런타임과 대화함. 그래서 노드에서 docker ps를 쳐도 컨테이너가 안 보이고 crictl ps를 사용해야 함.
- 옛날: kubelet → dockershim → Docker → containerd
- 지금: kubelet → CRI → containerd
- 무엇이 빠졌나: Docker 는 사람이 쓰는 도구였을 뿐, 실행 규격에서는 우회됨
- 이미지는 OCI 표준이라 그대로 호환됨
- 노드 명령 대응: docker ps → crictl ps, docker images → crictl images, docker logs → crictl logs
주의: Docker 로 만든 이미지는 그대로 씀. 이미지 규격(OCI)과 실행 규격(CRI)은 다른 이야기임
kube-proxy 와 iptables
Service 라는 가상 IP를 실제로 동작하게 만드는 것이 각 노드의 규칙표임. kube-proxy는 트래픽을 중계하지 않고 규칙만 써 넣고 빠짐.
# Service 를 만들면 각 노드에 이런 규칙이 생긴다 (개념)
# ClusterIP 10.100.141.251:80 으로 온 것을
# → 172.31.6.2:8080 (Pod A) 확률 1/3
# → 172.31.6.7:8080 (Pod B) 확률 1/3
# → 172.31.12.9:8080 (Pod C) 확률 1/3
kubectl get svc shop-api # ClusterIP 확인
kubectl get endpoints shop-api # 규칙의 오른쪽 목록
# 노드에서 (권한이 있다면)
# iptables -t nat -L KUBE-SERVICES -n | grep 10.100.141.251
# ipvsadm -Ln # IPVS 모드인 경우
정리: Endpoints 가 곧 규칙의 오른쪽임. 비어 있으면 규칙이 갈 곳이 없어 연결이 거부됨
컴포넌트가 멈추면 무엇이 함께 멈추나
전부 다 멈추지는 않음. 무엇이 계속 동작하는지 아는 것이 장애 대응의 시작이며, 이미 실행 중인 Pod는 대부분의 장애에서 계속 동작해 사용자는 아무것도 못 느낌.
| 멈춘 것 | 계속 되는 것 | 안 되는 것 |
| API 서버 | 돌던 Pod · 기존 트래픽 | 조회 · 배포 · 모든 변경 |
| etcd | 돌던 Pod | API 서버도 사실상 마비 |
| 스케줄러 | 돌던 Pod · 기존 Pod 재시작 | 새 Pod 배치 (Pending) |
| 컨트롤러 매니저 | 돌던 Pod | 멈춘 Pod 복구 · 롤아웃 |
| kubelet (한 노드) | 다른 노드 Pod | 그 노드가 NotReady → 축출 |
| kube-proxy (한 노드) | 돌던 Pod | 그 노드의 Service 연결 |
| CoreDNS | IP 로 하는 통신 | 이름으로 하는 모든 통신 |
실무: 장애 신고를 받으면 먼저 사용자 트래픽이 실제로 끊겼나를 확인함. 컨트롤 플레인 장애는 대개 사용자에게 안 보임
[핵심 요약] 클러스터 내부
컴포넌트 이름을 외우는 게 목적이 아니라, 증상에서 볼 곳을 찾는 것이 목적임. 모든 컴포넌트가 같은 방식으로 일함 — 지켜보다가 다르면 움직임.
| 컴포넌트 | 한 줄로 | 이 증상이면 여기 |
| API 서버 | 모든 요청의 유일한 입구 | Unauthorized · Forbidden |
| etcd | 클러스터의 유일한 기억 | 전체 조회 불가 |
| 스케줄러 | Pod 를 어느 노드에 둘지 | Pending |
| 컨트롤러 매니저 | status 를 spec 에 맞춘다 | 복구가 안 됨 |
| CCM | AWS 자원을 만들고 지운다 | LB · EBS 가 안 생김 |
| kubelet | 노드의 대리인 · 프로브 실행 | ContainerCreating |
| 런타임 | 컨테이너를 실제로 실행 | ImagePullBackOff |
| kube-proxy | Service 규칙을 노드에 쓴다 | 연결 즉시 거부 |
| CoreDNS | 이름 → IP | 이름 해석 실패 |
체크: 요청 관문 여섯 단계와 Pod 가 뜨는 일곱 단계, 이 두 표가 이 장의 결론임
5. 실습 환경과 kubectl
도구 설치 · 클러스터 접속 · 조회와 출력 · 권한 확인
필요한 도구와 버전 확인
네 가지면 됨. 버전이 안 맞아 생기는 문제가 의외로 많으니 먼저 확인하고 시작함. kubectl은 클러스터와 마이너 버전 ±1 안이어야 안전함.
# 1) 설치 확인 — 네 개가 다 나와야 한다
kubectl version --client -o yaml | head -5 # v1.35 ~ v1.37
aws --version # aws-cli/2.x (v1 은 안 된다)
docker --version # 이미지 빌드용
helm version --short # v3.x (선택 · 심화 과정용)
# 2) 없으면 (macOS)
brew install kubectl awscli helm
# 3) AWS 자격증명 확인 — 이게 되어야 클러스터에 붙는다
aws sts get-caller-identity
# { "Account": "123456789012",
# "Arn": "arn:aws:iam::123456789012:user/class-6" }
주의: awscli v1 은 EKS 인증 토큰을 못 만듦. aws --version이 aws-cli/1.x 면 v2 로 교체함
EKS 클러스터에 접속하기
update-kubeconfig 한 번이면 kubectl이 클러스터를 바라보게 됨. 이 명령은 클러스터를 만드는 게 아니라 접속 설정을 로컬에 쓰는 것이며, 비밀번호가 저장되지 않고 매번 IAM으로 토큰을 새로 만듦.
# 1) 접속 설정 생성 (~/.kube/config 에 기록된다)
aws eks update-kubeconfig \
--region ap-northeast-2 --name skala-2026 --profile skala-2026
# 2) 연결 확인
kubectl cluster-info
kubectl get nodes -o wide
# 3) 내 네임스페이스를 기본값으로 (매번 -n 을 안 써도 된다)
kubectl config set-context --current --namespace=class-6
# 4) 지금 내가 무엇을 할 수 있는지 확인
kubectl auth can-i create deployment # yes
kubectl auth can-i delete node # no
kubectl auth can-i get pods -n class-7 # no (다른 반)
실무: kubectl auth can-i는 권한 문제를 30초에 끝내 줌. RBAC 에러가 나면 추측하지 말고 이걸로 확인함
컨텍스트와 KUBECONFIG
클러스터가 둘 이상이면 지금 어디를 보고 있나가 사고의 원인이 됨. 운영 클러스터를 보면서 dev 명령을 치는 사고가 실무에서 실제로 일어남.
# 지금 어디를 보고 있나 — 명령 치기 전에 확인하는 습관
kubectl config current-context
kubectl config get-contexts # * 표시가 현재
# 네임스페이스 기본값 바꾸기 (매번 -n 안 쓰려고)
kubectl config set-context --current --namespace=class-6
# 컨텍스트 전환
kubectl config use-context <이름>
# 파일 단위로 격리 — 가장 안전한 방법
export KUBECONFIG=~/.kube/skala-2026.yaml
kubectl get nodes # 이 셸에서만 이 클러스터
# 프롬프트에 표시하기 (kube-ps1 등) — 눈으로 계속 보이게 한다
실무: 클러스터가 둘 이상이면 KUBECONFIG 를 파일로 나눔. 전역 ~/.kube/config 하나에 몰아넣으면 언젠가 사고가 남
kubectl 명령의 문법
동사 · 종류 · 이름 · 옵션. 이 네 칸이면 대부분의 명령이 만들어짐. 명령을 외우지 말고 문법을 외우며, 나머지는 --help 와 탭 완성이 해 줌.
| 칸 | 예 | 설명 |
| 동사 | get · describe · logs · apply · delete | 무엇을 할 것인가 |
| 동사 | exec · edit · scale · rollout · port-forward | 자주 쓰는 나머지 |
| 종류 | pod · deployment · service · configmap | 단수 · 복수 · 약어 모두 통한다 |
| 이름 | shop-api-7d9f8-x2k4l | 생략하면 전체 목록 |
| 옵션 | -n class-6 | 네임스페이스 |
| 옵션 | -l app=shop-api | 라벨로 고르기 |
| 옵션 | -o wide · -o yaml · -o json | 출력 형식 |
| 옵션 | -w | 변화를 실시간으로 지켜보기 |
| 옵션 | --all-namespaces (-A) | 전체 네임스페이스 |
정리: kubectl <동사> <종류> <이름> <옵션>. 이 한 줄이 kubectl 문법의 전부임
get 과 describe 는 보는 범위가 다르다
get은 목록을, describe는 한 리소스의 전체 사정을 보여 줌. 장애 때는 describe 부터 시작하며, describe의 맨 아래 Events가 원인의 90%를 알려 줌.
# 목록 — 지금 무엇이 있나
kubectl get pod # 기본
kubectl get pod -o wide # 노드·IP 까지 (아주 자주 쓴다)
kubectl get all # 주요 리소스 한 번에
kubectl get pod -w # 변화를 지켜본다
# 상세 — 이 하나에 무슨 일이 있었나
kubectl describe pod shop-api-7d9f8-x2k4l
# Containers: ... State: Waiting Reason: ImagePullBackOff
# Events:
# Warning Failed 2m kubelet Failed to pull image ...
# 최근 이벤트만 시간순으로 (클러스터 전체 진단에 유용)
kubectl get events --sort-by=.lastTimestamp | tail -20
실무: -o wide 는 어느 노드에 있고 IP 가 무엇인지를 보여 줌. 분산 확인 · 네트워크 진단의 출발점임
정렬하고 걸러서 조회하기
get에 옵션 하나를 더하면 눈으로 훑을 것을 명령이 대신해 줌. 재시작이 많은 Pod 찾기는 장애 대응에서 가장 먼저 하는 일 중 하나임.
# 정렬 — 문제 있는 것을 위로 올린다
kubectl get pods --sort-by='.status.containerStatuses[0].restartCount'
kubectl get pods --sort-by=.metadata.creationTimestamp
kubectl get pv --sort-by=.spec.capacity.storage
# 상태로 거르기
kubectl get pods --field-selector=status.phase=Running
kubectl get pods --field-selector=status.phase!=Running # 문제만
kubectl get events --field-selector=type=Warning
# 라벨로 거르기
kubectl get pods --show-labels
kubectl get pods -l app=shop-api
kubectl get pods -l 'app in (shop-api,worker)'
kubectl get pods -l app!=shop-api
체크: --field-selector=status.phase!=Running 한 줄이면 문제 있는 Pod 만 남음. 아침에 클러스터를 살필 때 이것부터 침
출력을 원하는 대로 뽑기
-o jsonpath와 custom-columns를 알면 kubectl이 조회 도구에서 운영 도구가 됨. 스크립트에 쓸 값 하나를 뽑을 때 jsonpath 만 한 게 없음.
# 전체 정의를 그대로 (필드 이름을 확인할 때)
kubectl get deploy shop-api -o yaml
# 값 하나만 — 지금 도는 이미지 태그는?
kubectl get deploy shop-api \
-o jsonpath='{.spec.template.spec.containers[0].image}'
# 원하는 열만 표로 — Pod 가 어느 노드에 몇 번 재시작했나
kubectl get pod -o custom-columns=\
'NAME:.metadata.name,NODE:.spec.nodeName,RESTART:.status.containerStatuses[0].restartCount'
# 준비 안 된 Pod 만 골라 내기
kubectl get pod --field-selector=status.phase!=Running
# 이름만 뽑아 다른 명령에 넘기기
kubectl get pod -l app=shop-api -o name | xargs -n1 kubectl logs --tail=5
실무: 장애 대응 스크립트는 대부분 -o jsonpath 한 줄로 시작함. 필드 경로는 -o yaml 로 먼저 확인함
jsonpath 로 값 하나만 뽑기
스크립트에서 쓰려면 표가 아니라 값 하나가 필요함. 키에 점이 들어 있으면 역슬래시로 escape 해야 하며, 가장 자주 막히는 지점임.
# 기본 — 중첩 필드를 점으로 따라간다
kubectl get pod my-pod -o jsonpath='{.status.podIP}'
kubectl get svc shop-api -o jsonpath='{.spec.clusterIP}'
# 배열 — [*] 는 전부, [0] 은 첫 번째
kubectl get endpoints shop-api -o jsonpath='{.subsets[0].addresses[*].ip}'
kubectl get nodes -o jsonpath='{.items[*].metadata.name}'
# 키에 점이 있으면 escape — ConfigMap 키가 대표적이다
kubectl get cm my-config -o jsonpath='{.data.app\.properties}'
# 조건식 — 여러 컨테이너 중 이름으로 고르기
kubectl get deploy shop-api -o jsonpath=\
'{.spec.template.spec.containers[?(@.name=="app")].image}'
# 표로 뽑기 — 여러 값을 열로
kubectl get pods -o custom-columns=\
'NAME:.metadata.name,NODE:.spec.nodeName,IP:.status.podIP'
주의: ?(@.name=="app")에서 ?()는 조건 시작, @는 현재 항목임. 작은따옴표로 감싸야 셸이 안 건드림
로그와 컨테이너 안으로
logs로 무슨 일이 있었는지 보고, exec로 직접 들어가 확인함 — 진단의 두 축임. 재시작한 Pod는 --previous 로 죽기 직전 로그를 봐야 원인이 나옴.
# 로그 — 기본
kubectl logs shop-api-7d9f8-x2k4l --tail=100
kubectl logs -f deploy/shop-api # 실시간, Deployment 단위
kubectl logs -l app=shop-api --tail=20 --prefix # 여러 Pod 를 한 번에
# 죽기 직전 로그 — CrashLoopBackOff 진단의 핵심
kubectl logs shop-api-7d9f8-x2k4l --previous
# 컨테이너 안으로 들어가기
kubectl exec -it shop-api-7d9f8-x2k4l -- sh
kubectl exec shop-api-7d9f8-x2k4l -- env | grep SPRING # 한 줄 실행
# 로컬 포트로 끌어와 브라우저에서 확인
kubectl port-forward deploy/shop-api 8080:8080
주의: CrashLoop 상태에서 그냥 logs 를 치면 새로 뜨는 컨테이너의 빈 로그가 나옴. --previous 를 씀
만들고 고치고 지우기
운영에 남는 것은 전부 apply로 함. 그 전에 diff로 무엇이 바뀔지 먼저 보며, apply는 없으면 만들고 있으면 갱신하므로 몇 번을 실행해도 결과가 같음. kubectl diff는 적용 전에 변경분을 보여 주는, 운영에서는 필수 습관임.
# 적용 전에 무엇이 바뀌는지 본다 ← 습관으로
kubectl diff -f k8s/deployment.yaml
# 적용 (디렉터리 통째로도 된다)
kubectl apply -f k8s/deployment.yaml
kubectl apply -f k8s/ # 폴더 전체
kubectl apply -k overlays/prod # Kustomize (심화 과정)
# 급할 때만 — 반드시 파일에도 반영한다
kubectl scale deploy/shop-api --replicas=5
kubectl set image deploy/shop-api app=shop-api:1.0.1
# 지우기 — 파일 기준으로 지우면 빠뜨림이 없다
kubectl delete -f k8s/deployment.yaml
kubectl delete pod --all -n class-6 # 위험: 확인하고 실행
주의: delete pod 는 Deployment 가 있으면 다시 만들어짐. 정말 멈추려면 replicas 0 또는 Deployment 삭제가 필요함
파일 없이 바로 적용하기
heredoc을 쓰면 파일을 만들지 않고도 매니페스트를 그대로 적용할 수 있음. 실습 · 데모에 좋지만, 기록이 안 남으므로 운영에는 파일을 씀.
kubectl apply -f - <<'EOF'
apiVersion: v1
kind: ConfigMap
metadata:
name: my-config
data:
app.properties: |
app.name=MyApp
app.version=1.0.0
log.level: INFO
max.connections: "100" # 숫자도 반드시 문자열로
EOF
# 여러 리소스를 한 번에 — --- 로 나눈다
# 지울 때도 같은 방식이 편하다
kubectl delete -f - <<'EOF'
...
EOF
주의: <<'EOF' 처럼 따옴표를 붙임. 안 붙이면 셸이 $VAR 를 먼저 치환해 매니페스트가 망가짐
첫 배포를 손으로 확인한다
아직 매니페스트를 안 배웠지만, 명령형으로 한 번 띄워 보고 3장의 구조를 눈으로 확인함. 이번만 명령형으로 하고, 7장부터는 전부 매니페스트로 진행함.
# 1) Deployment 하나 만들기 (명령형 — 이번만)
kubectl create deployment hello --image=nginx:1.27 --replicas=3
# 2) 3장에서 본 것들을 눈으로 확인
kubectl get deploy,rs,pod # Deployment → ReplicaSet → Pod
kubectl get pod -o wide # 어느 노드에 흩어졌나
# 3) 조정 루프 실험 — Pod 하나를 지워 본다
kubectl delete pod <이름>
kubectl get pod # 이름이 다른 Pod 가 새로 생긴다
# 4) 정리
kubectl delete deployment hello
체크: Pod 를 지웠더니 이름이 다른 Pod 가 생김. 이유를 옆 사람에게 한 문장으로 설명해 봄
실습 리소스 정리하기
만든 것을 지우는 것도 실습임. 클라우드에서는 안 지운 것이 요금이 되며, Deployment를 지워도 Service · Ingress · PVC는 남아 종류마다 따로 지워야 함. Ingress를 지우면 ALB도 함께 삭제되므로(11장) 반드시 확인함.
# ① 지금 내 네임스페이스에 무엇이 있나
kubectl get all # Pod·Service·Deployment·RS
kubectl get ingress,configmap,secret,pvc,hpa # all 에 안 나오는 것들
# ② 라벨로 한꺼번에 지우기 (라벨을 붙여 두면 정리가 쉽다)
kubectl delete all,ingress,configmap,pvc -l app=shop-api
# ③ 파일 기준으로 지우기 — 빠뜨림이 없다
kubectl delete -f k8s/
# ④ 마지막 확인 — ALB·EBS 가 남아 있지 않은가
kubectl get ingress,pvc
# aws elbv2 describe-load-balancers --query 'LoadBalancers[].LoadBalancerName'
# aws ec2 describe-volumes --filters Name=status,Values=available
주의: kubectl get all 은 이름과 달리 전부를 보여 주지 않음. Ingress · ConfigMap · Secret · PVC 는 따로 조회해야 함
[핵심 요약] 실습 환경과 kubectl
5장에서 남길 것은 손에 붙여야 할 명령과 순서임.
| 항목 | 한 줄 정리 | 실무 포인트 |
| 도구 | kubectl · awscli v2 · docker · helm | kubectl 은 클러스터와 ±1 마이너 버전 |
| 접속 | aws eks update-kubeconfig 한 번 | 토큰은 매번 IAM 으로 새로 만든다 |
| 기본 NS | set-context --current --namespace | 안 하면 default 를 보며 헤맨다 |
| 권한 확인 | kubectl auth can-i | RBAC 문제를 추측하지 말고 확인한다 |
| 문법 | 동사 · 종류 · 이름 · 옵션 | 외우지 말고 문법 + --help |
| 진단 순서 | get → describe(Events) → logs | Pending 에는 로그가 없다 |
| 재시작 로그 | logs --previous | CrashLoop 원인은 여기 있다 |
| 출력 가공 | -o wide / -o jsonpath | wide 는 노드·IP, jsonpath 는 값 하나 |
| 변경 | diff 로 보고 apply | 급할 때 명령형, 그날 안에 파일 반영 |
체크: Pod 가 CrashLoopBackOff 임. 어떤 명령을 어떤 순서로 칠 것인가?
6. kubectl 실무
포트포워딩과 exec · 수정과 삭제 · k9s · API 직접 보기
포트 포워딩으로 직접 붙기
Ingress 없이도 내 노트북에서 클러스터 안 서비스에 붙을 수 있음. 디버깅 전용이며, 터미널을 닫으면 끊기므로 운영 접근 경로로 쓰지 않음.
# Service 로 — 뒤의 Pod 중 하나로 연결된다
kubectl port-forward svc/shop-api 8080:80
# Pod 로 직접 — 특정 Pod 를 찍어서 (부하 분산을 건너뛴다)
kubectl port-forward pod/shop-api-7d9f8 8080:8080
# 관리 포트만 열어 보기 — 외부에 노출되지 않은 것을 확인할 때
kubectl port-forward deploy/shop-api 8081:8081
curl -s localhost:8081/actuator/health | jq .
# 백그라운드로 띄우고 쓰기
kubectl port-forward svc/shop-api 8080:80 >/dev/null 2>&1 &
curl -s localhost:8080/api/info | jq .
kill %1
실무: 특정 Pod 하나만 이상할 때 pod/ 로 직접 붙어 보면 Service 문제인지 그 Pod 문제인지 바로 갈림
컨테이너 안으로 들어가기
로그로 안 보이는 것은 들어가서 봐야 함. 다만 들어가기 전에 describe와 logs를 먼저 보며, 대부분 거기서 끝남.
# 명령 한 번만 실행
kubectl exec shop-api-7d9f8 -- ls -al /
kubectl exec shop-api-7d9f8 -- env | grep SPRING
# 셸로 들어가기
kubectl exec -it shop-api-7d9f8 -- /bin/sh # 경량 이미지는 sh
kubectl exec -it shop-api-7d9f8 -- /bin/bash # bash 가 있으면
# 컨테이너가 여러 개면 -c 로 고른다
kubectl exec -it my-pod -c sidecar -- sh
# 셸이 아예 없는 이미지(distroless)라면 — 임시 컨테이너를 붙인다
kubectl debug -it shop-api-7d9f8 --image=busybox --target=app
# 클러스터 안에서 네트워크 확인용 Pod 하나 띄우기
kubectl run nettest --rm -it --image=nicolaka/netshoot -- bash
주의: exec 로 고친 것은 컨테이너가 재시작되면 사라짐. 원인을 찾는 데만 쓰고, 고치는 것은 매니페스트로 함
리소스 사용량 보기
requests를 정할 때도, 느려졌을 때도 먼저 실측함. top은 metrics-server가 있어야 동작하며, 없으면 에러가 발생함.
kubectl top nodes # 노드별 사용량
kubectl top pods # 네임스페이스 전체
kubectl top pods -l app=shop-api # 라벨로 거르기
# 컨테이너 단위로 — 사이드카가 얼마나 쓰는지
kubectl top pod shop-api-7d9f8 --containers
# 정렬 — 많이 쓰는 것부터
kubectl top pods --sort-by=cpu
kubectl top pods --sort-by=memory
# 이 값과 requests/limits 를 비교한다
kubectl get pod shop-api-7d9f8 -o jsonpath=\
'{.spec.containers[0].resources}'
체크: top 은 지금 이 순간임. requests 를 정하려면 며칠 치 그래프가 필요하며, 그것이 심화 과정의 모니터링임
라벨과 애노테이션 다루기
라벨은 고르기 위한 것, 애노테이션은 붙여 두기 위한 것임. 라벨로는 검색이 되고 애노테이션으로는 안 됨 — 그것이 둘을 가르는 기준임.
# 라벨 — 붙이고, 덮어쓰고, 지운다
kubectl label pod my-pod owner=P000
kubectl label pod my-pod owner=P001 --overwrite # 있으면 --overwrite 필수
kubectl label pod my-pod owner- # 끝에 - 를 붙이면 삭제
# 라벨로 고르기 — 여기가 라벨의 존재 이유다
kubectl get pods -l owner=P000
kubectl delete pods -l owner=P000
# 애노테이션 — 검색용이 아니라 기록·설정 전달용
kubectl annotate deploy shop-api \
kubernetes.io/change-cause="1.1.0 배포 — 결제 버그 수정"
kubectl annotate ingress shop-api \
nginx.ingress.kubernetes.io/proxy-body-size=8m
kubectl get pod my-pod -o jsonpath='{.metadata.annotations}' | jq .
정리: Ingress 애노테이션이 컨트롤러에 설정을 전달하는 통로임. 11장에서 본 그것이 이 문법임
고치는 네 가지 방법
apply · edit · patch · replace 는 무엇을 언제 사용하는지가 다름. 운영에서는 apply 하나만 쓰고, 나머지는 급할 때나 실습용임.
| 명령 | 무엇을 하나 | 언제 | 위험 |
| apply | 파일 기준으로 맞춘다 | 항상 이것 | - |
| edit | 에디터로 즉석 수정 | 급할 때 · 실습 | 파일과 어긋난다 |
| patch | 일부 필드만 명령으로 | 스크립트 | 파일과 어긋난다 |
| set image | 이미지만 바꾸는 지름길 | 배포 실습 | 파일과 어긋난다 |
| replace --force | 지우고 새로 만든다 | 고칠 수 없는 필드 | 중단이 생긴다 |
주의: replace --force 는 삭제 후 재생성임. Pod 가 잠시 사라지므로 운영에서는 트래픽이 끊김
지우는 여러 가지 방법
무엇을 지우는지 확인하고 지움 — 되돌릴 수 없음. --all 과 -l 을 칠 때는 먼저 get 으로 같은 조건을 확인함.
# ① 파일 기준 — 만든 것과 같은 방식으로 지운다 (가장 안전)
kubectl delete -f deploy.yaml
./render.sh l16-complete/*.yaml | kubectl delete -f -
# ② 이름으로
kubectl delete deploy shop-P000
# ③ 라벨로 — 먼저 get 으로 확인하고 지운다
kubectl get pods,svc -l owner=P000 # ← 반드시 먼저
kubectl delete pods,svc -l owner=P000
# ④ 전부 — 공유 네임스페이스에서는 절대 쓰지 않는다
kubectl delete pod --all # 위험
# 즉시 삭제 — graceful shutdown 을 건너뛴다
kubectl delete pod my-pod --now # 종료 유예 무시
kubectl delete pod my-pod --grace-period=0 --force # 최후의 수단
주의: 우리 실습은 반 전체가 한 네임스페이스를 사용함. --all 은 옆 사람 것까지 지우므로 사용하지 않음
kubectl cp 로 파일을 주고받는다
컨테이너 안팎으로 파일을 옮김. 로그 파일이나 힙 덤프를 꺼낼 때 쓰며, 컨테이너에 tar 가 있어야 동작하므로 경량 이미지에서는 실패할 수 있음.
# 로컬 → Pod
kubectl cp ./data my-pod:/shared-data/data
# Pod → 로컬 (장애 분석에서 이 방향을 더 자주 쓴다)
kubectl cp my-pod:/shared-data/logs ./download
# 네임스페이스를 명시하는 형식
kubectl cp ./data class-6/my-pod:/shared-data/data
# 컨테이너가 여러 개면
kubectl cp ./data my-pod:/tmp/data -c app
# 확인
kubectl exec my-pod -- ls -al /shared-data/data
# 힙 덤프 꺼내기 (자바 앱 장애 분석)
kubectl exec my-pod -- jcmd 1 GC.heap_dump /tmp/heap.hprof
kubectl cp my-pod:/tmp/heap.hprof ./heap.hprof
실무: 꺼내는 방향이 진짜 쓸모임. OOM 직전 힙 덤프나 스레드 덤프를 받아 로컬 도구로 분석함
k9s 는 터미널용 대시보드이다 (별도 설치 필요)
kubectl 을 수백 번 치는 대신 화면에서 돌아다님. 관찰에 특히 좋으며, 상태가 실시간으로 갱신되어 get -w 를 여러 개 실행할 필요가 없음.
| 키 | 무엇 | kubectl 로는 |
| k9s -n class-6 | 네임스페이스를 열고 시작 | - |
| :pod · :svc · :deploy | 리소스 종류 전환 | get <종류> |
| /문자열 | 필터 | -l · grep |
| d | describe | describe |
| l | 로그 | logs |
| s | 셸로 들어가기 | exec -it |
| Ctrl-d | 삭제 | delete |
| :q · Esc | 종료 · 뒤로 | - |
주의: Ctrl-d 가 삭제임. 확인 창이 뜨지만 습관적으로 넘기기 쉬우니 익숙해지기 전에는 공유 네임스페이스에서 조심함
kubectl 없이 API 직접 보기
kubectl은 REST API 클라이언트일 뿐이며, 그 아래를 직접 볼 수 있음. --v=8 을 붙이면 kubectl 이 실제로 보낸 요청이 그대로 보여 학습에 좋음.
# kubectl 이 무슨 요청을 보내는지 그대로 본다
kubectl get pods --v=8 2>&1 | grep -E 'GET|Request|Response'
# API 를 직접 호출 — 인증은 kubectl 이 대신해 준다
kubectl get --raw /api
kubectl get --raw /apis
kubectl get --raw /api/v1/namespaces/class-6/pods | jq -r '.items[].metadata.name'
# 로그도 API 다
kubectl get --raw \
/api/v1/namespaces/class-6/pods/my-pod/log
# 프록시를 띄우면 브라우저로도 볼 수 있다
kubectl proxy --port=8080 &
curl -s localhost:8080/api/v1/namespaces/class-6/pods | jq -r '.items[].metadata.name'
참고: 이 경로가 곧 RBAC 의 단위임. resources: ["pods/log"] 같은 표기가 위 URL 구조에서 나옴
API 리소스 스스로 찾기
필드 이름이 기억 안 날 때 검색하지 말고 클러스터에 물어봄. explain은 지금 이 클러스터 버전 기준이라 인터넷 문서보다 정확함.
# 어떤 리소스가 있나 — 축약형과 API 그룹까지
kubectl api-resources
kubectl api-resources --namespaced=true # 네임스페이스에 속하는 것
kubectl api-resources --namespaced=false # 클러스터 범위 (Node, PV, SC)
kubectl api-resources | grep -i ingress
# 필드가 뭐가 있나 — 계층을 따라 내려간다
kubectl explain deployment
kubectl explain deployment.spec.strategy
kubectl explain deployment.spec.template.spec.containers.livenessProbe
kubectl explain pod --recursive | head -40 # 전체 트리
# API 버전 확인 — 매니페스트의 apiVersion 이 맞는지
kubectl api-versions | grep autoscaling
체크: no matches for kind 에러가 나면 api-resources 로 그 종류가 있는지, api-versions 로 그 버전이 맞는지 확인함
적용 전에 확인하는 습관
무엇이 바뀔지 보고 나서 적용함. 공유 클러스터에서는 필수이며, diff는 클러스터에 실제로 물어보는 것이지 로컬 파일끼리 비교하는 것이 아님.
# ① 무엇이 바뀌는가 — 클러스터의 현재 상태와 비교한다
kubectl diff -f deploy.yaml
kubectl diff -k overlays/dev
# ② 보내 보되 저장은 안 한다 — 스키마·정책 검증까지 받는다
kubectl apply -f deploy.yaml --dry-run=server
# ③ 로컬에서만 문법 확인 (클러스터에 연결 안 함)
kubectl apply -f deploy.yaml --dry-run=client
# ④ 매니페스트를 만들되 적용은 안 하기 — 뼈대 만들 때 편하다
kubectl create deploy demo --image=nginx --dry-run=client -o yaml > deploy.yaml
kubectl create cm my-config --from-literal=K=V --dry-run=client -o yaml | kubectl apply -f -
실무: --dry-run=client -o yaml로 뼈대를 만든 뒤 고쳐 쓰는 것이 매니페스트를 처음부터 타이핑하는 것보다 빠르고 정확함
[핵심 요약] kubectl 실무
명령을 외우는 게 아니라 무엇을 알고 싶은가에서 명령으로 가는 길을 익힘. 막히면 explain 과 --v=8 로 확인하며, 클러스터에 직접 물어보면 검색보다 정확함.
| 알고 싶은 것 | 명령 |
| 지금 상태가 어떤가 | get · get -o wide · --sort-by |
| 왜 그런가 | describe 의 Events |
| 앱이 뭐라고 하나 | logs · logs --previous · -c |
| 값 하나만 뽑고 싶다 | -o jsonpath · custom-columns |
| 문제 있는 것만 보고 싶다 | --field-selector=status.phase!=Running |
| 안에 들어가 보고 싶다 | exec -it · debug · netshoot |
| 내 노트북에서 붙고 싶다 | port-forward |
| 얼마나 쓰고 있나 | top pods --containers |
| 적용하면 뭐가 바뀌나 | diff · --dry-run=server |
| 필드 이름이 뭐였지 | explain · api-resources |
| 권한이 있나 | auth can-i |
실무: delete 앞에는 반드시 get 을 한 번 침. 같은 조건으로 무엇이 지워질지 눈으로 보고 나서 지움
7. Pod
Pod 의 정의 · 생명주기 · 프로브 · 디버깅
Pod 는 컨테이너가 아니다
Pod는 컨테이너 하나 이상을 묶은 껍데기임 — 네트워크와 저장소를 공유하는, 같이 사는 단위임. 같은 Pod 안의 컨테이너는 IP를 공유해 서로를 localhost로 부르며, 스케줄 · 재시작 · 삭제가 Pod 단위로 일어나 컨테이너 하나만 옮길 수 없음.
| 공유하는 것 | 무엇을 뜻하나 | 실무 의미 |
| 네트워크 네임스페이스 | IP · 포트 공간이 하나 | 같은 포트를 두 컨테이너가 못 쓴다 |
| localhost | 서로를 127.0.0.1 로 호출 | 사이드카와의 통신이 네트워크를 안 탄다 |
| 볼륨 | 같은 볼륨을 마운트 가능 | 로그 파일을 사이드카가 읽는 구조 |
| 생명주기 | 같이 뜨고 같이 종료된다 | 하나가 종료되면 재시작 대상은 Pod |
| 노드 | 항상 같은 노드에 배치 | 떨어뜨릴 수 없다 |
| 공유 안 하는 것 | 파일시스템(볼륨 제외) · 프로세스 | 각자 자기 이미지의 파일을 본다 |
정리: Pod = 반드시 같은 노드에서 함께 살아야 하는 컨테이너들의 묶음임. 대부분은 컨테이너 하나짜리임
Pod 매니페스트 읽기
실무에서 Pod를 직접 만들 일은 거의 없지만, 그래도 읽을 줄 알아야 함 — 문제는 늘 여기서 남. Deployment의 template 안에 이 내용이 그대로 들어감.
apiVersion: v1
kind: Pod
metadata:
name: shop-api
labels: { app: shop-api }
spec:
containers:
- name: app
image: 123456789012.dkr.ecr.ap-northeast-2.amazonaws.com/shop-api:1.0.0
ports:
- containerPort: 8080 # 문서용. 안 적어도 통신은 된다
env:
- name: SPRING_PROFILES_ACTIVE
value: prod
resources: # 13장에서 자세히
requests: { cpu: 200m, memory: 512Mi }
limits: { cpu: "1", memory: 1Gi }
restartPolicy: Always # Always | OnFailure | Never
참고: containerPort 는 문서일 뿐임. 안 적어도 Pod IP 로 그 포트에 접속되며, 적어 두면 읽는 사람이 편함
Pod 를 열어서 읽기
get -o yaml로 열면 내가 안 쓴 필드가 잔뜩 있음. 누가 채웠는지 알면 읽히며, 내가 쓴 것 · 기본값 · 클러스터가 채운 것 셋이 섞여 있음.
spec:
containers:
- image: skala-registry.skala-ai.com/class-6/shop-P000:1.0.0
imagePullPolicy: IfNotPresent # ← 기본값이 채워짐
terminationMessagePath: /dev/termination-log # ← 기본값
resources: { requests: { cpu: 200m } } # ← 내가 씀
nodeName: ip-172-31-40-45... # ← 스케줄러가 채움
serviceAccountName: default # ← admission 이 채움
status: # ← 전부 클러스터가 채움
phase: Running
podIP: 172.31.6.2
containerStatuses:
- restartCount: 0
imageID: docker-pullable://...@sha256:d59a78...
state: { running: { startedAt: "2026-08-24T01:12:03Z" } }
체크: status.containerStatuses[].imageID 가 지금 실제로 도는 이미지임. 태그만 보고 배포됐다고 믿지 않음
Pod 의 생명주기
Pending → Running → Succeeded/Failed. 상태 이름만 봐도 어디까지 왔는지 알 수 있음. Running은 컨테이너가 떴다는 뜻이지 서비스 가능이 아니며, 서비스 가능 여부는 Ready 조건이 따로 알려 줌.
| 상태(phase) | 무슨 뜻 | 여기서 멈추면 볼 곳 |
| Pending | 아직 노드에 배치되지 않았거나 이미지 받는 중 | describe 의 Events (자원 · taint · 이미지) |
| Running | 노드에 배치되고 컨테이너가 최소 하나 실행 중 | Ready 가 아니면 readinessProbe 확인 |
| Succeeded | 모든 컨테이너가 정상 종료 (Job 등) | 정상 |
| Failed | 컨테이너가 0 이 아닌 코드로 종료 | logs --previous 로 원인 |
| Unknown | 노드와 통신 불가 | 노드 상태 확인 (NotReady 인가) |
| Ready 조건 | 프로브를 통과해 트래픽을 받을 수 있다 | Endpoints 등록 여부와 직결 |
| CrashLoopBackOff | 상태가 아니라 컨테이너 사유, 재시작 반복 | logs --previous · 재시작 간격 증가 |
| ImagePullBackOff | 사유, 이미지를 못 받는다 | 태그 오타 · ECR 권한 · 플랫폼 불일치 |
주의: Running 인데 서비스가 503 이면 거의 항상 Ready 가 아님. kubectl get pod 의 READY 열 1/1 을 확인함
Pod 상태 이름 읽는 법
STATUS 열에 나오는 이름이 곧 어느 단계에서 멈췄는지를 말해 줌. Pending 이면 컨테이너가 아직 없으므로 logs 를 쳐도 나올 것이 없음.
| STATUS | 무슨 뜻 | 먼저 볼 곳 |
| Pending | 노드 배정 전 · 자원 부족 | describe 의 0/N nodes |
| ContainerCreating | 볼륨 · 설정을 준비 중 | describe Events (ConfigMap · PVC) |
| ImagePullBackOff | 이미지를 못 받음 | Events 의 unauthorized/not found |
| Running (READY 1/1) | 정상 | - |
| Running (READY 0/1) | readiness 실패 | get endpoints · 프로브 경로 |
| CrashLoopBackOff | 떴다 죽기를 반복 | logs --previous |
| Completed | 정상 종료 (Job 은 정상) | Deployment 면 비정상 |
| Evicted | 노드 자원 부족으로 축출 | QoS 클래스 · top nodes |
| Terminating (오래) | 종료가 안 끝남 | finalizer · 볼륨 detach |
정리: BackOff 가 붙으면 재시도 간격이 점점 길어진다는 뜻임. 5초, 10초, 20초 … 최대 5분으로, 나중에는 느리게 회복됨
초기화 컨테이너
본 컨테이너보다 먼저, 순서대로, 끝까지 성공해야 다음으로 넘어감. 의존성이 준비될 때까지 기다리는 일을 앱 코드가 아니라 매니페스트가 함.
spec:
initContainers:
- name: wait-for-db # ① 먼저 실행 · 성공해야 다음
image: busybox:1.36
command: ['sh','-c','until nc -z db 5432; do sleep 2; done']
- name: migrate # ② 그 다음
image: skala-registry.skala-ai.com/class-6/migrator:1.0.0
command: ['./migrate.sh']
containers: # ③ init 이 전부 끝나야 시작
- name: app
image: skala-registry.skala-ai.com/class-6/shop-P000:1.0.0
# 관찰
# kubectl get pod → STATUS: Init:0/2 → Init:1/2 → Running
# kubectl logs my-pod -c wait-for-db # init 로그는 -c 로
주의: init 이 실패하면 Pod 전체가 재시작됨. 그래서 init 은 반드시 여러 번 실행해도 안전(멱등)해야 함
프로브 세 가지는 역할이 다르다
살아 있는가(liveness), 받을 수 있는가(readiness), 다 떴는가(startup) — 셋은 하는 일이 다름. readiness는 트래픽을 끊고, liveness는 컨테이너를 죽여 결과가 완전히 다르며, 느리게 뜨는 앱은 startupProbe로 초기 유예를 줌.
| 프로브 | 실패하면 | 언제 쓰나 | 잘못 쓰면 |
| readinessProbe | Endpoints 에서 빠져 트래픽 중단 | 기동 중 · 과부하 · 의존성 장애 | 없으면 준비 전에 503 |
| livenessProbe | 컨테이너를 죽이고 재시작 | 데드락 등 스스로 못 푸는 상태 | 너무 민감하면 무한 재시작 |
| startupProbe | 컨테이너 재시작 | 기동이 오래 걸리는 앱 | 없으면 liveness 가 기동 중 죽인다 |
- 공통 옵션 — initialDelaySeconds: 첫 검사까지 대기(기동 시간 고려, 짧으면 기동 중 실패 처리), periodSeconds: 검사 주기(기본 10초, 짧으면 부하 · 길면 감지 지연), failureThreshold: 몇 번 실패해야 실패로 볼지(기본 3, 1이면 일시적 지연에도 재시작), timeoutSeconds: 응답 대기(기본 1초, JVM 은 늘려야 함)
주의: liveness 에 DB 연결 확인을 넣지 말 것. DB 가 잠깐 흔들리면 모든 Pod 가 동시에 재시작해 장애가 커짐
프로브 설정 예시
Spring Boot 앱 기준으로 세 프로브를 어떻게 사용하는지 봄 — 13장에서 그대로 쓸 설정임. startup이 성공할 때까지 liveness · readiness는 아예 실행되지 않음.
containers:
- name: app
image: shop-api:1.0.0
startupProbe: # 기동 유예: 최대 5×30 = 150초
httpGet: { path: /actuator/health/liveness, port: 8080 }
periodSeconds: 5
failureThreshold: 30
livenessProbe: # 프로세스가 살아 있는가 (가볍게)
httpGet: { path: /actuator/health/liveness, port: 8080 }
periodSeconds: 10
timeoutSeconds: 3
failureThreshold: 3
readinessProbe: # 트래픽을 받아도 되는가 (의존성 포함)
httpGet: { path: /actuator/health/readiness, port: 8080 }
periodSeconds: 5
timeoutSeconds: 3
failureThreshold: 3
정리: startupProbe 가 있으면 initialDelaySeconds 를 안 써도 됨. 기동이 빠른 날은 그만큼 빨리 서비스에 들어감
종료는 어떻게 일어나는가
Pod 삭제는 즉시가 아님. 트래픽을 끊고, SIGTERM을 보내고, 유예 시간을 준 뒤 강제 종료함. Endpoints 제거와 SIGTERM이 거의 동시에 일어나 잠깐 502가 날 수 있으며, terminationGracePeriodSeconds(기본 30초) 안에 안 끝나면 SIGKILL임.
| 순서 | 무슨 일 | 우리가 할 일 |
| ① | Pod 에 삭제 표시 (deletionTimestamp) | - |
| ② | Endpoints 에서 제거 — 새 트래픽 차단 | 비동기라 지연이 있다 |
| ③ | preStop 훅 실행 (설정했다면) | sleep 5 로 ②의 전파를 기다린다 |
| ④ | 컨테이너에 SIGTERM 전달 | 앱이 graceful shutdown 시작 |
| ⑤ | 처리 중인 요청을 마무리 | Spring Boot: graceful shutdown 설정 |
| ⑥ | grace period 종료 시 SIGKILL | 긴 작업은 period 를 늘린다 |
주의: ②가 비동기라서 이미 라우팅된 요청이 남음. preStop: sleep 5가 502 를 없애는 가장 간단한 해법임
재시작 정책과 백오프
언제 다시 실행할지는 정책이 정하고, 얼마나 기다릴지는 클러스터가 정함. Deployment의 Pod는 항상 Always이며, 다른 값이 필요하면 Job을 사용함.
| restartPolicy | 언제 재시작 | 어디에 쓰나 |
| Always (기본) | 종료 코드와 무관하게 항상 | Deployment · 서버 앱 |
| OnFailure | 0 이 아닌 코드로 끝났을 때만 | Job · 배치 |
| Never | 재시작하지 않음 | 일회성 · 디버깅 |
체크: RESTARTS 열에 (5m ago) 처럼 시각이 함께 나옴. 방금인지 어제인지가 진단을 가름
멀티 컨테이너 패턴
Pod에 컨테이너를 여러 개 두는 경우는 정해져 있음. 세 가지 패턴만 알면 충분하며, 앱 두 개를 한 Pod에 두는 것은 거의 항상 잘못된 설계임 — 따로 배포 · 확장할 수 없어짐.
| 패턴 | 무엇 | 예 | 판단 |
| 사이드카 | 앱을 돕는 보조 컨테이너 | 로그 수집, 프록시, 인증서 갱신 | 가장 흔하다 |
| 앰배서더 | 외부 연결을 대신 맡는다 | DB 프록시, 서비스 메시 프록시 | 앱 코드를 안 고쳐도 된다 |
| 어댑터 | 출력 형식을 바꿔 준다 | 앱 지표를 Prometheus 형식으로 | 레거시 앱에 유용 |
| 안티패턴 | 독립적인 두 앱을 한 Pod 에 | API + 배치 워커 | 각각 Deployment 로 분리 |
실무: Istio 같은 서비스 메시는 사이드카를 자동 주입함. 그래서 Pod 를 보면 컨테이너가 2/2 로 나옴
Pod 가 자기 정보를 아는 법
Pod 이름 · 노드 이름 · IP를 앱이 알아야 할 때가 있음. 환경변수로 주입할 수 있으며, 로그에 Pod 이름을 찍어 두면 어느 복제본에서 난 문제인지 즉시 앎.
env:
- name: POD_NAME
valueFrom: { fieldRef: { fieldPath: metadata.name } }
- name: POD_NAMESPACE
valueFrom: { fieldRef: { fieldPath: metadata.namespace } }
- name: NODE_NAME
valueFrom: { fieldRef: { fieldPath: spec.nodeName } }
- name: POD_IP
valueFrom: { fieldRef: { fieldPath: status.podIP } }
- name: MEM_LIMIT # 리소스 값도 가져올 수 있다
valueFrom:
resourceFieldRef: { containerName: app, resource: limits.memory }
# Spring Boot 에서 쓰기 (application.yml)
# logging.pattern.level: "%5p [${POD_NAME:local}]"
# management.metrics.tags.pod: ${POD_NAME:local}
실무: 지표에 Pod 이름 태그를 붙이면 특정 Pod 만 느리다 같은 문제를 그래프에서 바로 봄
Pod 를 직접 만들면 안 되는 이유
Pod는 스스로를 지키지 않음. 노드에 장애가 나면 그대로 사라지고 아무도 다시 만들지 않으며, 관리자 없는 Pod를 네이키드 Pod(naked pod)라 부르고 운영에 두지 않음.
| 방식 | 노드 장애 시 | 복제 · 확장 | 롤링 업데이트 | 쓸 곳 |
| Pod 직접 | 사라지고 끝 | 불가 | 불가 | 일회성 디버깅 |
| ReplicaSet | 다시 만든다 | 가능 | 불가 | 직접 쓰지 않는다 |
| Deployment | 다시 만든다 | 가능 | 가능 | 상태 없는 앱 전부 |
| StatefulSet | 다시 만든다 | 가능 | 가능 (순서대로) | DB · 큐 등 상태 보유 |
| DaemonSet | 노드마다 하나 | 노드 수만큼 | 가능 | 로그 · 모니터링 에이전트 |
| Job / CronJob | 재시도 | 병렬 가능 | - | 배치 작업 |
정리: 실무에서 만드는 것은 Deployment 이고 Pod 는 그 결과임. Pod 를 직접 만드는 것은 디버깅용 임시 컨테이너뿐임
Pod 디버깅 도구 상자
Pod가 이상할 때 순서대로 칠 명령들 — 이 순서가 진단 시간을 절반으로 줄임. 상태 → 이벤트 → 로그 → 안으로 순서를 지키며, 건너뛰면 헛다리를 짚음.
# ① 지금 상태 — READY 열과 RESTARTS 열을 본다
kubectl get pod -o wide
# ② 왜 그런지 — Events 가 답을 준다
kubectl describe pod <이름> | tail -30
# ③ 앱이 뭐라고 하는지 (컨테이너가 떴을 때만)
kubectl logs <이름> --tail=100
kubectl logs <이름> --previous # 재시작했다면 필수
# ④ 안에 들어가 확인 — 설정·연결·파일
kubectl exec -it <이름> -- sh
kubectl exec <이름> -- env | sort
# ⑤ 셸이 없는 이미지(distroless)라면 임시 컨테이너를 붙인다
kubectl debug -it <이름> --image=busybox:1.36 --target=app
실무: kubectl debug 는 원본 컨테이너를 건드리지 않고 도구 컨테이너를 Pod 에 끼워 넣음. 1.25 부터 정식 기능임
[핵심 요약] Pod
7장에서 남길 것은 Pod 를 다룰 때의 판단 기준임.
| 항목 | 한 줄 정리 | 실무 포인트 |
| Pod 란 | 네트워크 · 볼륨을 공유하는 컨테이너 묶음 | IP 가 하나. 포트를 나눠 쓴다 |
| Running vs Ready | 떴다 ≠ 받을 수 있다 | READY 0/1 이면 503 의 원인 |
| initContainer | 먼저 · 순서대로 · 끝나야 다음 | 마이그레이션은 복제본 수만큼 동시 실행 |
| readinessProbe | 실패 → 트래픽만 끊는다 | 의존성 확인을 여기에 |
| livenessProbe | 실패 → 컨테이너를 죽인다 | DB 확인을 넣으면 재시작 폭풍 |
| startupProbe | 기동 유예. 준비되면 즉시 넘어간다 | initialDelay 보다 낫다 |
| 종료 | Endpoints 제거가 비동기라 502 가 난다 | preStop sleep 5 로 해결 |
| 멀티 컨테이너 | 사이드카 · 앰배서더 · 어댑터만 | 따로 확장해야 하면 분리한다 |
| 직접 만들기 | 네이키드 Pod 는 노드와 함께 사라진다 | 운영은 전부 Deployment |
체크: 우리 앱의 liveness 경로가 DB 를 확인하고 있지는 않은가? Spring Boot 기본 /actuator/health 는 확인함