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 ไปซะก่อนได้หรือเปล่า

Docker: init daemon

หัวข้อนี้จะลงลึกไปถึงระบบลินุกซ์นะครับ แต่ก็เป็นจุดที่คนใช้ Docker ควรจะทราบไว้เพราะเป็นอะไรที่้ใช้ OS ปกติไม่เจอ ใช้ Docker จะเจอ แล้วเราจึงจะต้องเตรียมรับมือเอาไว้อย่าง signal forwarding

Signal forwarding

เมื่อเราสั่ง sudo docker stop นั้น Docker จะส่งสัญญาณ SIGTERM ให้โปรแกรมแรกใน container (process ID 1 หรือ PID 1) ซึ่งโปรแกรมทั่วๆ ไปเมื่อได้รับสัญญาณนี้แล้วก็จะปิดตัวเองไป หรือเราอาจจะเขียนโค้ดมาให้โปรแกรมเซฟข้อมูลก่อนก็ได้ (แทบทุกภาษาทำได้ครับ แม้แต่ shell script)

ปัญหาก็คือ… แน่ใจได้อย่างไรว่าโปรแกรมของเราได้รับสัญญาณจาก Docker เพราะถ้าไม่ได้รับแล้วสั่ง stop ไปมันก็จะไม่หยุดทำงาน (จน Docker timeout แล้ว kill ทิ้งแบบไม่สนใจ)

  • จุดแรกที่บางคนอาจจะไม่เคยสังเกตเลยคือ ถ้าเราใช้คำสั่ง CMD/ENTRYPOINT ในรูปแบบ string (CMD x) โปรแกรมแรกที่เปิดใน container คือ sh -c "x" ทำให้ sh เป็น PID 1 ไม่ใช่โปรแกรมของเรา วิธีที่ถูกคือต้องใช้ในรูปของ array (CMD ["x"]) (เลิกใช้แบบ string เถอะครับ ไม่มีเหตุผลจำเป็นเลย)
  • จุดที่สองคือคำสั่ง su/sudo นั้นก็ทำตัวเป็น PID 1 ได้ครับ ถ้าจำเป็นต้องใช้จริงๆ แนะนำ gosu ซึ่งมันทำงานเสร็จแล้วมันจะสลายตัวไปเอง ทั้งนี้ถ้าใช้ในลักษณะ shell script ใช้ sudo ก็ไม่ผิดครับ
  • จุดถัดมา บางคนเอา shell script เป็น entrypoint เพื่อให้มัน setup ก่อนเรียกโปรแกรม อันนี้ก็ต้องตรวจดูว่าการเรียกโปรแกรมของเรานั้นจะต้องใช้คำสั่ง exec นำหน้าด้วยนะครับ เช่น exec python /app/main.py เพื่อให้ sh ถูกแทนที่ด้วย python ไม่ใช่ให้ sh เป็นแม่ของ python

เหตุผลคือโปรแกรมทั้ง sh/bash/su/sudo นั้นจะไม่มีการส่งต่อสัญญาณหาโปรแกรมลูกครับ

ปัญหายังไม่จบครับ ปัญหาต่อมาคือโดยปกติ OS จะมี default behavior เวลาได้รับ signal ต่างๆ แต่พอโปรแกรมถูกรันเป็น PID 1 OS จะไม่มี default ให้ครับ นั่นคือถ้าไม่ได้เขียนระบุไว้ว่า SIGTERM ให้ปิด โปรแกรมของเราจะไม่ทำอะไรเลย (ใน Docker เองถ้า stop 10 วินาทีแล้วไม่ปิดมันจะ kill ทิ้งครับ นี่คือเหตุผลที่ stop โปรแกรมบางตัวนานมาก)

ปัญหานี้จะหมดไปถ้า container ของเราทำงานตามหลักที่ OS กำหนดให้ครับ โดยวิธีง่ายที่สุดก็คือการลง init daemon

