Small Scale Kubernetes Part II: ทำความเข้าใจ Kubernetes

ช่วงนี้งานเยอะมากครับ ตอนแรกว่าจะ publish ตอน 2 กลางสัปดาห์ แต่ก็ไม่มีเวลาเขียนเลย

สำหรับตอนนี้จะเป็นส่วนของทฤษฎีที่เกี่ยวกับ Kubernetes ครับ ซึ่งจะใช้คำศัพท์เฉพาะตัวเยอะพอสมควร ซึ่งอยากแนะนำให้ดูภาพประกอบจาก The Children’s Illustrated Guide to Kubernetes ด้วยครับ คิดว่าเป็นบทความที่เขียนดีมากๆ ผมก็เคยยืมไปนำเสนอในบริษัทมาแล้วหนหนึ่ง

Kubernetes

หน้าที่ของ Kubernetes มีอยู่ 4 อย่างด้วยกัน

  1. ทำให้จัดการเครื่องคอมพิวเตอร์หลายๆ เครื่อง ได้จากที่เดียว
  2. วาง container ไว้ในเครื่องคอมพิวเตอร์เหล่านั้น ไม่ให้แออัดเกินไป
  3. จับตาดู container ที่วางไว้ไม่ให้มันงอแง
  4. ให้บริการ service และ resource ที่ container จำเป็นต้องใช้ในการทำงาน

ในบทความนี้เราจะลองมาดูกันว่า Kubernetes ทำหน้าที่เหล่านี้อย่างไรบ้าง

Cluster Design

