[Kubernetes] kubectl 연습 - Service, Volume, Scheduling
서비스는 Pod 앞에 고정 창구를 세우는거고, 서비스의 타입에 따라서 접근 범위가 달라진다.
그리고 서비스의 타입은 3가지가 있음. (내부 전용부터 외부 노출까지)
1. ClusterIP
클러스터 내부 전용 아이피로 외부에서는 접근할 수 없다.
2. NodePort
ClusterIP + 노드의 포트를 열어서 외부에서 접근할 수 있도록 설정한다. (30000 ~ 32767)
3. LoadBalancer
NodePart + 클라우드의 외부 로드밸런서를 붙여서 서비스를 외부 인터넷에 접근할 때 사용한다.
클라우드에서만 가능. minikube 에서는 불가능.
지금은 로컬 맥북에서 하고있으니 NodePort를 사용해보자.
13months@13monthsBook k8s % kubectl create deployment web --image=nginx
deployment.apps/web created
13months@13monthsBook k8s % kubectl expose deployment web --port=80 --type=NodePort
service/web exposed
13months@13monthsBook k8s % kubectl get svc
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
kubernetes ClusterIP 10.96.0.1 <none> 443/TCP 23h
web NodePort 10.98.114.226 <none> 80:31242/TCP 8s
13months@13monthsBook k8s % minikube service web
┌───────────┬──────┬─────────────┬───────────────────────────┐
│ NAMESPACE │ NAME │ TARGET PORT │ URL │
├───────────┼──────┼─────────────┼───────────────────────────┤
│ default │ web │ 80 │ http://192.168.49.2:31242 │
└───────────┴──────┴─────────────┴───────────────────────────┘
🔗 Starting tunnel for service web.
┌───────────┬──────┬─────────────┬────────────────────────┐
│ NAMESPACE │ NAME │ TARGET PORT │ URL │
├───────────┼──────┼─────────────┼────────────────────────┤
│ default │ web │ │ http://127.0.0.1:57271 │
└───────────┴──────┴─────────────┴────────────────────────┘
🎉 Opening service default/web in default browser...
❗ Because you are using a Docker driver on darwin, the terminal needs to be open to run it.
192.168.49.2 는 minikube 노드의 아이피이고, 31242는 get svc에서 본 NodePort이다.
그러니 http://192.168.49.2:31242 로 접속할 수 있어야 하긴 하는데..
127.0.0.1 은 로컬호스트고 57271은 완전히 다른 포트. 실제로 브라우저를 열 때는 이 주소를 사용한다.
로그의 마지막 줄에서 확인할 수 있듯 맥에서 도커 드라이버를 사용하고 있으니 로컬호스트로 접근해야 함.
맥 브라우저 (127.0.0.1:57271) -> Docker Desktop 리눅스 VM -> minikube 컨테이너 (192.168.49.2:31242) -> nginx
minikube 컨테이너는 리눅스 VM 안의 네트워크 주소이기에 브라우저는 안쪽 네트워크에 도달할 수 없다.
그러니 minikube가 터널을 뚫어서 127.0.0.1:57271 로 오는 요청을 VM 안의 192.168.49.2:31242 로 전달해줌.
리눅스 서버에서 직접 구축한 클러스터라면 바로 노드 IP로 접근할 수 있다.
(노드의 방화벽이 30000 ~ 32767 포트를 열어뒀고 네트워크 설정이 외부망까지 뚫려있어야 함)
NodePort는 모든 노드에서 열린다. NodePort Service를 만들면 쿠버가 모든 노드에 31242번 포트로 들어오면 이 서비스로 보내라는 규칙을 심어두게 되고 각 노드의 kube-proxy가 그 규칙을 직접 실행하게 된다.
노드1에 nginx Pod가 있을 때 노드2로 요청이 들어온다면 노드2의 kube-proxy가 노드1의 Pod로 전달해준다.
즉, 자기한테 Pod가 없어도 다른 노드의 Pod로 중계하는 셈.
Pod는 죽고 살아나며 노드를 옮겨다니는데 이러면 접속 주소도 계속 바뀐다. 모든 노드에서 열어둔다면 노드 IP 아무거나 알고있으면 접속할 수 있게 된다.
일단 서비스도 그냥 etcd에 저장된 규칙일 뿐이지 실체는 없다.
특정 아이피로 요청이 들어오면 Pod로 보내라는 동작은 kube-proxy가 처리함.
NodePort는 노드의 포트를 열어두는거니 외부에서 서비스의 아이피로 접근하는게 아니라 노드의 아이피로 접근한다.
서비스의 아이피는 클러스터 내부 전용이지만 바뀌지 않고, Pod의 아이피도 클러스터 내부 전용이지만 죽을 때 마다 바뀐다.
외부 -> 노드(NodePort: 31242) -> 서비스 (Port: 80) -> Pod의 컨테이너 (targetPort: 80)
nodePort는 외부에서 들어오는 입구이고 Port는 서비스 자체의 포트를 의미한다. 그리고 targetPort는 최종목적지로 컨테이너 안에 있는 앱이 실제로 Listen 하는 포트.
그런데 여기서 제대로 정리하고 가야할게 있음. 외부에서는 NodePort로 들어오고 내부에서는 Port로 들어온다.
들어온 후에는 똑같이 서비스가 처리해서 targetPort로 보내준다.
Port가 필요한 이유는 클러스터 내부에서의 다른 앱이 서비스를 부를 때 사용할 주소가 있어야 되기 때문.
kube-proxy 규칙을 보면 명확해진다.
규칙 1: 10.98.114.226:80 으로 오면 → app=web Pod의 80번으로 (사실 아이피 말고 서비스 이름을 통해 접근하긴 함)
(ClusterIP:port — 내부용)
규칙 2: <노드IP>:31242 로 오면 → app=web Pod의 80번으로
(NodeIP:nodePort — 외부용)
즉, 외부 요청과 내부 요청 모두 서비스를 거치는건 맞지만 외부에서는 NodePort를 통해 서비스로 접근하고 내부에서는 Port를 통해 서비스로 접근한다. 들어온 후에는 서비스가 처리해서 targetPort로 보내게 됨.
13months@13monthsBook k8s % k get svc
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
kubernetes ClusterIP 10.96.0.1 <none> 443/TCP 24h
web NodePort 10.98.114.226 <none> 80:31242/TCP 49m
13months@13monthsBook k8s %
80:31242 에서 80은 Port이고 31242는 nodePort를 의미한다.
targetPort는 따로 지정해주지 않은 경우 기본값으로 Port와 같은 값을 사용한다.
당연히 저 세 값은 다르게 사용할 수 있음
서비스가 연결이 안되면 targetPort가 컨테이너 포트와 일치하는지 확인해보자.
kubectl describe svc 서비스명 명령어로 TargetPort가 컨테이너의 실제 포트와 일치하는지 확인하면 됨.
Endpoints가 none이면 서비스의 Selector와 Pod 라벨이 일치하지 않으니 kubectl get pods --show-labels로 확인하고 맞춘다.
13months@13monthsBook k8s % k describe svc web
Name: web
Namespace: default
Labels: app=web
Annotations: <none>
Selector: app=web
Type: NodePort
IP Family Policy: SingleStack
IP Families: IPv4
IP: 10.98.114.226
IPs: 10.98.114.226
Port: <unset> 80/TCP
TargetPort: 80/TCP
NodePort: <unset> 31242/TCP
Endpoints: 10.244.0.26:80
Session Affinity: None
External Traffic Policy: Cluster
Internal Traffic Policy: Cluster
Events: <none>
13months@13monthsBook k8s %
Endpoints에는 실제 연결된 Pod 아이피와 포트가 명시되어있다.
docker run -p 8080:80 nginx 라고 하면
8080은 호스트의 포트를 의미하고 80은 컨테이너 안의 포트를 의미한다.
즉, 내 컴에서 8080포트로 오는 요청을 80번 컨테이너로 넘기는 작업이다.
80은 내가 정하는게 아니고 nginx가 80번을 보도록 설정되어 있으니 80을 쓴다. (여기서 80은 앱마다 다르다 그냥 앱이 정하는것)
외부 -> 호스트(8080) -> 컨테이너(80)
쿠버에서도 비슷한데 서비스 층이 하나 더 추가될 뿐임.
외부 -> 노드(31242) -> 서비스(80) -> 컨테이너(80)
포트포워딩이 두 번 된다고 생각하면 된다.
서비스가 여러 Pod를 대표하기에 도커에서는 없는 중간 계층이 새로 생겨난 것.
13months@13monthsBook k8s % k run data-test --image=busybox -- sleep 3600
pod/data-test created
13months@13monthsBook k8s % k exec -it data-test -- sh
/ # echo "data create" > /data.txt
/ # cat /data.txt
data create
/ # exit
13months@13monthsBook k8s % k delete pod data-test
pod "data-test" deleted from default namespace
13months@13monthsBook k8s %
13months@13monthsBook k8s % k run data-test --image=busybox -- sleep 3600
pod/data-test created
13months@13monthsBook k8s % k exec -it data-test -- cat /data.txt
cat: can't open '/data.txt': No such file or directory
command terminated with exit code 1
13months@13monthsBook k8s %
docker rm 하면 그 안에 쓴 데이터도 같이 사라지듯 Pod를 죽였다 살리면 파일이 사라진다.
Pod안에 Stateless 앱이 들어가면 상관없는데, 데이터베이스처럼 Stateful 앱이 들어가면 큰일난다.
그러니 데이터를 컨테이너 바깥에다가 저장하자.
쿠버는 저장소를 두 개로 쪼개서 다룬다.
1. Persistent Volume
실제 저장소로 클러스터 관리자가 미리 준비해둔다. AWS EBS 노드의 폴더가 될 수 있음.
2. Persistent VolumeClaim
개발자가 작성하는 신청서로 Pod는 PVC를 통해 저장소를 사용한다.
그냥 Pod에 디스크를 쓰라고 연결하면 좋겠지만 추상화해두면 개발자가 인프라를 몰라도 되니 편하다.
PVC가 추상화해주니 실제 저장 공간이 AWS EBS든 NFS든 알 필요가 없다.
13months@13monthsBook k8s % vim my-pvc.yaml
13months@13monthsBook k8s % kubectl apply -f my-pvc.yaml
persistentvolumeclaim/my-pvc created
13months@13monthsBook k8s % kubectl get pvc
NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS VOLUMEATTRIBUTESCLASS AGE
my-pvc Bound pvc-f80bf27f-ba97-499e-9baf-4990563e02aa 1Gi RWO standard <unset> 6s
13months@13monthsBook k8s % vim pod-with-pvc.yaml
13months@13monthsBook k8s % kubectl apply -f pod-with-pvc.yaml
pod/pvc-test created
13months@13monthsBook k8s % k exec -it pvc-test -- sh -c 'echo "store in volume!" > /data/important.txt'
13months@13monthsBook k8s % k exec -it pvc-test -- cat /data/important.txt
store in volume!
13months@13monthsBook k8s % k delete pod pvc-test
pod "pvc-test" deleted from default namespace
13months@13monthsBook k8s % k apply -f pod-with-pvc.yaml
pod/pvc-test created
13months@13monthsBook k8s % k exec -it pvc-test -- cat /data/important.txt
store in volume!
13months@13monthsBook k8s %
// my-pvc.yaml
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: my-pvc
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 1Gi
// pod-with-pvc.yaml
apiVersion: v1
kind: Pod
metadata:
name: pvc-test
spec:
containers:
- name: test
image: busybox
command: ["sh", "-c", "sleep 3600"]
volumeMounts:
- name: my-storage
mountPath: /data
volumes:
- name: my-storage
persistentVolumeClaim:
claimName: my-pvc
PVC도 yaml이고 Pod에 PVC를 연결하는 것도 yaml이다. Pod를 만드는 파일에서 PVC를 가져다가 사용함.
yaml에서 kind 부분에 이 yaml파일이 무슨 리소스를 만드는지 정해준다.
Deployment + Service + ConfigMap을 yaml 하나에 --- 로 구분해서 넣기도 함.
모든 리소스는 etcd에 저장되고 그 리소스를 만드는 방법이 yaml이다.
yaml을 apply하면 api-server가 받아서 etcd에 저장한 후 해당 부품이 실행된다.
kubectl get pvc 명령어로 상태를 확인할 수 있음.
STORAGECLASS, ACCESS MODES 등 여러 옵션을 확인할 수 있다.
13months@13monthsBook k8s % kubectl get pv
NAME CAPACITY ACCESS MODES RECLAIM POLICY STATUS CLAIM STORAGECLASS VOLUMEATTRIBUTESCLASS REASON AGE
pvc-f80bf27f-ba97-499e-9baf-4990563e02aa 1Gi RWO Delete Bound default/my-pvc standard <unset> 6m24s
13months@13monthsBook k8s %
PVC만 만들었는데 PV가 생겼다. 여기서 StorageClass가 중요함.
StorageClass는 PV를 만들어주는 공장으로, PVC를 보고 PV를 즉석으로 만들어준다. (Dynamic Provisioning)
minikube에는 standard라는 StorageClass가 기본으로 있고, storage-provisioner을 통해 PV를 만든다.
반대로 관리자가 PV를 미리 만들어두고 PVC가 그 중 하나를 연결하는 경우는 Static Provisioning이다.
그런데 클라우드에서는 웬만하면 Dynamic Provisiong을 사용함.
RECLAIM POLICY는 Delete로 설정되어있는데, 이러면 PVC를 지울 때 PV와 실제 데이터까지 함께 삭제된다.
Retain으로 설정해두면 PVC를 지워도 PV와 데이터를 남겨두고 관리자가 수동으로 처리해야 한다.
13months@13monthsBook k8s % kubectl get pods -o wide
NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES
spread-test-5b55d5fd4f-4mqgz 1/1 Running 0 3m11s 10.244.0.3 minikube <none> <none>
spread-test-5b55d5fd4f-jbcqq 1/1 Running 0 3m11s 10.244.0.4 minikube <none> <none>
spread-test-5b55d5fd4f-lw7t8 1/1 Running 0 3m11s 10.244.1.3 minikube-m02 <none> <none>
spread-test-5b55d5fd4f-q5lt8 1/1 Running 0 3m11s 10.244.1.2 minikube-m02 <none> <none>
spread-test-5b55d5fd4f-sn5qk 1/1 Running 0 3m11s 10.244.2.2 minikube-m03 <none> <none>
spread-test-5b55d5fd4f-ztfr6 1/1 Running 0 3m11s 10.244.2.3 minikube-m03 <none> <none>
13months@13monthsBook k8s %
minikube를 시작할 때 노드를 3개로 설정하고 cni를 설정해주면 스케쥴링을 시작할 수 있다.
6개의 Pod가 3개의 노드에 2개씩 나눠서 분산되어있음. scheduler가 알아서 균등하게 분산시켜줬다.
각 노드가 자기만의 Pod 대역폭을 가지고 flannel cni가 대역을 연결해준다. 그러니 노드간 Pod 통신이 가능함.
scheduler가 알아서 분산시키기보다는 nodeSelector를 통해 특정 Pod는 특정 노드에만 배치하라고 설정할 수 있다.
라벨은 원래 서비스가 Pod를 찾을 때 사용하는데, 여기서는 노드에다가 라벨을 붙인다.
13months@13monthsBook k8s % kubectl label node minikube-m02 disktype=ssd
node/minikube-m02 labeled
13months@13monthsBook k8s % kubectl get nodes --show-labels | grep disktype
minikube-m02 Ready <none> 15m v1.35.1 beta.kubernetes.io/arch=arm64,beta.kubernetes.io/os=linux,disktype=ssd,kubernetes.io/arch=arm64,kubernetes.io/hostname=minikube-m02,kubernetes.io/os=linux,minikube.k8s.io/commit=c93a4cb9311efc66b90d33ea03f75f2c4120e9b0,minikube.k8s.io/name=minikube,minikube.k8s.io/primary=false,minikube.k8s.io/updated_at=2026_07_23T11_09_03_0700,minikube.k8s.io/version=v1.38.1
13months@13monthsBook k8s %
// ssd-pod.yaml
apiVersion: v1
kind: Pod
metadata:
name: ssd-pod
spec:
nodeSelector:
disktype: ssd
containers:
- name: nginx
image: nginx
라벨을 만들고 yaml을 작성할 때 nodeSelector: 부분을 추가하자.
이러면 해당 Pod는 disktype: ssd 라벨이 붙은 노드에만 배치한다.
nodeSelector 조건에 맞는 노드가 없다면 노드를 찾을수 없다고 나옴. STATUS는 계속 Pending으로 나타난다.
nodeSelector는 Pod가 노드를 고르지만 taint는 노드가 Pod를 밀어낸다. Pod가 주도하는 것과 노드가 주도하는 것의 차이임.
원래는 Control Plane 노드가 기본적으로 taint지만 minikube는 기본 노드가 1개라서 taint 제한을 풀어둠.
13months@13monthsBook k8s % k taint node minikube-m03 special=true:NoSchedule
node/minikube-m03 tainted
13months@13monthsBook k8s % k describe node minikube-m03 | grep -i taint
Taints: special=true:NoSchedule
13months@13monthsBook k8s % k create deployment taint-test --image=nginx --replicas=6
deployment.apps/taint-test created
13months@13monthsBook k8s % k get pods -o wide | grep taint-test
taint-test-7945ff45d4-4fks4 1/1 Running 0 18s 10.244.0.7 minikube <none> <none>
taint-test-7945ff45d4-4jbfx 1/1 Running 0 18s 10.244.0.5 minikube <none> <none>
taint-test-7945ff45d4-cjczk 1/1 Running 0 18s 10.244.1.6 minikube-m02 <none> <none>
taint-test-7945ff45d4-gqt7q 1/1 Running 0 18s 10.244.0.6 minikube <none> <none>
taint-test-7945ff45d4-hwxtl 1/1 Running 0 18s 10.244.1.7 minikube-m02 <none> <none>
taint-test-7945ff45d4-skfb6 1/1 Running 0 18s 10.244.1.5 minikube-m02 <none> <none>
13months@13monthsBook k8s %
그러니 노드에 taint를 걸고 NoSchedule로 새로운 Pod를 스케쥴하지 않도록 설정하자.
이제 m03 노드는 special=true 가 명시된 Pod만 받으니 m03은 Pod를 받지 않는다.
이미 있는 Pod는 taint 걸어도 쫓겨나지 않는다. 이미 돌고있으면 어쩔 수 없음.
쫓아내려면 NoExcute 옵션을 설정하면 됨.
taint만 있으면 해당 노드에 어떤 Pod도 갈 수 없는데, 특정 Pod은 그 노드에 넣어야 하는 경우가 있다.
예를 들어 평소에는 다 막아버리지만 GPU가 필요한 Pod만 넣어야 하는 경우 toleration을 사용한다.
toleration을 가지는 Pod는 taint를 견디고 노드에 들어갈 수 있음.
무조건 해당 노드로 가는건 아니고, 갈 수 있다는 것. 무조건 넣으려면 nodeSelector를 함께 명시해야 한다.
apiVersion: v1
kind: Pod
metadata:
name: tolerant-pod
spec:
tolerations:
- key: special
operator: Equal
value: "true"
effect: NoSchedule
nodeSelector:
kubernetes.io/hostname: minikube-m03
containers:
- name: nginx
image: nginx
Pod를 정의할 때 special=true 로 taint를 견딜 수 있음을 명시해준다.
해당 노드로 갈 수 있다 뿐이니 nodeSelector는 m03으로 가라고 명시해주자.
13months@13monthsBook k8s % kubectl get pod tolerant-pod -o wide
NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES
tolerant-pod 1/1 Running 0 9s 10.244.2.5 minikube-m03 <none> <none>
13months@13monthsBook k8s %
점검할 노드에 taint를 걸면 안전하게 노드를 비우고 점검할 수 있다. 기존 Pod까지 다 쫓겨나기 때문..
노드를 비울 때는 kubectl drain 명령어로 그 노드의 Pod를 모두 빼고 새로운 스케쥴도 막는다. (내부적으로 taint + eviction)
kubectl cordon 명령어는 기존 Pod는 유지하고 새 Pod만 받지 않도록 설정한다.
kubectl uncordon 명령어는 점검이 마무리된 후 다시 Pod를 받도록 설정한다.
Pod가 Pending인데 원인이 스케쥴링이라면 kubectl describe pod로 이벤트를 확인한다.
이러면 nodeSelector 조건에 맞는 노드가 없거나 taint를 견뎌낼 toleration이 없을 수도 있고 자원이 부족할 수 있음.
갑자기 Pod를 안받으면 그 노드가 taint가 걸렸는지 kubectl describe node로 확인하면 됨.
k taint node minikube-m03 special=true:NoSchedule-
k label node minikube-m02 disktype-
k label node minikube-m03 disktype-
맨 뒤에 -를 붙이면 제거를 의미한다. special=true:NoSchedule-은 taint를 떼어내는걸 의미하고 disktype-은 라벨을 떼어내는걸 의미.
'DevOps > Docker && Kubernetes' 카테고리의 다른 글
| [Kubernetes] Certified Kubernetes Administrator 연습 (2) (0) | 2026.08.04 |
|---|---|
| [Kubernetes] kubectl 연습 - RoleBinding (0) | 2026.07.26 |
| [Kubernetes] kubectl 연습 - Deployment ReplicaSet (0) | 2026.07.21 |
| [Kubernetes] Kubernetes Concept (0) | 2026.07.21 |
| [k8s] 쿠버네티스 도입 (0) | 2023.12.17 |
댓글
이 글 공유하기
다른 글
-
[Kubernetes] Certified Kubernetes Administrator 연습 (2)
[Kubernetes] Certified Kubernetes Administrator 연습 (2)
2026.08.04 -
[Kubernetes] kubectl 연습 - RoleBinding
[Kubernetes] kubectl 연습 - RoleBinding
2026.07.26 -
[Kubernetes] kubectl 연습 - Deployment ReplicaSet
[Kubernetes] kubectl 연습 - Deployment ReplicaSet
2026.07.21 -
[Kubernetes] Kubernetes Concept
[Kubernetes] Kubernetes Concept
2026.07.21