1. 복습
문제) 아래 내용을 참고하여 workernode 1번에 로그를 남기는 Deployment를 배포하고 ConfigMap을 통해 해당 로그 파일을 workernode 2번에 백업 하시오.
(단, Helm으로 배포하고 Helm Cart 작성시 하나의 파일에 작성하시오.) *언급이 없는 속성 값(필드 값)은 default 또는 자유롭게 설정하여 작성
[Deployment] 246p 참고
name: k8s-hostpath-volume
namespace: cloud
spec:
nodeSelector:
kubernetes.io/hostname: jinyung0101-worker1
hostPath:
path: /var/log/k8s-logs
type: DirectoryOrCreate
[CronJob]
name: k8s-log-backup
namespace: default
schedule: "*/1 * * * *"
백업 위치: /var/log/k8s-logs-backup/날짜/
spec:
nodeSelector:
kubernetes.io/hostname: jinyung0101-worker1
0.0. 작업 설명
0.0.1. 목적
서버(노드)가 여러 대인 클러스터가 있고, 그중 하나가 worker1임. 하려는 건 딱 두 가지.
- worker1이라는 서버의 디스크에 로그 파일을 계속 쌓는다 (Deployment가 하는 일)
- 1분마다 지금까지 쌓인 로그를, 오늘 날짜 이름의 폴더에 복사해서 보관해둔다 (CronJob이 하는 일)
⇒ 로그 쓰기 + 주기적으로 복사해서 안전하게 보관하기
worker1 서버
├── /var/log/k8s-logs/ ← Deployment Pod이 계속 로그 씀 (여기서 nodeSelector: worker1 필요)
└── /var/log/k8s-logs-backup/날짜/ ← CronJob Pod이 1분마다 위 로그를 복사해옴 (얘도 nodeSelector: worker1 필요, 같은 디스크를 봐야 하니까)
즉 처음부터 끝까지 worker1이라는 한 서버 안에서, 폴더만 두 개(원본/백업) 나눠서 쓰는 작업
0.0.2. nodeSelector
클러스터엔 서버가 여러 대 있어서, 그냥 Pod을 만들면 쿠버네티스가 아무 서버에나 배치할 수 있음.. 근데 우리는 로그를 worker1의 디스크에 쓴다고 정했음. 그러니까 그 Pod이 실제로 worker1에서 돌아야 로그가 worker1 디스크에 쌓임. 다른 서버에서 돌면 엉뚱한 서버 디스크에 쌓여서 우리가 원하는 곳이 아니게 됨. nodeSelector는 그냥 이 Pod은 반드시 worker1에서만 실행해라는 못박기.
0.0.3. cronJob은 왜 worker1?
0.0.4. ConfigMap은 어디에 쓰이는 거임?
아직 얘기 안 했는데, 문제에 ConfigMap을 통해 백업이라고 했죠. 이건 위에서 얘기한 “복사하는 명령어(mkdir + cp + date)“를 Deployment/CronJob YAML 안에 직접 쓰는 대신, 그 스크립트를 ConfigMap에 담아두고, CronJob이 그 ConfigMap을 파일처럼 불러와서 실행하는 방식이에요. 즉 ConfigMap = 백업 스크립트 보관함이라고 생각하면 돼요.
0.1. 내가 한 풀이
0.1.1. Chart.yaml
- 기본 예시
apiVersion: v1
name: multi-container
description: A Helm chart for Kubernetes
type: application
version: 0.1.0
appVersion: "0.1.0"
❓type 에 대해서 해당 차트가 배포 가능한 독립적인 애플리케이션인지, 아니면 다른 차트에서 재사용하기 위한 라이브러리인지 식별하는 메타데이터 라고 설명이 되어 있는데, 이거 그대로 둬도 되는 건지?
→ type: application 자체는 그대로 둬도 괜찮아요 (배포 가능한 일반 차트라는 뜻이고, 이 문제처럼 Deployment/CronJob 배포할 때 쓰는 게 맞는 값). library 타입은 다른 차트에서 재사용하는 템플릿 모음일 때 쓰는 거라 여기엔 해당 안 돼요.
[ 체크 ] helm 버전
ubuntu@jiseul494-master:~/20260728/review$ helm version
version.BuildInfo{Version:"v3.18.4", GitCommit:"d80839cf37d860c8aa9a0503fe463278f26cd5e2", GitTreeState:"clean", GoVersion:"go1.24.4"}
apiVersion이 지금 v1로 되어 있는데, type 필드는 Helm 2 (apiVersion v1) 차트에는 애초에 존재하지 않는 필드. type, dependencies 형식 등은 Helm 3부터 (apiVersion v2) 지원되는 스펙
수정한 Chart.yaml
apiVersion: v2 # Helm3 차트의 Chart.yaml에서 쓰는 apiVersion 값은 V2
name: multi-container
description: A Helm chart for Kubernetes
type: application
version: 0.1.0
appVersion: "0.1.0"
- Helm 버전 자체는 v3.18.4지만, Chart.yaml 스펙 버전 표기는 v1/v2 두 가지뿐이고 Helm 3가 지원하는 건 v2
0.1.2. hostPath deployment
apiVersion: v1
kind: Pod
metadata:
name: k8s-hostpath-volume
namespace: default
Spec:
containers:
- name: k8s-log-writer
image: busybox
command: [“sh”, “-c”]
args:
- while true; do
echo “$(date)” >> /var/log/k8s.log;
sleep 10;
done
volumeMounts:
- name: k8s-log-volume
mountPath: /var/log
volumes:
- name: k8s-log-volume
hostPath:
path: /var/log/k8s-logs
type: DirectoryOrCreate
❓주어진 예시와 값이 다른데, 어떤 게 필수값인지 몰라서 어떻게 작성해야 할 지 몰르겠음!!
→ 주어진 예시는 kind: Pod인데, 문제에서 요구하는 건 kind: Deployment예요. Pod와 Deployment는 spec 구조 자체가 달라요.
Pod는 spec: 바로 밑에 containers, volumes가 옵니다. 근데 Deployment는 spec: 밑에 바로 containers가 오는 게 아니라, 그 사이에 뭔가 하나 더 껴야 해요. Deployment의 spec에 필수로 들어가는 게 뭔지 기억나세요?
힌트: Deployment는 여러 개의 Pod를 관리하기 때문에, 어떤 Pod들을 관리할지 표시하는 필드(selector)와, 실제로 생성할 Pod의 틀(template)이 필요해요.
질문 3개 드릴게요:
Deployment의
apiVersion은 뭐가 되어야 할까요? (Pod는v1이었죠)spec.selector.matchLabels와spec.template.metadata.labels는 왜 서로 값이 같아야 할까요?Deployment는 Pod를 직접 만드는 게 아니라 ReplicaSet을 통해 어떤 라벨을 가진 Pod들을 내가 관리할지를 지정해야 해요. 그 지정 필드가
spec.selector.matchLabels고, 실제로 생성되는 Pod에는spec.template.metadata.labels로 그 라벨을 붙여줘야 해요.공식 문서(Deployments, kubernetes.io/docs/concepts/workloads/controllers/deployment/#selector)에 이렇게 나와 있어요:
.spec.selector.matchLabels필드는.spec.template.metadata.labels필드와 반드시 일치해야 하고, 안 그러면 API에서 거부된다고요. 즉 이건 권장이 아니라 필수 규칙이에요.
문제에서 준
nodeSelector,hostPath값은 Deployment의spec바로 밑에 들어가야 할까요, 아니면spec.template.spec밑에 들어가야 할까요?nodeSelector는 Deployment의 필드가 아니라 Pod의 필드예요 (공식문서: kubernetes.io/docs/concepts/scheduling-eviction/assign-pod-node/#nodeselector). Deployment 안에서 Pod 스펙이 정의되는 곳이spec.template.spec이니, 여기 들어가야 해요.hostPath도 마찬가지예요. 이건 그 자체로 최상위 필드가 아니라,volumes리스트 안의 항목 하나가 갖는 속성이에요.volumes도 Pod 스펙 필드라서 역시spec.template.spec.volumes밑에 들어가고, 각 volume은name이 있어야 컨테이너의volumeMounts에서 참조할 수 있어요.
❓그럼 hostPath 템플릿이 아니라 deployment 템플릿 참조임?
→ 응 맞아요. 예시로 준 건 Pod 스펙이니까, 그 안의 containers(busybox 로그 작성 부분)와 volumes(hostPath) 내용은 그대로 재사용하되, 뼈대는 Deployment 표준 템플릿(selector, template.metadata.labels, template.spec)을 가져와서 그 안에 끼워 넣는 방식이에요.
즉 작업 순서는:
Deployment 기본 골격 작성 (apiVersion: apps/v1, kind: Deployment, selector, template.metadata.labels)
template.spec안에 문제에서 준nodeSelector넣기template.spec.containers에 예시의 busybox 컨테이너(command/args/volumeMounts) 넣기template.spec.volumes에 문제에서 준hostPath내용을 name과 함께 넣기 (name은 컨테이너의 volumeMounts와 일치해야 함)
→ yaml 파일만 30분 붙들고 있어서 일단 클로드가 작성함 ㄱ-
apiVersion: apps/v1
kind: Deployment
metadata:
name: k8s-hostpath-volume
namespace: cloud
labels:
app: hostpath-deploy
spec:
replicas: 1
selector:
matchLabels:
app: hostpath-deploy
template:
metadata:
labels:
app: hostpath-deploy
spec:
nodeSelector:
kubernetes.io/hostname: anhyunji494-worker1
containers:
- name: k8s-log-writer
image: busybox
command: ["sh", "-c"]
args:
- while true; do echo "$(date)" >> /var/log/k8s-logs/k8s.log; sleep 10; done
volumeMounts:
- name: k8s-log-volume
mountPath: /var/log/k8s-logs
volumes:
- name: k8s-log-volume
hostPath:
path: /var/log/k8s-logs
type: DirectoryOrCreate
💡yaml 파일 확인 팁
kubectl explain deployment.spec.template.spec같은 명령어로 실제 필드 구조를 직접 조회하는 습관을 들이세요. 외우는 것보다 훨씬 정확해요.작성 후 바로
kubectl apply --dry-run=client -f 파일명.yaml로 문법 검증하세요. 적용 전에 에러를 미리 잡아줘요.한글/워드에서 복사하지 말고 터미널 에디터(vim, nano)나 VSCode에 직접 타이핑하세요. 스마트따옴표 문제가 여기서 계속 나왔어요.
0.1.3. cronjob
ubuntu@jiseul494-master:~/20260728/review/templates$ k create cronjob k8s-log-backup --image=busybox -n default --schedule='*/1 * * * *' --
dry-run="client" -o yaml > cronjob.yaml
- 초기 cronjob.yaml
apiVersion: batch/v1
kind: CronJob
metadata:
creationTimestamp: null
name: k8s-log-backup
namespace: default
spec:
jobTemplate:
metadata:
creationTimestamp: null
name: k8s-log-backup
spec:
template:
metadata:
creationTimestamp: null
spec:
containers:
- image: busybox
name: k8s-log-backup
resources: {}
restartPolicy: OnFailure
schedule: '*/1 * * * *'
status: {}
❓ubuntu@jiseul494-master:~/20260728/review/templates$ k explain cronjob.spec.jobTemplate.spec 의 결과를 해석 못하겠음
CronJob은 이렇게 4단계로 겹쳐 있어요:
CronJob
└─ spec (CronJobSpec)
└─ jobTemplate (JobTemplateSpec)
└─ spec (JobSpec) ← 방금 보신 게 이거예요
└─ template (PodTemplateSpec) ← 여기 밑에 또 spec이 있음!
└─ spec (PodSpec) ← nodeSelector, containers, volumes 여기!즉 Deployment에서 spec.template.spec이 Pod 스펙이었던 것처럼, CronJob은 한 단계 더 있어서 spec.jobTemplate.spec.template.spec이 Pod 스펙이에요. 방금 보신 출력에도 template <PodTemplateSpec> -required-라고 나와 있죠? 그게 다음 단계로 들어가는 문이에요.
이렇게 확인해보세요:
kubectl explain cronjob.spec.jobTemplate.spec.template.spec
❓백업 위치는 어디에 넣어야 하지?
백업 위치(/var/log/k8s-logs-backup/날짜/)는 조금 더 생각해볼 부분이 있어요. 몇 가지 질문 드릴게요:
이 CronJob 컨테이너가 로그를 백업하려면 원본 로그(
/var/log/k8s-logs)를 읽을 수 있어야 하고, 백업 위치에 쓸 수도 있어야 해요. 이걸 위해 volumes/volumeMounts가 몇 개 필요할까요?
CronJob 컨테이너가 할 일: ① 원본 로그 읽기, ② 백업 경로에 쓰기. 이 둘은 서로 다른 호스트 경로(
/var/log/k8s-logsvs/var/log/k8s-logs-backup)니까, hostPath volume도 2개 필요해요. 각각 다른mountPath로 컨테이너 안에 마운트하고요.
날짜 폴더를 매번 자동으로 만들려면 컨테이너 안에서 어떤 명령어로 오늘 날짜를 얻어야 할까요? (busybox에도 있는 명령어예요)
busybox 안에서
date +%Y-%m-%d같은 명령어로 오늘 날짜 문자열을 뽑을 수 있어요. 그걸로mkdir -p /백업마운트경로/$(date +%Y-%m-%d)해서 폴더를 만들고, 그 안에cp로 로그 파일을 복사하면 돼요.
❓cronjob은 무조건 복사를 담당해? 아니지 않아?
맞아요, CronJob 자체는 복사라는 개념을 전혀 몰라요. CronJob은 그냥 정해진 시간마다 이 작업을 실행해라라는 예약 알람 기능일 뿐이에요. 이번 문제에서 우리가 그 안에 넣을 명령어가 우연히 로그 파일 복사하기였을 뿐이지, CronJob이 복사 전용 도구는 아니에요. 예를 들어 같은 CronJob으로 매일 자정에 오래된 데이터 삭제해라도 시킬 수 있고, 매주 리포트 만들어서 이메일 보내라도 시킬 수 있어요. 지금은 그 안에 넣는 내용이 백업 스크립트인 것뿐이에요.
❓hostpath는 여전히 이해가 안 돼. cronjob이 왜 worker1에서 돌아야 하는지는 이해 됐어. 근데 노드들 간에도 통신은 가능하지 않아? 왜 같은 노드에서 돌아야해?
노드 간 통신은 되는데, hostPath는 그 통신을 안 써요
맞아요, 노드끼리 네트워크 통신은 당연히 가능해요 (그래야 클러스터가 돌아가죠). 근데 hostPath라는 기능 자체가 네트워크를 아예 안 쓰고, 내가 지금 서 있는 서버의 로컬 디스크에 뚫린 구멍으로 바로 들여다보기로 설계된 거예요.
비유하면: 노드 간 통신이 전화 걸기라면, hostPath는 옆방으로 통하는 벽에 난 구멍이에요. 전화(네트워크)는 다른 건물(다른 노드)에도 걸 수 있지만, 벽 구멍은 물리적으로 바로 붙어있는 옆방(같은 서버의 디스크)만 볼 수 있어요. hostPath는 애초에 전화기가 없어요, 구멍만 있어요. 그래서 다른 노드 디스크는 절대 못 봐요 — 이건 통신이 안 돼서가 아니라, hostPath라는 도구 자체가 그런 기능을 안 가지고 있어서예요.
(참고로 쿠버네티스엔 NFS 같은, 네트워크로 다른 서버 디스크에도 접근 가능한 다른 볼륨 종류도 있어요. 근데 이번 문제는 hostPath를 쓰라고 했으니 그 제약을 그대로 따르는 거예요.)
❓configmap은 cronjob을 통해 기록한 로그를 worker2에 백업하는 개념인 거지? 근데 그 백업하는 명령어를 configmap에 담아두고 사용한다는 거고?
거의 맞았는데 한 가지만 정정할게요. worker2에 백업은 이제 아니고 (앞서 정리한 대로 다 worker1 안에서 폴더만 나뉘는 거였죠), ConfigMap은 그냥 복사 명령어(스크립트)를 담아두는 보관함이에요. 후반부에 말씀하신 백업하는 명령어를 configmap에 담아두고 사용한다—이건 정확해요. YAML 안에 명령어를 직접 박아넣는 대신, ConfigMap이라는 별도 오브젝트에 스크립트 텍스트를 저장해두고, CronJob이 그걸 파일처럼 불러와서 실행하는 방식이에요.
2. 정적 프로비저닝
- 폴더
static-provisioning-chart - 22일 실습에서
static-.yaml전부 복사해서templates에 넣기
2.0. 가이드라인
2.0.1. 폴더 구조
static--provisioning-chart
├── Chart.yaml
└── templates
├── static-pod.yaml
├── static-volume-pv.yaml
├── static-volume-pvc.yaml
└── static-volume-storageclass.yaml
2.0.1 static-volume.yaml 로 통합
helm문법 연습- {{- $global := .Values.global }}, {{- $deployment := .Values.deployment}} 등으로 상단 선언해서 사용
- {{to yaml}}
- range 문법
2.0.2. static-pod.yaml 를 deployment로 전환하여 배포
- replicas: 1개
- name: pod→ deploy로 수정
2.0.3. 과정 이해하기
2.0.3.1. 정적 프로비저닝 Static Provisioning
PV(PersistentVolume) 을 관리자가 미리 만들어두고, PVC(PersistentVolumeClaim) 가 그걸 수동으로 바인딩해서 사용하는 방식
vs. 동적 프로비저닝은 StorageClass가 PV를 자동 생성
2.0.3.2. Helm 패키징 연습
기존에 따로따로 kubectl apply 하던 정적 프로비저닝 리소스들(PV, PVC, Pod, StorageClass)을 Helm 차트 구조(Chart.yaml + templates/)로 옮기는 거야. 즉 날 것의 YAML을 재사용 가능한 템플릿으로 바꾸는 연습.
그 다음 2.2에서는 여러 YAML을 static-volume.yaml 하나로 합치면서 Helm 문법 3가지를 연습해:
- 변수 선언:
{{- $global := .Values.global }}처럼.Values안의 값을 짧은 변수명으로 꺼내 쓰기 {{ toYaml ... }}: Values에 있는 객체(맵/리스트)를 YAML 블록으로 그대로 찍어내기 (예: labels, resources 같은 중첩 구조)range: 리스트를 순회하면서 반복되는 YAML 블록 생성 (예: 여러 개의 volumeMount나 포트를 반복 생성할 때)
2.0.3.3 Pod → Deployment 전환 (2.3)
Pod는 컨트롤러가 없어서 죽으면 그걸로 끝이야. Deployment로 감싸면 ReplicaSet이 생성/관리를 책임지고, replicas, selector, template 구조가 추가돼. 이 구조적 차이를 직접 손으로 바꿔보면서 이해하는 게 목적.
2.1. 풀이과정 (deployment)
2.1.1. 일단 deployment 파일 구조 뽑아와서 비교하기
# deploy.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
creationTimestamp: null
labels:
app: static-deploy
name: static-deploy
spec:
replicas: 1
selector:
matchLabels:
app: static-deploy
strategy: {}
template:
metadata:
creationTimestamp: null
labels:
app: static-deploy
spec:
containers:
- image: busybox
name: busybox
resources: {}
status: {}
⁉️Deployment는 왜 Pod와 달리 이 Pod들을 몇 개 유지할지와 어떤 Pod를 내가 관리하는 대상으로 볼지를 명시해야 할까? (replicas, selector가 왜 필요한지에 대한 질문)
deployment는 pod를 관리하는 도구라서, pod를 몇 개 복사하고 (replicas), 어떤 파드를 선택할지 명시되어야 하기 때문이야.
정확해.
replicas: 몇 개 복제할지
selector.matchLabels: Deployment가 내가 관리하는 Pod를 찾을 때 쓰는 라벨 필터
template.metadata.labels: 실제 생성되는 Pod에 붙는 라벨
여기서 제일 중요한 규칙 하나: selector.matchLabels와 template.metadata.labels는 반드시 일치해야 해. 안 그러면 Deployment가 자기가 만든 Pod를 못 찾아서 에러남.
# static-pod.yaml
apiVersion: v1
kind: pod
metadata:
name: k8s-pod
labels:
name: app
spec:
containers:
- name: app
image: busybox
command: ['sh', '-c', 'echo "Platform as a service!" > /mnt/k8s-1.log && sleep 3600']
volumeMounts:
- name: k8s-local-volume
mountPath: /mnt
volumes:
- name: k8s-local-volume
persistentVolumeClaim:
claimName: k8s-pvc1
---
apiVersion: v1
kind: Pod
metadata:
name: k8s-pod2
labels:
name: app
spec:
containers:
- name: app
image: busybox
command: ["/bin/sh", "-c"]
args:
- while true; do
date >> /mnt/k8s-2.log;
sleep 10;
done
volumeMounts:
- name: k8s-local-volume
mountPath: /mnt
volumes:
- name: k8s-local-volume
persistentVolumeClaim:
claimName: k8s-pvc2
---
apiVersion: v1
kind: Pod
metadata:
name: k8s-pod3
labels:
name: app
spec:
containers:
- name: app
image: nginx
volumeMounts:
- name: k8s-local-volume
mountPath: /var/log/nginx
volumes:
- name: k8s-local-volume
persistentVolumeClaim:
claimName: k8s-pvc3
2.1.2. pod → deploy 옮겨가기
2.1.2.1. pod1 을 deployment 뼈대로 옮긴다면?
# pod1
metadata:
name: k8s-pod1
labels:
name: app
spec:
containers: ...
volumes: ...
# static-deploy.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
creationTimestamp: null
labels:
app: static-deploy
name: static-deploy
spec:
replicas: 1
selector:
matchLabels:
app: static-deploy
strategy: {}
template: # pod1의 metadata
단, template.metadata는 name을 안 넣는게 정석.
deploymen의 (정확히는 ReplicaSet)가 Pod를 만들 때 이름 뒤에 랜덤 해시를 붙여서 자동 생성하기 때문.
따라서 template.metadata엔 보통 labels만 있고, name은 최상위 metadata.name에만 들어감
metadata:
creationTimestamp: null
labels:
app: static-deploy
spec: # pod1의 spec
containers:
- image: busybox
name: busybox
resources: {}
status: {}
2.1.2.2. pod1/2/3을 비교하면 서로 다른 점은?
apiVersion: v1
kind: pod
metadata:
name: k8s-pod
labels:
name: app
spec:
containers:
- name: app
image: busybox
command: ['sh', '-c', 'echo "Platform as a service!" > /mnt/k8s-1.log && sleep 3600']
volumeMounts:
- name: k8s-local-volume
mountPath: /mnt
volumes:
- name: k8s-local-volume
persistentVolumeClaim:
claimName: k8s-pvc1
apiVersion: v1
kind: Pod
metadata:
name: k8s-pod2
labels:
name: app
spec:
containers:
- name: app
image: busybox
command: ["/bin/sh", "-c"]
args:
- while true; do
date >> /mnt/k8s-2.log;
sleep 10;
done
volumeMounts:
- name: k8s-local-volume
mountPath: /mnt
volumes:
- name: k8s-local-volume
persistentVolumeClaim:
claimName: k8s-pvc2
apiVersion: v1
kind: Pod
metadata:
name: k8s-pod3
labels:
name: app
spec:
containers:
- name: app
image: nginx
volumeMounts:
- name: k8s-local-volume
mountPath: /var/log/nginx
volumes:
- name: k8s-local-volume
persistentVolumeClaim:
claimName: k8s-pvc3
⇒ 차이점
- metadata.name
- spec.containers.image
- spec.containers.command
- spec.containers.volumeMounts
- spec.containers.args
- spec.volumes.persistentVolumeClaim.claimName
2.1.2.3.위에서 본 차이점을 토대로 deploy.yaml 파일 하드코딩
→ static-deploy.yaml 작성해보기
# static-deploy1.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
labels:
app: static-deploy
name: static-deploy
spec:
replicas: 1
selector:
matchLabels:
app: static-deploy
template: # pod1의 metadata
# 단, template.metadata는 name을 안 넣는게 정석.
# deploymen의 (정확히는
# ReplicaSet)가 Pod를 만들 때 이름 뒤에 랜덤 해시를 붙여서 자동 생성하기 때문.
# 따라서 template.metadata엔 보통 labels만 있고, name은 최상위 metadata.name에만 들어감
metadata:
labels:
app: static-deploy
spec: # pod1의 spec
containers:
- image: busybox
name: app
command: ['sh', '-c', 'echo "Platform as a service!" > /mnt/k8s-1.log && sleep 3600']
volumeMounts:
- name: k8s-local-volume
mountPath: /mnt
resources: {}
volumes:
- name: k8s-local-volume
persistentVolumeClaim:
claimName: k8s-pvc1
status: {}
# static-deploy2.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
labels:
app: static-deploy2
name: static-deploy2
spec:
replicas: 1
selector:
matchLabels:
app: static-deploy2
template: # pod2의 metadata
metadata:
labels:
app: static-deploy2
spec: # pod2의 spec
containers:
- image: busybox
name: app
command: ["/bin/sh", "-c"]
args:
- while true; do
date >> /mnt/k8s-2.log;
sleep 10;
done
volumeMounts:
- name: k8s-local-volume
mountPath: /mnt
resources: {}
volumes:
- name: k8s-local-volume
persistentVolumeClaim:
claimName: k8s-pvc2
# static-deploy3.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
labels:
app: static-deploy3
name: static-deploy3
spec:
replicas: 1
selector:
matchLabels:
app: static-deploy3
template: # pod3의 metadata
metadata:
labels:
app: static-deploy3
spec: # pod3의 spec
containers:
- image: nginx
name: app
volumeMounts:
- name: k8s-local-volume
mountPath: /var/log/nginx
volumes:
- name: k8s-local-volume
persistentVolumeClaim:
claimName: k8s-pvc3
💡문법 검증
kubectl apply --dry-run=client -fubuntu@jiseul494-master:~/20260728/static-provisioning-chart/templates$ k apply --dry-run=client -f static-deploy.yaml
deployment.apps/static-deploy created (dry run)
ubuntu@jiseul494-master:~/20260728/static-provisioning-chart/templates$ k apply --dry-run=client -f static-deploy2.yaml
deployment.apps/static-deploy2 created (dry run)
ubuntu@jiseul494-master:~/20260728/static-provisioning-chart/templates$ k apply --dry-run=client -f static-deploy3.yaml
deployment.apps/static-deploy3 created (dry run)
2.1.3. 변수화 (values.yaml)
💡 여기서 까다로운 지점 하나: pod3(nginx)는 command도 args도 아예 없어. 근데 pod1은 command만 있고, pod2는 command+args 둘 다 있어.
만약 values.yaml에 리스트로 이 3개를 넣고 range로 템플릿 하나를 돌린다면, pod3 차례에서 command/args 필드는 어떻게 처리해야 할까? 무조건 두 필드를 다 출력하되 비워두면 될까, 아니면 값이 있을 때만 필드 자체를 출력해야 할까?
(힌트: Pod의 command/args를 실제로 빈 값 []으로 넣으면 컨테이너 동작이 어떻게 될지 생각해봐.)
필드를 출력하되 비워두어도 실제로 동작은 해 — command/args를 빈 리스트([])로 두면 길이가 0이라서 kubelet이 지정 안 한 것과 똑같이 취급하고 이미지의 기본 ENTRYPOINT/CMD를 그대로 써. 그래서 pod3 케이스에서 결과적으로 문제는 안 생겨.
근데 이게 일반적인 규칙으로 확장 가능할까? 예를 들어 나중에 다른 필드(가령 env, ports, resources.limits 등)를 템플릿화할 때도 값 없으면 그냥 빈 값으로 찍어도 문제없다가 항상 성립할까? (힌트: 모든 k8s 필드가 command/args처럼 비어있으면 무시된다는 동작을 하진 않아. 예를 들면 selector.matchLabels: {}처럼 비면 아예 다른 의미가 되거나 에러가 나는 필드도 있어.)
그래서 Helm 템플릿을 짤 때의 일반적인 원칙은: 값이 없으면 필드 자체를 아예 안 찍는 게 더 안전하고 명확해. 이때 쓰는 게 {{- if }} 조건문이야. 대략 이런 형태:
{{- if .command }}
command: {{ toYaml .command | nindent 8 }}
{{- end }}이렇게 하면 .command 값이 없는 pod3 차례에선 그 줄 자체가 아예 생성 안 돼.
정리하면, range로 3개를 순회하는 템플릿을 짤 때 이런 조건 분기(if)가 필요해질 거야. 자, 이제 실제로 values.yaml을 채워볼 차례인데 — 아까 내가 제안했던 구조 기억나?
deployments:
- name: ...
image: ...
command: [...]
args: [...]
claimName: ...이걸 pod1/2/3 실제 값으로 채워보자.
2.1.3.1 values.yaml ver.1
# values.yaml
global:
replicas: 1
containerName: app
volumeName: k8s-local-volume
deploy1:
name: static-deploy1
containerImage: busybox
command: ['sh', '-c', 'echo "Platform as a service!" > /mnt/k8s-1.log && sleep 3600']
mountPath: /mnt
clamName: k8s-pvc1
deploy2:
containerImage: busybox
name: static-deploy2
command: ["/bin/sh", "-c"]
mountPath: /mnt
clamName: k8s-pvc2
args:
- while true; do
date >> /mnt/k8s-2.log;
sleep 10;
done
deploy3:
containerImage: nginx
name: static-deploy3
mountPath: /var/log/nginx
clamName: k8s-pvc3
💡변수 선정 자체(global로 공통값 뽑고, 각 deploy별로 다른 값 분리)는 잘했어. 근데 짚을 점이 있어.
구조 - map vs list
지금은 deploy1, deploy2, deploy3라는 개별 키(map)로 나눴어. 근데 우리가 하려는 건 range로 반복문 돌려서 하나의 템플릿으로 N개를 찍어내는 거잖아.
질문: deploy1/deploy2/deploy3처럼 이름이 다 다른 개별 키 형태라면, range로 이걸 순회하려면 어떻게 해야 할까? 그리고 나중에 deploy4가 추가되면 템플릿 코드도 같이 손봐야 할까, 아니면 안 건드려도 될까?
(힌트: range는 보통 리스트(배열) 를 순회할 때 제일 깔끔해. deployments: 라는 하나의 키 밑에 리스트로 3개를 넣는 형태와 비교해서 어느 쪽이 새 항목 추가해도 템플릿은 안 건드려도 되는 구조에 더 가까울지 생각해봐.)
2.1.3.2 values.yaml ver.2
# values.yaml
global:
replicas: 1
containerName: app
volumeName: k8s-local-volume
deploy:
- name: static-deploy1
containerImage: busybox
command: ['sh', '-c', 'echo "Platform as a service!" > /mnt/k8s-1.log && sleep 3600']
mountPath: /mnt
claimName: k8s-pvc1
- name: static-deploy2
containerImage: busybox
command: ["/bin/sh", "-c"]
mountPath: /mnt
claimName: k8s-pvc2
args:
- while true; do
date >> /mnt/k8s-2.log;
sleep 10;
done
- name: static-deploy3
containerImage: nginx
mountPath: /var/log/nginx
claimName: k8s-pvc3
2.1.4. 템플릿 작성 (deploy.yaml)
💡템플릿 작성으로 넘어가기 전에 중요한 질문 하나. range는 Go 템플릿 문법인데, 이게 반복문 안에서 .(dot, 현재 컨텍스트)를 바꿔버려.
{{- $global := .Values.global }}
{{- range .Values.deploy }}
... 여기 안에서 . 은 뭘 가리킬까?
{{- end }}range .Values.deploy 블록 안에서 .은 이제 뭘 가리키게 될까? (전체 .Values? 아니면 리스트의 각 항목 하나, 예를 들면 {name: static-deploy1, containerImage: busybox, ...}?)
그리고 만약 그 안에서 global.replicas 값이 필요하면 .Values.global.replicas라고 써도 될까, 아니면 왜 미리 $global이라는 변수로 빼놨어야 했을까?
💡Helm 의 컨텍스트 (.) 개념
1. . (dot)은 지금 내가 보고 있는 데이터 덩어리를 가리키는 변수
템플릿 최상위에서 .은 전체 객체를 가리킴. 그 안에 .Values, .Release, .Chart 같은 게 다 들어있음. 그래서 최상위에서는 .Values.global.replicas처럼 써서 값에 접근함.
2. range, with 같은 블록에 들어가면 .이 재정의(rebind)
{{- range .Values.deploy }}
여기 안에서는 . 이 더 이상 전체 객체가 아니라
.Values.deploy 리스트의 "현재 순회 중인 항목 1개"가 됨
{{- end }}e.g. .Values.deploy가 이런 리스트라면:
deploy:
- name: static-deploy1
containerImage: busybox
...
- name: static-deploy2
...range 블록 안 첫 번째 순회에서 .은 {name: static-deploy1, containerImage: busybox, ...} 이 객체 자체가 됨. 그래서 안에서는 .name, .containerImage라고 바로 쓰면 됨 (.Values.deploy.name이 아니라!).
3. 문제: range 안에서는 .Values가 사라져.
.이 리스트 항목으로 바뀌어버렸으니까, range 블록 안에서 .Values.global.replicas라고 쓰면 에러 남. 왜냐하면 지금 .은 {name: ..., containerImage: ...} 객체인데, 이 객체 안엔 Values라는 키가 없기때문.
4. 그래서 $변수가 필요함.
Go 템플릿에서 $로 시작하는 이름은 사용자가 선언한 변수고, range/with가 .을 바꿔도 변수 값은 그대로 유지됨
{{- $global := .Values.global }}
{{- range .Values.deploy }}
replicas: {{ $global.replicas }}
name: {{ .name }}
{{- end }}range밖에서$global에.Values.global을 미리 담아둠range안에서.은 리스트 항목(바뀜),$global은 안 바뀌고 그대로 접근 가능
(참고로 $ 하나만 쓰면 최상위 루트 컨텍스트를 가리키는 특수 변수라서, range 안에서도 언제든 $.Values.global.replicas라고 쓸 수도 잇음. $global을 미리 빼두는 건 그냥 더 짧게 쓰기 위한 관례.)
2.1.4.1. deploy.yaml (변수화된 템플릿) ver.1
# deploy.yaml
{{- with .Values.global }}
{{- $global := .Values.global }}
apiVersion: apps/v1
kind: Deployment
{{- range .Values.deploy}}
metadata:
labels:
app: {{ .name }}
name: {{ .name }}
spec:
replicas: {{ $global.replicas }}
selector:
matchLabels:
app: {{ .name }}
template: # pod의 metadata
metadata:
labels:
app: {{ .name }}
spec: # pod의 spec
containers:
- image: {{ .containerImage }}
name: {{ $global.containerName }}
{{- if .command }}
command: {{ toYaml .command | nindent 8 }}
{{- end }}
{{- if .args }}
args: {{ .args }}
{{- end }}
volumeMounts:
- name: {{ $global.volumeName }}
mountPath: {{ .mountPath }}
volumes:
- name: {{ $global.volumeName }}
persistentVolumeClaim:
claimName: {{ .claimName }}
{{- end }}
{{- end }}
💡1. with .Values.global이 문제야.
with도 range처럼 .을 재정의해. with .Values.global 블록 안에서는 .이 이제 global 객체 자체({replicas: 1, containerName: app, ...})가 돼. 근데 그 안에서 .Values.global이라고 다시 쓰면? global 객체 안에 Values라는 키가 없으니까 에러 나. 그리고 이 with 블록 안에서 나중에 .Values.deploy로 range 돌리는 것도 마찬가지로 깨져.
→ with는 여기서 아예 필요 없어. $global := .Values.global은 최상위 컨텍스트(.이 아직 root인 상태) 에서 선언하면 끝이야. with 지우고, 중복으로 두 번 선언한 것도 하나로 줄여봐.
최상위 컨텍스트에서 선언한 뒤에는 end로 닫아줄 필요 없음
2. apiVersion/kind가 range 밖에 있어.
지금 구조는:
apiVersion: apps/v1
kind: Deployment
{{- range .Values.deploy}}
metadata: ...
spec: ...
{{- end }}이러면 apiVersion/kind는 딱 한 번만 찍히고, metadata/spec이 3번 반복돼서 붙어버려. 근데 우리가 만들고 싶은 건 완전히 독립된 Deployment 문서 3개잖아. 각 문서는 apiVersion, kind, metadata, spec을 전부 다 가지고 있어야 하고, 문서 사이에 --- 구분자도 있어야 해.
→ apiVersion, kind를 range 안으로 옮기고, 각 반복 시작 부분에 ---를 넣어야 해. (range 시작 직후에 ---를 두면 3번 다 잘 찍혀.)
3. args: {{ .args }}
command는 toYaml .command | nindent 8로 잘 처리했는데, args는 그냥 {{ .args }}로만 되어 있어. .args도 리스트니까 command랑 똑같은 방식으로 처리해야 하지 않을까? 왜 그런지 이유도 한번 생각해보고 고쳐봐.
❓toYaml / nindent가 왜 필요한지
.command 값은 Helm 내부적으로 리스트(배열) 객체야. 이걸 그냥 {{ .command }}라고 찍으면 Go 템플릿 기본 문자열 변환이 적용돼서 [sh -c echo ...] 같은 이상한 형태로 나와. YAML이 인식하는 리스트 문법이 아니야.
toYaml은 이 값을 YAML 문법에 맞는 텍스트로 바꿔줘라는 Helm 함수야. toYaml .command를 하면:
- sh
- -c
- echo "Platform as a service!" > /mnt/k8s-1.log && sleep 3600이렇게 각 항목이 - 로 시작하는 제대로 된 YAML 리스트 텍스트(문자열)로 변환돼.
근데 이 텍스트를 그냥 command: 뒤에 붙이면 들여쓰기가 하나도 안 된 상태(맨 왼쪽 시작)라 문제가 생겨. nindent 8은 두 가지 일을 해: (1) 앞에 줄바꿈을 하나 넣고 (2) 모든 줄 앞에 8칸 공백을 붙여줘. 그래서 결과가:
command:
- sh
- -c
- echo "..."이렇게 command: 키 밑에 제대로 들여쓰기된 블록으로 들어가. (8인 이유는 이 템플릿에서 command:가 8칸 들여쓰기 위치에 있어서, 그 리스트 항목들도 같은 열에 맞춘 거야.)
❓--가 왜 필요한지
YAML 파일 하나에 여러 개의 독립된 문서(리소스)를 넣을 때, ---(줄에 딱 세 개의 하이픈만)로 구분해줘야 kubectl/Helm이 여기서 문서 하나 끝, 다음 문서 시작으로 인식해. range로 3번 반복하면 Deployment 객체 3개가 생기는데, 구분자 없이 이어붙이면 문법적으로 뒤죽박죽 하나의 문서로 잘못 파싱돼. 그래서 매 반복 시작에 ---를 찍어주는 거야. (참고: 파일 맨 처음에 ---가 와도 무해해서, 매 반복마다 앞에 붙이는 게 제일 간단한 패턴이야.)
2.1.4.1. deploy.yaml (변수화된 템플릿) ver.2
# deploy.yaml
{{- $global := .Values.global }}
{{- range .Values.deploy}}
---
apiVersion: apps/v1
kind: Deployment
metadata:
labels:
app: {{ .name }}
name: {{ .name }}
spec:
replicas: {{ $global.replicas }}
selector:
matchLabels:
app: {{ .name }}
template: # pod의 metadata
metadata:
labels:
app: {{ .name }}
spec: # pod의 spec
containers:
- image: {{ .containerImage }}
name: {{ $global.containerName }}
{{- if .command }}
command: {{ toYaml .command | nindent 8 }}
{{- end }}
{{- if .args }}
args: {{ toYaml .args | nindent 8 }}
{{- end }}
volumeMounts:
- name: {{ $global.volumeName }}
mountPath: {{ .mountPath }}
volumes:
- name: {{ $global.volumeName }}
persistentVolumeClaim:
claimName: {{ .claimName }}
{{- end }}
2.2. 풀이과정 (pvc)
💡PV/PVC/StorageClass가 왜 필요한지
컨테이너(Pod)는 기본적으로 일회성이야. Pod가 재시작되면 그 안의 파일은 다 날아가. 그런데 로그 파일처럼 재시작해도 남아있어야 하는 데이터가 있으면, Pod 바깥의 영구 저장 공간에 써야 해. 이게 Volume이고, 그 중 Pod가 죽어도 데이터가 살아남는 걸 PersistentVolume(PV)라고 해.
역할 분담:
PV: 관리자가 미리 준비해두는 실제 저장 공간 한 덩어리 (여기선 워커 노드의 로컬 디스크 경로 하나하나)
PVC: 사용자(Pod)가 이만큼 용량의 저장 공간 하나 주세요라고 요청하는 표(claim)
StorageClass: PV들을 묶는 분류/정책 (여기선 정적으로 만든 것들이라는 표시 역할)
Pod는 PVC 이름만 알면 되고, PVC가 알아서 조건에 맞는 PV랑 바인딩돼
바인딩 원리: PVC가 생성되면 k8s가 같은 storageClassName을 가진 PV들 중에서, 요청한 용량(requests.storage) 이상이고 accessModes가 맞는 PV를 찾아서 자동으로 짝지어줘(바인딩).
2.2.1. 소스코드 살펴보기
2.2.1.1. static-volume-pv.yaml
apiVersion: v1
kind: PersistentVolume
metadata:
name: k8s-pv1
spec:
storageClassName: k8s-storageclass
persistentVolumeReclaimPolicy: Delete
capacity:
storage: 1G
accessModes:
- ReadWriteOnce
local:
path: /data/volumes/pv1
nodeAffinity:
required:
nodeSelectorTerms:
- matchExpressions:
- key: kubernetes.io/hostname
operator: In
values:
- anhyunji494-worker1
---
apiVersion: v1
kind: PersistentVolume
metadata:
name: k8s-pv2
spec:
storageClassName: k8s-storageclass
persistentVolumeReclaimPolicy: Delete
capacity:
storage: 2G
accessModes:
- ReadWriteOnce
local:
path: /data/volumes/pv2
nodeAffinity:
required:
nodeSelectorTerms:
- matchExpressions:
- key: kubernetes.io/hostname
operator: In
values:
- anhyunji494-worker2
---
apiVersion: v1
kind: PersistentVolume
metadata:
name: k8s-pv3
spec:
storageClassName: k8s-storageclass
persistentVolumeReclaimPolicy: Delete
capacity:
storage: 3G
accessModes:
- ReadWriteOnce
local:
path: /data/volumes/pv3
nodeAffinity:
required:
nodeSelectorTerms:
- matchExpressions:
- key: kubernetes.io/hostname
operator: In
values:
- anhyunji494-worker3
2.2.1.2. static-volume-pvc.yaml
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: k8s-pvc1
spec:
storageClassName: k8s-storageclass
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 1G
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: k8s-pvc2
spec:
storageClassName: k8s-storageclass
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 2G
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: k8s-pvc3
spec:
storageClassName: k8s-storageclass
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 3G
2.2.1.3. static-volume-storageclass.yaml
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: k8s-storageclass
annotations:
storageclass.kubernetes.io/is-default-class: 'true'
provisioner: kubernetes.io/no-provisioner
reclaimPolicy: Delete
volumeBindingMode: Immediate
→ 어떻게 템플릿화 하지?
💡PV 3개끼리: storageClassName(전부 k8s-storageclass), persistentVolumeReclaimPolicy(전부 Delete), accessModes(전부 ReadWriteOnce) — 이건 다 똑같아. 다른 건 name, capacity.storage, local.path, nodeAffinity의 노드 이름 뿐.
PVC 3개끼리: storageClassName, accessModes도 똑같음. 다른 건 name, resources.requests.storage.
StorageClass: name이 k8s-storageclass인데, 이 이름이 PV/PVC 6곳에서 전부 반복돼서 나와. (storageClassName: k8s-storageclass가 6번!) 이것도 값은 같지만 여러 군데서 반복되는 케이스라, 변수화하면 나중에 이름 하나 바꿀 때 한 곳만 고치면 되니까 편해져.
💡PV랑 PVC는 여전히 서로 다른 별개의 리소스로 만들어져야 해 (그건 안 바뀌어).
내가 말한 합치기는 values.yaml이라는 데이터 구조에서의 얘기야. values.yaml은 k8s 리소스 종류랑 1:1로 맞출 필요가 없는 그냥 순수 데이터(설정값 모음)야. 템플릿이 그 데이터를 갖고 뭘 몇 개를 만들지는 템플릿 작성자 마음이야.
그러니까 이런 그림이야:
volumes:
- pvName: k8s-pv1
claimName: k8s-pvc1
storage: 1G
path: /data/volumes/pv1
nodeName: jinyung0101-worker1
- pvName: k8s-pv2
claimName: k8s-pvc2
storage: 2G
...여기서 리스트 항목 하나(세트)는 pv1과 pvc1 둘 다에 필요한 값을 한꺼번에 담고 있어. 그리고 템플릿에서는:
{{- range .Values.volumes }}
---
kind: PersistentVolume
...
name: {{ .pvName }}
...
---
kind: PersistentVolumeClaim
...
name: {{ .claimName }}
...
{{- end }}이렇게 range 한 번 반복(iteration)마다 PV 하나 + PVC 하나, 총 2개의 리소스를 찍어내는 거야. Deployment 때는 1개 항목 → 1개 리소스였는데, 이번엔 1개 항목 → 2개 리소스(PV+PVC)인 거지. range는 반복 횟수만 정할 뿐, 그 안에서 YAML 문서를 몇 개 찍든 자유야.
💡리소스 스펙상 항상 고정인 문법 (apiVersion, kind, key, operator, matchExpressions 구조 등) → 템플릿에 그대로 하드코딩
값 자체가 실제 설정이고, 재사용되거나 나중에 바뀔 수 있는 것 (storageClassName, capacity, path, node 이름 등) →
values.yaml로 변수화
2.2.2. values.yaml 작성
# values.yaml
global:
replicas: 1
containerName: app
volumeName: k8s-local-volume
storageClassName: k8s-storageclass # 여기부터 pv/pvc
reclaimPolicy: Delete
accessModes: ReadWriteOnce
deploy:
- name: static-deploy1
containerImage: busybox
command: ['sh', '-c', 'echo "Platform as a service!" > /mnt/k8s-1.log && sleep 3600']
mountPath: /mnt
claimName: k8s-pvc1
- name: static-deploy2
containerImage: busybox
command: ["/bin/sh", "-c"]
mountPath: /mnt
claimName: k8s-pvc2
args:
- while true; do
date >> /mnt/k8s-2.log;
sleep 10;
done
- name: static-deploy3
containerImage: nginx
mountPath: /var/log/nginx
claimName: k8s-pvc3
volumes:
- pvName: k8s-pv1
claimName: k8s-pvc1
storage: 1G
path: /data/volumes/pv1
nodeName: anhyunji494-worker1
- pvName: k8s-pv2
claimName: k8s-pvc2
storage: 2G
path: /data/volumes/pv2
nodeName: anhyunji494-worker2
- pvName: k8s-pv3
claimName: k8s-pvc3
storage: 3G
path: /data/volumes/pv3
nodeName: anhyunji494-worker3