ใช้ Kubernetes pause ใน Docker เปล่าๆ

สำหรับใครที่ใช้ Kubernetes และเคยเข้าไป list container ดูภายใน node ก็จะเคยเห็น container อันนึงชื่อ pause ซึ่งจะมี 1 pause ต่อ 1 pod

EKS Pause เขียนอธิบายหน้าที่ของมันไว้ว่า

The Kubernetes pause container serves as the parent container for all of the containers in a pod. The pause container has two core responsibilities. First, it serves as the basis of Linux namespace sharing in the pod. Second, with PID (process ID) namespace sharing enabled, it serves as PID 1 for each pod and reaps zombie processes. It is responsible for creating shared network, assigning IP addresses within the pod for all containers inside this pod. If the pause container is terminated, Kubernetes will consider the whole pod as terminated and kill it and reschedule a new one.

แปลได้ว่า

Container pause ของ Kubernetes มีหน้าที่เป็น container หลักสำหรับทุก container ใน pod เดียวกัน โดย pause container มี 2 หน้าที่คือ

  1. เป็นฐานของการแชร์ Linux namespace ภายใน pod
  2. ถ้าหากเปิดการแชร์ PID (process ID) namespace ไว้ด้วยกัน มันก็จะเป็น PID 1 สำหรับแต่ละ pod ทำหน้าที่เก้บ zombie process

หน้าที่รับผิดชอบของมันคือสร้าง network ที่แชร์ กำหนด IP ภายใน pod สำหรับทุก container

ถ้าหาก pause container ถูกปิด Kubernetes จะถือว่าทั้ง pod หยุดทำงานและจะสร้าง pod ใหม่

ฟังดูยุ่งๆ ไม่เข้าใจ แต่ผมพบว่าหลังๆ มา ผมมี use case เรื่อง namespace sharing ใช้ใน Docker เปล่าๆ หลายทีแล้ว ก็เลยอยากมาให้ดูว่า namespace sharing คืออะไรและใช้นอก Docker อย่างไร

Network sharing

Is this cattle or pet

ใครที่เคยทำหลาย container ต่อ 1 pod ใน Kubernetes (sidecar pattern) จะทราบว่า localhost บน 2 container นั้นจะแชร์กัน อยากคุยกันก็ยิงผ่าน localhost ได้ หรือถ้ามีการเปิด port จะเปิดทับกันไม่ได้ ซึ่งผมพบว่าผมต้องการทำแบบนี้แต่ไม่ใช้ Kubernetes

ช่วงนี้ผมเริ่มย้าย Network ภายในบางส่วนมาใช้โปรแกรม Tailscale ซึ่งเป็นโปรแกรม VPN รูปแบบหนึ่ง ฟีเจอร์หนึ่งของ Tailscale คือสามารถแชร์เครื่องให้กับคนภายนอกได้ ใน use case ที่ผมต้องการใช้ก็คือจะเล่นเกม Minecraft กับคนอื่น ถ้าหากเปิด IP Minecraft ออก Internet แล้วไม่ดูแลก็อาจจะถูก scan และ Hack network ภายในได้ ก็เลยจะต้องเข้ามาใน VPN ก่อนจึงจะเล่นได้

ในอดีต use case ลักษณะนี้ที่จะนิยมกันก็คือใช้โปรแกรม Hamachi แต่ว่าโปรแกรม Hamachi นั้นจะมองเห็นเป็น LAN เดียวกันเข้าถึงกันได้หมด แปลว่าถ้าหากเครื่องที่เข้ามาต่อมี firewall ภายในไม่ถูกต้องอาจจะทำให้เพื่อนล้วงข้อมูลของเราได้ หรือติด worm ต่อกัน สำหรับ Tailscale นั้นจะใช้ระบบให้ 1 คน 1 network ของใครของมัน แล้วผมสามารถแชร์เครื่องให้เพื่อนได้ซึ่งจะทำให้มองเห็นเฉพาะเครื่องนั้นอยู่บน network ของเพื่อน (เครื่องที่แชร์จะไม่สามารถเปิด connection ออกไปหา network คนอื่นได้ ทำให้เราไม่สามารถ scan network เพื่อนได้เช่นกัน)

วิธีการใช้งาน Tailscale ที่ดีก็คือติดตั้งเป็น sidecar ของ Container ทำให้เราเห็น 1 container = 1 machine ใน Tailscale แล้วเราแชร์ออกไปเฉพาะ container นั้นได้ แปลว่าใครเล่น Minecraft กับผมก็เข้ามา Factorio server ไม่ได้ ถ้าไม่รู้ลิงค์แชร์ Factorio

