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 เท่าไร แต่ในโปรแกรมของเราก็ลองพิจารณาดูครับ