Deployment และ ReplicaSet
ไอเดียในหนึ่งประโยค
หัวข้อที่มีชื่อว่า “ไอเดียในหนึ่งประโยค”ReplicaSet ทำให้จำนวน Pod ที่ตรงกับ label selector คงที่ตามที่กำหนดอยู่ตลอดเวลา ส่วน Deployment จัดการ ReplicaSet ให้คุณ เพื่อทำ rolling update แบบ declarative ที่มี version และมี rollback history ให้ด้วย
ReplicaSet: จำนวน Pod ที่ตรงเงื่อนไขคงที่
หัวข้อที่มีชื่อว่า “ReplicaSet: จำนวน Pod ที่ตรงเงื่อนไขคงที่”หน้าที่ของ ReplicaSet แคบมาก คือรับ Pod template กับ label selector มา แล้วทำให้มี Pod ที่ตรงกับ selector นั้นอยู่พอดี replicas ตัวเสมอ ถ้า Pod ที่ตรง selector หายไป ReplicaSet จะ create ตัวใหม่มาแทน ถ้ามีเกินก็จะลบส่วนเกินทิ้ง ReplicaSet ยังสามารถ adopt Pod ที่มีอยู่แล้วซึ่งตัวเองไม่ได้ create ขึ้นมาได้ด้วย ตราบใดที่ label ของ Pod นั้นตรงกับ selector
apiVersion: apps/v1kind: ReplicaSetmetadata: name: web-rsspec: replicas: 3 selector: matchLabels: app: web template: metadata: labels: app: web spec: containers: - name: web image: myregistry/web:1.0.0 ports: - containerPort: 8080ในการใช้งานจริงคุณแทบไม่เขียน ReplicaSet ตรง ๆ แบบนี้เอง แต่จะ create Deployment แล้ว Deployment จะ create และเป็นเจ้าของ ReplicaSet ให้แทน
Deployment: rolling update ที่อยู่บน ReplicaSet
หัวข้อที่มีชื่อว่า “Deployment: rolling update ที่อยู่บน ReplicaSet”Deployment ห่อ ReplicaSet ไว้พร้อม rollout strategy, revision history และการ rollback เมื่อคุณเปลี่ยน Pod template ใน Deployment (image tag ใหม่ หรือแก้ env var) Deployment จะ create ReplicaSet ใหม่ ที่ใช้ template นั้น แล้วค่อย ๆ ย้าย replica จาก ReplicaSet เก่าไปยังตัวใหม่
apiVersion: apps/v1kind: Deploymentmetadata: name: webspec: replicas: 3 selector: matchLabels: app: web strategy: type: RollingUpdate rollingUpdate: maxUnavailable: 1 maxSurge: 1 template: metadata: labels: app: web spec: containers: - name: web image: myregistry/web:1.1.0 ports: - containerPort: 8080chain ความเป็นเจ้าของจะเป็น Deployment → ReplicaSet → Pod เสมอ Deployment เป็นเจ้าของ ReplicaSet หนึ่งตัวหรือมากกว่านั้น (ตัวปัจจุบัน บวกกับตัวเก่าที่เก็บไว้เพื่อ rollback history) และ ReplicaSet แต่ละตัวก็เป็นเจ้าของ Pod ของตัวเอง
flowchart LR dep["Deployment web"] --> rsOld["ReplicaSet web-abc123 (old): 3 -> 0"] dep --> rsNew["ReplicaSet web-def456 (new): 0 -> 3"] rsOld --> p1["Pod (terminating)"] rsNew --> p2["Pod (running)"] rsNew --> p3["Pod (running)"] rsNew --> p4["Pod (running)"]
Rollout strategy: RollingUpdate เทียบกับ Recreate
หัวข้อที่มีชื่อว่า “Rollout strategy: RollingUpdate เทียบกับ Recreate”maxUnavailable จำกัดว่า replica ที่ต้องการมีได้กี่ตัวที่ unavailable พร้อมกันระหว่าง rollout และ maxSurge จำกัดว่ายอม Pod เกินจากจำนวนที่ต้องการได้กี่ตัวชั่วคราวระหว่างที่ตัวใหม่กำลังขึ้น สองค่านี้รวมกันปรับ trade-off ระหว่างความเร็วของ rollout กับ capacity ที่ใช้งานได้ ค่าด้านบนยอมให้ Pod ดับได้หนึ่งตัวและมี Pod เกินได้หนึ่งตัวพร้อมกัน สำหรับ Deployment ที่มี 3 replica
อีก strategy หนึ่งคือ Recreate ซึ่ง terminate Pod เก่าทุกตัวก่อนที่จะ create ตัวใหม่ตัวไหนเลย
spec: strategy: type: RecreateRecreate ทำให้เกิด outage สั้น ๆ จึงสงวนไว้ใช้กับกรณีที่ version เก่ากับใหม่รันคู่กันจริง ๆ ไม่ได้ เช่น application ที่ทนไม่ได้ถ้ามี schema สอง version ยิง database เดียวกันพร้อมกัน
maxUnavailable: 0 กับ maxSurge: 0 พร้อมกัน: API server reject จริง
หัวข้อที่มีชื่อว่า “maxUnavailable: 0 กับ maxSurge: 0 พร้อมกัน: API server reject จริง”สองปุ่มนี้ไม่ได้เป็นอิสระจากกัน รวมกันแล้วต้องเหลือทาง บางอย่าง ให้ rollout เดินหน้าได้ ตั้งทั้งคู่เป็น 0 หมายความว่า “ห้าม Pod unavailable เด็ดขาด แล้วก็ห้าม create เพิ่มด้วย” นั่นไม่ใช่ strategy มันคือ rollout ที่ไม่มีวันย้าย replica จากเก่าไปใหม่ได้เลยแม้แต่ตัวเดียว API server reject มันตรง ๆ ไม่ใช่ตอน runtime แต่ตอน apply:
# k8s/deployments-and-replicasets/deployment-zero-zero.yaml — @expect-error maxUnavailable: 0 is invalid when maxSurge is also 0apiVersion: apps/v1kind: Deploymentmetadata: name: webspec: replicas: 3 selector: matchLabels: app: web strategy: type: RollingUpdate rollingUpdate: maxUnavailable: 0 maxSurge: 0 template: metadata: labels: app: web spec: containers: - name: web image: registry.k8s.io/pause:3.10$ kubectl apply --dry-run=server -f deployment-zero-zero.yamlThe Deployment "web" is invalid: spec.strategy.rollingUpdate.maxUnavailable: Invalid value: 0: may not be 0 when `maxSurge` is 0นั่นคือข้อความ error จริงจาก API server 1.37 ตัวจริง ถูกจับได้ตอน apply เลย ไม่ใช่ไปเจอทีหลังว่า rollout ค้าง
minReadySeconds: ready ไม่เท่ากับ available
หัวข้อที่มีชื่อว่า “minReadySeconds: ready ไม่เท่ากับ available”Pod ที่ถึง Ready แล้วไม่ได้นับเป็น available ให้ rollout ทันที minReadySeconds คือเวลาเพิ่มที่ Pod ต้องอยู่ใน Ready ต่อเนื่องก่อนที่ Deployment controller จะถือว่ามัน available และปล่อยให้ rollout ไปต่อที่ Pod ถัดไป ด้วย minReadySeconds: 5 บน Deployment ที่ Pod ถึง Ready เกือบจะทันที rollout จริงจะหยุดค้างที่ข้อความนี้ซ้ำ ๆ หลายครั้งก่อนจะจบจริง:
apiVersion: apps/v1kind: Deploymentmetadata: name: webspec: replicas: 3 minReadySeconds: 5 progressDeadlineSeconds: 30 selector: matchLabels: app: web strategy: type: RollingUpdate rollingUpdate: maxUnavailable: 1 maxSurge: 1 template: metadata: labels: app: web spec: containers: - name: web image: registry.k8s.io/pause:3.10$ kubectl apply -f deployment-with-minreadyseconds.yaml && kubectl rollout status deployment/webWaiting for deployment "web" rollout to finish: 0 of 3 updated replicas are available...Waiting for deployment "web" rollout to finish: 0 of 3 updated replicas are available...Waiting for deployment "web" rollout to finish: 0 of 3 updated replicas are available...deployment "web" successfully rolled outข้อความ “0 of 3 … available” สามบรรทัดติดกัน สำหรับ Pod ที่ Ready ไปแล้วภายในไม่ถึงวินาที คือ minReadySeconds กำลังทำงานของมัน Deployment จงใจกัน Pod ใหม่ไว้ที่สถานะ “Ready แต่ยังไม่ available” อีก 5 วินาทีก่อนจะเชื่อมัน ซึ่งเป็น guard ที่ต้องการเป๊ะ ๆ กับ Pod ที่ผ่าน readiness probe ทันทีแต่จริง ๆ ยังไม่ warm ขึ้นมา
progressDeadlineSeconds กับ ProgressDeadlineExceeded
หัวข้อที่มีชื่อว่า “progressDeadlineSeconds กับ ProgressDeadlineExceeded”progressDeadlineSeconds จำกัดว่า Deployment controller จะรอ rollout ให้มี progress ใด ๆ นานแค่ไหนก่อนจะยอมแพ้แล้ว mark ว่าค้าง นี่คือสิ่งที่จับ rollout ไปยัง image พังที่ไม่มีวันผ่าน readiness probe แทนที่จะปล่อยให้มันเป็น “in progress” ตลอดไป พิสูจน์: Deployment ที่มี progressDeadlineSeconds: 15 กับ readiness probe ที่ fail เสมอ:
apiVersion: apps/v1kind: Deploymentmetadata: name: web-baddeployspec: replicas: 2 progressDeadlineSeconds: 15 selector: matchLabels: app: web-baddeploy strategy: type: RollingUpdate rollingUpdate: maxUnavailable: 1 maxSurge: 1 template: metadata: labels: app: web-baddeploy spec: containers: - name: web image: registry.k8s.io/pause:3.10 readinessProbe: exec: command: ['false'] periodSeconds: 2 failureThreshold: 1$ kubectl rollout status deployment/web-baddeploy --timeout=5serror: deployment "web-baddeploy" exceeded its progress deadline$ kubectl get deployment web-baddeploy -o jsonpath='{range .status.conditions[*]}{.type}{"="}{.status}{" reason="}{.reason}{" msg="}{.message}{"\n"}{end}'Available=False reason=MinimumReplicasUnavailable msg=Deployment does not have minimum availability.Progressing=False reason=ProgressDeadlineExceeded msg=ReplicaSet "web-baddeploy-56d9694579" has timed out progressing.Progressing=False กับ reason: ProgressDeadlineExceeded คือสัญญาณจริงที่ query ได้ alert หรือ CI gate ควร watch condition นี้ ไม่ใช่แค่ “Deployment เสร็จหรือยัง” เพราะ rollout ที่ค้างจะนั่งเงียบ ๆ ที่ Progressing=True ตลอดไปโดยไม่มี readiness มาถึงเลยถ้าไม่มีตัวนี้
selector เป็น immutable
หัวข้อที่มีชื่อว่า “selector เป็น immutable”spec.selector ของ Deployment ถูกตั้งครั้งเดียวแล้วไม่มีวันเปลี่ยนได้อีกเลย แม้แต่จะเพิ่ม label เข้าไปก็ไม่ได้ เพราะการเปลี่ยน selector จะทำให้ Pod/ReplicaSet เดิมที่ selector เก่าจับอยู่กลายเป็น orphan แบบเงียบ ๆ พิสูจน์ เปลี่ยน selector ของ Deployment ที่มีอยู่แล้วจาก {app: web} เป็น {app: web, tier: frontend}:
$ kubectl apply -f deployment-selector-changed.yamlThe Deployment "web" is invalid: spec.selector: Invalid value: {"matchLabels":{"app":"web","tier":"frontend"}}: field is immutableถ้าจำเป็นต้องเปลี่ยน selector จริง ๆ ทางเดียวคือลบแล้ว create Deployment ใหม่ (ใช้ --cascade=orphan ถ้าอยากให้ Pod เดิมรอด แล้วค่อยไป adopt ภายใต้ selector ใหม่) ไม่มีทาง migrate ในที่เดิมได้เลย
pod-template-hash: chain ความเป็นเจ้าของทำงานยังไงจริง ๆ
หัวข้อที่มีชื่อว่า “pod-template-hash: chain ความเป็นเจ้าของทำงานยังไงจริง ๆ”Pod ทุกตัวที่ ReplicaSet ของ Deployment สร้างขึ้นจะได้ label เพิ่มมาอันหนึ่ง pod-template-hash คำนวณมาจาก (และไม่ซ้ำกับ) Pod template ของ ReplicaSet นั้น นี่คือวิธีที่ Deployment แยก “Pod ของ ReplicaSet เก่า” ออกจาก “Pod ของ ReplicaSet ใหม่” โดยไม่ต้องมี bookkeeping อะไรเพิ่มเลย:
$ kubectl get pods -l app=web --show-labelsNAME READY STATUS RESTARTS AGE LABELSweb-795568db48-6c6tv 1/1 Running 0 5s app=web,pod-template-hash=795568db48web-795568db48-j4fh2 1/1 Running 0 5s app=web,pod-template-hash=795568db48web-795568db48-wll9g 1/1 Running 0 5s app=web,pod-template-hash=795568db48ReplicaSet เองก็ชื่อ <deployment>-<pod-template-hash> (web-795568db48 ข้างบน) hash เดียวกันนี้คือทั้งส่วนต่อท้ายชื่อ ReplicaSet และ label pod-template-hash ของ Pod ทุกตัวของมัน ซึ่งก็เป็นเหตุผลที่ Service selector ที่จับแค่ app: web (ไม่จับ hash) ยัง route ถูกตลอด rollout ด้วย มันไม่จำเป็นต้องรู้เลยว่า ReplicaSet ไหนคือตัว current
จัดการ rollout
หัวข้อที่มีชื่อว่า “จัดการ rollout”# Trigger a rollout by changing the Pod template (e.g. a new image), then watch itkubectl set image deployment/web web=myregistry/web:1.2.0kubectl annotate deployment/web kubernetes.io/change-cause="bump to 1.2.0" --overwritekubectl rollout status deployment/web
# See every revision this Deployment has keptkubectl rollout history deployment/web
# Roll back to the previous revisionkubectl rollout undo deployment/web
# Scale independently of a rolloutkubectl scale deployment/web --replicas=5output จริงจาก sequence นี้ บน Deployment ที่ create มาพร้อม kubernetes.io/change-cause: "initial rollout: web:1.0.0" แล้วอัปเดตเป็น pause:3.9 พร้อม change-cause ใหม่ แล้ว rollback:
$ kubectl rollout history deployment/webdeployment.apps/webREVISION CHANGE-CAUSE1 initial rollout: web:1.0.02 bump pause 3.9 -> testing rollback
$ kubectl rollout history deployment/web --revision=2deployment.apps/web with revision #2Pod Template: Labels: app=web pod-template-hash=587c49f48d Annotations: kubernetes.io/change-cause: bump pause 3.9 -> testing rollback Containers: web: Image: registry.k8s.io/pause:3.9...
$ kubectl rollout undo deployment/webWarning: resource deployments/web was previously managed with 'kubectl apply'. Rolling back will not update the kubectl.kubernetes.io/last-applied-configuration annotation, which may cause unexpected behavior on future 'kubectl apply' operations. Consider using 'kubectl apply' with your previous configuration file instead.deployment.apps/web rolled back
$ kubectl rollout history deployment/webdeployment.apps/webREVISION CHANGE-CAUSE2 bump pause 3.9 -> testing rollback3 initial rollout: web:1.0.0มีสองอย่างที่น่าสังเกตใน output จริง: kubectl rollout history จะโชว์ CHANGE-CAUSE ก็ต่อเมื่อ Deployment มี annotation kubernetes.io/change-cause เท่านั้น (kubectl 1.36 ไม่มี flag --record แล้ว มันถูกเอาออกไป ต้อง annotate เองตรง ๆ) และ rollout undo ไม่ได้ลบ revision 1 ทิ้ง มันทำให้การ rollback กลายเป็น revision ใหม่ (3) โดย copy template ของ revision 1 มาข้างหน้า แล้ว CLI ตัวจริงก็เตือนด้วยว่า Deployment ที่จัดการด้วย apply อาจ drift annotation last-applied-configuration หลัง undo ได้
อ่านโค้ดที่ AI เขียน
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI เขียน”ตอน review Deployment ที่ AI generate มา เช็คพวกนี้
maxUnavailable: 0คู่กับmaxSurge: 0พิสูจน์แล้วข้างบนว่านี่คือ API server reject จริงทันที ไม่ใช่ค่า rollout ที่ “ปลอดภัยเป็นพิเศษ”selectorที่ถูกเปลี่ยนบน Deployment ที่มีอยู่แล้ว (แทนที่จะตั้งครั้งเดียวตอน create) พิสูจน์แล้วข้างบนว่าspec.selectorเป็น immutable apply จะ fail ด้วยfield is immutableทางแก้ที่ generate มาควร create Deployment ใหม่ ไม่ใช่พยายามแก้ selector ในที่เดิม- ไม่มี
progressDeadlineSecondsเลย (หรือปล่อยเป็น default 600 วินาที) บน Deployment ที่อยู่หลัง SLA/alert ถ้าไม่มีอันนี้ rollout ไปยัง image ที่พังจะนั่งอยู่ที่Progressing=Trueตลอดไปโดยไม่มีสัญญาณProgressDeadlineExceededอัตโนมัติให้ page พิสูจน์แล้วข้างบน - rollout ที่เปลี่ยนแปลงโดยไม่มี annotation
kubernetes.io/change-causeเลยkubectl rollout historyจะโชว์ revision ที่CHANGE-CAUSEว่างเปล่า ทำให้rollout undoกลายเป็นการเดาแทนที่จะเป็นการตัดสินใจที่มีข้อมูล minReadySecondsถูกเข้าใจผิดว่าหมายถึง “รอนานแค่ไหนก่อนจะเริ่ม Pod ถัดไป” ทั้งที่จริง ๆ มันหมายถึง “Pod ต้องอยู่ Ready นานแค่ไหนก่อนจะนับเป็น available” ความต่างนี้สำคัญตอนอ่าน output ของkubectl rollout statusพิสูจน์แล้วข้างบนด้วยบรรทัด “0 of N replicas are available” ที่ซ้ำกัน
🐛 หา bug ในโค้ดนี้
apiVersion: apps/v1kind: Deploymentmetadata:name: webspec:replicas: 6selector: matchLabels: app: webstrategy: type: RollingUpdate rollingUpdate: maxUnavailable: 0 maxSurge: 0template: metadata: labels: app: web spec: containers: - name: web image: myregistry/web:2.0.0เฉลย
โค้ดนี้พยายามจะสื่อ “zero-downtime ไม่ใช้ capacity เกิน” แต่จริง ๆ มันอธิบาย rollout ที่ไม่มีวันขยับได้เลย ด้วย maxUnavailable: 0 ไม่มี Pod เดิมตัวไหนดับได้ก่อนตัวแทนจะพร้อม ด้วย maxSurge: 0 ก็สร้าง Pod แทนเกินจำนวนที่ต้องการไม่ได้เลยด้วย ไม่เหลือทางไหนให้สร้าง Pod ใหม่หรือเคลียร์ที่ให้เลย นี่ไม่ใช่ rollout ที่ช้า มันคือ rollout ที่ ถูก reject พิสูจน์แล้วข้างบนบน API server 1.37 จริง:
The Deployment "web" is invalid: spec.strategy.rollingUpdate.maxUnavailable: Invalid value: 0: may not be 0 when `maxSurge` is 0ทางแก้คือเลือกปุ่มใดปุ่มหนึ่งให้เป็น “พื้นที่ให้ขยับ” maxSurge: 1 (สร้าง Pod เกินทีละตัว zero-downtime จริง) หรือ maxUnavailable: 1 (ยอมให้ Pod หายไปชั่วคราวทีละตัว ไม่ใช้ capacity เกิน) ห้ามเป็น 0 ทั้งคู่เด็ดขาด
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”Deployment ไม่ใช่ “สิ่งที่รัน Pod ของคุณ” มันคือ ตัวสร้าง ReplicaSet ทีละตัวต่อ Pod template ที่แตกต่างกันทุกแบบที่มันเคยได้รับมา และ rollout ทั้งหมดก็คือเรื่องราวของการย้ายจำนวน replica ระหว่างสองตัวนี้เป๊ะ ๆ (เก่า, ใหม่) ภายใต้ข้อจำกัดของ maxUnavailable/maxSurge feature อื่น ๆ ที่เหลือล้วนเป็นข้อจำกัดของการย้ายนี้ (minReadySeconds ทำให้ Pod ใหม่นับช้าลง progressDeadlineSeconds จำกัดว่าการย้ายจะค้างได้นานแค่ไหน) หรือเป็นข้อเท็จจริงเกี่ยวกับ bookkeeping ของมัน (selector คือคำถามที่ immutable ว่า “Pod ไหนเป็นของฉัน” pod-template-hash คือคำตอบที่ immutable ว่า “Pod นี้มาจาก generation ไหน” annotation change-cause คือร่องรอยที่มนุษย์อ่านได้ผ่าน rollout history)
ข้อแลกเปลี่ยน
หัวข้อที่มีชื่อว่า “ข้อแลกเปลี่ยน”| ทางเลือก | ได้อะไร | เสียอะไร |
|---|---|---|
maxSurge: 1, maxUnavailable: 0 | zero-downtime จริง capacity ไม่มีวันลดลงเลย | ใช้ทรัพยากรมากกว่าจำนวน replica ปกติชั่วคราว |
maxUnavailable: 1, maxSurge: 0 | ไม่มีวันใช้ทรัพยากรเกินจำนวน replica ปกติเลย | capacity ลดลงหนึ่ง replica ชั่วคราวระหว่าง rollout |
maxUnavailable: 0, maxSurge: 0 | ไม่มีเลย พิสูจน์แล้วข้างบนว่านี่คือ reject จริงทันที | apply ไม่ผ่านเลย |
minReadySeconds > 0 | จับ Pod ที่ผ่าน readiness ทันทีแต่จริง ๆ ยังไม่ warm ขึ้นมาได้ (พิสูจน์ข้างบน) | rollout ทุกครั้งช้าลงตามจำนวนวินาทีนั้น ไม่ว่าจะจำเป็นหรือไม่ |
progressDeadlineSeconds (ใส่เอง ไม่ใช้ default 600 วินาที) | rollout ที่ค้างจะโผล่ ProgressDeadlineExceeded เร็วพอจะ page ได้ (พิสูจน์ข้างบน) | ตั้งค่าต่ำเกินไปอาจ flag rollout ที่แค่ช้าจริง ๆ (image ใหญ่ cache เย็น) ว่าพังไปด้วย |
annotation kubernetes.io/change-cause | rollout history/rollout undo กลายเป็นการดำเนินการที่มีข้อมูลและตรวจสอบได้ (พิสูจน์ข้างบน) | ต้องจำตั้งทุกครั้งที่เปลี่ยนแปลง ไม่มีอะไรบังคับ |
ความเข้าใจผิดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ความเข้าใจผิดที่พบบ่อย”- “ตั้งทั้ง maxUnavailable และ maxSurge เป็น 0 คือ rollout ที่ปลอดภัยที่สุด” พิสูจน์แล้วข้างบน มันไม่ถูก apply เลย API server reject ด้วย error จริง
- “minReadySeconds ดีเลย์ตอนที่ rollout เริ่ม” พิสูจน์แล้วข้างบน มันดีเลย์ตอนที่ Pod ที่ Ready อยู่แล้วจะนับเป็น available ซึ่งเป็นตัวที่ gate ขั้นตอนถัดไปของ rollout Pod นั้น serve traffic จริงอยู่ตลอดช่วงนี้ แค่ยังไม่ถูกเชื่อว่าเป็นสัญญาณ progress ของ rollout เท่านั้น
- “selector ของ Deployment แก้ได้เหมือน field อื่น ตราบใดที่ selector ใหม่ยัง match Pod เดิม” พิสูจน์แล้วข้างบน
spec.selectorเป็น immutable ที่ระดับ API ไม่ว่าค่าใหม่จะ match Pod เดิมหรือ Pod ต่างกันก็ตาม - “kubectl rollout undo apply manifest ของ revision 1 กลับมาแล้วก็ไม่มีอะไรให้ undo ย้อนไปได้อีก” พิสูจน์แล้วข้างบน undo สร้าง revision ใหม่เอี่ยม (3) ที่มี template ของ revision 1 history ยังโตต่อไปเรื่อย ๆ มันไม่ใช่ stack ที่ว่างเปล่าลง