Kubernetes minikube로 Node API 배포하기 | Deployment·Service
이 글의 핵심
로컬에서 docker build한 이미지로 배포했는데 ImagePullBackOff가 나는 문제는 minikube가 호스트와 다른 Docker 데몬을 쓰기 때문입니다. 이미지를 클러스터에 전달하는 두 가지 방법을 비교하고, 레플리카와 프로브 설정, 자주 만나는 오류를 짚어 운영 클러스터로 넘어가기 전 기본기를 다지게 합니다.
들어가며
Kubernetes는 컨테이너를 선언적 YAML로 배치·스케일·복구하는 오케스트레이션 도구입니다. 프로덕션 전에 로컬에서 동일한 리소스 모델(Pod·Deployment·Service)을 익히려면 minikube가 여전히 널리 쓰이는 선택지입니다. Kubernetes minikube 배포 입문은 “클러스터를 띄우고, 이미지를 넣으며, kubectl apply로 서비스까지 연결”까지의 최소 루프를 의미합니다.
Node.js API는 상태 없는 HTTP 서버에 잘 맞으며, 헬스 엔드포인트와 graceful shutdown만 갖추면 Deployment의 liveness/readiness probe와 자연스럽게 맞물립니다. 이 글은 minikube의 Docker 드라이버를 가정하며, Docker로 빌드한 이미지를 클러스터에 올리는 두 가지 방식(minikube image load, minikube docker-env)을 모두 다룹니다.
배포 파이프라인에서는 Node.js 테스트·GitHub Actions CI/CD로 이미지 전에 품질을 고정하며, 단일 호스트 스택은 Docker Compose·Node.js 배포 가이드와 이어집니다. C++·네이티브 바이너리는 C++ Docker·C++ GitHub Actions에서 같은 빌드→이미지→실행 패턴을 확인할 수 있습니다. API 뒤의 데이터 계층은 Node.js DB 연동·C++ DB 연동과, 캐시·엣지는 Redis 캐싱·Nginx·PostgreSQL vs MySQL 선택과 연결해 읽으면 좋습니다. 노드·컨테이너 호스트 용량은 Linux 디스크/inode 이슈와도 맞닿습니다.
Kubernetes의 핵심은 “원하는 상태를 선언하면 컨트롤러가 현재 상태를 거기에 맞춘다”는 것입니다. Deployment에 레플리카 2개를 선언하면 Pod가 죽을 때마다 같은 스펙으로 다시 띄우고, Service는 Pod가 교체되어 IP가 바뀌어도 안정적인 이름과 포트를 유지합니다.
개념 설명
Kubernetes에서 Node API가 자리 잡는 방식
| 리소스 | 역할 |
|---|---|
| Pod | 컨테이너 실행의 최소 단위(보통 Deployment가 생성·유지) |
| Deployment | 원하는 레플리카 수, 롤링 업데이트, 이미지 버전을 선언 |
| Service | Pod에 안정적인 DNS·포트를 부여(ClusterIP, NodePort, LoadBalancer) |
minikube는 단일 노드지만 API 서버·스케줄러·kubelet이 갖춰져 있어 프로덕션과 같은 kubectl 명령·매니페스트를 연습할 수 있습니다.
왜 minikube인가
- 비용 없이 Ingress·HPA·Metrics API 등을 실습할 수 있습니다(애드온).
- 팀 온보딩 시 “Docker Compose만 쓰던 사람”이 선언적 배포로 넘어가는 데 적합합니다.
실전 구현
사전 준비
- Docker 또는 minikube가 사용할 VM 드라이버
- kubectl, minikube (패키지 매니저 또는 공식 바이너리, 2026년 기준 최신 안정판 권장)
minikube version
kubectl version --client
minikube 클러스터 기동
minikube start --driver=docker # 또는 vm 드라이버
kubectl cluster-info
kubectl get nodes
minikube start --driver=docker: 로컬에 단일 노드 클러스터를 띄웁니다. Docker 드라이버는 호스트 Docker 위에 VM/노드가 올라가는 형태라 리소스를 적게 쓰는 편입니다.kubectl cluster-info: API 서버 엔드포인트가 응답하는지 확인합니다.kubectl get nodes: 노드가 Ready인지 봅니다. kubectl이 minikube를 가리키는지 확인합니다.
kubectl config current-context
# 예: minikube
Node.js API 예시 (헬스 포함)
server.mjs:
import http from 'http';
const port = Number(process.env.PORT || 3000);
const server = http.createServer((req, res) => {
if (req.url === '/health') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ status: 'ok' }));
return;
}
res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' });
res.end('hello from node on k8s\n');
});
server.listen(port, '0.0.0.0', () => {
console.log(`listening on ${port}`);
});
// Kubernetes는 Pod를 내릴 때 SIGTERM을 보내고, 유예 시간(기본 30초) 뒤 SIGKILL
process.on('SIGTERM', () => {
console.log('SIGTERM received, closing server');
server.close(() => process.exit(0)); // 새 연결은 거부, 진행 중인 요청은 마무리
});
SIGTERM 처리를 넣은 이유가 있습니다. 컨테이너 안에서 node가 PID 1로 실행되면, 리눅스 커널은 PID 1에게 기본 시그널 동작(종료)을 적용하지 않으므로 핸들러가 없는 Node.js 프로세스는 SIGTERM을 받아도 그대로 살아 있습니다. 그러면 Kubernetes는 유예 시간 30초를 꽉 채워 기다린 뒤 SIGKILL로 강제 종료하고, 그동안 롤아웃이 느려지며 진행 중이던 요청은 끊깁니다. kubectl delete pod가 이유 없이 30초씩 걸린다면 대부분 이 문제입니다. 핸들러를 두는 대신 docker run --init이나 tini 같은 작은 init 프로세스를 PID 1로 두는 방법도 있습니다.
Dockerfile:
FROM node:22-alpine
WORKDIR /app
COPY server.mjs .
ENV NODE_ENV=production
EXPOSE 3000
USER node
CMD ["node", "server.mjs"]
로컬에서 빌드:
docker build -t demo-node-api:1.0.0 .
이미지를 minikube가 보도록 하기 (택1)
방법 A — minikube Docker 데몬에 직접 빌드
eval $(minikube docker-env)
docker build -t demo-node-api:1.0.0 .
eval $(minikube docker-env -u)
방법 B — 호스트에서 빌드 후 로드
docker build -t demo-node-api:1.0.0 .
minikube image load demo-node-api:1.0.0
두 방법의 차이는 이미지가 어느 Docker 데몬에 만들어지느냐입니다. minikube 노드는 호스트와 별개의 컨테이너 런타임을 갖고 있어서, 호스트에서 docker build만 하면 노드는 그 이미지를 볼 수 없습니다. 방법 A는 셸의 DOCKER_HOST를 minikube 노드의 데몬으로 바꿔 거기서 직접 빌드하므로 전송 단계가 없고 빠르지만, minikube를 --container-runtime=containerd로 띄웠다면 노드에 Docker 데몬이 없어 docker-env가 동작하지 않습니다. 방법 B는 런타임과 무관하게 동작하지만 이미지를 통째로 복사하므로 이미지가 크면 시간이 걸립니다.
여기서 가장 흔히 겪는 함정은 태그입니다. 이미지를 demo-node-api:latest나 태그 없이 만들면, Kubernetes는 latest 태그의 imagePullPolicy 기본값을 Always로 잡아 노드에 이미지가 있어도 레지스트리에서 받으려 하고, 결국 ImagePullBackOff가 납니다. 반대로 1.0.0처럼 고정 태그에 IfNotPresent를 쓰면, 코드를 고쳐 같은 태그로 다시 빌드·로드해도 노드에 이미 같은 이름의 이미지가 있어 Pod가 옛 이미지로 계속 뜹니다. 로컬에서 반복 개발할 때는 빌드마다 태그를 바꾸거나(1.0.1, 커밋 SHA 등), kubectl rollout restart와 함께 태그를 올리는 습관을 들이는 편이 혼란이 적습니다.
Deployment·Service 매니페스트
deployment.yaml:
apiVersion: apps/v1
kind: Deployment
metadata:
name: demo-node-api
spec:
replicas: 2
selector:
matchLabels:
app: demo-node-api
template:
metadata:
labels:
app: demo-node-api
spec:
containers:
- name: api
image: demo-node-api:1.0.0
imagePullPolicy: IfNotPresent
ports:
- containerPort: 3000
env:
- name: PORT
value: '3000'
readinessProbe:
httpGet:
path: /health
port: 3000
initialDelaySeconds: 3
periodSeconds: 5
livenessProbe:
httpGet:
path: /health
port: 3000
initialDelaySeconds: 10
periodSeconds: 10
---
apiVersion: v1
kind: Service
metadata:
name: demo-node-api
spec:
selector:
app: demo-node-api
ports:
- port: 80
targetPort: 3000
type: ClusterIP
적용:
kubectl apply -f deployment.yaml
kubectl rollout status deployment/demo-node-api
kubectl get pods,svc
kubectl apply -f: 매니페스트를 선언 상태로 클러스터에 반영합니다(이미 있으면 필요한 부분만 갱신).rollout status: 새 ReplicaSet으로 롤아웃이 끝났는지 기다립니다.get pods,svc: Pod와 Service가 원하는 대로 떴는지 확인합니다.
접속 확인
kubectl port-forward service/demo-node-api 8080:80
# 다른 터미널
curl -s http://127.0.0.1:8080/health
kubectl port-forward service/... 로컬포트:서비스포트: 클러스터 안의 ClusterIP Service를 로컬 포트로 임시로 이어 브라우저·curl로 검증할 때 씁니다. 매니페스트에서 읽을 포인트readinessProbe: 트래픽을 받을 준비가 됐는지 봅니다. 실패하면 Service 엔드포인트에서 빠져 준비 안 된 Pod로 요청이 가지 않게 합니다.livenessProbe: 데드락·무한 루프처럼 살아 있지만 일을 못 하는 컨테이너를 재시작하는 데 사용됩니다.initialDelaySeconds/periodSeconds: 기동 직후 프로브 실패로 재시작 루프에 빠지지 않도록 유예와 주기를 조정합니다.
예제는 두 프로브가 같은 /health를 보지만, 실제 서비스에서는 역할을 나누는 편이 안전합니다. readiness는 “지금 요청을 받아도 되는가”라서 DB 연결 확인 같은 의존성 점검을 넣어도 되지만, liveness에 DB 점검을 넣으면 DB가 잠깐 느려졌을 때 모든 Pod가 동시에 재시작되어 장애가 커집니다. liveness는 프로세스 자체가 응답 가능한지만 확인하는 가벼운 엔드포인트로 두는 것이 원칙입니다. 기동이 느린 앱이라면 initialDelaySeconds를 크게 잡기보다 startupProbe를 따로 두어, 시작이 끝날 때까지 liveness 검사를 미루는 방법이 더 정확합니다.
고급 활용
- ConfigMap·Secret: 환경별
PORT가 아니라 외부 API 키·DB URL은 Secret으로 분리합니다. - Resource limits:
resources.requests/limits로 OOM·스로틀링을 제어합니다. - Ingress 애드온:
minikube addons enable ingress후 Ingress 리소스로 호스트 기반 라우팅을 연습합니다. - 네임스페이스:
kubectl create ns dev후-n dev로 팀·환경을 격리합니다.
성능·비교
| 항목 | 메모 |
|---|---|
| 레플리카 | 로컬 CPU에 맞춰 1~2개가 일반적; 스케일 아웃은 워커 노드가 여럿인 클러스터에서 의미가 큼 |
| 프로브 주기 | 너무 짧으면 부하, 너무 길면 트래픽이 죽은 Pod로 감—staging에서 조정 |
| 이미지 크기 | alpine 베이스·멀티스테이지 빌드로 풀 시간을 줄이면 롤아웃이 빨라짐 |
실무 사례
- CI 파이프라인: 이미지를 레지스트리에 푸시한 뒤
image: registry/app:git-sha로 Deployment만 바꾸는 흐름으로 확장합니다(로컬은 레지스트리 없이image load로 대체). - 개발자 랩톱: Compose로 띄우던 API를 동일 Dockerfile로 빌드해 minikube에 올려, 스테이징과 같은 YAML을 리뷰합니다.
- 장애 연습: Pod 하나를
kubectl delete pod로 지워 ReplicaSet이 재생성하는지 확인합니다.
트러블슈팅
| 증상 | 점검 |
|---|---|
ImagePullBackOff | 이미지가 노드에 없음 → minikube image load 또는 minikube docker-env 빌드 확인, imagePullPolicy: Never는 로컬 전용 |
CrashLoopBackOff | kubectl logs deploy/demo-node-api --previous, 프로브 경로·포트 불일치 |
| 코드를 고쳤는데 옛 동작 그대로 | 같은 태그로 재빌드 + IfNotPresent → 태그 변경 후 재배포 |
| Pod 삭제·롤아웃이 30초씩 걸림 | SIGTERM 핸들러 없음(PID 1 문제) → 핸들러 추가 또는 tini |
OOMKilled (종료 코드 137) | resources.limits.memory가 너무 작음, Node 힙 한도(--max-old-space-size)를 limit보다 작게 설정 |
kubectl이 다른 클러스터를 봄 | kubectl config use-context minikube |
| Service는 있는데 연결 안 됨 | targetPort와 컨테이너 containerPort 일치, kubectl get endpoints demo-node-api |
마무리
minikube 배포의 핵심은이미지를 클러스터가 읽을 수 있게 만든 뒤, Deployment로 Pod를 유지하고 Service로 안정적으로 노출하는 것입니다. 이 루프가 익숙해지면 프로덕션의 Ingress·GitOps·HPA로 확장하기 훨씬 수월합니다. Compose 기반 배포와 병행해 비교해 보세요.
자주 묻는 질문 (FAQ)
Q. 로컬에서 빌드한 이미지로 배포했는데 ImagePullBackOff가 나는 이유는 무엇인가요?
A. 호스트 Docker에서 빌드한 이미지는 minikube 노드 안에 없어서 레지스트리에서 받으려다 실패하는 것입니다. eval $(minikube docker-env)로 minikube의 Docker 데몬에서 직접 빌드하거나, 호스트에서 빌드한 뒤 minikube image load로 이미지를 올려야 합니다. 이때 imagePullPolicy: Never는 로컬 전용 설정이므로 실제 클러스터 매니페스트에는 그대로 가져가지 않도록 주의합니다.