By Khtan66 (Own work) [CC BY-SA 4.0 (http://creativecommons.org/licenses/by-sa/4.0)], via Wikimedia Commons

ในระบบ Kubernetes แบ่งเครื่องคอมพิวเตอร์เป็น 2 แบบ คือ

  1. Master ทำหน้าที่ให้บริการ Kubernetes API (เป็น REST)
  2. Node ทำหน้าที่ run container

ซึ่งเราสามารถมีเครื่องในประเภทหนึ่งมากกว่า 1 ตัวก็ได้

Master

Master จะให้บริการ REST API ซึ่งโปรแกรม kubectl ใช้อยู่ในเบื้องหลัง

เวลาเราสั่งให้มันสร้าง object สักอย่าง (เช่น Pod) Master ก็จะเก็บ data ที่เราส่งเข้ามาไว้ใน etcd แล้วก็จัดหา Node ที่เหมาะสมให้ติดตั้ง Pod ตัวนั้น

ฉะนั้นแล้วถ้า Master ตาย ก็จะไม่สามารถสร้าง Pod หรือใช้ kubectl ได้ แต่ Container ที่รันอยู่ทั้งหมดยังสามารถใช้งานปกติ

Node

ในฝั่งของ Node นั้นก็จะมีหน้าที่รัน Container ต่างๆ ซึ่งเราสามารถดูสถานะของ node ได้ด้วยคำสั่งดังนี้

$ kubectl get nodes
NAME                         STATUS    AGE       VERSION
gke-tipme-n1-a7ee1a90-mwzz   Ready     6d        v1.7.2
$ kubectl describe no gke-tipme-n1-a7ee1a90-mwzz
[...]
Labels:         beta.kubernetes.io/instance-type=n1-standard-1
            failure-domain.beta.kubernetes.io/region=asia-southeast1
            failure-domain.beta.kubernetes.io/zone=asia-southeast1-a
[...]
Capacity:
 cpu:       1
 memory:    3794520Ki
 pods:      110
[...]
Non-terminated Pods:
  Namespace      Name                       CPU Requests    CPU Limits  Memory Requests Memory Limits
  ---------      ----                       ------------    ----------  --------------- -------------
  kube-system    event-exporter-v0.1.4-4272745813-86m8f     0 (0%)      0 (0%)      0 (0%)      0 (0%)
  kube-system    fluentd-gcp-v2.0-z02z6             100m (10%)  0 (0%)      200Mi (5%)  300Mi (8%)
  kube-system    heapster-v1.4.0-807765746-p8pd0            138m (13%)  138m (13%)  301656Ki (7%)   301656Ki (7%)
  kube-system    kube-dns-1413379277-mb7gl          260m (26%)  0 (0%)      110Mi (2%)  170Mi (4%)
  kube-system    kube-dns-autoscaler-3880103346-sfxdw       20m (2%)    0 (0%)      10Mi (0%)   0 (0%)
  kube-system    kube-proxy-gke-tipme-n1-a7ee1a90-mwzz      100m (10%)  0 (0%)      0 (0%)      0 (0%)
Allocated resources:
  (Total limits may be over 100 percent, i.e., overcommitted.)
  CPU Requests  CPU Limits  Memory Requests Memory Limits
  ------------  ----------  --------------- -------------
  905m (90%)    138m (13%)  2087512Ki (55%) 2617944Ki (68%)

(ProTip: สามารถย่อ nodes เหลือ no ได้ เช่น kubectl get no)

จะเห็นว่า Kubernetes นั้นจะมีการบันทึกไว้ว่าเครื่องนี้มี resource เท่าไร ทั้ง CPU Core และ Memory นอกจากนี้ยังมี Label กำกับไว้ด้วย โดย Kubernetes จะแปะมาให้เราอัตโนมัติ เช่นใน GKE ก็จะมีการแปะ instance type, node pool, zone, region ให้ แต่ถ้าจะแปะเพิ่มเองก็ทำได้เช่นกัน

Pod

หน่วยเล็กที่สุดใน Kubernetes เรียกว่า Pod ซึ่งมันคือ กลุ่มของ container ที่จะอยู่ด้วยกัน ซึ่งแปลว่า

  • 1 Pod จะอยู่บน 1 Node เท่านั้น ไม่ว่า pod นั้นจะมี container กี่ตัวก็ตาม
  • ภายใน Pod จะ share network card กัน แปลว่าถ้ามี container นึงเปิด port 3000 อีก container หนึ่งสามารถเข้า localhost:3000 เพื่อคุยกันได้เลย (แต่จะเปิด port 3000 พร้อมกันไม่ได้)
  • ฉะนั้น 1 Pod จะมี 1 IP เท่านั้น ไม่ได้มีตามจำนวน container

ใน use case 90% 1 Pod จะมีเพียงแค่ 1 Container เท่านั้น สำหรับตัวอย่าง use case ที่ 1 Pod มีหลาย container ก็จะเป็นขั้น advance หน่อย เช่น

  • Kubernetes DNS เคยใช้ 3 container แบบนี้ (ปัจจุบันเปลี่ยน design ไปแล้ว)
    • Container ตัวนึงจะรัน SkyDNS เป็น DNS Server
    • SkyDNS ไม่รู้จัก Kubernetes ก็เลยจะต้องมีโปรแกรมตัวนึงที่อ่าน Kubernetes API มาแล้วเขียนลง database ให้ SkyDNS อ่าน
    • สำหรับ Database ที่ใช้ก็คือ etcd ฉะนั้นเลยต้องมี etcd ส่วนตัวด้วย
  • ผมเคยรัน Django กับ static server ใน Pod เดียวกัน เพราะจะแชร์ volume กัน ให้ Django collectstatic ไปใส่ volume ที่ใช้ร่วมกัน แล้วให้ web server serve

ข้อสำคัญของ Pod คือ Pod เป็น Immutable เช่นเดียวกับ Container เมื่อสร้าง Pod แล้วจะไม่สามารถแก้ไขได้ และ Pod ยัง #yolo ด้วย เมื่อตายแล้วจะไม่นำกลับมา start ใหม่อีกครั้ง

สำหรับหน้าตาของ Pod เราได้เห็นไปในตอนที่แล้วแล้ว คราวนี้จะลองมาเพิ่มของเล่นดูบ้าง

apiVersion: v1
kind: Pod
metadata:
  name: postgres
  labels:
    app: postgres
spec:
  containers:
  - name: postgres
    image: postgres
    env:
    - name: POSTGRES_PASSWORD
      value: postgres
    - name: PGDATA
      value: /var/lib/postgresql/data/pgdata
    resources:
      requests:
        memory: "256Mi"
        cpu: "30m"
      limits:
        memory: "512Mi"
        cpu: "1"
    livenessProbe:
      tcpSocket:
        port: 5432
    readinessProbe:
      tcpSocket:
        port: 5432
    volumeMounts:
    - mountPath: /var/lib/postgresql/data
      name: postgres
  volumes:
  - name: postgres
    gcePersistentDisk:
      pdName: postgres
      fsType: ext4

จะสังเกตว่าจาก Pod definition นั้น จะเพิ่มความสามารถมากกว่า Docker ดังนี้

  • Pod สามารถกำหนด resource request ได้ ซึ่ง Kubernetes จะไม่ยัด Pod ลงในเครื่องเดียวจนเกิน resource ที่มีอยู่ของเครื่องนั้น เช่น ถ้าเครื่องแรม 1GB ก็สามารถรัน Pod ที่ request แรม 256MB ได้ 4 ตัวเท่านั้น
    • สำหรับ CPU Request นั้น Kubernetes กำหนดให้ 1 core (vCPU) = 1 แต่ปกติแล้วโปรแกรมไม่น่าจะใช้ CPU 100% ตลอดเวลา เราก็จะซอย CPU เล็กลงไป เช่น 0.5 ก็คือครึ่ง CPU ปกติจะนิยมเขียนกันในหน่วย millicpu เช่นในตัวอย่าง 30m ก็เท่ากับ 0.03 CPU Core (3% ของ core)
    • ถ้า resource เต็มทุกเครื่อง Pod จะติดสถานะ Pending ไม่ได้รัน ฉะนั้นการกำหนด resource ให้เหมาะสมเป็นเรื่องสำคัญ
  • สำหรับ resource limit นั้นจะเหมือนกับของ Docker นั่นคือถ้าใช้แรมเกิน 512MB ปุ๊บ pod จะโดนยิงทิ้งทันที ส่วน CPU Limit นั้นจะถูก throttle แทน
    • การบังคับใช้ CPU Limit จะต้องใช้ Container-optimized OS เท่านั้นถึงจะมีผล
  • เราสามารถกำหนด livenessProbe/readinessProbe ได้ โดย Kubernetes จะ poll TCP เราเรื่อยๆ หรือจะให้ยิง web request ก็ได้ เพื่อตรวจสอบว่า Pod พร้อมทำงานอยู่ ถ้าหากไม่สามารถเชื่อมต่อได้ หรือ web request return error ก็จะทำดังนี้
    • ถ้าเป็น readinessProbe จะเอาออกจาก Load balancer
    • ถ้าเป็น livenessProbe pod จะถูกปิด

นอกจากนี้ Pod ยังสามารถ mount disk ได้ด้วย ซึ่งในตัวอย่างจะใช้ disk ชื่อ postgres ใน GCE (เราจะต้องสร้างเตรียมไว้ก่อน) เวลา Pod ไปอยู่เครื่องไหน Kubernetes ก็จะบอก Compute Engine ให้นำ disk ไปต่อกับเครื่องนั้น และในเครื่องนั้นก็จะเอาไปต่อเข้าใน container อีกที และถ้าลบ Pod ก็จะปลด disk ออกจากเครื่องนั้นด้วย

ReplicationController

ปัญหาของ Pod คือมันมีชีวิตอยู่ได้แค่รอบเดียว เจ้า ReplicationController จะทำหน้าที่สร้าง Pod อีกทีหนึ่งให้รักษาจำนวนที่กำหนดไว้ตลอด อาจจะเทียบกับ Autoscale Group ใน AWS ก็ได้

หน้าตา ReplicationController จะเป็นแบบนี้

apiVersion: v1
kind: ReplicationController
metadata:
  name: postgres
spec:
  replicas: 1
  selector:
    app: postgres
  template:
    metadata:
      labels:
        app: postgres
    spec:
      containers:
      - name: postgres
        image: postgres
        env:
        - name: POSTGRES_PASSWORD
          value: postgres
        - name: PGDATA
          value: /var/lib/postgresql/data/pgdata
        resources:
          requests:
            memory: "256Mi"
            cpu: "30m"
          limits:
            memory: "512Mi"
            cpu: "1"
        livenessProbe:
          tcpSocket:
            port: 5432
        readinessProbe:
          tcpSocket:
            port: 5432
        volumeMounts:
        - mountPath: /var/lib/postgresql/data
          name: postgres
      volumes:
      - name: postgres
        gcePersistentDisk:
          pdName: postgres
          fsType: ext4

จะสังเกตว่าข้างใน spec.template นี่มันก๊อปมาจาก spec ของ Pod ทั้งดุ้น และจะมีการกำหนด replicas: 1 แปลว่าเอา 1 Pod ถ้าจะเอาเยอะๆ ก็แก้เลขได้เลย

(ReplicationController ไม่ใช่ immutable สามารถแก้ไขเลขได้ตลอดเวลาด้วยคำสั่ง kubectl edit rc postgres หรือ kubectl scale rc postgres --replicas=10 ก็ได้)

พอสร้างตามนี้แล้วถ้าเรา kubectl get pod ออกมาดู จะเห็นว่าชื่อ Pod ที่สร้างขึ้นมีข้อความสุ่มต่อท้าย และถ้าเราลองลบ Pod นั้นดู ก็จะเห็นว่าจะมี Pod ใหม่ถูกสร้างมาทดแทนตลอดเวลา

(อ่านเบื้องหลังเกมนี้ได้ blog Google Cloud)

ถามว่าทำแบบนี้ดีกว่าไป stop/start container ปกติยังไง?
คำตอบคือ Pod ที่ถูกสร้างใหม่ไม่จำเป็นต้องอยู่ที่เดิม ซึ่งจะมีประโยชน์มากๆ เวลาเราจะ maintenance เครื่องเราสามารถใช้ท่านี้ได้

$ kubectl cordon gke-tipme-n1-a7ee1a90-mwzz
$ kubectl drain gke-tipme-n1-a7ee1a90-mwzz

โดยคำสั่ง cordon จะทำให้เครื่องที่ระบุไม่มีการนำ pod ใหม่นำมาติดตั้ง พอเราสั่ง drain จะทำให้ pod ทุกตัวในเครื่องนั้นจะถูกลบ ReplicationController ก็จะรีบสร้าง pod มาชดเชยในเครื่องอื่นๆ ทันที

(จริงๆ drain จะรวม cordon อยู่แล้ว ถ้า drain เล่นไปแล้วจะยกเลิกให้ uncordon node นั้นๆ แล้วอาจจะต้องไปลบ pod เพื่อให้มันกลับมาสร้างใหม่ที่เดิม)

ถ้า Pod เปิดวนได้หลายครั้ง ก็จะทำให้ย้ายเครื่องได้ไม่สะดวกเท่าลบสร้างใหม่เรื่อยๆ

Service

ปัญหาถัดมาคือถ้าเรามี pod หลายตัว แล้วเราจะติดต่อหา pod ยังไงดี? แถม pod IP ไม่ fix ด้วยนะ ถ้าโดนลบ pod ใหม่ก็จะเปลี่ยน IP ใหม่

วิธีการใน Kubernetes ก็คือเราจะต้องประกาศ service หน้าตาแบบนี้

apiVersion: v1
kind: Service
metadata:
  name: postgres
spec:
  selector:
    app: postgres
  ports:
    - port: 5432

จะสังเกตว่าใน spec จะมีการกำหนด selector ซึ่งจะไปตรงกับ labels ใน Pod อีกทีนึง ตรง labels นี้ไม่จำเป็นจะต้องใช้ app: postgres เสมอไป แต่จะใช้เป็น key-value อะไรก็ได้ เช่น tier: backend ก็ไม่ได้ห้ามอะไร (แต่ต้องตรงกับใน Pod นะ)

สำหรับ service ตัวอย่างก็จะประกาศว่า service postgres จะเปิด port 5432 ต่อเข้าหา pod ที่มี label postgres

เมื่อ add เข้าไปแล้วก็สามารถใช้คำสั่ง kubectl get services หรือย่อ kubectl get svc เพื่อดูสถานะได้

NAME           CLUSTER-IP      EXTERNAL-IP   PORT(S)    AGE
postgres       10.27.254.4     <none>        5432/TCP   19d

ก็จะเห็นว่า service จะมี IP เฉพาะตัวของมันเลย โดยด้านหลังสิ่งที่ Kubernetes ทำก็คือ เซต iptables DNAT ให้เวลาส่งข้อมูลมาที่ IP นี้ ถูก rewrite หา IP ของ Pod อีกทีหนึ่ง โดยจะมีการ load balance ให้ด้วย

เวลาจะต่อเข้าหา service นั้นก็สามารถใช้ชื่อ DNS เรียกว่า postgres ได้เลย ไม่จำเป็นจะต้องจำ IP ตรงๆ หรืออ่านจาก environment variable POSTGRES_SERVICE_HOST ก็จะได้ IP ออกมาเหมือนกัน

นี่คือบริการ service discovery ที่ใช้ใน Kubernetes แบบนึง (อีกแบบนึงก็คือไป query จาก Kubernetes API ตรงๆ ก็ได้เหมือนกัน)

Deployment

เมื่อกี้เราพบว่า ReplicationController สร้าง Pod แล้ว

Deployment คือผู้สร้าง ReplicationController อีกทีนึง (จริงๆ มันใช้ ReplicaSet ที่เป็น API ใหม่กว่า RC) และเป็นจุดขายของ Kubernetes เลยทีเดียว

โดย Deployment จะมีการทำงานแบบนี้

  1. เวลาสร้าง Deployment ครั้งแรก มันจะไปสร้าง ReplicaSet แล้ว ReplicaSet จะไปสร้าง Pod
  2. เราสามารถแก้ไข deployment ได้ ด้วยคำสั่ง kubectl apply -f deployment.yaml (ใส่พาธไปที่ไฟล์ที่เก็บ deployment ที่แก้แล้ว) หรือจะใช้ kubectl edit deployment name ก็ได้
  3. เมื่อแก้ไขแล้ว Deployment จะสร้าง ReplicaSet ขึ้นมาใหม่อีกอันหนึ่ง (เท่ากับว่ามี 2 ตัว)
  4. ReplicaSet ใหม่จะเปิด Pod ขึ้นมา 1 ตัว
  5. พอ Pod ตัวนี้สร้างเสร็จแล้ว (หรือถ้า config readinessProbe ไว้ก็จะรอจน Ready แล้ว) Deployment จะลดขนาด ReplicaSet เดิมลง 1 และเพิ่มขนาด ReplicaSet ใหม่อีก 1
  6. ทำซ้ำไปเรื่อยๆ จนกระทั่ง ReplicaSet ใหม่ขนาดเท่าเดิม และอันเดิมขนาดเหลือ 0

พูดง่ายๆ ก็คือ Deployment จะแทนที่ Pod เก่าด้วย Pod ใหม่ทีละ Pod ทำให้เวลา deploy แล้วระบบเราจะไม่มี downtime เกิดขึ้นเลย

นอกจากนี้ Deployment ยังจะเก็บ ReplicaSet ของเก่าไว้ด้วย นั่นแปลว่าถ้า deploy แล้วพังก็สามารถสั่ง kubectl rollout undo เพื่อถอยกลับได้อีกด้วย

หน้าตาของ Deployment จะเป็นแบบนี้

apiVersion: apps/v1beta1
kind: Deployment
metadata:
  name: tm-static
spec:
  replicas: 1
  template:
    metadata:
      labels:
        app: tmstreamlabs
        service: static
    spec:
      containers:
      - name: static
        image: registry/tmstreamlabs:version
        command: [/caddy, -conf, /app/Caddyfile]
        resources:
          requests:
            memory: "64Mi"
            cpu: "1m"
        readinessProbe: &probe
          httpGet:
            path: /js/runtime.js
            port: 3030
        livenessProbe: *probe

(ตรง readinessProbe/livenessProbe ผมให้มันก๊อปกันมาโดยใช้ YAML Anchor ที่ต้องทั้งใส่ 2 แบบเพราะต้องการให้เช็คตอน deploy ว่า ready แล้วหรือยัง และเวลารันอยู่ถ้าค้างก็ให้ kill ไปเลย ไม่ใช่แค่เอาออกจาก service)

อ้อ ถ้าใครชอบไม่ติด tag Docker image ใช้แต่ latest จะใช้ Deployment ไม่ได้ เพราะ apply ไปแล้วมันจะไม่เจอว่าแก้อะไร

Namespace

สุดท้ายถ้าหากเราใช้ cluster กันหลายๆ project สามารถแยกของไม่ให้ปนกันได้ด้วยการใช้ Namespace แบบนี้

apiVersion: v1
kind: Namespace
metadata:
  name: app2

เวลาเราจะทำอะไรก็เรียก kubectl --namespace app2 ... ก็จะเห็นเฉพาะของที่อยู่ใน namespace นั้นๆ และเวลาเรียก service ก็จะพิมพ์ชื่อได้เฉพาะใน namespace เดียวกันเท่านั้น

(แต่ไม่ได้บล็อคนะ แค่ต้องพิมพ์ชื่อเต็มๆ ของ service คือ postgres.default)

โดยปกติแล้ว Kubernetes จะมี namespace สำคัญๆ 2 อัน ก็คือ default ที่เราใช้ได้เวลาไม่ระบุชื่อ namespace และ kube-system ที่เก็บ service ของ Kubernetes

สรุป

จากตอนนี้เราจะเห็นว่า Kubernetes มีการออกแบบตามสไตล์ Design for failure มากๆ ถ้าอะไรสักอันพังไปใน cluster ก็จะไม่ส่งผลกระทบต่อการทำงาน และเผลอๆ จะสามารถกู้ได้เองอีกด้วย

  • ถ้า Master ตาย ระบบยังสามารถทำงานได้ปกติ แค่ไม่สามารถ schedule pod ใหม่ได้
  • ถ้า Node ตาย Pod ก็จะถูก ReplicationController สร้างใหม่ที่เครื่องอื่นๆ
  • ถ้า Pod ค้าง ก็จะตรวจพบจาก livenessProbe แล้ว kill ทิ้งไปสร้างใหม่

บางจุดอาจจะเห็นว่าใช้งานซับซ้อนพอสมควร แต่ก็เป็นวิธีที่ดีถ้าหากเราต้องการระบบที่เสถียรจริงๆ

ในตอนหน้าจะลองมาพูดถึงของเล่นที่ advance ไปกว่าตอนนี้กันบ้างครับ