ข้ามไปยังเนื้อหา

Service: ClusterIP, NodePort, LoadBalancer

Service ให้กลุ่ม Pod ที่เปลี่ยนไปเรื่อย ๆ มี virtual IP กับ DNS name เดียวที่เสถียร แล้ว load balance traffic ไปยัง Pod ที่ match และ healthy อยู่ในขณะนั้น

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: v1
kind: Service
metadata:
name: payments
spec:
selector:
app: payments
ports:
- port: 80
targetPort: 8080
Terminal window
# See which Pods this Service is currently routing to
kubectl get endpoints payments
kubectl get endpointslices -l kubernetes.io/service-name=payments

Pod ตัวไหนก็ตามที่ label ตรงกับ app: payments และผ่าน readiness probe จะถูกเพิ่มเข้าลิสต์นี้อัตโนมัติ ลบ Pod ทิ้งก็หลุดจากลิสต์ไปเอง Pod ตัวใหม่ที่ label เดียวกันก็ถูกเพิ่มกลับเข้ามาแทน โดยที่ IP กับ DNS name ของ 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: v1
kind: Service
metadata:
name: payments-public
spec:
type: LoadBalancer
selector:
app: payments
ports:
- port: 80
targetPort: 8080
nodePort: 30080
Terminal window
kubectl get svc payments-public
# EXTERNAL-IP column is populated once the cloud provider finishes provisioning

พิสูจน์การ deprecate จริง — แค่ kubectl get endpoints ธรรมดาบน Kubernetes 1.37 ก็ขึ้น warning แล้ว:

k8s/services-clusterip-nodeport-loadbalancer/svc-demo.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: web
spec:
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: v1
kind: Service
metadata:
name: web-clusterip
spec:
selector:
app: web-svc-demo
sessionAffinity: ClientIP
sessionAffinityConfig:
clientIP:
timeoutSeconds: 60
ports:
- port: 80
targetPort: 80
$ kubectl get endpoints web-clusterip
Warning: v1 Endpoints is deprecated in v1.33+; use discovery.k8s.io/v1 EndpointSlice
NAME ENDPOINTS AGE
web-clusterip 10.244.138.135:80,10.244.147.20:80 10s
$ kubectl get endpointslices -l kubernetes.io/service-name=web-clusterip
NAME ADDRESSTYPE PORTS ENDPOINTS AGE
web-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}
$ kubectl get svc web-headless
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
web-headless ClusterIP None <none> 80/TCP 11s
$ kubectl get endpointslices -l kubernetes.io/service-name=web-headless
NAME ADDRESSTYPE PORTS ENDPOINTS AGE
web-headless-wx6fm IPv4 80 10.244.147.20,10.244.138.135 11s

headless Service (clusterIP: None) ยังได้ EndpointSlice ปกติที่ track Pod อยู่ดี — ความต่างอยู่ที่ CoreDNS เอาไปทำอะไรต่อทั้งหมด (อยู่ในบท DNS) ไม่ใช่ที่กลไก Endpoints/EndpointSlice เอง

$ 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 ใหม่แยกกัน

$ 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 range
apiVersion: v1
kind: Service
metadata:
name: web-badport
spec:
type: NodePort
selector:
app: web-svc-demo
ports:
- port: 80
targetPort: 80
nodePort: 1080
$ kubectl apply -f nodeport-out-of-range.yaml
The 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-32767
k8s/services-clusterip-nodeport-loadbalancer/svc-lb.yaml
apiVersion: v1
kind: Service
metadata:
name: web-lb
spec:
type: LoadBalancer
externalTrafficPolicy: Local
selector:
app: web-svc-demo
ports:
- port: 80
targetPort: 80
$ kubectl get svc web-lb
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
web-lb LoadBalancer 10.96.139.218 <pending> 80:31953/TCP 10s
$ kubectl describe svc web-lb
...
External Traffic Policy: Local
HealthCheck NodePort: 31312
Endpoints: 10.244.138.135:80,10.244.147.20:80

EXTERNAL-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 อยู่ในเครื่องจริงไหม

