Service: ClusterIP, NodePort, LoadBalancer
ไอเดียในหนึ่งประโยค
หัวข้อที่มีชื่อว่า “ไอเดียในหนึ่งประโยค”Service ให้กลุ่ม Pod ที่เปลี่ยนไปเรื่อย ๆ มี virtual IP กับ DNS name เดียวที่เสถียร แล้ว load balance traffic ไปยัง Pod ที่ match และ healthy อยู่ในขณะนั้น
ปัญหา: Pod IP ไม่เสถียร
หัวข้อที่มีชื่อว่า “ปัญหา: Pod IP ไม่เสถียร”Pod เป็นของใช้แล้วทิ้ง Deployment ย้าย Pod ที่ crash ไปโหนดอื่น rollout แทนที่ Pod ทุกตัวด้วยตัวใหม่ และ cluster autoscaler เพิ่มลดโหนดไปเรื่อย ๆ เหตุการณ์พวกนี้แต่ละอย่างทำให้ Pod ได้ IP ใหม่ได้ตลอด อะไรก็ตามที่ hardcode Pod IP ไว้จะพังทันทีที่ Pod นั้นถูกแทนที่
Service แก้ปัญหานี้ด้วยการยืนอยู่หน้ากลุ่ม Pod โดยเลือกสมาชิกด้วย label selector และติดตามว่า Pod ไหนพร้อมใช้งานอยู่ผ่าน Endpoints object (หรือ EndpointSlice ที่เป็นเวอร์ชันใหม่ที่ scale ได้ดีกว่า) พอ Pod เกิดหรือหายไป controller จะอัปเดต Endpoints/EndpointSlice ให้อัตโนมัติ client ไม่ต้องรู้เรื่องนี้เลย
apiVersion: v1kind: Servicemetadata: name: paymentsspec: selector: app: payments ports: - port: 80 targetPort: 8080# See which Pods this Service is currently routing tokubectl get endpoints paymentskubectl get endpointslices -l kubernetes.io/service-name=paymentsPod ตัวไหนก็ตามที่ label ตรงกับ app: payments และผ่าน readiness probe จะถูกเพิ่มเข้าลิสต์นี้อัตโนมัติ ลบ Pod ทิ้งก็หลุดจากลิสต์ไปเอง Pod ตัวใหม่ที่ label เดียวกันก็ถูกเพิ่มกลับเข้ามาแทน โดยที่ IP กับ DNS name ของ Service ไม่เปลี่ยนเลย
Service สามแบบ ต่อยอดกันเป็นชั้น ๆ
หัวข้อที่มีชื่อว่า “Service สามแบบ ต่อยอดกันเป็นชั้น ๆ”spec.type กำหนดว่า Service ถูกเข้าถึงยังไง และสามแบบหลักที่ใช้กันบ่อยต่อยอดกันเป็นขั้นบันได แต่ละแบบสร้างบนแบบก่อนหน้า
- ClusterIP (ค่า default) virtual IP ที่เข้าถึงได้แค่จากภายใน cluster การสื่อสารระหว่าง Pod ส่วนใหญ่ใช้แบบนี้
- NodePort ทำทุกอย่างที่ ClusterIP ทำ บวกกับเปิด port เดียวกันบนทุกโหนดใน cluster อยู่ในช่วง
30000-32767โดย default ยิงไปที่<ip-โหนดไหนก็ได้>:<nodePort>ก็เข้าถึง Service จากนอก cluster ได้ - LoadBalancer ทำทุกอย่างที่ NodePort ทำ บวกกับให้ cluster ขอ cloud provider provision external load balancer ที่ forward ต่อไปยัง NodePort
apiVersion: v1kind: Servicemetadata: name: payments-publicspec: type: LoadBalancer selector: app: payments ports: - port: 80 targetPort: 8080 nodePort: 30080kubectl get svc payments-public# EXTERNAL-IP column is populated once the cloud provider finishes provisioningEndpointSlice จริง เทียบกับ Endpoints API ที่ deprecated แล้ว
หัวข้อที่มีชื่อว่า “EndpointSlice จริง เทียบกับ Endpoints API ที่ deprecated แล้ว”พิสูจน์การ deprecate จริง — แค่ kubectl get endpoints ธรรมดาบน Kubernetes 1.37 ก็ขึ้น warning แล้ว:
apiVersion: apps/v1kind: Deploymentmetadata: name: webspec: replicas: 2 selector: matchLabels: app: web-svc-demo template: metadata: labels: app: web-svc-demo spec: containers: - name: web image: nginx:1.27-alpine ports: - containerPort: 80---apiVersion: v1kind: Servicemetadata: name: web-clusteripspec: selector: app: web-svc-demo sessionAffinity: ClientIP sessionAffinityConfig: clientIP: timeoutSeconds: 60 ports: - port: 80 targetPort: 80$ kubectl get endpoints web-clusteripWarning: v1 Endpoints is deprecated in v1.33+; use discovery.k8s.io/v1 EndpointSliceNAME ENDPOINTS AGEweb-clusterip 10.244.138.135:80,10.244.147.20:80 10s
$ kubectl get endpointslices -l kubernetes.io/service-name=web-clusteripNAME ADDRESSTYPE PORTS ENDPOINTS AGEweb-clusterip-ftr9n IPv4 80 10.244.147.20,10.244.138.135 11sทั้งสอง API ยังถูก serve อยู่และตอบ Pod IP เบื้องหลังเดียวกันเป๊ะ แต่ server เองบอกตรง ๆ ว่าอันไหน deprecated แล้ว EndpointSlice จริงมีมากกว่าลิสต์ IP แบนราบ — มี condition ready/serving/terminating จริงต่อ address และมี nodeName ซึ่ง Endpoints แบบเก่าไม่เคยมี:
$ kubectl get endpointslices -l kubernetes.io/service-name=web-clusterip -o yaml... endpoints: - addresses: ["10.244.147.20"] conditions: {ready: true, serving: true, terminating: false} nodeName: storage-net-worker targetRef: {kind: Pod, name: web-5b685b6556-c2b7l, namespace: default} - addresses: ["10.244.138.135"] conditions: {ready: true, serving: true, terminating: false} nodeName: storage-net-worker2 targetRef: {kind: Pod, name: web-5b685b6556-n5rqk, namespace: default}Headless Service: EndpointSlice จริง ไม่มี ClusterIP
หัวข้อที่มีชื่อว่า “Headless Service: EndpointSlice จริง ไม่มี ClusterIP”$ kubectl get svc web-headlessNAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGEweb-headless ClusterIP None <none> 80/TCP 11s
$ kubectl get endpointslices -l kubernetes.io/service-name=web-headlessNAME ADDRESSTYPE PORTS ENDPOINTS AGEweb-headless-wx6fm IPv4 80 10.244.147.20,10.244.138.135 11sheadless Service (clusterIP: None) ยังได้ EndpointSlice ปกติที่ track Pod อยู่ดี — ความต่างอยู่ที่ CoreDNS เอาไปทำอะไรต่อทั้งหมด (อยู่ในบท DNS) ไม่ใช่ที่กลไก Endpoints/EndpointSlice เอง
sessionAffinity: ClientIP
หัวข้อที่มีชื่อว่า “sessionAffinity: ClientIP”$ kubectl get svc web-clusterip -o jsonpath='{.spec.sessionAffinity}{" "}{.spec.sessionAffinityConfig}'ClientIP {"clientIP":{"timeoutSeconds":60}}ตั้ง sessionAffinity: ClientIP แล้ว rule ที่ kube-proxy program ไว้จะ route client IP เดิมไปที่ Pod เบื้องหลังตัวเดิมตลอด timeoutSeconds ที่กำหนด (60s ที่นี่) แทนที่จะ load balance ทุก connection ใหม่แยกกัน
ช่วง NodePort: ยอมรับจริงและ reject จริง
หัวข้อที่มีชื่อว่า “ช่วง NodePort: ยอมรับจริงและ reject จริง”$ kubectl expose deployment web --name=web-nodeport --type=NodePort --port=80 --target-port=80 --dry-run=server -o yaml | grep -A2 ports: ports: - nodePort: 32769 port: 80 protocol: TCPไม่ระบุเอง API server จะเลือก NodePort ให้อัตโนมัติจากช่วง default — 32769 ในที่นี้ อยู่ใน 30000-32767 อย่างปลอดภัย บังคับให้ใช้ port นอกช่วงนี้คือ reject จริงทันที:
# k8s/services-clusterip-nodeport-loadbalancer/nodeport-out-of-range.yaml — @expect-error nodePort 1080 is outside the default 30000-32767 rangeapiVersion: v1kind: Servicemetadata: name: web-badportspec: type: NodePort selector: app: web-svc-demo ports: - port: 80 targetPort: 80 nodePort: 1080$ kubectl apply -f nodeport-out-of-range.yamlThe Service "web-badport" is invalid: spec.ports[0].nodePort: Invalid value: 1080: provided port is not in the valid range. The range of valid ports is 30000-32767LoadBalancer ค้าง <pending> บน kind และ externalTrafficPolicy: Local
หัวข้อที่มีชื่อว่า “LoadBalancer ค้าง <pending> บน kind และ externalTrafficPolicy: Local”apiVersion: v1kind: Servicemetadata: name: web-lbspec: type: LoadBalancer externalTrafficPolicy: Local selector: app: web-svc-demo ports: - port: 80 targetPort: 80$ kubectl get svc web-lbNAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGEweb-lb LoadBalancer 10.96.139.218 <pending> 80:31953/TCP 10s
$ kubectl describe svc web-lb...External Traffic Policy: LocalHealthCheck NodePort: 31312Endpoints: 10.244.138.135:80,10.244.147.20:80EXTERNAL-IP ค้าง <pending> ตลอดไปบน kind ธรรมดา — จริง และคาดไว้แล้ว: ไม่มีอะไร watch Service แบบ LoadBalancer เพื่อ provision อะไรให้เลย สองวิธีจริงที่แก้ได้บนเครื่อง: MetalLB (mode BGP หรือ L2 แจก IP จริงจาก pool ที่ config ไว้ให้ Service LoadBalancer ตัวไหนก็ได้) หรือ cloud-provider-kind (โปรเจกต์ Kubernetes SIG ที่จำลอง cloud provider ให้ kind โดยเฉพาะ ทำให้ LoadBalancer resolve เหมือนอยู่บน cloud จริง) แยกออกมา externalTrafficPolicy: Local (ตั้งไว้ด้านบน) คือเหตุผลที่ HealthCheck NodePort: 31312 มีอยู่เลย — field นี้โผล่มาแค่ตอนใช้ Local เท่านั้น เพราะ Local forward traffic ไปแค่ Pod บนโหนดที่รับ traffic เข้ามาจริง (รักษา client source IP จริงไว้ ไม่มี hop เพิ่ม) แทนที่จะ forward ข้ามโหนดได้แบบ policy default Cluster แต่ละโหนดเลยต้องมีวิธีรายงานว่าตัวเองมี backend ที่ healthy อยู่ในเครื่องจริงไหม
kube-proxy mode: จริง จาก ConfigMap ของ cluster นี้เอง
หัวข้อที่มีชื่อว่า “kube-proxy mode: จริง จาก ConfigMap ของ cluster นี้เอง”$ kubectl -n kube-system get configmap kube-proxy -o yaml | grep -A2 "mode:" mode: iptables nftables: masqueradeAll: falsekube-proxy ของ kind 0.33 / Kubernetes 1.37 cluster นี้รันอยู่ใน mode iptables จริง ๆ (ยังเป็น default เหนือ ipvs/nftables ที่นี่) — ต้องเช็ค ConfigMap ของ cluster นั้น ๆ เองเสมอ อย่าเดา mode เพราะเป็น configuration ต่อ cluster ไม่ใช่พฤติกรรมตายตัวของ Kubernetes
อ่านโค้ดที่ AI เขียน
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI เขียน”ตอนรีวิว Service manifest กับโค้ดที่ query มัน ให้เช็ค
- โค้ดที่ยังเรียก API
Endpointsคิดว่าเป็นวิธีสมัยใหม่ในการดู backend — พิสูจน์ด้านบนแล้วว่าทั้งสอง API ยังใช้ได้ แต่ server เองก็ flagEndpointsว่า deprecated ไปทางEndpointSliceแล้ว - NodePort ที่ hardcode ไว้นอก
30000-32767คิดว่า “ก็น่าจะใช้ได้” — พิสูจน์ด้านบนแล้วว่า reject จริงทันที - คิดว่า Service
type: LoadBalancerจะได้EXTERNAL-IPบน cluster ไหนก็ได้ รวมถึง kind local — พิสูจน์ด้านบนแล้วว่าค้าง<pending>ถ้าไม่ติดตั้ง MetalLB/cloud-provider-kind - เพิ่ม
externalTrafficPolicy: Local“เพื่อ performance” โดยไม่เข้าใจว่ามันเปลี่ยนการกระจาย traffic ข้ามโหนดด้วย (มีแค่โหนดที่มี backend healthy ในเครื่องเท่านั้นที่ได้ traffic เลย) ไม่ใช่แค่เรื่อง client IP และมันเพิ่มHealthCheck NodePortจริงตามที่พิสูจน์ด้านบน
🐛 หา bug ในโค้ดนี้
apiVersion: v1kind: ServiceAccountmetadata:name: endpoint-watcher---apiVersion: rbac.authorization.k8s.io/v1kind: Rolemetadata:name: endpoint-watcherrules:- apiGroups: [""] resources: ["endpoints"] verbs: ["get", "list", "watch"]---apiVersion: rbac.authorization.k8s.io/v1kind: RoleBindingmetadata:name: endpoint-watchersubjects:- kind: ServiceAccount name: endpoint-watcherroleRef:kind: Rolename: endpoint-watcherapiGroup: rbac.authorization.k8s.ioเฉลย
RBAC นี้ให้สิทธิ์ get/list/watch แค่กับ resource endpoints เท่านั้น — API เก่าที่ deprecated แล้ว พิสูจน์ด้านบนด้วย warning จริงบน Kubernetes 1.37 controller หรือ tool ที่สร้างมาโดยพึ่ง Role นี้จะอ่าน object EndpointSlice ไม่ได้เลยตรง ๆ (คนละ resource กัน คือ discovery.k8s.io/v1 ที่ rule นี้ไม่ครอบคลุมเลย) — วันนี้ยังทำงานได้ (ทั้งสอง API ยัง serve อยู่) แต่ติดอยู่กับ API ที่ตัวโปรเจกต์เองกำลังย้ายออกจาก และจะไม่เห็น condition ready/serving/terminating ต่อ address หรือ nodeName ต่อ address ที่มีแค่ EndpointSlice เท่านั้น ทางแก้: ให้สิทธิ์ apiGroups: ["discovery.k8s.io"], resources: ["endpointslices"] แทน (หรือเพิ่มเข้าไปด้วยระหว่างช่วง migration)
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”Service คือ address ที่เสถียร EndpointSlice คือ ลิสต์สมาชิกที่ sharded และเป็นสด เบื้องหลัง kube-proxy คือ กลไก ที่ทำให้ address นั้นใช้งานได้จริง ไม่ว่า cluster หนึ่ง ๆ จะรันอยู่ใน mode ไหน (iptables พิสูจน์ด้านบน) Service สามแบบต่อยอดกันเป็นบันไดที่เข้มงวด (ClusterIP → NodePort → LoadBalancer) แต่ละชั้นเพิ่มการเข้าถึงจากภายนอกอีกชั้น ไม่เคยแทนที่ชั้นก่อนหน้า — และทุกชั้นยังต้องพึ่งอะไรบางอย่างภายนอก (cloud provider, MetalLB, cloud-provider-kind) ถึงจะใช้งานได้จริงนอก cluster พิสูจน์ด้านบนด้วย LoadBalancer ที่ค้าง <pending> ถ้าไม่มี
ข้อแลกเปลี่ยน
หัวข้อที่มีชื่อว่า “ข้อแลกเปลี่ยน”| ทางเลือก | ข้อดี | ต้นทุน |
|---|---|---|
sessionAffinity: ClientIP | client เดิม hit Pod เบื้องหลังตัวเดิมตลอด พิสูจน์ด้านบนด้วย spec จริง | load ไม่เท่ากันถ้า client ตัวเดียวส่ง traffic เยอะผิดปกติ |
externalTrafficPolicy: Local | รักษา client source IP จริง ไม่มี hop เพิ่ม พิสูจน์ด้านบน | traffic ไปถึงแค่โหนดที่มี backend healthy ในเครื่องเท่านั้น — อาจไม่เท่ากันมาก และต้องมี HealthCheck NodePort จริงตามที่พิสูจน์ด้านบน |
| NodePort | ใช้งานได้โดยไม่ต้องมี cloud integration | ทุกโหนดเปิด port เดียวกัน ไม่มี cloud LB มาไว้หน้า อึดอัดที่ scale จริง |
| LoadBalancer | จุดเข้าจากภายนอกที่สะอาดที่สุด | ต้องมี cloud provider หรืออะไรอย่าง MetalLB/cloud-provider-kind บนเครื่อง — พิสูจน์ด้านบนแล้วว่า kind เปล่า ๆ ค้าง <pending> ตลอดไป |
ความเข้าใจผิดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ความเข้าใจผิดที่พบบ่อย”- “Endpoints กับ EndpointSlice คือ API เดียวกัน แค่เปลี่ยนชื่อ” ทั้งคู่ยังใช้ query ได้บน 1.37 (พิสูจน์ด้านบน) แต่ Endpoints deprecated แล้วพร้อม warning จริงจาก server — EndpointSlice คือตัวแทนที่ scale ได้ (sharded, มี condition ต่อ address ที่ละเอียดกว่า)
- “NodePort จะเป็น port ไหนก็ได้ตามใจ” พิสูจน์ด้านบนแล้ว — API server บังคับช่วง default
30000-32767จริง reject นอกช่วงด้วย error จริง - “Service แบบ LoadBalancer ยังไงก็ได้ external IP ในที่สุด” พิสูจน์ด้านบนแล้ว — บน kind ธรรมดาค้าง
<pending>ตลอดไปถ้าไม่มี MetalLB หรือ cloud-provider-kind
kube-proxy คือตัวที่ทำให้ virtual IP ใช้งานได้จริง
หัวข้อที่มีชื่อว่า “kube-proxy คือตัวที่ทำให้ virtual IP ใช้งานได้จริง”ClusterIP ของ Service ไม่ได้มี network interface จริงอยู่ที่ไหนเลย เป็นแค่ address เสมือน kube-proxy ที่รันอยู่บนทุกโหนดคอย watch API server ดู Service กับ Endpoints/EndpointSlice แล้วเขียน rule ในเครื่อง (ปกติเป็น iptables หรือ IPVS แล้วแต่ mode ที่ตั้งไว้) เพื่อให้ packet ที่ส่งไปหา virtual IP ถูก rewrite แล้วส่งต่อไปยัง Pod ตัวใดตัวหนึ่งที่อยู่เบื้องหลังโดยอัตโนมัติ นี่คือเหตุผลที่ Service ทำงานเหมือนกันไม่ว่า client Pod จะอยู่โหนดไหน
flowchart LR client["Client Pod"] --> vip["Service virtual IP\n(ClusterIP)"] vip --> kp["kube-proxy rules\n(iptables / IPVS)"] kp --> p1["Pod 1"] kp --> p2["Pod 2"] kp --> p3["Pod 3"]