Docker & Kubernetes 입문 가이드 | 컨테이너부터 오케스트레이션까지
이 글의 핵심
Docker로 이미지를 만들고 Compose로 로컬 스택을 맞춘 뒤, Kubernetes의 Pod·Deployment·Service로 넘어가는 입문 경로입니다. 멀티 스테이지 빌드, depends_on과 헬스체크의 차이, 컨테이너가 안 뜰 때 확인하는 순서까지 함께 다룹니다.
이 글의 핵심
이미지·컨테이너·Compose로 로컬을 맞춘 뒤, Pod·Deployment·Service까지 Kubernetes 맥락을 잡습니다. “한 번에 전부”보다는 자주 쓰는 경로를 따라가도록 정리했고, 중간중간 입문자가 가장 많이 막히는 지점(멀티 스테이지 빌드 경로 실수, depends_on과 준비 상태의 차이, 컨테이너가 계속 재시작될 때의 확인 순서)을 짚습니다.
역사 한 줄과 왜 지금 Docker·Kubernetes인가
격리는 오래전부터 있었습니다. 유닉스의 chroot(1982)는 프로세스가 보는 루트 디렉터리를 바꿔 파일 시스템 관점의 감옥을 만들었으며, 이후 FreeBSD jail, Linux namespaces와 cgroups가 합쳐지며 “가벼운 격리”가 성숙했습니다. Docker(2013년 전후, dotCloud에서 공개)는 이런 커널 기능을 이미지·레지스트리·CLI로 포장해, 애플리케이션을 재현 가능한 단위로 나누기 쉽게 만들었습니다. 즉 Docker의 핵심은 “가상 OS를 또 올린다”가 아니라, 같은 커널 위에서 프로세스 묶음을 패키징한다는 점입니다.
Kubernetes는 구글 내부의 Borg 경험을 바탕으로, 컨테이너가 많아졌을 때 생기는 문제—어디에 몇 개 띄울지, 죽으면 어떻게 다시 띄울지, 서로 어떻게 찾을지—를 선언적으로 풀려는 오케스트레이션입니다. 2015년 공개 이후 CNCF로 이관되어 생태계가 커졌으며, 지금은 “VM 한 대에 Compose”에서 시작해 팀·트래픽·규정이 커지면 클러스터를 검토하는 식의 단계적 도입이 흔합니다.
아래 사전 지식 절의 VM 대비 도표는 “왜 컨테이너가 가벼운가”를 보여 주고, 이어지는 장들은 그다음 무엇을 맞춰야 운영이 되는가(이미지, Compose, kubectl)를 실습 중심으로 이어집니다. 한 번에 외울 필요 없이, 로컬 한 스택을 먼저 재현하고 필요할 때마다 절을 다시 열어도 됩니다.
사전 지식 (초보자를 위한 기초)
가상화란?
가상화는 한 대의 물리 서버에서 여러 환경을 나눠 돌리는 기술입니다.
전통적인 방식:
┌─────────────────┐
│ 애플리케이션 │
├─────────────────┤
│ 운영체제 │
├─────────────────┤
│ 물리 서버 │
└─────────────────┘
가상 머신 (VM):
┌─────────────────┐
│ 앱 A │ 앱 B │
├─────────┼───────┤
│ OS A │ OS B │ ← 각각 전체 OS 필요 (무거움)
├─────────────────┤
│ 하이퍼바이저 │
├─────────────────┤
│ 물리 서버 │
└─────────────────┘
컨테이너 (Docker):
┌─────────────────┐
│ 앱 A │ 앱 B │
├─────────┼───────┤
│ Docker Engine │ ← OS 커널 공유 (가벼움)
├─────────────────┤
│ 운영체제 │
├─────────────────┤
│ 물리 서버 │
└─────────────────┘
차이점:
- VM: 게스트 OS 전체를 포함하고 하이퍼바이저가 CPU·메모리를 나눠 줍니다. 이미지가 GB 단위이고 부팅에 시간이 걸립니다.
- 컨테이너: 호스트 커널을 공유하고 사용자 공간(user space)만 격리합니다. 이미지가 보통 MB~수백 MB 단위이고 수 초 안에 뜹니다.
다만 컨테이너의 격리는 하드웨어 가상화만큼 강하지 않습니다. 커널을 공유하기 때문에 커널 취약점은 모든 컨테이너에 영향을 줍니다. 그래서 앱 단위 배포·스케일에는 컨테이너가, OS 단위로 강하게 분리해야 하는 환경(서로 다른 고객의 워크로드 등)에는 VM이나 VM 기반 샌드박스가 여전히 쓰입니다.
왜 Docker를 사용할까?
“내 컴퓨터에서는 되는데…” 문제를 줄이기 위해서입니다.
개발자 A의 환경:
- Python 3.9
- PostgreSQL 13
- Ubuntu 20.04
개발자 B의 환경:
- Python 3.11
- PostgreSQL 15
- macOS
→ 같은 코드인데 다르게 동작할 수 있습니다.
Docker를 쓰면 같은 이미지를 기준으로 환경을 맞추기 쉬워져서, 환경 차이로 생기는 디버깅을 줄이는 데 도움이 됩니다. “Build once, run anywhere”라는 표현은 정확히 말하면 어디서나 같은 이미지를 돌린다는 뜻에 가깝습니다. 호스트 CPU 아키텍처(amd64/arm64)가 다르면 이미지도 그에 맞게 빌드되어 있어야 합니다.
컨테이너의 장점
이식성 (Portability)
# 개발 환경에서 테스트
docker run myapp
# 프로덕션 환경에서도 같은 이미지로 실행
docker run myapp
격리성 (Isolation)
컨테이너 A: Node.js 18
컨테이너 B: Node.js 22
→ 서로 영향 없이 독립적으로 실행
효율성 (Efficiency)
VM: 부팅 시간 수십 초~수 분, 메모리 GB 단위
컨테이너: 시작 시간 수 초 이하, 메모리는 앱이 쓰는 만큼
Docker의 핵심 개념과 아키텍처
Docker의 핵심 개념
Docker는 애플리케이션을 컨테이너로 묶어 실행하는 플랫폼입니다.
Docker 구성 요소:
┌─────────────────────────────────┐
│ Docker Image │ ← 레시피 + 재료 박스
│ (읽기 전용, 불변) │
└─────────────────────────────────┘
↓ docker run
┌─────────────────────────────────┐
│ Docker Container │ ← 그걸로 실제로 돌아가는 프로세스
│ (읽기/쓰기 레이어, 실행 중) │
└─────────────────────────────────┘
이미지는 읽기 전용 레이어의 묶음이며, 컨테이너는 그 위에서 돌아가는 프로세스와 쓰기 레이어까지 포함한 실행 단위입니다. 레이어는 “바뀐 것만 위에 쌓는” 구조라서, 같은 베이스 이미지를 쓰는 이미지 여러 개가 디스크의 공통 레이어를 공유합니다. 빌드 캐시도 이 레이어 단위로 동작하기 때문에, 뒤에서 볼 Dockerfile 명령 순서가 빌드 속도를 크게 좌우합니다.
Docker 아키텍처
┌──────────────────────────────────────┐
│ Docker Client (CLI) │
│ docker run, docker build │
└──────────────┬───────────────────────┘
│ REST API
┌──────────────▼───────────────────────┐
│ Docker Daemon │
│ - 이미지 관리 │
│ - 컨테이너 실행 │
│ - 네트워크 관리 │
└──────────────┬───────────────────────┘
│
┌──────────────▼───────────────────────┐
│ Docker Registry │
│ (Docker Hub, 사내 레지스트리) │
│ - 이미지 저장소 │
└──────────────────────────────────────┘
Docker 설치와 기본 명령어
Docker 설치
Windows: WSL2를 먼저 켜고(wsl --install) Docker Desktop을 설치하는 조합이 가장 무난합니다. Docker Desktop이 WSL2 백엔드를 사용하므로, 소스 코드도 Windows 드라이브(/mnt/c/...)가 아니라 WSL 안의 리눅스 파일 시스템에 두어야 bind mount 성능이 제대로 나옵니다.
macOS: 공식 Docker Desktop 앱을 받거나 brew install --cask docker로 설치합니다.
Linux (Ubuntu):
# 편의 스크립트로 Docker Engine 설치 (공식 문서의 apt 저장소 방식도 가능)
curl -fsSL https://get.docker.com -o get-docker.sh
sudo sh get-docker.sh
# 현재 사용자를 docker 그룹에 추가 (다시 로그인해야 적용)
sudo usermod -aG docker $USER
# 설치 확인
docker --version
docker compose version
docker 그룹에 속한 사용자는 사실상 root 권한을 갖는다는 점은 알고 쓰는 것이 좋습니다. 배포판별 세부 절차는 공식 문서가 항상 최신이므로, 운영 서버라면 그쪽 설치 절차를 따르는 편을 권합니다.
기본 명령어
1) 이미지 다운로드 및 실행
# Docker Hub에서 이미지 다운로드
docker pull nginx
# 컨테이너 실행
docker run -d -p 8080:80 --name my-nginx nginx
# -d: 백그라운드 실행
# -p 8080:80: 호스트 8080 포트를 컨테이너 80 포트로 매핑
# --name: 컨테이너 이름 지정
# 브라우저에서 http://localhost:8080 접속 → Nginx 페이지 확인
실제 앱을 띄울 때는 환경 변수(-e)와 볼륨(-v)까지 한 번에 주는 경우가 많습니다.
docker run -d --name my-api -p 8080:3000 \
-e NODE_ENV=production \
-v my-api-data:/app/data \
my-api:1.0
2) 컨테이너 관리
# 실행 중인 컨테이너 목록
docker ps
# 모든 컨테이너 목록 (중지된 것 포함)
docker ps -a
# 컨테이너 중지 / 시작 / 재시작
docker stop my-nginx
docker start my-nginx
docker restart my-nginx
# 컨테이너 삭제
docker rm my-nginx
# 컨테이너 로그 확인 (-f: 계속 따라가기, --tail: 마지막 N줄)
docker logs -f --tail=50 my-nginx
# 컨테이너 내부 셸 접속 (alpine 계열 이미지는 bash가 없으므로 sh)
docker exec -it my-nginx sh
# 설정·네트워크·마운트 등 상세 정보
docker inspect my-nginx
docker logs, docker exec, docker inspect 세 가지는 문제가 생겼을 때 가장 먼저 꺼내는 명령입니다. 뒤의 트러블슈팅 절에서 순서대로 다시 씁니다.
3) 이미지 관리
# 이미지 목록
docker images
# 이미지 삭제
docker rmi nginx
# 태그 없는(dangling) 이미지 정리
docker image prune
# 중지된 컨테이너, 사용하지 않는 네트워크·이미지·빌드 캐시 정리
docker system prune -a
docker system prune -a는 실행 중이 아닌 컨테이너가 참조하는 이미지까지 전부 지웁니다. --volumes를 붙이면 named volume도 지워지므로 로컬 DB 데이터가 날아갈 수 있습니다. 공용 빌드 서버나 팀 개발 머신에서는 누가 언제 어떤 옵션으로 돌릴지 미리 정해 두는 편이 안전합니다.
Dockerfile 작성과 레이어 캐싱
Dockerfile이란?
Dockerfile은 이미지를 빌드하기 위한 설계도입니다. 각 명령(FROM, COPY, RUN 등)이 레이어 하나를 만들고, 빌드 캐시는 “이 명령과 입력 파일이 지난번과 같은가”로 재사용 여부를 판단합니다.
Node.js 애플리케이션 예제
1) 프로젝트 구조
my-app/
├── Dockerfile
├── .dockerignore
├── package.json
├── package-lock.json
└── server.js
2) server.js
const express = require('express');
const app = express();
const PORT = 3000;
app.get('/', (req, res) => {
res.json({ message: 'Hello from Docker!' });
});
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
3) package.json
{
"name": "my-app",
"version": "1.0.0",
"dependencies": {
"express": "^4.18.0"
},
"scripts": {
"start": "node server.js"
}
}
4) Dockerfile
# 1. 베이스 이미지 선택
FROM node:20-alpine
# 2. 작업 디렉토리 설정
WORKDIR /app
# 3. 의존성 파일만 먼저 복사 (레이어 캐시용)
COPY package*.json ./
# 4. lockfile 기준으로 프로덕션 의존성만 설치
RUN npm ci --omit=dev
# 5. 애플리케이션 코드 복사
COPY . .
# 6. root가 아닌 사용자로 실행
USER node
# 7. 포트 문서화 (실제 공개는 -p로)
EXPOSE 3000
# 8. 실행 명령어
CMD ["node", "server.js"]
npm install 대신 npm ci를 쓰는 이유는 lockfile과 정확히 같은 버전을 설치하고, lockfile과 package.json이 맞지 않으면 실패해 주기 때문입니다. 예전 글에서 자주 보이는 --production 플래그는 최신 npm에서 --omit=dev로 대체되었습니다.
5) 이미지 빌드 및 실행
# 이미지 빌드
docker build -t my-app:1.0 .
# 컨테이너 실행
docker run -d -p 3000:3000 --name my-app my-app:1.0
# 테스트
curl http://localhost:3000
# {"message":"Hello from Docker!"}
Dockerfile 최적화
레이어 캐싱 활용
# ❌ 비효율적 (코드 한 줄만 바뀌어도 매번 npm install)
FROM node:20-alpine
WORKDIR /app
COPY . .
RUN npm ci
CMD ["npm", "start"]
# ✅ 효율적 (package.json·lockfile이 바뀔 때만 npm ci)
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
CMD ["npm", "start"]
COPY . .는 빌드 컨텍스트의 파일이 하나라도 바뀌면 캐시가 깨집니다. 그래서 자주 바뀌지 않는 의존성 정의를 먼저 복사하고, 자주 바뀌는 소스 코드는 나중에 복사합니다. 빌드가 매번 처음부터 도는 것 같다면 --no-cache를 의심하기 전에 이 순서와 .dockerignore부터 확인합니다.
.dockerignore로 빌드 컨텍스트 줄이기
# .dockerignore
node_modules
npm-debug.log
.git
.env*
*.md
dist
coverage
node_modules와 .git이 컨텍스트에 들어가면 전송 시간이 늘고, 파일이 조금만 바뀌어도 COPY . . 캐시가 깨집니다. .env를 제외하는 것은 성능보다 보안 문제입니다. 한 번 이미지 레이어에 들어간 비밀 값은 나중 레이어에서 지워도 이미지 히스토리에 남습니다.
멀티 스테이지 빌드
TypeScript 컴파일이나 번들링처럼 빌드 도구가 필요한 앱은, 빌드용 스테이지와 실행용 스테이지를 나누는 것이 사실상 기본값입니다. 빌드 스테이지에는 devDependencies·컴파일러·테스트 도구가 모두 들어가지만, 최종 이미지에는 실행에 필요한 파일만 남깁니다. 이미지가 작아지는 것도 좋지만, 더 중요한 이점은 컴파일러나 패키지 매니저 같은 도구가 프로덕션 이미지에 남지 않아 공격 표면이 줄어든다는 점입니다.
# 빌드 스테이지
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# 프로덕션 스테이지
FROM node:20-alpine
WORKDIR /app
ENV NODE_ENV=production
COPY package*.json ./
RUN npm ci --omit=dev
COPY --from=builder /app/dist ./dist
USER node
EXPOSE 3000
CMD ["node", "dist/server.js"]
빌드 스테이지의 node_modules를 통째로 복사하면 devDependencies까지 따라오므로, 실행 스테이지에서 npm ci --omit=dev를 다시 돌리는 편이 깔끔합니다. Go라면 golang 스테이지에서 정적 바이너리를 빌드한 뒤 scratch나 distroless 이미지로 바이너리만 넘기는 식이고, Python이라면 slim 베이스에 빌드된 wheel만 옮기는 식으로 원칙은 같습니다.
멀티 스테이지에서 가장 흔한 실수는 COPY --from=builder 경로입니다. 빌드 스테이지에서 npm run build는 성공했는데, 산출물이 /app/build에 생겼거나 경로를 한 단계 잘못 적어서 실행 스테이지에 dist가 아예 없는 경우가 많습니다. 이러면 컨테이너는 Cannot find module '/app/dist/server.js'를 남기고 바로 종료하고, docker ps에는 Restarting만 반복해서 보입니다. 로그만 보고 앱 코드를 의심하기 전에, 같은 이미지로 셸을 띄워 실제 파일 구조를 확인하면 금방 드러납니다.
# CMD를 무시하고 셸로 들어가 이미지 내부를 확인
docker run --rm -it --entrypoint sh my-app:1.0
ls -la /app /app/dist
멀티 스테이지 빌드를 더 깊게 다룬 내용은 Docker 멀티스테이지 빌드 최적화를 참고하세요.
Docker Compose로 Node.js·PostgreSQL·Redis 띄우기
Docker Compose란?
Docker Compose는 여러 컨테이너를 한 번에 띄우고 묶어 주는 도구입니다. 지금은 Docker CLI 플러그인(Compose V2)으로 포함되어 docker compose(하이픈 없음)로 실행합니다. 예전의 docker-compose(Python 기반 V1)는 지원이 종료되었고, 파일 맨 위의 version: 키도 더 이상 필요하지 않습니다(넣으면 경고만 나옵니다).
실전 예제: Node.js + PostgreSQL + Redis
compose.yaml (docker-compose.yml 파일명도 그대로 인식됩니다)
services:
# Node.js 애플리케이션
app:
build: .
ports:
- "3000:3000"
environment:
DATABASE_URL: postgresql://user:password@db:5432/mydb
REDIS_URL: redis://redis:6379
depends_on:
db:
condition: service_healthy
redis:
condition: service_started
volumes:
- ./src:/app/src # 로컬 개발용 bind mount: 코드 변경 즉시 반영
# PostgreSQL 데이터베이스
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: user
POSTGRES_PASSWORD: password
POSTGRES_DB: mydb
volumes:
- postgres_data:/var/lib/postgresql/data # named volume: 데이터 보존
healthcheck:
test: ["CMD-SHELL", "pg_isready -U user -d mydb"]
interval: 5s
timeout: 3s
retries: 10
# Redis 캐시
redis:
image: redis:7-alpine
volumes:
postgres_data:
여기서 두 가지를 짚고 넘어가겠습니다.
depends_on은 준비 완료를 기다리지 않습니다. depends_on: [db]처럼 짧게 쓰면 Compose는 db 컨테이너를 먼저 시작할 뿐, PostgreSQL이 연결을 받을 준비가 끝났는지는 확인하지 않습니다. 그래서 앱이 시작하자마자 마이그레이션을 돌리면 connection refused가 나는 일이 흔합니다. 위처럼 db에 healthcheck를 두고 앱 쪽에서 condition: service_healthy로 기다리게 하면, pg_isready가 성공한 뒤에 앱이 뜹니다. 그래도 운영 환경에서는 DB가 중간에 재시작될 수도 있으므로, 애플리케이션 자체에 연결 재시도 로직을 두는 것이 근본적인 해법입니다.
named volume과 bind mount는 용도가 다릅니다. postgres_data처럼 이름을 붙인 볼륨은 Docker가 관리하며 컨테이너를 지워도 남습니다. ./src:/app/src 같은 bind mount는 호스트 디렉터리를 그대로 연결하므로 개발 중 핫 리로드에 편하지만, 호스트와 컨테이너의 UID가 달라 파일 권한 문제가 자주 생깁니다. DB 데이터는 named volume, 로컬 소스 동기화는 bind mount로 나누는 것이 일반적입니다. DB·Redis 포트는 호스트에서 직접 접속할 일이 없다면 굳이 ports로 열지 않는 편이 낫습니다. 같은 Compose 프로젝트의 서비스끼리는 기본 네트워크 안에서 서비스 이름(db, redis)으로 DNS 조회가 되기 때문입니다.
실행 명령어
# 모든 서비스 시작 (필요하면 이미지 빌드)
docker compose up -d --build
# 서비스 상태 확인 (health 상태 포함)
docker compose ps
# 로그 확인
docker compose logs -f
# 특정 서비스 로그
docker compose logs -f app
# 서비스 중지 및 컨테이너·네트워크 삭제
docker compose down
# named volume까지 삭제 (DB 데이터 초기화)
docker compose down -v
Compose의 네트워크·프로필·프로덕션 설정은 Docker Compose로 멀티 컨테이너 앱 운영하기에서 더 자세히 다룹니다.
Kubernetes 아키텍처와 핵심 개념
Kubernetes란?
Kubernetes (K8s)는 컨테이너를 배포·확장·복구까지 자동화하려는 오케스트레이션 플랫폼입니다.
Docker: 한 호스트에서 컨테이너 실행
Kubernetes: 여러 노드에 걸친 컨테이너를 원하는 상태로 유지
예시:
- 컨테이너가 죽으면 자동으로 재시작
- 노드가 죽으면 다른 노드에 Pod 재배치
- 부하에 따라 Pod 수 조정 (HPA)
- Service로 여러 Pod에 트래픽 분산
Docker와 Kubernetes의 관계는 “런타임과 오케스트레이터”로 이해하면 됩니다. 참고로 Kubernetes 1.24부터는 dockershim이 제거되어 노드가 Docker Engine이 아니라 containerd나 CRI-O로 컨테이너를 실행합니다. 하지만 Docker로 빌드한 이미지는 OCI 표준 이미지이므로 그대로 사용할 수 있어서, 개발자 입장에서 달라지는 것은 거의 없습니다.
Kubernetes 아키텍처
┌─────────────────────────────────────────┐
│ Control Plane │
│ - API Server: 모든 요청 처리 │
│ - Scheduler: Pod 배치 결정 │
│ - Controller Manager: 상태 맞추기 │
│ - etcd: 클러스터 데이터 저장 │
└─────────────┬───────────────────────────┘
│
┌─────────┼─────────┐
│ │ │
┌───▼───┐ ┌──▼────┐ ┌──▼────┐
│ Node1 │ │ Node2 │ │ Node3 │ ← Worker Nodes (kubelet + 컨테이너 런타임)
│ Pod A │ │ Pod B │ │ Pod C │
│ Pod D │ │ Pod E │ │ │
└───────┘ └───────┘ └───────┘
핵심 개념
Pod
- 가장 작은 배포 단위
- 1개 이상의 컨테이너 포함
- 같은 Pod 내 컨테이너는 네트워크 네임스페이스(IP·포트)를 공유 Deployment
- Pod의 선언적 업데이트
- 롤링 업데이트, 롤백 지원
- 레플리카 수 관리 Service
- Pod 집합에 대한 안정적인 네트워크 접근점
- 로드 밸런싱
- 고정된 가상 IP와 DNS 이름 제공
Minikube 설치와 kubectl 기본 명령어
Minikube 설치 (로컬 테스트용)
# macOS
brew install minikube
# Windows (winget 또는 Chocolatey)
winget install Kubernetes.minikube
# Linux
curl -LO https://storage.googleapis.com/minikube/releases/latest/minikube-linux-amd64
sudo install minikube-linux-amd64 /usr/local/bin/minikube
# Minikube 시작
minikube start
# kubectl 설치 확인 (없으면 minikube kubectl -- 로 대신 사용 가능)
kubectl version --client
기본 kubectl 명령어
# 클러스터 정보
kubectl cluster-info
# 노드 목록
kubectl get nodes
# Pod 목록
kubectl get pods
# 주요 리소스 목록
kubectl get all
# 상세 정보 (이벤트 포함)
kubectl describe pod <pod-name>
# 로그 확인
kubectl logs <pod-name>
# Pod 내부 접속 (이미지에 bash가 없으면 sh)
kubectl exec -it <pod-name> -- sh
Pod와 Deployment
Pod 생성
nginx-pod.yaml
apiVersion: v1
kind: Pod
metadata:
name: nginx-pod
labels:
app: nginx
spec:
containers:
- name: nginx
image: nginx:1.27
ports:
- containerPort: 80
# Pod 생성
kubectl apply -f nginx-pod.yaml
# Pod 확인
kubectl get pods
# Pod 삭제
kubectl delete pod nginx-pod
Pod를 직접 만들면 죽었을 때 아무도 다시 만들어 주지 않습니다. 실제로는 거의 항상 아래의 Deployment를 통해 Pod를 만듭니다.
Deployment 생성
nginx-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: nginx-deployment
spec:
replicas: 3 # 3개의 Pod 실행
selector:
matchLabels:
app: nginx
template:
metadata:
labels:
app: nginx
spec:
containers:
- name: nginx
image: nginx:1.27
ports:
- containerPort: 80
resources:
requests:
memory: "64Mi"
cpu: "250m"
limits:
memory: "128Mi"
cpu: "500m"
# Deployment 생성
kubectl apply -f nginx-deployment.yaml
# Deployment 확인
kubectl get deployments
# Pod 확인 (3개 생성됨)
kubectl get pods
# 스케일링 (5개로 증가)
kubectl scale deployment nginx-deployment --replicas=5
# 롤링 업데이트
kubectl set image deployment/nginx-deployment nginx=nginx:1.28
# 업데이트 상태 확인
kubectl rollout status deployment/nginx-deployment
# 롤백
kubectl rollout undo deployment/nginx-deployment
kubectl scale이나 kubectl set image로 바꾼 값은 YAML 파일에는 반영되지 않습니다. 다음에 kubectl apply -f를 다시 하면 파일의 값으로 되돌아가므로, 실습이 끝나면 변경 사항을 매니페스트에 옮겨 두는 습관이 필요합니다.
Service와 Ingress
Service 생성
nginx-service.yaml
apiVersion: v1
kind: Service
metadata:
name: nginx-service
spec:
type: LoadBalancer # ClusterIP, NodePort, LoadBalancer
selector:
app: nginx
ports:
- protocol: TCP
port: 80
targetPort: 80
# Service 생성
kubectl apply -f nginx-service.yaml
# Service 확인
kubectl get services
# Minikube에서 서비스 접속 (LoadBalancer의 EXTERNAL-IP는 minikube tunnel을 켜야 할당됨)
minikube service nginx-service
Service 타입 비교
1. ClusterIP (기본값)
- 클러스터 내부에서만 접근 가능
- 외부 노출 안 됨
2. NodePort
- 각 노드의 특정 포트로 접근 가능
- 포트 범위: 30000-32767
3. LoadBalancer
- 클라우드 로드 밸런서 자동 생성
- 외부 IP 할당 (로컬 클러스터에서는 pending으로 남을 수 있음)
HTTP 서비스가 여러 개라면 서비스마다 LoadBalancer를 만드는 대신, ClusterIP Service 앞에 Ingress(또는 Gateway API)를 두고 호스트·경로 기준으로 라우팅하는 구성이 일반적입니다. Ingress 리소스는 Ingress Controller(ingress-nginx 등)가 설치되어 있어야 동작합니다.
Node.js 앱 배포와 ConfigMap·Secret
Node.js 애플리케이션 배포
1) Dockerfile
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
USER node
EXPOSE 3000
CMD ["node", "server.js"]
2) deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: myapp
spec:
replicas: 3
selector:
matchLabels:
app: myapp
template:
metadata:
labels:
app: myapp
spec:
containers:
- name: myapp
image: myapp:1.0
ports:
- containerPort: 3000
env:
- name: NODE_ENV
value: "production"
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: db-secret
key: url
---
apiVersion: v1
kind: Service
metadata:
name: myapp-service
spec:
type: LoadBalancer
selector:
app: myapp
ports:
- port: 80
targetPort: 3000
한 파일에 여러 리소스를 넣을 때는 ---로 구분합니다. 이 구분자가 리소스 중간(예: secretKeyRef의 name과 key 사이)에 들어가면 YAML이 둘로 쪼개져 적용이 실패하므로 위치를 꼭 확인합니다.
3) 배포
# 이미지 빌드
docker build -t myapp:1.0 .
# Minikube에 이미지 로드 (레지스트리에 올리지 않고 로컬 이미지 사용)
minikube image load myapp:1.0
# Secret 생성
kubectl create secret generic db-secret \
--from-literal=url='postgresql://user:pass@db:5432/mydb'
# 배포
kubectl apply -f deployment.yaml
# 확인
kubectl get all
latest 태그를 쓰면 기본 imagePullPolicy가 Always가 되어 로컬에 로드한 이미지를 두고도 레지스트리에서 받으려다 ImagePullBackOff가 납니다. 로컬 실습에서도 1.0 같은 명시적인 태그를 쓰는 편이 헷갈리지 않습니다.
ConfigMap과 Secret
configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: app-config
data:
APP_NAME: "MyApp"
LOG_LEVEL: "info"
API_URL: "https://api.example.com"
secret.yaml
apiVersion: v1
kind: Secret
metadata:
name: app-secret
type: Opaque
stringData:
DB_PASSWORD: "mypassword"
API_KEY: "secret-key-123"
deployment에서 사용
apiVersion: apps/v1
kind: Deployment
metadata:
name: myapp
spec:
selector:
matchLabels:
app: myapp
template:
metadata:
labels:
app: myapp
spec:
containers:
- name: myapp
image: myapp:1.0
envFrom:
- configMapRef:
name: app-config
- secretRef:
name: app-secret
Secret은 기본적으로 base64로 인코딩될 뿐 암호화되지 않습니다. 위의 secret.yaml처럼 평문 값을 담은 파일을 Git에 커밋하면 그대로 유출되므로, 실제 프로젝트에서는 External Secrets, Sealed Secrets, 클라우드 비밀 관리 서비스 같은 방식을 씁니다. Docker 이미지도 마찬가지로 비밀 값을 ENV나 COPY로 넣지 말고 실행 시점에 주입해야 합니다.
Liveness·Readiness Probe로 헬스 체크
Liveness와 Readiness Probe
apiVersion: apps/v1
kind: Deployment
metadata:
name: myapp
spec:
selector:
matchLabels:
app: myapp
template:
metadata:
labels:
app: myapp
spec:
containers:
- name: myapp
image: myapp:1.0
ports:
- containerPort: 3000
# Liveness Probe (컨테이너가 살아있는지 확인, 실패하면 재시작)
livenessProbe:
httpGet:
path: /health
port: 3000
initialDelaySeconds: 30
periodSeconds: 10
failureThreshold: 3
# Readiness Probe (트래픽 받을 준비가 됐는지 확인, 실패하면 Service에서 제외)
readinessProbe:
httpGet:
path: /ready
port: 3000
initialDelaySeconds: 5
periodSeconds: 5
헬스 체크 엔드포인트 구현
// server.js
app.get('/health', (req, res) => {
// 프로세스가 요청에 응답할 수 있는지만 확인
res.status(200).json({ status: 'ok' });
});
app.get('/ready', (req, res) => {
// DB 연결 등 외부 의존성 확인
if (dbConnected) {
res.status(200).json({ status: 'ready' });
} else {
res.status(503).json({ status: 'not ready' });
}
});
두 프로브를 구분하는 이유가 중요합니다. liveness에 DB 연결 확인을 넣으면, DB가 잠깐 끊겼을 때 멀쩡한 앱 Pod까지 전부 재시작되는 연쇄 장애가 납니다. DB 상태는 readiness에서만 보고, liveness는 “프로세스가 멈추지 않았는가”만 보는 것이 안전합니다. 이는 앞의 Compose 절에서 본 depends_on과 healthcheck의 관계와 같은 문제를 Kubernetes 방식으로 푸는 것입니다.
볼륨과 영속성
Volume 타입
1) emptyDir (임시 볼륨)
apiVersion: v1
kind: Pod
metadata:
name: cache-pod
spec:
containers:
- name: app
image: myapp:1.0
volumeMounts:
- name: cache
mountPath: /app/cache
volumes:
- name: cache
emptyDir: {} # Pod 삭제 시 데이터 삭제
2) PersistentVolume (영구 볼륨)
# PersistentVolume (학습용 hostPath: 단일 노드 클러스터에서만 의미 있음)
apiVersion: v1
kind: PersistentVolume
metadata:
name: postgres-pv
spec:
capacity:
storage: 10Gi
accessModes:
- ReadWriteOnce
hostPath:
path: /data/postgres
---
# PersistentVolumeClaim
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: postgres-pvc
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 10Gi
---
# Deployment에서 사용
apiVersion: apps/v1
kind: Deployment
metadata:
name: postgres
spec:
selector:
matchLabels:
app: postgres
template:
metadata:
labels:
app: postgres
spec:
containers:
- name: postgres
image: postgres:16
volumeMounts:
- name: postgres-storage
mountPath: /var/lib/postgresql/data
volumes:
- name: postgres-storage
persistentVolumeClaim:
claimName: postgres-pvc
실제 클러스터에서는 PV를 직접 만들기보다 StorageClass가 PVC 요청을 받아 디스크를 동적으로 만들어 주는 방식을 씁니다. 또 DB처럼 Pod마다 고유한 저장소와 이름이 필요한 워크로드는 Deployment보다 StatefulSet이 맞습니다. 위 예시는 PV·PVC의 관계를 이해하기 위한 최소 구성으로 보면 됩니다.
네임스페이스와 리소스 쿼터
네임스페이스
# 네임스페이스 생성
kubectl create namespace dev
kubectl create namespace prod
# 특정 네임스페이스에 배포
kubectl apply -f deployment.yaml -n dev
# 네임스페이스 목록
kubectl get namespaces
# 특정 네임스페이스의 리소스 확인
kubectl get all -n dev
리소스 쿼터
apiVersion: v1
kind: ResourceQuota
metadata:
name: dev-quota
namespace: dev
spec:
hard:
requests.cpu: "10"
requests.memory: 20Gi
limits.cpu: "20"
limits.memory: 40Gi
pods: "50"
네임스페이스에 CPU·메모리 쿼터를 걸면, 그 네임스페이스의 모든 Pod가 requests·limits를 명시해야 생성됩니다. 쿼터를 건 뒤 갑자기 Pod가 안 만들어진다면 kubectl describe replicaset의 이벤트에서 이 이유가 보입니다.
롤링 업데이트와 Blue-Green 배포
롤링 업데이트
apiVersion: apps/v1
kind: Deployment
metadata:
name: myapp
spec:
replicas: 5
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 2 # 원하는 수보다 최대 2개 더 띄울 수 있음
maxUnavailable: 1 # 동시에 최대 1개까지 준비 안 된 상태 허용
selector:
matchLabels:
app: myapp
template:
metadata:
labels:
app: myapp
spec:
containers:
- name: myapp
image: myapp:2.0
배포 과정 (대략적인 흐름):
초기: [v1] [v1] [v1] [v1] [v1]
1단계: [v1] [v1] [v1] [v1] [v1] [v2] [v2]
(새 Pod 2개 추가)
2단계: [v1] [v1] [v1] [v1] [v2] [v2]
(v1 1개 종료)
3단계: [v1] [v1] [v1] [v2] [v2] [v2]
...
최종: [v2] [v2] [v2] [v2] [v2]
새 Pod가 readiness probe를 통과해야 다음 단계로 넘어가므로, readiness probe가 없으면 아직 준비되지 않은 Pod로 트래픽이 가는 순간이 생깁니다. 롤링 업데이트의 안전성은 결국 프로브 설계에 달려 있습니다.
Blue-Green 배포
# Blue (현재 버전, label version=blue)
kubectl apply -f deployment-blue.yaml
# Green (새 버전, label version=green) 배포
kubectl apply -f deployment-green.yaml
# Service selector를 Green으로 전환
kubectl patch service myapp -p '{"spec":{"selector":{"app":"myapp","version":"green"}}}'
# 문제 발생 시 Blue로 롤백
kubectl patch service myapp -p '{"spec":{"selector":{"app":"myapp","version":"blue"}}}'
두 버전이 동시에 떠 있는 동안 리소스가 두 배로 필요하고, DB 스키마가 바뀌는 배포라면 두 버전이 같은 DB를 견딜 수 있도록 마이그레이션을 나눠야 한다는 점이 트레이드오프입니다.
모니터링과 로그 수집
기본 모니터링
# 리소스 사용량 확인 (metrics-server 필요, minikube는 addons enable metrics-server)
kubectl top nodes
kubectl top pods
# 이벤트 확인
kubectl get events --sort-by='.lastTimestamp'
# Pod 상태와 배치된 노드 확인
kubectl get pods -o wide
# 특정 Pod 상세 정보
kubectl describe pod <pod-name>
로그 수집
# 실시간 로그
kubectl logs -f <pod-name>
# 이전 컨테이너 로그 (재시작된 경우)
kubectl logs <pod-name> --previous
# 여러 Pod 로그 (label selector)
kubectl logs -l app=myapp --tail=100
컨테이너가 안 뜰 때: ImagePullBackOff·CrashLoopBackOff·Pending
컨테이너가 안 뜰 때 확인 순서
Docker든 Kubernetes든 컨테이너 문제는 대부분 아래 순서로 내려가면 원인이 좁혀집니다. 로그를 보지 않고 재시작만 반복하는 것이 가장 시간을 많이 잃는 방식입니다.
- 앱 로그:
docker logs --tail=50 <이름>/kubectl logs <pod> --previous. 재시작된 컨테이너는 현재 로그가 비어 있을 수 있으므로--previous가 중요합니다. - 이미지 안의 파일 구조: 로그에 파일·모듈을 못 찾는다는 메시지가 있으면
docker run --rm -it --entrypoint sh <이미지>로 들어가ls로 확인합니다. 멀티 스테이지COPY --from경로 실수가 여기서 드러납니다. - 네트워크와 이름 해석: 같은 네트워크에 있는지(
docker network inspect), 서비스 이름(DNS)이 맞는지, 앱이127.0.0.1이 아니라0.0.0.0에서 listen하는지 확인합니다. 컨테이너 안의localhost는 그 컨테이너 자신입니다. - 리소스 제한:
docker stats나kubectl describe pod의OOMKilled(종료 코드 137)로 메모리 제한에 걸렸는지 봅니다.
자주 만나는 Pod 상태 에러
1) ImagePullBackOff
# 증상
kubectl get pods
# NAME READY STATUS RESTARTS
# myapp-x 0/1 ImagePullBackOff 0
# 원인: 이미지를 다운로드할 수 없음
# 해결:
# - 이미지 이름·태그 오타 확인
# - 비공개 레지스트리라면 imagePullSecrets 설정 확인
# - 로컬 이미지라면 Minikube에 로드: minikube image load myapp:1.0
2) CrashLoopBackOff
# 증상
kubectl get pods
# NAME READY STATUS RESTARTS
# myapp-x 0/1 CrashLoopBackOff 5
# 원인: 컨테이너가 시작 후 바로 종료
# 해결:
kubectl logs myapp-x --previous # 직전에 죽은 컨테이너의 로그
kubectl describe pod myapp-x # 종료 코드, OOMKilled 여부, 이벤트
# 일반적인 원인:
# - 애플리케이션 에러, 실행 파일 경로 오류
# - 환경 변수·Secret 누락
# - 의존성 서비스 미준비
# - liveness probe가 너무 일찍/엄격하게 실패
3) Pending 상태
# 증상
kubectl get pods
# NAME READY STATUS RESTARTS
# myapp-x 0/1 Pending 0
# 원인: 스케줄링 불가
kubectl describe pod myapp-x
# 일반적인 원인:
# - 리소스 부족 (requests를 만족하는 노드가 없음)
# - PVC 바인딩 실패
# - nodeSelector·taint 미매칭
Pod 상태별 원인 분석은 Kubernetes Pod가 안 뜰 때에서 더 자세히 다룹니다.
Docker vs Kubernetes 비교
| 항목 | Docker (+ Compose) | Kubernetes |
|---|---|---|
| 용도 | 컨테이너 빌드·실행 | 컨테이너 오케스트레이션 |
| 규모 | 단일 호스트 | 다중 노드 클러스터 |
| 자동 복구 | restart 정책으로 같은 호스트에서 재시작 | 재시작 + 다른 노드로 재배치 |
| 스케일링 | 수동 (--scale) | 수동 + 자동 (HPA) |
| 로드 밸런싱 | 없음 (앞단 프록시 필요) | Service로 내장 |
| 롤링 업데이트 | 기본 제공 안 됨 | Deployment로 지원 |
| 학습 곡선 | 낮음 | 높음 |
언제 무엇을 사용할까?
Docker + Compose로 충분한 경우:
- 개발 환경
- 소규모 프로젝트
- 서버 한 대로 감당되는 서비스
Kubernetes를 검토할 경우:
- 여러 노드에 걸친 고가용성이 필요
- 서비스 수가 많아 배포·스케일을 표준화해야 함
- 트래픽에 따른 자동 스케일링 필요
Kubernetes는 운영 비용(클러스터 업그레이드, 네트워킹, 모니터링)이 큰 도구입니다. 서버 한 대에서 Compose로 잘 돌아가는 서비스를 “프로덕션이니까” Kubernetes로 옮기는 것은 오히려 장애 지점을 늘리는 결과가 될 수 있습니다.
Docker 보안과 Kubernetes 운영 팁
Docker 보안·최적화
- 비밀 값은 이미지에 넣지 않습니다. 실행 시점에 환경 변수나 비밀 저장소로 주입합니다.
- root가 아닌 사용자로 실행합니다. 공식 Node 이미지에는
node사용자가 있으므로USER node한 줄로 충분합니다. - 이미지 취약점 스캔을 CI에 넣습니다. Trivy나
docker scout cves같은 도구를 빌드 파이프라인에 한 번 얹어 두면 비용 대비 효과가 큽니다. - 베이스 이미지 태그를 고정합니다.
node:latest대신node:20-alpine처럼 메이저 버전 이상을 명시해야 빌드가 재현됩니다.
Kubernetes 최적화
1) 리소스 요청·제한 설정
resources:
requests:
memory: "128Mi"
cpu: "100m"
limits:
memory: "256Mi"
cpu: "200m"
requests는 스케줄러가 노드를 고를 때 쓰는 값이고, limits는 실제 상한입니다. 메모리 limit을 넘으면 컨테이너가 OOMKilled되고, CPU limit을 넘으면 죽지는 않지만 스로틀링되어 응답이 느려집니다.
2) Horizontal Pod Autoscaler
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: myapp-hpa
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: myapp
minReplicas: 2
maxReplicas: 10
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 70
CPU 사용률은 requests 대비 비율로 계산되므로, requests가 없으면 HPA가 동작하지 않습니다. metrics-server도 설치되어 있어야 합니다.
다음 학습 로드맵
학습 로드맵
1단계: Docker 기초
✓ 컨테이너 개념 이해
✓ 기본 명령어 숙지
✓ Dockerfile 작성
2단계: Docker 실전
✓ Docker Compose (healthcheck, 볼륨)
✓ 멀티 스테이지 빌드
✓ 이미지 최적화·보안
3단계: Kubernetes 기초
✓ Pod, Deployment
✓ Service
✓ kubectl 명령어
4단계: Kubernetes 실전
✓ ConfigMap, Secret
✓ 볼륨 관리
✓ 헬스 체크
5단계: 고급 주제
✓ Helm (패키지 관리)
✓ Ingress Controller / Gateway API
✓ 모니터링 (Prometheus, Grafana)
✓ CI/CD 연동
추천 학습 자료
공식 문서:
- Docker 공식 문서: https://docs.docker.com
- Kubernetes 공식 문서: https://kubernetes.io/ko/docs 실습 환경:
- Play with Docker: https://labs.play-with-docker.com
- 로컬 클러스터: minikube, kind, k3d
FAQ
Q1. Docker와 VM의 차이는? VM은 게스트 OS까지 올리는 반면, 컨테이너는 호스트 커널을 공유합니다. 따라서 기동 속도·이미지 크기·오버헤드에서 보통 컨테이너가 유리하지만, 격리 강도는 VM이 더 강합니다.
Q2. Kubernetes 없이 Docker만 써도 되나요? 개발·스테이징·소규모 서비스는 Compose까지로도 충분한 경우가 많습니다. 여러 노드 스케일·셀프 힐링·롤링 배포까지 표준으로 가져가려 할 때 Kubernetes를 본격적으로 고릅니다.
Q3. Minikube와 Docker Desktop의 Kubernetes는 무엇이 다른가요? Minikube는 로컬에 클러스터를 올리기 위한 전용 도구로 애드온(metrics-server, ingress 등)을 켜기 쉽습니다. Docker Desktop에 포함된 Kubernetes는 같은 머신에서 빌드한 이미지를 바로 쓰기 편합니다. 둘 다 학습용으로 자주 고릅니다.
Q4. 프로덕션에서는 어떤 Kubernetes를 사용하나요?
- AWS: EKS (Elastic Kubernetes Service)
- GCP: GKE (Google Kubernetes Engine)
- Azure: AKS (Azure Kubernetes Service)
- 자체 호스팅: kubeadm, k3s, Rancher
Q5. 빌드가 매번 처음부터 다시 되는 것 같습니다.
.dockerignore에 node_modules·.git이 빠져 있거나, COPY . .가 의존성 설치보다 앞에 있는 경우가 대부분입니다. Dockerfile 작성의 레이어 캐싱 예시처럼 lockfile을 먼저 복사하는 순서로 바꿔 보세요.
같이 보면 좋은 글
- Kubernetes 핵심 오브젝트: Pod, Deployment, Service, ConfigMap·Secret, Ingress, 헬스체크, HPA
- Docker Compose로 멀티 컨테이너 앱 운영하기: 서비스·네트워크·볼륨, 헬스체크, 프로덕션 설정
- Docker Compose로 Node API·PostgreSQL·Redis 한 번에 띄우기
- Docker 멀티스테이지 빌드 최적화
- GitHub Actions로 Node.js CI/CD 파이프라인 만들기