쿠버네티스를 공부하려면 만질 클러스터가 하나 있어야 한다. 클라우드에 만들면
돈이 나가고, 지웠다 다시 만들기도 번거롭다. 맥북 한 대에 통째로 올리는 방법을
쓴다. 도구는 kind 다.
다 만들고 나면 브라우저에서 http://localhost 를 열었을 때 클러스터 안의
애플리케이션이 응답한다. 여기까지 오는 길에 한 번 크게 막히는 자리가 있는데,
오류 메시지가 안 뜨고 조용히 안 되는 종류라 원인 찾기가 고약하다. 그 대목이
이 글의 절반이다.
이 글에서 만드는 것#
| 항목 | 값 |
|---|---|
| 도구 | kind v0.33.0 |
| 쿠버네티스 | v1.37.0 (kindest/node:v1.37.0) |
| 노드 | control-plane 1대 + worker 2대 |
| 네트워크 플러그인 | kindnet (kind 기본) |
| 저장소 | standard (기본값으로 이미 들어있음) |
| 외부 접속 | ingress-nginx controller-v1.15.1 |
| 접근 주소 | 맥에서 http://localhost |
유휴 상태에서 메모리 약 1.25GB, 디스크 8GB 정도를 쓴다.
미리 알아 둘 말#
처음이면 여기부터 읽는다. 이미 아는 내용이면 건너뛴다.
클러스터 — 쿠버네티스가 관리하는 컴퓨터들의 묶음이다.
노드 — 그 묶음에 속한 컴퓨터 한 대. 두 종류가 있다.
- control-plane(컨트롤 플레인) — 지휘하는 쪽. "어느 애플리케이션을 어디에 띄울지" 를 정하고 전체 상태를 관리한다. 관리자 역할이라 **일반 애플리케이션은 여기에 안 띄우는 것이 기본**이다.
- worker(워커) — 실제로 애플리케이션이 도는 쪽.
파드(Pod) — 쿠버네티스가 다루는 가장 작은 단위. 컨테이너 한 개(혹은 몇 개)를
감싼 것이라고 보면 된다. 애플리케이션은 파드로 뜬다.
매니페스트 — "이런 상태를 원한다" 를 적은 YAML 파일. 쿠버네티스는 명령을
받아 실행하는 게 아니라, 원하는 상태를 적어 주면 그렇게 되도록 알아서 맞춘다.
이 글에서 YAML 을 계속 쓰는 이유다.
kind — 노드를 도커 컨테이너로 흉내 내서 클러스터를 만들어 주는 도구다.
진짜 서버 3대 대신 컨테이너 3개를 띄우고, 그 안에서 쿠버네티스를 돌린다.
이름은 Kubernetes IN Docker 의 줄임말이다.
참고
왜 kind 인가. 로컬에 쿠버네티스를 올리는 도구는 여럿이다(minikube, k3d 등).
kind 를 고른 이유는 정식 쿠버네티스를 그대로 올리기 때문이다.k3s 같은 경량 배포판은 가볍게 쓰라고 일부를 단순화하고 편의 기능을 미리 넣어
둔다. 빠르게 띄우기엔 좋지만, 공부가 목적이면 그 편의가 오히려 실제 클러스터와
다른 모습을 보여 준다. kind 는 실제 운영 클러스터를 만들 때 쓰는kubeadm이라는
도구로 설치하므로, 회사에서 만나는 클러스터와 구조가 같다.대신 k3s 라면 이미 들어있을 것들을 직접 넣어야 하는데, 그 과정이 곧 실습이다.
준비물#
- Apple Silicon 맥 (이 글은 M1 / 16GB / macOS 14.8.3 기준)
- 도커를 돌릴 수단 — Docker Desktop 이든 Colima 든
- 디스크 여유 10GB 이상
- 걸리는 시간 15분 안팎. 대부분 이미지 내려받는 시간이다
팁
Docker Desktop 이 이미 깔려 있다면 그대로 쓰는 편이 낫다. Colima 로 갈아타면
Docker Desktop 을 지우기 전까지 가상 머신이 두 개가 되어 디스크를 오히려 더 먹는다.
1. 설치하고 버전을 맞춘다#
kind 를 설치한다.
brew install kind
kind v0.33.0 go1.27.0 darwin/arm64
여기서 한 번 걸린다. kubectl 의 버전을 확인해 보자.
참고
kubectl이 무엇인가. 클러스터에 명령을 보내는 프로그램이다. 내 맥에서
돌면서 클러스터의 API 서버(control-plane 에서 돌며 모든 요청을 받는 창구)와
통신한다. 앞으로 치는kubectl ...은 전부 "클러스터야, 이렇게 해 줘" 라는 뜻이다.
kubectl version --client
Client Version: v1.31.3
kind v0.33.0 이 만드는 클러스터는 쿠버네티스 v1.37.0 인데, 내 kubectl 은
v1.31.3 이었다. 가운데 숫자가 6 차이 난다.
주의
버전 스큐(skew)가 무엇인가. 쿠버네티스 버전은
v1.37.0처럼 세 자리다.
가운데 숫자(37)를 마이너 버전이라고 부르고, 기능이 추가되거나 바뀌는 단위다.쿠버네티스는
kubectl과 클러스터 사이의 마이너 버전 차이를 ±1 까지만
정상 동작을 보장한다. 그 밖은 "될 수도 있다" 수준이라, 평소엔 멀쩡하다가
특정 명령에서 엉뚱하게 터진다. 6 차이는 한참 벗어난다.
맞춰 준다.
brew upgrade kubernetes-cli
kubernetes-cli 1.31.3 -> 1.37.0
참고
kind 가 쓸 쿠버네티스 버전은 이렇게 미리 확인할 수 있다.
bash
strings $(which kind) | grep -oE 'kindest/node:v1.[0-9]+.[0-9]+' | sort -u뒤에 나올 설정 파일에
image:로 버전을 못 박아 두면, 나중에 kind 를 업그레이드해도
클러스터 버전이 멋대로 따라 바뀌지 않는다.
2. 클러스터를 설정 파일로 정의한다#
kind 는 kind create cluster 한 줄로도 클러스터를 만들어 준다. 하지만 설정
파일로 두면 언제든 똑같은 클러스터를 다시 만들 수 있고, 왜 그렇게 했는지가
파일에 주석으로 남는다.
cluster/kind-config.yaml 을 이렇게 쓴다.
kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
name: lab
nodes:
- role: control-plane
image: kindest/node:v1.37.0
labels:
ingress-ready: "true"
extraPortMappings:
- containerPort: 80
hostPort: 80
protocol: TCP
- containerPort: 443
hostPort: 443
protocol: TCP
- role: worker
image: kindest/node:v1.37.0
- role: worker
image: kindest/node:v1.37.0
세 가지를 의도했다. 하나씩 보자.
워커를 2대 둔다#
노드가 하나뿐이면 쿠버네티스가 "어디에 띄울지" 고를 대상이 하나밖에 없다.
그러면 파드를 어느 노드에 보낼지 조종하는 기능들(nodeSelector, affinity,
taint/toleration 등)을 실습해도 결과가 늘 똑같아서 배울 게 없다.
워커를 2대 두면 "이 파드가 어느 노드로 갔는지" 가 눈에 보인다. kind 는 노드가
도커 컨테이너라 2대를 더 띄워도 메모리 400MB 수준이다.
맥의 80/443 포트를 control-plane 노드에 연결한다#
extraPortMappings 가 그 설정이다. 맥의 80번 포트로 들어온 요청을
control-plane 노드 컨테이너의 80번 포트로 넘긴다.
참고
왜 이게 필요한가. 클라우드에서는
LoadBalancer라는 것을 만들면 클라우드
업체가 진짜 공인 IP 를 하나 붙여 준다. 하지만 내 맥에는 그렇게 해 줄 주체가 없다.그래서 kind 에서는 이 포트 매핑이 바깥에서 클러스터로 들어오는 유일한 통로다.
이 점이 3단계에서 막히는 문제의 원인이 되므로 기억해 둔다.
control-plane 에 ingress-ready 라벨을 붙인다#
라벨(label) 은 쿠버네티스 객체에 붙이는 이름표다. 키=값 형태로 아무거나
붙일 수 있고, 나중에 "이 이름표가 붙은 것" 을 골라내는 데 쓴다.
여기서는 나중에 "Ingress 컨트롤러는 이 이름표가 붙은 노드에만 떠라" 고 지정하는
데 쓸 것이다. 3단계에서 이유가 나온다.
만든다#
kind create cluster --config cluster/kind-config.yaml
Creating cluster "lab" ...
✓ Ensuring node image (kindest/node:v1.37.0) 🖼️
✓ Preparing nodes 📦 📦 📦
✓ Writing configuration 📜
✓ Starting control-plane 🕹️
✓ Installing CNI 🔌
✓ Installing StorageClass 💾
✓ Joining worker nodes 🚜
Set kubectl context to "kind-lab"
1분 25초 걸렸다. 대부분 노드 이미지를 내려받는 시간이라, 두 번째부터는 훨씬 빠르다.
잘 떴는지 본다.
kubectl get nodes
NAME STATUS ROLES AGE VERSION
lab-control-plane Ready control-plane 37s v1.37.0
lab-worker Ready <none> 22s v1.37.0
lab-worker2 Ready <none> 22s v1.37.0
STATUS 가 셋 다 Ready 면 성공이다. ROLES 를 보면 어느 것이 지휘하는 쪽인지
알 수 있다. 워커는 <none> 으로 나오는데, 역할 표시가 없을 뿐 정상이다.
딸려 온 것들#
위 출력에 Installing CNI, Installing StorageClass 가 있었다. kind 가 알아서
넣어 준 것들이다.
CNI 는 파드에 IP 를 주고 파드끼리 통신하게 해 주는 네트워크 플러그인이다.
kind 는 kindnet 이라는 단순한 것을 넣어 준다.
참고
이걸 실무에서 많이 쓰는 Calico 로 바꾸는 과정과, 그때 만나는 IP 대역 충돌 문제는
kind 의 CNI 를 kindnet 에서 Calico 로 바꾸기에
따로 정리했다.
StorageClass 는 "파드가 저장 공간을 달라고 하면 어떻게 만들어 줄지" 를 정해
둔 것이다.
kubectl get storageclass
NAME PROVISIONER RECLAIMPOLICY VOLUMEBINDINGMODE
standard (default) rancher.io/local-path Delete WaitForFirstConsumer
(default) 가 붙어 있으니, 파드가 저장 공간을 요청하면 이게 알아서 처리한다.
데이터를 저장하는 실습을 바로 시작할 수 있다는 뜻이다.
참고
이 저장 공간을 실제로 써서 파드가 죽어도 데이터가 남게 만드는 방법은
파드가 죽으면 데이터도 사라진다 — StatefulSet 과 PVC 로 남기는 법에
정리했다. 위 출력의WaitForFirstConsumer가 무슨 뜻인지도 거기서 다룬다.
3. Ingress 컨트롤러 — 여기서 막힌다#
지금 상태로는 클러스터 밖에서 안으로 들어갈 방법이 없다. 그 통로를 만든다.
참고
Ingress 가 무엇인가. "
example.com/api로 온 요청은 이 서비스로,/blog로
온 요청은 저 서비스로 보내라" 같은 규칙이다. 규칙일 뿐이라 그 자체로는
아무 일도 하지 않는다.실제로 요청을 받아 규칙대로 넘겨 주는 프로그램이 따로 필요한데, 그것을
Ingress 컨트롤러라고 한다. 여기서는 nginx 로 만든ingress-nginx를 쓴다.결국 리버스 프록시다. 그 개념 자체는
nginx 를 앞에 세우는 이유 — 리버스 프록시가 실제로 해 주는 일에
정리해 두었다. ingress-nginx 는 그 nginx 를 쿠버네티스가 대신 조종하는 형태다.
ingress-nginx 는 kind 전용 설치 파일을 따로 준다. 버전을 고정해서 받는다.
curl -sSL https://raw.githubusercontent.com/kubernetes/ingress-nginx/controller-v1.15.1/deploy/static/provider/kind/deploy.yaml
-o manifests/ingress-nginx/deploy.yaml
팁
주소에
main대신controller-v1.15.1을 넣었다.main은 계속 바뀌므로
어제 되던 게 오늘 안 될 수 있다. 버전을 박아 두면 언제 다시 받아도 같은 파일이다.
바로 적용하지 말고 열어서 읽어 본다. 남이 만든 설치 파일은 내 환경을 모른다.
grep -n 'ingress-ready' manifests/ingress-nginx/deploy.yaml
(아무것도 안 나온다)
예상과 달랐다. 예전 이 파일은 ingress-ready=true 라벨이 붙은 노드에만 컨트롤러를
띄우도록 돼 있었다. 그래서 인터넷의 안내 글 대부분이 kind 설정에 그 라벨을 붙이라고
말한다. v1.15.1 에는 그 지정이 없다.
대신 이렇게 생겼다.
ports:
- containerPort: 80
hostPort: 80 # 파드가 뜬 "그 노드의" 80 을 점유한다
- containerPort: 443
hostPort: 443
...
nodeSelector:
kubernetes.io/os: linux # 이게 전부다
tolerations:
- effect: NoSchedule
key: node-role.kubernetes.io/control-plane
operator: Equal
무엇이 문제인가#
두 가지를 겹쳐 보면 문제가 드러난다.
첫째, hostPort 는 파드가 뜬 노드의 포트를 연다. 컨트롤러 파드가 어느
노드에 뜨든, 그 노드의 80번 포트가 열린다.
둘째, 이 파드는 아무 노드에나 갈 수 있다. nodeSelector 가
kubernetes.io/os: linux 뿐이라 세 노드 모두 조건을 만족한다.
참고
taint 와 toleration. control-plane 노드에는 기본적으로 taint(얼룩) 가
발라져 있다. "여기엔 아무 파드나 오지 마라" 는 표시다. 그래서 보통 애플리케이션은
control-plane 에 안 뜬다.그 표시를 무시해도 좋다는 허가증이 toleration(용인) 이다. 위 YAML 에
toleration 이 달려 있으므로, 이 컨트롤러는 control-plane 에도 갈 수 있다.
즉 세 노드 전부 후보다.
그런데 2단계에서 나는 맥의 80/443 을 control-plane 노드에만 연결해 뒀다.
정리하면 이렇다.
| 컨트롤러가 뜬 노드 | 80번 포트가 열리는 곳 | 맥에서 http://localhost |
|---|---|---|
lab-control-plane |
control-plane 컨테이너 | 닿는다 |
lab-worker |
worker 컨테이너 | 닿지 않는다 |
lab-worker2 |
worker2 컨테이너 | 닿지 않는다 |
어디에 뜨느냐는 그때그때 다르다. 클러스터를 다시 만들면 결과가 바뀔 수 있어서,
"어제는 됐는데 오늘은 안 된다" 가 나오는 종류의 문제다.
주의
이 상태에서 증상은 오류 메시지가 아니라 침묵이다.
curl http://localhost가
그냥 연결을 못 하는데, 파드도 서비스도 전부 정상으로 보여서 어디가 잘못됐는지
알기 어렵다.이럴 때는
kubectl get pods -n ingress-nginx -o wide로 컨트롤러가 어느 노드에
있는지부터 본다.-o wide를 붙여야 노드 이름이 나온다.
해결 — 노드를 고정한다#
2단계에서 control-plane 에 붙여 둔 ingress-ready 라벨이 여기서 쓰인다.
"이 이름표가 붙은 노드에만 떠라" 고 지정하면 된다.
받아 온 파일을 직접 고쳐도 되지만, 그러면 나중에 버전을 올릴 때 내가 고친 부분을
다시 찾아 넣어야 한다. 원본은 그대로 두고 바뀔 부분만 따로 적는 방법을 쓴다.
kustomize 라는 도구가 그 일을 해 준다. kubectl 에 이미 포함돼 있다.
바꿀 내용만 적은 파일 manifests/ingress-nginx/pin-to-control-plane.yaml 이다.
apiVersion: apps/v1
kind: Deployment
metadata:
name: ingress-nginx-controller
namespace: ingress-nginx
spec:
template:
spec:
nodeSelector:
kubernetes.io/os: linux
ingress-ready: "true" # ← 이 줄을 더한다
둘을 합치라고 알려 주는 파일 manifests/ingress-nginx/kustomization.yaml 이다.
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- deploy.yaml # 받아 온 원본
patches:
- path: pin-to-control-plane.yaml # 덧씌울 내용
target:
kind: Deployment
name: ingress-nginx-controller
적용하기 전에 합쳐진 결과가 의도대로인지 먼저 본다. kubectl kustomize 는
합치기만 하고 클러스터에는 보내지 않는다.
kubectl kustomize manifests/ingress-nginx | grep -A3 nodeSelector | head -4
nodeSelector:
ingress-ready: "true"
kubernetes.io/os: linux
두 줄이 됐다. 적용한다. -k 는 "이 디렉터리의 kustomization 을 써라" 는 뜻이다.
kubectl apply -k manifests/ingress-nginx
kubectl wait --namespace ingress-nginx
--for=condition=ready pod
--selector=app.kubernetes.io/component=controller
--timeout=180s
pod/ingress-nginx-controller-575c4d8bc6-mbgz5 condition met
팁
kubectl wait는 조건이 만족될 때까지 기다렸다가 끝난다. 파드가 뜨는 데
시간이 걸리므로,get을 여러 번 치는 대신 이걸 쓰면 편하다.
의도한 자리에 떴는지 확인한다. 이게 이 단계의 핵심이다.
kubectl get pods -n ingress-nginx -o wide
NAME READY STATUS NODE
ingress-nginx-controller-575c4d8bc6-mbgz5 1/1 Running lab-control-plane
맨 오른쪽이 lab-control-plane 이다. 포트를 연결해 둔 그 노드다.
EXTERNAL-IP 가 pending 인 건 정상이다#
kubectl get svc -n ingress-nginx
NAME TYPE EXTERNAL-IP PORT(S)
ingress-nginx-controller LoadBalancer <pending> 80:31596/TCP,443:32121/TCP
<pending> 을 보고 설치가 덜 끝난 줄 알기 쉽다. 고장이 아니다.
LoadBalancer 는 원래 클라우드 업체가 공인 IP 를 붙여 주는 방식인데, 내 맥에는
그럴 주체가 없어서 영원히 pending 이다. 우리 트래픽은 이 서비스가 아니라
2단계에서 만든 포트 매핑으로 들어온다. 여기서 시간을 버리는 사람이 많다.
4. 확인 — 끝에서 끝까지#
이제 진짜로 이어졌는지 본다. 확인할 경로는 이렇다.
브라우저(맥) :80
→ kind 포트 매핑 (control-plane 컨테이너 :80)
→ ingress-nginx (hostPort :80)
→ Ingress 규칙
→ Service
→ Pod (워커에 분산)
시험용 애플리케이션을 올린다. agnhost 는 쿠버네티스가 자체 테스트에 쓰는 공식
이미지다. arm64 맥에서 그대로 돌고, /hostname 으로 요청하면 처리한 파드 이름을
돌려준다. 어느 파드가 응답했는지 눈으로 볼 수 있다.
apiVersion: apps/v1
kind: Deployment
metadata:
name: echo
labels: { app: echo }
spec:
replicas: 3 # 파드를 3개 띄운다
selector:
matchLabels: { app: echo }
template:
metadata:
labels: { app: echo }
spec:
topologySpreadConstraints: # 파드를 노드에 고르게 흩뿌린다
- maxSkew: 1
topologyKey: kubernetes.io/hostname
whenUnsatisfiable: ScheduleAnyway
labelSelector:
matchLabels: { app: echo }
containers:
- name: agnhost
image: registry.k8s.io/e2e-test-images/agnhost:2.47
args: ["netexec", "--http-port=8080"]
ports:
- containerPort: 8080
---
apiVersion: v1
kind: Service
metadata:
name: echo
spec:
selector: { app: echo } # app=echo 라벨이 붙은 파드들에게 넘긴다
ports:
- port: 80
targetPort: 8080
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: echo
spec:
ingressClassName: nginx
rules:
- host: localhost # localhost 로 온 요청을
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: echo # echo 서비스로 보낸다
port: { number: 80 }
참고
Deployment 와 Service 의 역할.
- Deployment 는 "이 파드를 3개 유지해 줘" 를 맡는다. 하나가 죽으면 알아서 새로 띄운다.
- Service 는 파드들 앞에 서는 고정된 주소다. 파드는 죽고 살아나며 IP 가 계속 바뀌는데, 서비스 이름(
echo)은 그대로라 그걸로 부르면 된다. 요청이 오면 뒤의 파드들에게 나눠 준다.
kubectl apply -f manifests/smoke-test/echo.yaml
kubectl get pods -l app=echo -o wide
NAME READY STATUS NODE
echo-557947968f-6mppk 1/1 Running lab-worker
echo-557947968f-nlxwv 1/1 Running lab-worker2
echo-557947968f-wd8nq 1/1 Running lab-worker
워커 2대에 나뉘었다. control-plane 에는 하나도 안 갔다 — 앞에서 말한 taint 가
발라져 있고, 이 파드에는 그걸 무시할 toleration 이 없기 때문이다. 워커를 2대 둔
값이 여기서 나온다.
이제 맥에서 직접 요청해 본다. 30번 보내고 어느 파드가 몇 번 답했는지 센다.
for i in $(seq 1 30); do curl -s http://localhost/hostname; echo; done | sort | uniq -c
9 echo-557947968f-6mppk
10 echo-557947968f-nlxwv
11 echo-557947968f-wd8nq
세 파드가 9번, 10번, 11번씩 답했다. 맥에서 시작한 요청이 노드 컨테이너를 지나
워커의 파드까지 닿았고, Service 가 고르게 나눠 주고 있다는 뜻이다. 구성 끝이다.
5. 실제로 드는 비용#
유휴 상태 기준이다.
docker stats --no-stream $(docker ps -q --filter "label=io.x-k8s.kind.cluster=lab")
NAME CPU % MEM USAGE
lab-control-plane 14.80% 833.2MiB
lab-worker 1.75% 212.3MiB
lab-worker2 1.81% 204.0MiB
메모리 합이 약 1.25GB 다. 16GB 맥에서는 부담이 아니다.
디스크는 노드 이미지 4.8GB, 볼륨 3.0GB 로 8GB 가까이 쓴다. 이쪽을 미리 봐
두는 편이 낫다. 나는 여유가 27GB 뿐이라 정리부터 하고 시작했다.
팁
다 쓰고 치울 때는 클러스터만 지우면 노드 컨테이너와 볼륨이 함께 사라진다.
bash
kind delete cluster --name lab노드 이미지 4.8GB 는 남는다. 다시 만들 때 재사용되니 두는 편이 낫지만,
정말 정리하려면docker image rm kindest/node:v1.37.0.
정리#
- kind 는 노드를 도커 컨테이너로 흉내 내서 정식 쿠버네티스를 올린다. 경량 배포판과 달리 미리 들어있는 게 적어 직접 넣어야 하는데, 공부가 목적이면 그 과정이 값이다.
- 워커를 2대 이상 둔다. 노드가 하나면 "어디에 떴는지" 가 안 보여서 스케줄링 관련 실습이 전부 무의미해진다.
- ingress-nginx 의 kind 용 파일은
ingress-ready라벨을 더 이상 쓰지 않는다.hostPort로 포트를 여는 구조라 컨트롤러가 뜬 노드가 곧 입구가 된다. 포트를 연결해 둔 노드에nodeSelector로 고정해야 한다. 안 하면 될 때도 있고 안 될 때도 있다. EXTERNAL-IP <pending>은 고장이 아니다. 내 맥에는 LoadBalancer 에 IP 를 붙여 줄 주체가 없으니 당연한 결과다.- 남이 만든 설치 파일은 적용하기 전에 열어 본다. 이번 건은
grep한 번으로 걸렀다. 적용하고 나서 "왜 안 되지" 를 헤맸다면 훨씬 오래 걸렸을 문제다.
팁
이 클러스터를 채팅으로 조회하고 싶다면
mcp-server-kubernetes 로 Hermes·Claude 에 kind 클러스터 붙이기를 이어서 본다.

댓글