ทีนี้เราก็เลยจะต้องติดตั้ง Tailscale บน “เครื่อง” เดียวกับ Minecraft หรือก็คือเราต้องทำให้ localhost เดียวมีทั้ง Tailscale + Minecraft ผมก็นึกถึงท่า Kubernetes ขึ้นมาเลยว่าภายใน pod ก็เป็นแบบนั้นเป๊ะ

ดังนั้น Docker compose ก็จะเป็นลักษณะนี้

version: "2.2"
services:
  minecraft:
    restart: unless-stopped
    image: itzg/minecraft-server
    tty: true
    stdin_open: true
    environment:
      EULA: 'true'
    volumes:
      - /path/to/minecraft:/data:Z
  tailscale:
    image: ghcr.io/tailscale/tailscale:latest
    restart: unless-stopped
    network_mode: service:minecraft
    cap_add:
      - NET_ADMIN
      - NET_RAW
    volumes:
      - ts_data:/state/
    devices:
      - /dev/net/tun
    environment:
      TS_HOSTNAME: minecraft
      TS_STATE_DIR: /state/
      TS_USERSPACE: 'false'
      TS_DEBUG_FIREWALL_MODE: nftables
volumes:
  ts_data: {}

ในส่วนของ Tailscale container เราจะกำหนด TS_USERSPACE=false เพราะเราจะให้ CAP_NET_ADMIN กับ container ไปเลย ซึ่งเทียบเท่ากับ privileged container ใน Kubernetes และจะทำให้ preserve source IP ไว้ได้ (Minecraft จะเห็น source IP จริง ไม่ใช่ 127.0.0.1) และมีการกำหนด network_mode: service:minecraft เพื่อบอกว่ามีการแชร์ network namespace ร่วมกับ minecraft container ทำให้ localhost เห็นทะลุกันเหมือนกับใน Kubernetes pod

ท่านี้ก็เหมือนจะดี จนกระทั่งผมเริ่ม mod Minecraft แล้วต้อง restart ก็พบว่า Tailscale network หลุด ใช้งานไม่ได้ ต้องลบสร้างใหม่อย่างเดียว และ docker-compose มันไม่ลบด้วยเพราะเราไม่ได้แก้อะไร ที่เป็นแบบนี้เพราะว่า network card หายไปแล้วตอนที่ Minecraft หยุดทำงาน ทำให้ Tailscale ไม่มีช่องทางเชื่อมต่อเน็ตอีก

ดังนั้นถ้าเรามี container ที่รันนานๆ ไม่ต้อง restart ไม่ crash เลย ก็น่าจะแก้ไขปัญหานี้ได้ ซึ่งทำให้ผมนึกถึงว่านี่คือเหตุผลเดียวกับที่ Kubernetes มี pause container ดังนั้นเราสามารถขโมย pause container ออกมาใช้ได้

services:
  pause:
    image: public.ecr.aws/eks-distro/kubernetes/pause:3.9
    restart: unless-stopped

(แล้วแก้ container อื่นๆ ให้มี network_mode: service:pause)

อันนี้คือผมขโมย pause container ของ EKS มาเลย ซึ่งน่าจะเป็น Kubernetes ที่น่าเชื่อถือตัวหนึ่งและมันหาลิงค์ง่ายดี หลังจากใช้ pause แล้วเราก็สามารถ restart Minecraft ได้เรื่อยๆ ไม่ต้อง recreate Tailscale อีกต่อไป ซึ่งดีมากๆ เพราะว่าอีกเกมหนึ่งที่เล่นกันคือ Satisfactory แล้วมัน crash บ่อยมาก ก็ปล่อย Docker restart ให้ได้เลยไม่ต้องเข้าไป recreate Tailscale ตามหลัง

PID sharing

อีกท่าหนึ่งที่ผมมีใช้งานก็คือการ share PID กัน ซึ่ง use case ที่จะใช้ก็คือผมมี container Certbot และ application ต่างๆ ที่ใช้ Cert ใน container อื่นๆ โดยแชร์ volume ให้เป็น readonly

ปัญหาก็คือปกติแล้วเวลา certbot มัน update cert ให้ มันจะ restart nginx ให้ แต่มันอยู่คนละ container กันจะทำอย่างไรดี ท่าต่อไปนี้ก็ไม่ควรทำ

  1. Mount Docker socket เข้าไปให้ certbot ก็จะทำให้มันยุ่ง container ที่ไม่เกี่ยวข้องได้
  2. Restart container อื่นด้วย cronjob ซึ่งก็พอใช้ได้ แต่มัน hack ไปหน่อย โปรแกรมบางตัวอาจจะต้อง restart ตอนเปลี่ยน cert ถ้าทำทุกวันอาจจะ disruptive
  3. Run certbot ใน host อาจจะยากตรงที่ต้องหา binary ที่มี plugin ที่ต้องการใช้งานให้ครบ
  4. ไม่ควรดัดแปลง container ให้มีหลายๆ process เช่นมี supervisor + nginx + cron + certbot

