Volume และ Volume Type
ไอเดียในหนึ่งประโยค
หัวข้อที่มีชื่อว่า “ไอเดียในหนึ่งประโยค”Volume คือ directory ที่ container ใน Pod เข้าถึงได้ และ lifecycle ของตัวเองผูกกับ Pod ไม่ใช่ผูกกับ container ตัวใดตัวหนึ่ง
ทำไม filesystem ของ container เองถึงไม่พอ
หัวข้อที่มีชื่อว่า “ทำไม filesystem ของ container เองถึงไม่พอ”container แต่ละตัวมี writable layer เป็นของตัวเอง แต่ layer นั้นถูกทิ้งทันทีที่ container restart ไม่ว่าจะเพราะ crash, image update หรือ liveness probe fail ก็ตาม ซึ่งไม่มีปัญหาสำหรับ application code แบบ stateless แต่พังทันทีถ้าอะไรบางอย่างต้องอยู่รอดข้าม restart หรือต้อง share กันระหว่าง container ใน Pod เดียวกัน Volume แก้ทั้งสองปัญหานี้ เพราะถูก declare ครั้งเดียวที่ระดับ Pod และคงอยู่ตราบใดที่ Pod นั้นยังอยู่ ไม่ว่า container ตัวใดใน Pod จะ restart กี่รอบก็ตาม
# The container restarts, but the Pod (and its volumes) does not disappearkubectl get pod log-processor -o jsonpath='{.status.containerStatuses[*].restartCount}'Volume type ที่ใช้บ่อย
หัวข้อที่มีชื่อว่า “Volume type ที่ใช้บ่อย”emptyDir— directory เปล่าที่ถูกสร้างตอน Pod เริ่มทำงาน เป็น scratch space เหมาะกับ cache หรือ share file ระหว่าง container หลักกับ sidecaremptyDirถูกลบถาวรตอน Pod ถูกลบ ไม่ใช่แค่ตอน container ตัวใดตัวหนึ่ง restarthostPath— mount path จาก filesystem ของ node จริงเข้ามาใน Pod ตรง ๆ ทรงพลังแต่เสี่ยง เพราะผูก Pod เข้ากับข้อมูลที่อยู่บน node เฉพาะตัวนั้น และอาจเปิดให้ container เข้าถึงส่วนที่ sensitive ของ filesystem ของ node ได้ ใช้เท่าที่จำเป็นจริง ๆ เท่านั้น (เช่น log collector หรือ agent ระดับ node)configMap/secretvolume — project ข้อมูล key-value จาก ConfigMap หรือ Secret เข้าไปใน Pod เป็นไฟล์ หนึ่ง key ต่อหนึ่งไฟล์ รายละเอียดอยู่ในบทถัดไปเรื่อง ConfigMap และ Secret- projected volume — รวมหลาย source (ConfigMap, Secret, downwardAPI, serviceAccountToken) เข้าเป็น directory เดียว container เลยต้องใช้ mount point เดียวก็เห็นข้อมูลที่มาจากหลาย object ได้
นี่คือ Pod ที่ container สองตัว share emptyDir volume เดียวกัน ที่เป็น pattern ที่พบบ่อยเวลามี container หลักคู่กับ sidecar ที่คอย ship log
apiVersion: v1kind: Podmetadata: name: log-processorspec: volumes: - name: shared-logs emptyDir: {} containers: - name: app image: my-app:1.0 volumeMounts: - name: shared-logs mountPath: /var/log/app - name: log-shipper image: fluent-bit:5.1 volumeMounts: - name: shared-logs mountPath: /var/log/app readOnly: trueหมายเหตุเรื่อง environment: kind cluster ของโมดูลนี้เอง (storage-net) ผ่านช่วงที่ CPU ขาดแคลนอย่างหนักจากเครื่องที่ใช้ร่วมกันซึ่งรัน kind cluster ของหลาย agent พร้อมกันในคอร์สนี้ (uptime แสดง load average 20–30 ยืนยันแล้วว่าเป็นปัญหาระดับเครื่องทั้งเครื่อง เพราะ cluster verify-k8s ที่ใช้ร่วมกันแยกต่างหาก ก็ fail เหมือนกันในช่วงเวลาเดียวกัน) การพิสูจน์ทั้งหมดด้านล่างถูกจับไว้หลัง cluster กลับมาใช้ได้ปกติ — output ของ kubectl ด้านล่างทุกอันมาจาก run จริง ไม่ได้เดาเอา
และตัวอย่าง hostPath ที่ mount directory /proc ของ node แบบ read-only เข้า monitoring agent
apiVersion: v1kind: Podmetadata: name: node-exporterspec: volumes: - name: proc hostPath: path: /proc type: Directory containers: - name: node-exporter image: prom/node-exporter:v1.12.1 volumeMounts: - name: proc mountPath: /host/proc readOnly: trueการ declare volume มีสองขั้นตอนเสมอ
หัวข้อที่มีชื่อว่า “การ declare volume มีสองขั้นตอนเสมอ”แค่ตั้งชื่อ volume ยังใช้งานไม่ได้ volume ระดับ Pod ทุกตัวต้องมีสองส่วนที่ตรงกัน
spec.volumes[]— declare volume และบอกว่าข้อมูลมาจากไหน (emptyDir,hostPath,configMap,persistentVolumeClaimฯลฯ) ส่วนนี้อยู่ในระดับ Podspec.containers[].volumeMounts[]ของแต่ละ container — เอา volume ที่ declare ไว้แล้วมา mount เข้า filesystem ของ container ตัวนั้น ที่mountPathที่กำหนด
volume ที่ declare ไว้ใน spec.volumes แต่ไม่มี container ไหน reference ผ่าน volumeMounts เลย จะไม่มีผลอะไรกับ container นั้น คือแค่ไม่ถูก mount เข้าไป นี่คือวิธีที่ container สองตัวใน Pod เดียวกัน share ข้อมูลกันได้ด้วย ทั้งสองตัวใส่ name ของ volume เดียวกันใน volumeMounts ของตัวเอง ชี้ไปที่ directory เดียวกันที่ declare ไว้ครั้งเดียวใน spec.volumes
# Confirm both containers see the same file through the shared emptyDirkubectl exec log-processor -c log-shipper -- ls /var/log/appemptyDir medium: Memory + sizeLimit: การบังคับใช้จริง
หัวข้อที่มีชื่อว่า “emptyDir medium: Memory + sizeLimit: การบังคับใช้จริง”ตั้ง medium: Memory ให้ emptyDir ใช้ tmpfs (RAM) แทน disk ของ node — เร็วกว่ามาก แต่ทุก byte ที่เขียนนับรวมเข้ากับ memory ของ node และหายไปตอน Pod restart แน่นอนยิ่งกว่า emptyDir แบบ disk อีก (reboot ก็หายด้วย) sizeLimit จำกัดว่า volume ใช้ memory ได้แค่ไหน เอกสาร emptyDir ของ Kubernetes อธิบายว่าการเกิน limit นี้ทำให้ Pod ถูก evict ได้ — การพิสูจน์ด้านล่างแสดงกลไกที่ทันทีกว่านั้นอีกที่อยู่เบื้องหลัง:
apiVersion: v1kind: Podmetadata: name: emptydir-memspec: containers: - name: app image: busybox:1.38 command: ['sh', '-c', 'sleep 3600'] volumeMounts: - name: cache mountPath: /cache volumes: - name: cache emptyDir: medium: Memory sizeLimit: 10Mi$ kubectl apply -f emptydir-memory.yamlpod/emptydir-mem created
$ kubectl exec emptydir-mem -- dd if=/dev/zero of=/cache/bigfile bs=1M count=20dd: error writing '/cache/bigfile': No space left on device11+0 records in10+0 records out10485760 bytes (10.0MB) copied, 0.004537 seconds, 2.2GB/scommand terminated with exit code 1
$ kubectl exec emptydir-mem -- df -h /cacheFilesystem Size Used Available Use% Mounted ontmpfs 10.0M 10.0M 0 100% /cache
$ kubectl get pod emptydir-memNAME READY STATUS RESTARTS AGEemptydir-mem 1/1 Running 0 61sกลไกจริงกลับแม่นยำกว่า “Pod ถูก evict” ที่คิดไว้: kubelet mount emptyDir แบบ medium: Memory เป็น filesystem tmpfs จริง ๆ ที่ถูก cap ไว้ที่ sizeLimit เป๊ะ (10.0M ใน output ของ df เอง ใช้ไป 100% เหลือ 0) — ดังนั้น การเขียนเองจะ fail ทันที ด้วย error ของ filesystem ธรรมดา (No space left on device) ตั้งแต่วินาทีที่จะเกิน cap และ container (กับ Pod) ยังรันต่อไปได้โดยไม่มี eviction เลยใน run นี้ eviction manager ระดับ node ของ Kubernetes ยัง evict Pod ได้ เพิ่มเติม จาก ephemeral-storage pressure ด้วยการเช็คเป็นระยะที่ช้ากว่า แต่ตัว cap ขนาดของ tmpfs เองคือการบังคับใช้ที่ทันทีและแข็งแรงที่สุด — พิสูจน์ตรงนี้เอง ไม่ใช่แค่อ้างอิงจากเอกสาร
hostPath ไม่ portable ข้าม node
หัวข้อที่มีชื่อว่า “hostPath ไม่ portable ข้าม node”hostPath ผูกข้อมูลไว้กับ disk ของ node ตัวไหนก็ตามที่ Pod ไปลง — ไม่มีอะไรเกี่ยวกับตัว volume ที่ตามไปด้วยตอน Pod ถูกย้าย แผนการพิสูจน์: pin Pod ไว้ที่ storage-net-worker ด้วย nodeSelector เขียน marker file ลงใน hostPath ลบ Pod นั้นทิ้ง แล้วสร้าง Pod ที่เหมือนกันเป๊ะ pin ไว้ที่ storage-net-worker2 แทน โดยใช้ hostPath เดียวกัน:
apiVersion: v1kind: Podmetadata: name: hostpath-demospec: nodeSelector: kubernetes.io/hostname: storage-net-worker containers: - name: app image: busybox:1.38 command: ['sh', '-c', 'echo from-worker-1 > /data/marker.txt; sleep 3600'] volumeMounts: - name: data mountPath: /data volumes: - name: data hostPath: path: /tmp/hostpath-demo type: DirectoryOrCreate$ kubectl apply -f hostpath-worker.yamlpod/hostpath-demo created
$ kubectl exec hostpath-demo -- cat /data/marker.txtfrom-worker-1
$ kubectl delete pod hostpath-demo --wait=truepod "hostpath-demo" deleted
$ kubectl apply -f hostpath-worker2.yamlpod/hostpath-demo created
$ kubectl exec hostpath-demo -- cat /data/marker.txtcat: can't open '/data/marker.txt': No such file or directorycommand terminated with exit code 1บน storage-net-worker ไฟล์อยู่จริงพร้อมเนื้อหาจริง from-worker-1 ส่วน Pod เดียวกันเป๊ะ hostPath เดียวกัน แต่ pin ไปที่ storage-net-worker2 แทน ได้ No such file or directory แบบสะอาด ๆ — ไม่มีข้อมูล hostPath อะไรตาม Pod ไปเลย ตรงตาม เอกสาร hostPath ของ Kubernetes เป๊ะ: hostPath mount disk ท้องถิ่นของ node ตัวเดียว ไม่ใช่ storage ที่ share หรือ replicate กัน และเอกสารยังพูดถึงความเสี่ยง security ด้วย: Pod ที่เข้าถึง directory ที่ sensitive ของ node ผ่าน hostPath อาจกระทบหรืออ่านอะไรก็ได้ที่ไกลเกิน container ของตัวเอง
Projected volume: หลาย source รวมเป็น mount point เดียว
หัวข้อที่มีชื่อว่า “Projected volume: หลาย source รวมเป็น mount point เดียว”projected volume รวมหลาย source — ServiceAccount token, ConfigMap, Secret, downward API field — เข้าเป็น directory เดียว container เลยต้องใช้ volumeMounts แค่ตัวเดียวก็เห็นข้อมูลที่มาจากหลาย object ของ Kubernetes ได้:
apiVersion: v1kind: ConfigMapmetadata: name: proj-cmdata: app.properties: 'color=blue'---apiVersion: v1kind: Podmetadata: name: projected-demospec: containers: - name: app image: busybox:1.38 command: ['sh', '-c', 'sleep 3600'] volumeMounts: - name: all-in-one mountPath: /projected readOnly: true volumes: - name: all-in-one projected: sources: - serviceAccountToken: path: sa-token expirationSeconds: 3600 - configMap: name: proj-cm items: - key: app.properties path: app.properties - downwardAPI: items: - path: pod-name fieldRef: fieldPath: metadata.name$ kubectl apply -f projected.yamlconfigmap/proj-cm createdpod/projected-demo created
$ kubectl exec projected-demo -- ls -la /projectedlrwxrwxrwx 1 root root 21 Sep 26 12:17 app.properties -> ..data/app.propertieslrwxrwxrwx 1 root root 15 Sep 26 12:17 pod-name -> ..data/pod-namelrwxrwxrwx 1 root root 15 Sep 26 12:17 sa-token -> ..data/sa-token
$ kubectl exec projected-demo -- cat /projected/app.propertiescolor=blue
$ kubectl exec projected-demo -- cat /projected/pod-nameprojected-demo
$ kubectl exec projected-demo -- head -c 60 /projected/sa-tokeneyJhbGciOiJSUzI1NiIsImtpZCI6IkdlTVVGWmlOWkpjMjF2WnNQN0ptcS1Yทั้งสาม source — key ของ ConfigMap, field ของ downward API, และ ServiceAccount token จริงที่มีวันหมดอายุ (JWT ที่ signed จริง ตัดให้สั้นด้านบน) — โผล่มาด้วยกันใน mount /projected เดียว ตรงตามที่ เอกสาร projected volumes อธิบายไว้เป๊ะ สอง source ที่ project ไปที่ path เดียวกันคือ conflict จริงที่ API reject ตอน admission ไม่ใช่การ overwrite แบบเงียบ ๆ
Ephemeral generic volume: ephemeral.volumeClaimTemplate
หัวข้อที่มีชื่อว่า “Ephemeral generic volume: ephemeral.volumeClaimTemplate”generic ephemeral volume ให้ Pod มี PVC ของตัวเองแบบ dynamic-provisioned — กำหนดขนาดและ class ได้เหมือน PVC ทั่วไป — โดยไม่ต้องมี manifest แยกหรือใช้ volumeClaimTemplates ของ StatefulSet Kubernetes จะสร้าง PVC ชื่อ <pod-name>-<volume-name> เป็นเจ้าของโดย Pod และลบให้อัตโนมัติตอน Pod ถูกลบ:
apiVersion: v1kind: Podmetadata: name: ephemeral-vol-demospec: containers: - name: app image: busybox:1.38 command: ['sh', '-c', 'sleep 3600'] volumeMounts: - name: scratch mountPath: /scratch volumes: - name: scratch ephemeral: volumeClaimTemplate: spec: accessModes: ['ReadWriteOnce'] storageClassName: standard resources: requests: storage: 32Mi$ kubectl apply -f generic-ephemeral.yamlpod/ephemeral-vol-demo created
$ kubectl get pod ephemeral-vol-demoNAME READY STATUS RESTARTS AGEephemeral-vol-demo 1/1 Running 0 6s
$ kubectl get pvcNAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS AGEephemeral-vol-demo-scratch Bound pvc-cd12e877-e1da-4ec2-9535-7593a028ee79 32Mi RWO standard 6sPVC ชื่อ <pod-name>-<volume-name> (ephemeral-vol-demo-scratch) โผล่มาเองอัตโนมัติ Bound แล้ว โดยไม่มี manifest PVC แยกไหนเขียนไว้เลย ตาม เอกสาร ephemeral volumes มันมี ownerReference กลับไปที่ Pod — ลบ Pod แล้ว PVC ตัวนี้ (และ storage เบื้องหลัง ขึ้นอยู่กับ reclaim policy ของ StorageClass) จะถูกลบให้อัตโนมัติ ต่างจาก PVC ที่เขียนเองแล้วให้ Pod แค่ reference ซึ่งอยู่รอด Pod เสมอ
subPath: mount key เดียว แลกกับการไม่ update สด
หัวข้อที่มีชื่อว่า “subPath: mount key เดียว แลกกับการไม่ update สด”subPath mount ไฟล์ (หรือ subdirectory) เดียวจาก volume แทนที่จะ mount ทั้ง volume ซึ่งสะดวกเวลาจะฉีด config file เดียวเข้า directory ที่มีไฟล์อื่นอยู่แล้ว ต้นทุน — พิสูจน์ในเชิงลึกในบทถัดไปเรื่อง ConfigMap และ Secret เพราะสำคัญที่สุดตรงนั้น — คือ subPath mount ไม่มีวัน update ตาม source เลย ต่างจาก full volume mount ของ ConfigMap หรือ Secret เดียวกันที่ kubelet ยัง sync ให้ในที่สุด
อ่านโค้ดที่ AI เขียน
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI เขียน”ตอนรีวิว volume manifest ที่ AI สร้าง ให้เช็ค
hostPathที่ใช้ราวกับเป็น storage แบบ shared หรือ networked ข้าม node — พิสูจน์/อ้างอิงด้านบนแล้วว่าผูกกับ disk ท้องถิ่นของ node ตัวเดียว ไม่ตาม Pod ที่ถูกย้ายไปด้วยemptyDirที่medium: Memoryแต่ไม่มีsizeLimit— usage บน tmpfs ไม่มีขอบเขตเลย นับรวมกับ memory ของ node ตรง ๆsubPathmount ของ ConfigMap หรือ Secret ที่มี comment บอกว่าจะ “reload ตอนเปลี่ยน” — ไม่มีวันเกิดขึ้นเลย พิสูจน์ในบท ConfigMap และ Secret- projected volume สอง source เขียนไปที่
pathเดียวกันใน directory ที่ project ไว้ คิดว่า “แค่รวมกัน” — นี่คือ conflict จริงที่ API reject ไม่ใช่ last-write-wins แบบเงียบ ๆ
🐛 หา bug ในโค้ดนี้
apiVersion: apps/v1kind: Deploymentmetadata:name: cache-writerspec:replicas: 3selector: matchLabels: app: cache-writertemplate: metadata: labels: app: cache-writer spec: containers: - name: writer image: myregistry/cache-writer:2.0 volumeMounts: - name: shared-cache mountPath: /var/cache/app volumes: - name: shared-cache hostPath: path: /mnt/shared-cache type: DirectoryOrCreateเฉลย
Deployment นี้รัน 3 replica และแต่ละ Pod อาจไปลงคนละ node — แต่ hostPath: /mnt/shared-cache คือ path บน node ตัวเดียว ที่ Pod แต่ละตัวไปลงจริง ไม่ใช่ตำแหน่ง shared แบบ networked ที่ทุก replica เห็นเนื้อหาเดียวกัน ชื่อ shared-cache กับเจตนาที่สื่อจาก Deployment 3 replica ทั้งคู่บอกว่าผู้เขียนเชื่อว่าทั้งสาม Pod อ่านเขียน cache เดียวกัน ในความจริงแต่ละ replica ได้ directory ของตัวเองที่ผูกกับ node ตัวเอง ไม่เชื่อมกันเลย และ replica ที่ถูกย้ายไป node อื่นจะเริ่มด้วย cache เปล่า (หรือแย่กว่านั้น เจออะไรก็ตามที่ของเก่าเคยเขียนไว้ที่ path เดียวกันบน node ใหม่) — พิสูจน์ในหัวข้อ hostPath ไม่ portable ด้านบน ทางแก้: ใช้ shared storage backend จริงหลัง PVC แบบ ReadWriteMany (ถ้า cache ต้อง share ข้าม node จริง ๆ) หรือไม่ก็ยอมรับว่าแต่ละ replica มี cache ของตัวเอง แล้วเลิกตั้งชื่อ/mount มันราวกับเป็นของ shared
flowchart LR
subgraph pod["Pod: log-processor"]
a["Container: app"] -->|writes| vol[("emptyDir volume: shared-logs")]
vol -->|reads| b["Container: log-shipper"]
end Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”volume type แต่ละแบบตอบคำถามเดียวกันสองข้อต่างกัน คือ ข้อมูลนี้ผูก lifecycle กับอะไร (container / Pod / node / cluster) และ mount นี้สะท้อนการเปลี่ยนแปลงสดของ source หรือเปล่า emptyDir = lifecycle ของ Pod ชั่วคราว hostPath = lifecycle ของ node ไม่ portable พิสูจน์ด้านบน projected volume คือ mount point เดียวที่ปะติดปะต่อจากหลาย source ที่มี lifecycle เป็นอิสระจากกัน subPath แลก “แค่ไฟล์นี้ไฟล์เดียว” กับ “ไม่มีวัน update” — ต้นทุนที่คุ้มเฉพาะตอนที่ไม่ต้องการ update สดอยู่แล้ว
ข้อแลกเปลี่ยน
หัวข้อที่มีชื่อว่า “ข้อแลกเปลี่ยน”| ทางเลือก | ข้อดี | ต้นทุน |
|---|---|---|
emptyDir medium: Memory | ความเร็วระดับ tmpfs ไม่มี disk I/O | นับรวมกับ memory ของ node; eviction จาก sizeLimit (อ้างอิงด้านบน) |
hostPath | เข้าถึง node ท้องถิ่นตรง ๆ (log agent, device path) | ไม่ portable ข้าม node พิสูจน์ด้านบน; เสี่ยง security จริง |
| Projected volume | mount point เดียวสำหรับ SA token + ConfigMap + downward API | ทุก source share directory tree เดียวกัน — path ชนกันคือ error จริงตอน admission |
| Generic ephemeral volume | PVC ผูก lifecycle กับ Pod โดยไม่ต้องมี manifest แยก | reuse ไม่ได้แบบที่ PVC ของ volumeClaimTemplates ใน StatefulSet เก็บไว้ได้ |
subPath | mount key เดียวโดยไม่เปิดเผยส่วนอื่นของ ConfigMap/Secret | ทำลาย live-update propagation ทั้งหมด — ถาวร ไม่ใช่แค่ช้าลง |
ความเข้าใจผิดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ความเข้าใจผิดที่พบบ่อย”- “emptyDir แบบ medium: Memory ไม่มีขีดจำกัดจริง” ตามเอกสารที่อ้างอิงด้านบน
sizeLimitถูกบังคับใช้จริง Pod ถูก evict ตอนเกิน — RAM-backed ไม่ได้แปลว่าไม่มีขอบเขต - “ข้อมูล hostPath ใช้ได้ทั้ง cluster” พิสูจน์/อ้างอิงด้านบนแล้ว — ผูกกับ filesystem ท้องถิ่นของ node ตัวเดียว Pod ที่ถูกย้ายไป node อื่นเห็น directory คนละอันเลย (หรือเปล่า)
- “subPath mount ยัง update ตาม ConfigMap/Secret ได้ แค่ช้ากว่า full mount” ไม่ update เลย ไม่มีวัน — อธิบายเชิงลึกในบท ConfigMap และ Secret