Init daemon

ในระบบ UNIX ทั่วๆ ไปแล้วโปรแกรม process ID 1 นั้นจะเป็นโปรแกรมประเภท init daemon

init ที่ใช้กันก็มีหลายเจ้า ไม่ว่าจะเป็น sysvinit, upstart ของ Ubuntu, systemd ที่กำลังมาแรงในขณะนี้, OpenRC หรือแม้แต่ OS X ก็มี init ของตัวเองชื่อ launchd แต่ใน Docker เราจะไม่เอาโปรแกรมพวกนี้มาใช้ เพราะมันไม่ได้ออกแบบมาเหมาะสำหรับ container เท่าไร (ผมเคยมี container ที่รัน systemd เวลารันต้อง mount ไฟล์ระบบเข้าไป แถม host os ต้องเป็น systemd อีกต่างหาก)

เท่าที่เห็นกันในโลก Docker ตัวที่ใช้หลักๆ มีดังนี้ครับ

tini

สำหรับการใช้งาน tini นั้นค่อนข้างง่ายครับ เพิ่มใน Dockerfile ว่า

ENV TINI_VERSION v0.10.0
ADD https://github.com/krallin/tini/releases/download/${TINI_VERSION}/tini /tini
RUN chmod +x /tini
ENTRYPOINT ["/tini", "--"]

หรือถ้าใช้ alpine ก็

RUN apk add --no-cache tini
ENTRYPOINT ["/sbin/tini", "--"]

(โปรแกรมของเราให้ใช้ CMD ใส่ได้เหมือนเดิมครับ)

s6

อีกตัวหนึ่งที่อยากแนะนำตัว และผมใช้อยู่ปัจจุบันคือ s6 ครับ โดย s6 จะเหมือนกับ init ทั่วไปของลินุกซ์มากกว่า คือสามารถ start process หลายๆ ตัวได้ด้วย และมีความสามารถอื่นๆ หลายอย่าง เช่นคำสั่งแทน sudo, logrotate แต่มีขนาดเล็ก

ผมใช้ s6 กับ s6-overlay ซึ่งเหมือนเป็น s6 ที่ config เพิ่มความสามารถเข้ามาดังนี้ครับ

  • การ set permission file ก่อนรัน (อาจจะใช้กับ volume ที่ mount มาตอน runtime)
  • รันคำสั่งก่อนเริ่มทำงาน

วิธีการใช้ s6-overlay ไม่ยาก ทำตาม guide ของเค้าได้เลย

ติดตั้ง

วิธีติดตั้งไม่ยากครับ ให้ใส่ใน Dockerfile ว่า

ADD https://github.com/just-containers/s6-overlay/releases/download/v1.11.0.1/s6-overlay-amd64.tar.gz /tmp/
RUN tar xzf /tmp/s6-overlay-amd64.tar.gz -C /
ENTRYPOINT ["/init"]

แล้วโปรแกรมของเราก็ใช้ CMD ["..."] ได้ตามปกติเลย หรืออาจจะใช้วิธี start service จาก config ก็ได้ครับ

Config

สำหรับไฟล์ config หลักๆ ของ s6-overlay มีดังนี้ครับ

/etc/fix-attrs.d/

สำหรับ folder นี้จะถูกรันเป็นลำดับแรก โดยมีหน้าที่ setup permission ต่างๆ มีรูปแบบดังนี้

/var/lib/mysql true mysql 0600 0700

แปลว่าให้กำหนดให้ /var/lib/mysql เป็นของ user mysql (สามารถระบุแบบ user:group ได้เลย) ให้ไฟล์ภายในมีสิทธิ์ 0600 โฟลเดอร์ 0700 และให้เซตแบบ recursive ด้วย (จากคำสั่ง true)

สำหรับการตั้งชื่อไฟล์ตั้งอะไรก็ได้ครับ แต่โดยปกติแล้วจะตั้งเป็น 01-name เพื่อจะได้กำหนดลำดับการรันก่อน-หลังได้