วิธีที่ทำได้ก็ยังใช้ pause container เช่นเดิม โดย

  1. สร้าง pause container ไว้ก่อน
  2. Run certbot container โดยกำหนด --pid pause เพื่อแชร์ pid namespace กับ pause
  3. Run application container โดยกำหนด --pid pause เช่นกัน

จากนั้นใน certbot เราก็ install hook ให้ killall -s HUP nginx ก็เป็นอันเรียบร้อย เพราะอยู่ใน namespace เดียวกันสามารถส่ง signal ไปหา process อื่นๆ ภายใน namespace ได้เลย

ข้อควรระวังคือไม่ควรมี container อยู่ใน pid namespace โดยไม่จำเป็นเพราะมันสามารถ list process ข้ามกันได้ และถ้า user ID ตรงกันหรือเป็น root ใน container ก็ kill process ข้ามกันได้

1 เดือนกับ Google Cloud Container Builder

Google Cloud แอบ launch Container Builder มาสักพักแล้วครับ (ยังไม่ขึ้นในหน้า product list) ก็เลยว่าจะลองเล่นสักหน่อย

Background

เดิมที TMStreamlabs อยู่บน GitLab ครับ และใช้ Docker ในการ deploy มาตั้งแต่ต้นอยู่แล้วเพราะบน Infra ใหม่ ทุกอย่างต้องเป็น Docker หมด

ของเดิมเลยผมจะใช้ GitLab CI มา build image และเก็บไว้บน GitLab Registry ซึ่งก็โอเคดียกเว้นว่า GitLab Registry pull ช้ามาก

ย้ายบ้าน

หลังๆ GitLab เริ่มไม่นิ่ง ผมก็เลยว่าได้เวลาย้ายบ้านแล้ว พอดีนึกออกว่า GitHub (Edu) ให้ private repo ไม่อั้นมาเป็นปีแล้ว ก็เลยย้ายไป GitHub พร้อมๆ กับย้าย builder มาใช้ Container Builder ซึ่งเค้าว่ามันเร็ว

สำหรับ Container Builder มีสมบัติดังนี้ครับ

  • (เค้าว่า) มันเร็ว เพราะใช้ network ของ Google
  • Builder รันบนเครื่อง n1-standard-1 ซึ่งได้ Dedicated CPU Core เลย ในขณะที่ GitLab ใช้ DigitalOcean ที่จะ share core ระหว่างผู้ใช้
  • ผมเดาว่า builder อยู่ในอเมริกา ซึ่งผมก็ส่ง feature request ไปแล้วว่าอยากให้เลือกที่อื่นได้
  • Parallel build step ได้ แต่ parallel build ทั้งหมดไม่ได้ยกเว้นข้าม project
  • ราคาไม่แพงเท่าไร และฟรีวันละ 120 นาทีซึ่งผมยังไม่เคยใช้หมด
  • โค้ดเราจะโดน mirror จาก GitHub มาใส่ Google Cloud Source Repositories

ซึ่ง build ของ TMStreamlabs ก็ไม่มีอะไรอยู่แล้วครับ ใช้ Dockerfile ได้เลย แล้วก็จะมี test step อีกทีนึง (เรารัน test หลัง build เพราะต้องการ test ด้วย container ผลลัพท์จริงๆ ซึ่งจะจับบั๊กบางประเภทได้ด้วย เช่นลืมลง shared library) วิธีการก็ไม่ยากครับ ใน cloudbuild.yaml ก็เขียนไปว่า

steps:
- name: gcr.io/cloud-builders/docker
  args: ['build', '-t', 'asia.gcr.io/$PROJECT_ID/tmstreamlabs', '.']
- name: gcr.io/cloud-builders/docker
  entrypoint: /workspace/scripts/test.sh
  env:
    - IMAGE_NAME=asia.gcr.io/$PROJECT_ID/tmstreamlabs
images:
  - asia.gcr.io/$PROJECT_ID/tmstreamlabs

Shared workspace

ความเจ๋ง (?) ของ Container Builder คือ folder ที่ build จะแชร์กันข้าม build step ครับ จะต่างกับ GitLab ตรงที่ GitLab จะต้องบอกให้มันก๊อปออกมาเอง (เพราะอาจจะรัน build step ที่เครื่องอื่น) ฉะนั้นแล้วมันทำให้เราเล่นอะไรได้สะดวกมากๆ