$ kubectl -n kube-system get configmap kube-proxy -o yaml | grep -A2 "mode:"
mode: iptables
nftables:
masqueradeAll: false

kube-proxy ของ kind 0.33 / Kubernetes 1.37 cluster นี้รันอยู่ใน mode iptables จริง ๆ (ยังเป็น default เหนือ ipvs/nftables ที่นี่) — ต้องเช็ค ConfigMap ของ cluster นั้น ๆ เองเสมอ อย่าเดา mode เพราะเป็น configuration ต่อ cluster ไม่ใช่พฤติกรรมตายตัวของ Kubernetes

ตอนรีวิว Service manifest กับโค้ดที่ query มัน ให้เช็ค

  • โค้ดที่ยังเรียก API Endpoints คิดว่าเป็นวิธีสมัยใหม่ในการดู backend — พิสูจน์ด้านบนแล้วว่าทั้งสอง API ยังใช้ได้ แต่ server เองก็ flag Endpoints ว่า 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 ในโค้ดนี้

k8s/services-clusterip-nodeport-loadbalancer/legacy-endpoints-watch.yaml
apiVersion: v1
kind: ServiceAccount
metadata:
name: endpoint-watcher
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: endpoint-watcher
rules:
- apiGroups: [""]
resources: ["endpoints"]
verbs: ["get", "list", "watch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: endpoint-watcher
subjects:
- kind: ServiceAccount
name: endpoint-watcher
roleRef:
kind: Role
name: endpoint-watcher
apiGroup: 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)

Service คือ address ที่เสถียร EndpointSlice คือ ลิสต์สมาชิกที่ sharded และเป็นสด เบื้องหลัง kube-proxy คือ กลไก ที่ทำให้ address นั้นใช้งานได้จริง ไม่ว่า cluster หนึ่ง ๆ จะรันอยู่ใน mode ไหน (iptables พิสูจน์ด้านบน) Service สามแบบต่อยอดกันเป็นบันไดที่เข้มงวด (ClusterIP → NodePort → LoadBalancer) แต่ละชั้นเพิ่มการเข้าถึงจากภายนอกอีกชั้น ไม่เคยแทนที่ชั้นก่อนหน้า — และทุกชั้นยังต้องพึ่งอะไรบางอย่างภายนอก (cloud provider, MetalLB, cloud-provider-kind) ถึงจะใช้งานได้จริงนอก cluster พิสูจน์ด้านบนด้วย LoadBalancer ที่ค้าง <pending> ถ้าไม่มี

ทางเลือกข้อดีต้นทุน
sessionAffinity: ClientIPclient เดิม 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

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"]
Client เข้าถึง virtual IP ของ Service ผ่าน kube-proxy ไปยัง Pod ที่อยู่เบื้องหลัง
ทำไม Kubernetes ต้องมี Service object ทั้งที่ Pod แต่ละตัวก็มี IP ของตัวเองอยู่แล้ว
ความต่างในทางปฏิบัติระหว่าง NodePort กับ ClusterIP คืออะไร
Service ตัดสินใจยังไงว่าตอนนี้มี Pod ตัวไหนอยู่เบื้องหลังบ้าง
component ไหนที่ทำให้ Service ClusterIP กลายเป็น traffic ที่เข้าถึงได้จริงบนแต่ละโหนด
warning จริงนี้ขึ้นบน Kubernetes 1.37 cluster ควรเปลี่ยนวิธีที่โค้ดใหม่ query backend ของ Service ยังไง
$ kubectl get endpoints web-clusterip
Warning: v1 Endpoints is deprecated in v1.33+; use discovery.k8s.io/v1 EndpointSlice
NAME            ENDPOINTS
web-clusterip   10.244.138.135:80,10.244.147.20:80
นี่คือ output จริงบน kind cluster ธรรมดา ทำไม EXTERNAL-IP ถึงค้างที่ <pending>
$ kubectl get svc web-lb
NAME     TYPE           CLUSTER-IP      EXTERNAL-IP   PORT(S)
web-lb   LoadBalancer   10.96.139.218   <pending>     80:31953/TCP