/etc/cont-init.d

ใน folder นี้จะเก็บ shell scripts ที่จะรันก่อนเปิดโปรแกรมของเรา อย่าลืม chmod +x ให้ไฟล์ด้วยนะครับ และตั้งชื่อไฟล์ปกติก็จะเหมือนกันคือ 01-name

shell script ใน s6 จะไม่เห็น environment ที่เรากำหนดเข้ามา (docker run -e ...) นะครับ ถ้าจะให้เห็นให้เราเปลี่ยน #!/bin/sh เป็น #!/usr/bin/with-contenv sh

/etc/services.d

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

(แต่อย่าลืมว่า container ไม่ใช่ VM รันที่จำเป็นจริงๆ ก็พอ ถ้าเป็นไปได้แยกโปรแกรมละ container ไปเลยยิ่งดี)

วิธีใช้งานคือให้สร้าง folder ย่อยลงไป เช่น /etc/services.d/nginx/ แล้วด้านในมีไฟล์ชื่อ run (อย่าลืม chmod +x ไฟล์นี้) เป็น shell script เขียนคำสั่งที่ต้องการรัน (อย่าลืมใช้คำสั่ง exec)

ถ้าโปรแกรมของเราปิดตัว s6 จะพยายาม restart ให้เองครับ ถ้าไม่ต้องการให้เราสร้างอีกไฟล์มาชื่อ finish (เช่นกัน chmod +x ด้วย) และเขียนข้างในว่า

#!/usr/bin/execlineb -S0

s6-svscanctl -t /var/run/s6/services

โดย finish จะเป็น script ที่รันทุกครั้งที่โปรแกรมเราถูก restart และคำสั่งในนี้ก็คือคำสั่งบอกให้ s6 ปิดตัวนั่นเอง

s6 ยังมีฟีเจอร์อีกมากมาย ลองศึกษาได้จากเว็บของ s6 เอง และของ s6-overlay ดูครับ

ไม่ใช้ init ได้ไหม

หน้าที่ที่ PID 1 ที่ OS กำหนดให้มีดังนี้ครับ

  1. ส่งสัญญาณที่ OS ให้มาไปหาโปรแกรมลูก
  2. ทำตามสัญญาณที่ OS ส่งมาให้ ซึ่งปกติโปรแกรมทั่วไป OS จะเซต default ให้ แต่ไม่ใช่กับ PID 1
  3. ถ้ามี zombie process (process ที่ตายแล้วและ process ผู้สร้างมันก็ตายไปแล้วเช่นกัน) PID 1 จะต้องบอก OS ว่าไม่ใช้งานแล้ว เพื่อให้ OS คืนหน่วยความจำ

จะเห็นว่าหน้าที่พวกนี้โปรแกรมของเราไม่ได้ทำเลยครับ (แม้แต่ sh/bash ก็ไม่ได้ทำ) ฉะนั้นแล้วการใช้ init จึงจะทำให้ container ของเราทำงานได้อย่างที่เราคิดไว้ คือสั่ง stop แล้วปิดได้จริงๆ ถ้าโปรแกรมของเรามีการเรียกโปรแกรมอื่นๆ ก็จะทำให้ไม่เกิด memory leak ขึ้น

ตรงข้อ 3 นี้ถ้าไม่ได้แยก process ลูกไปมั่วๆ ก็ไม่จำเป็นเท่าไรครับ และโปรแกรมบางตัวมีการจัดการ SIGTERM เองอยู่แล้ว (เพราะจะทำอะไรบางอย่างก่อนปิดโปรแกรม) เราเลยจะเห็นว่าอิมเมจของโปรแกรมดังๆ จะไม่ได้ใช้ init เท่าไร แต่ในโปรแกรมของเราก็ลองพิจารณาดูครับ