ตอนหลังผมเลยจัดชุดใหญ่เลยครับ ด้วยการเพิ่ม Gulp + Webpack เข้ามาใน project Django ซึ่งเดิมทีผมตั้งคำถามหลายรอบว่าจะรัน build step แบบนี้ตรงไหนดี ก็พบว่า pattern แบบนี้แหละเวิร์คที่สุดแล้ว

steps:
- name: yarnpkg/node-yarn
  args: ['./scripts/minify.sh']
- name: gcr.io/cloud-builders/docker
  args: ['build', '-t', 'asia.gcr.io/$PROJECT_ID/tmstreamlabs', '.']
- name: gcr.io/cloud-builders/docker
  entrypoint: /workspace/scripts/test.sh
  env:
    - IMAGE_NAME=asia.gcr.io/$PROJECT_ID/tmstreamlabs
images:
  - asia.gcr.io/$PROJECT_ID/tmstreamlabs

สังเกตว่าเราจะเพิ่มอีก step ไปก่อนหน้าครับ โดยจะรัน minify.sh ซึ่งข้างในก็จะรัน yarn install + gulp แล้วก็ลบ node_modules ทิ้งไป

สำหรับในฝั่ง Django จะเซตใน settings.py ดังนี้ครับ

built_static_root = os.path.join(BASE_DIR, 'static')
if os.path.isdir(built_static_root):
    STATICFILES_DIRS = [built_static_root]

ซึ่ง script Gulp ของผมนั้นจะไป scan หา /static/js/ แล้ววิ่งผ่าน uglifyjs เข้าไปเก็บที่ static/js/* ด้านนอก ทำให้ตอน dev ก็จะเรียกไฟล์ไม่ minify เวลา compile แล้วก็จะเรียกไฟล์ minify ได้เลย และวิ่งผ่าน collectstatic ได้ด้วยทำให้สามารถใช้ Manifest static file storage ได้ (สำหรับไฟล์ที่ใช้ webpack จะเก็บแยกนอก static ครับ ซึ่งเวลา dev ต้องเปิด gulp watch ค้างไว้ให้มัน build ตลอด)

Parallel Step

ตอนแรกๆ Parallel step มีบั๊กครับ คือ schema จะ validate ไม่ผ่านเลย แต่ตอนนี้เหมือนจะแก้แล้ว ทำให้เราสามารถรัน step หลายๆ อันพร้อมกันได้ เช่นแบบนี้

steps:
- name: yarnpkg/node-yarn
  args: ['./scripts/minify.sh']
- name: gcr.io/cloud-builders/gsutil
  args: ['cp', '-r', 'gs://tmsbuildassets/', '/workspace/gs/']
  waitFor: ['-']
- name: gcr.io/cloud-builders/docker
  args: ['build', '-t', 'asia.gcr.io/$PROJECT_ID/tmstreamlabs', '.']

# prepull images in use
- name: gcr.io/cloud-builders/docker
  args: ['pull', 'mariadb:latest']
  waitFor: ['-']
- name: gcr.io/cloud-builders/docker
  args: ['pull', 'redis:alpine']
  waitFor: ['-']
- name: gcr.io/cloud-builders/docker
  args: ['pull', 'willwill/wait-for-it']
  waitFor: ['-']

- name: gcr.io/cloud-builders/docker
  entrypoint: /workspace/scripts/test.sh
  env:
    - IMAGE_NAME=asia.gcr.io/$PROJECT_ID/tmstreamlabs
images:
  - asia.gcr.io/$PROJECT_ID/tmstreamlabs

นี่คือทั้งไฟล์ที่ใช้อยู่แล้วครับ ซึ่งจะเห็นว่ามันจะ pull images ทั้งหมดมาพร้อมกันเลย และพอเราบอกว่ามันไม่ depends on อะไรเลยจะทำให้มัน pull พร้อมๆ กับรัน yarn เลย

อีกอันนึงที่จะเห็นนะครับ คือผมใช้ gsutil ใน build script ด้วย ซึ่งเจ้าเครื่อง container builder นี่จะมีสิทธิ์พิเศษใน IAM (จะเขียนว่า Google APIs service account อีเมล projectid@cloudbuild.gserviceaccount.com) ทำให้เราสามารถ grant สิทธิ์ให้เครื่อง builder โดยเฉพาะได้ ไม่ต้องเปิด public

ข้อเสีย

สำหรับตอนนี้ข้อเสียที่เจอคือ error reporting มันไม่ดีเลยครับ เพราะ build เสร็จมันจะเงียบไปเลย ในขณะที่ GitLab CI จะเมลสถานะกลับมา ตรงนี้อาจจะต้องเอา image อีกอันมา trigger service แจ้งเตือนอีกที ซึ่งก็ไม่รู้ว่ามันจะทำให้ระบบไม่หยุดทำงานตอนเจอ error ไปซะก่อนได้หรือเปล่า