Wall of Text #15: Who compile the compiler

เป็นคำถามที่สงสัยตั้งแต่เด็กๆ ว่าโปรแกรมเราเขียนใน Python แล้วเวลารันต้องใช้ Python interpreter ที่โหลดในเน็ตมา ตัวไฟล์ Python interpreter มันสร้างขึ้นมาได้ยังไง

คำตอบก็ไปดูง่ายๆ คือ CPython ที่โหลดในเว็บมันเขียนด้วยภาษา C ก็เลยใช้ C compiler สร้างไฟล์นั้นขึ้นมา แล้วใครสร้างไฟล์ของ C compiler?

Linux from Scratch

ตอนประมาณ ม. ต้นเคยทำ Linux from Scratch ซึ่งเป็นหนังสือที่สอนวิธีสร้าง Linux ตั้งแต่ต้นด้วยตนเอง ไม่ได้สร้างมาจาก Linux อื่นๆ ที่มีอยู่แล้ว เขาจะมีแผ่นซีดีมาให้ซึ่งมีโปรแกรมพื้นฐานให้ประมาณหนึ่ง (Linux จอดำ) จากนั้นให้เรา compile โปรแกรมต่างๆ

ในเล่มบอกว่าตัว C compiler ที่ใช้คือโปรแกรม GCC ซึ่งเขียนด้วยภาษา C โดยให้เราเอา C compiler ในแผ่นซีดี compile GCC source code ก็แปลว่าต้องมี C compiler ก่อน จึงจะคอมไพล์ C compiler ได้ ก็เลยเป็นปัญหาไก่กับไข่ แต่มันตอบคำถามหนึ่งคือถ้าเราสร้างคอมพิวเตอร์สถาปัตยกรรมใหม่ (new computer architecture) ขึ้นมาเราจะเอา C compiler มาจากไหน โดยวิธีที่เขาเขียน สมมุติว่าผมสร้างเครื่องสถาปัตยกรรม whs32 ขึ้นมา และใช้เครื่อง amd64 เป็นเครื่อง desktop ให้ทำดังนี้

  1. แก้ไขโค้ด gcc เพิ่มให้สามารถ generate machine code ของ whs32 ได้
  2. เอา compiler ในเครื่อง amd64 ที่ generate machine code ของ amd64 มา compile gcc ตัวใหม่ เรียกว่า stage1 ซึ่งยังรันบน amd64 แต่ให้ผลลัพท์เป็นโค้ดที่รันใน whs32 (gcc สามารถเลือกให้ output machine code เป็นสถาปัตยกรรมเครื่องใดๆ ก็ได้ ไม่จำเป็นต้องเป็นเครื่องที่รันอยู่)
  3. เอา stage1 ที่รันในเครื่อง amd64 มา compile ตัวเอง จะได้ gcc อีกตัวเรียกว่า stage2 ซึ่งรันบน whs32 และให้ผลลัพท์เป็นโค้ดที่รันใน whs32
  4. เอา stage2 รันบนเครื่อง whs32 มา compile ตัวเองใหม่ จะได้ stage3 เป็นอันเสร็จ
    • ที่ต้องทำ stage3 เพราะว่า stage2 อาจจะมีการอ้างอิง library, path ต่างๆ บนเครื่อง amd64 ทำให้ไปใช้งานจริงบนเครื่องอื่นไม่ได้ วิธีแก้ไขคือปิดฟีเจอร์ที่ไม่จำเป็นใน stage2 ออกไปก่อนเพื่อให้ compiler จบในตัว แล้วค่อย compile stage3 ที่มีฟีเจอร์ครบถ้วนในระบบจริง
    • เวลา gcc compile ตัวเอง แบบไม่ข้ามสถาปัตยกรรม มันจะ compile 3 ครั้ง
      1. ใช้ stage2 compile ตัวเองจาก source code เรียกว่า stage3.1
      2. ใช้ stage3.1 compile ตัวเองอีก 2 ครั้ง เรียกว่า stage3.2 และ stage3.3
      3. ตรวจสอบว่า stage3.2 = stage3.3 เพื่อทดสอบว่า compiler ทำงานได้ถูกต้อง
      4. ใช้ stage3.3 เป็นผลลัพท์ stage3

สรุปแล้วเราก็พอเข้าใจว่าถ้าสร้างชิพใหม่ขึ้นมาเราจะเอา compiler มาจากไหน แต่ compiler ตัวแรกมาจากไหน…? ก่อนจะเล่าต่อไป ขอไปเล่าเรื่องอื่นก่อนแล้วกัน

Why does this matter

Ken Thompson ผู้สร้างระบบปฏิบัติการ Unix และภาษา Go เคยตีพิมพ์ paper เรื่อง Reflection on trusting trust (1984) ว่าคุณจะรู้ได้อย่างไรว่า compiler ที่คุณใช้ไม่มี backdoor จริง

สมมุติว่ามี compiler ตัวหนึ่งซึ่งมันจะ detect source code pattern อย่างหนึ่งแล้วแทรก backdoor ลงไป และ detect source code ตัวเองเพื่อแทรกโค้ดตัวมันเองเข้าไป ถ้าเรา compile compiler ตัวนี้แล้วก็จะทำให้ backdoor ฝังอยู่ใน compiler ซึ่ง compile compiler ใหม่ก็ไม่หาย แต่ตรวจสอบหา backdoor ใน source code ไม่พบ ถ้าอย่างนั้นแล้วคุณจะแน่ใจได้อย่างไร ว่าระบบที่ใช้งานอยู่ไม่มี backdoor

ปรากฏว่ามีผู้พบว่า Ken Thompson เคยโจมตีด้วยท่านี้จริง โดยเขาเล่าในปี 1995 ว่าหลายสิบปีก่อนเขาสร้าง compiler แบบนี้จริงแล้วส่งให้แผนก UNIX support ติดตั้ง binary เข้าไปในระบบโดยอ้างว่ามีฟีเจอร์ใหม่ที่ไม่สามารถใช้ compiler เก่า compile source code ใหม่ได้ ใน compiler ใหม่นี้มีการฝัง backdoor ของโปรแกรม login ไว้

ต่อมามีผู้พบว่า symbol table (function list) ของ compiler มันแปลกๆ ก็เลยสั่งให้ compiler print assembly ออกมาซึ่งระบบแทรก backdoor ไม่ได้เขียนไว้รองรับคำสั่งนี้ แล้วเอา assembly ไปแปลงเป็น machine code ทำให้ backdoor หายไป

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

Go

Compiler ของภาษา Go เขียนขึ้นด้วยภาษา C ในตอนแรก จากนั้นถูกใช้เครื่องมือแปลงภาษา C เป็น Go ภายหลัง ทำให้ Go compiler ในปัจจุบันเขียนด้วยภาษา Go ดังนั้นทีมงาน Go จึงแนะนำวิธีสร้าง Go compiler ดังนี้

  1. ดาวน์โหลดซอร์สโค้ด Go 1.4 ซึ่งเป็นรุ่นสุดท้ายที่เขียนด้วยภาษา C
  2. Compile Go 1.4
  3. Compile Go 1.17.13 ด้วย Go 1.4
  4. Compile Go 1.20
  5. Compile Go รุ่นต่อๆ ไป โดยทีมงาน Go กำหนดว่า minimum compiler version ที่ใช้ compile รุ่นล่าสุดได้คือ version ที่ออกเมื่อประมาณ 1 ปีที่แล้ว

อีกวิธีหนึ่งที่ทำได้คือมีอีกโปรแกรมที่ compile ภาษา Go ได้นั่นคือ gccgo ซึ่งเขียนด้วยภาษา C และรองรับโค้ด Go ในระดับหนึ่ง สามารถใช้ gccgo compile Go รุ่นใหม่ได้เลย

Reproducible builds

ในปัจจุบัน Linux distribution หลายตัวเข้าร่วมโครงการ Reproducible builds ซึ่งเป็นอีกวิธีหนึ่งที่พอจะแก้ปัญหาความปลอดภัยด้านบนได้บ้าง โดย Linux distro จะทำให้ผลลัพท์ของการสร้าง package นั้นสามารถทำซ้ำได้ไฟล์เดียวกัน 100% ซึ่งใครก็สามารถทำซ้ำ ตรวจสอบได้ตลอดเวลาเพื่อยืนยันว่า package นั้นเกิดจาก source code จริงๆ เพราะ Linux บางตัวไม่มีระบบ CI/CD กลางเนื่องจากเป็นค่าใช้จ่าย แต่ให้ผู้สร้างแพคเกจ compile บนเครื่องตัวเอง เซ็นแล้วอัพโหลดไปแจกจ่าย

ขณะที่เขียนนี้ Package ทั้งหมดของ Linux ต่อไปนี้สามารถทำซ้ำ ตรวจสอบได้ด้วยตนเอง

  • Arch Linux 78.5%
  • Debian amd64 90.8%
  • openSUSE 94%

รวมถึง Go compiler 1.21 ด้วยที่ตัวที่ให้โหลดในหน้าเว็บ สามารถทำซ้ำได้ด้วยตนเอง

ฟังดูเหมือนว่าง่ายๆ ว่าโค้ดเดิมก็ควรจะได้ผลลัพท์เดิมเสมอ แต่ในความเป็นจริงแล้วอาจจะมีปัจจัยต่างๆ ที่ทำให้ได้ผลลัพท์ต่างกันไป เช่น

  • File metadata ไม่ตรงกัน ทำให้เวลาสร้าง/แตก zip แล้วไม่ตรง เช่น ชื่อเจ้าของไฟล์ สิทธิ์ หรือเวลาสร้างไฟล์
  • สถาปัตยกรรมไม่ตรงกัน เช่น compile ครั้งแรกใช้เครื่อง 32 bit ต่อมาใช้เครื่อง 64 bit แต่ compile เป็นโปรแกรม 32 bit ทั้งคู่ อาจจะได้ผลลัพท์ต่างกันเพราะมีการบันทึกค่าไว้
  • มีการฝังวันเวลาที่ compile เข้าไปในโปรแกรม (เช่นคำสั่ง version/about) ทำให้ได้ผลลัพท์ไม่ตรงกัน ซึ่งต้องลบโค้ดออกหรือกำหนดเป็นวันเวลาที่ fix ไว้
  • มีการฝัง path ที่ compile เข้าไปในโปรแกรม (เช่นใน stack trace เวลา crash) เวลาทำซ้ำอาจจะทำที่ path อื่น หรือระบุ path คนละแบบกัน (absolute/relative) ซึ่งจะต้องบันทึก path ไว้หรือให้ compiler normalize path ได้
  • มีการฝังข้อมูลเครื่องที่ compile เข้าไปในโปรแกรม เช่น username, hostname, timezone, language
  • Library ที่ใช้ไม่ตรงกัน เช่น มีการใช้ library ในเครื่องที่ไม่แจ้งไว้ พอคนอื่นไป compile แล้วหาไม่เจอ, ใช้ version ไม่ตรงกัน, compiler คนละตัวกัน
  • ในโค้ดมีการสุ่ม เช่นอ่านข้อมูลจาก filesystem หรือ HashMap มาแล้วไม่ได้ sort แต่ละครั้งจึงได้ลำดับสลับกัน

ดังนั้นผู้สร้างแพคเกจจะต้องตรวจหาปัญหาเหล่านี้และแก้ไขให้เรียบร้อย ซึ่งบางครั้ง compiler เองก็อาจจะเป็นที่มาของปัญหาเหล่านี้ได้เช่นกัน ไม่ใช่เฉพาะแค่ใน source code

เนื่องจาก Linux หลายตัวสามารถสร้างได้จาก source code อยู่แล้ว (ถึงอาจจะไม่ได้เท่ากันทุก bytes) ดังนั้นบริษัทใหญ่ๆ หลายที่จึงสร้าง Linux ของตัวเองซึ่งโปรแกรมส่วนมากก็เอามาจาก Linux อื่นๆ แต่ compile เองจะได้ไม่ต้องไว้ใจคนอื่น และอาจจะ modify ตามที่ตัวเองสนใจได้ด้วย เช่น

Guix Bootstrap

ที่เล่าไปด้านบนก็คือวิธีบริหารความเสี่ยงที่ใช้กันทั่วไปว่า Linux ที่ใช้งานอยู่นั้นเชื่อถือได้ แต่ยังอยู่บนข้อจำกัดว่าระบบที่ใช้งานอยู่เชื่อถือได้ และ C compiler ปัจจุบันเชื่อถือได้ ถ้าเราต้องการ C compiler ที่เชื่อถือได้จะทำอย่างไรดี

Guix เป็น Linux distribution ของโครงการ GNU ซึ่งมองว่าระบบ Linux นั้นสามารถ reproducible ได้จาก source code โดยขั้นตอนการ reproduce ก็เป็น source code เช่นเดียวกันซึ่งเขียนด้วยภาษา Scheme การติดตั้งแพคเกจต่างๆ ก็คือให้มัน reproduce build ของโปรแกรมนั้นๆ ขึ้นมา

ในขณะนี้ Guix ใช้โครงการ bootstrap-seeds เป็นจุดเริ่มต้น โดย bootstrap-seed ทำงานดังนี้

  1. กำหนดไฟล์ hex0 เป็นการแสดงเลขฐาน 2 ในรูปแบบเลขฐาน 16 (เช่นในไฟล์เขียนว่าว่า F0 ให้ output 11110000) และรองรับการใส่ comment
  2. สร้างโปรแกรมสำหรับแปลงไฟล์ hex0 ซึ่งเขียนด้วย machine code ของเครื่อง x86 ในรูปแบบไฟล์ hex0 โดย Guix จะใช้ไฟล์ hex0-seed ขนาด 357 bytes ที่แปลงมาสำเร็จรูปแล้ว แต่เราจะแปลงเองด้วยมือก็ได้
  3. Compile โปรแกรม hex1 ซึ่งเขียนด้วย hex0 โดย hex1 เป็นภาษาที่ปรับปรุงขึ้นมาจาก hex0 สามารถใช้ label สำหรับ jump ได้ (ก็คือต้องสามารถแทรก relative offset ของจุดที่แปะ label ไว้ในไฟล์จากจุดปัจจุบันได้)
  4. Compile โปรแกรม hex2 ซึ่งเขียนด้วย hex1 โดย hex2 จะเพิ่มความสามารถที่ label มีหลายตัวอักษรได้และสามารถหาตำแหน่งแบบ absolute ของ label ได้
  5. Compile โปรแกรม M0 ซึ่งเขียนด้วย hex2 ซึ่งสามารถกำหนด macro ได้ (ชุดคำสั่งที่ให้พิมพ์ซ้ำ)
  6. Compile โปรแกรม cc ซึ่งเขียนด้วย M0 เป็น C compiler แบบง่ายๆ ที่ใช้ชุดคำสั่งได้จำกัด
  7. Compile โปรแกรม M2-Planet ซึ่งเขียนด้วยภาษา C เท่าที่โปรแกรม cc รองรับ เป็น C compiler ที่รองรับชุดคำสั่งได้มากขึ้น
  8. Compile โปรแกรม GNU Mes ซึ่งเป็น C compiler และ interpreter ของภาษา Scheme (ที่ Guix ใช้) โดยสามารถคอมไพล์ได้จาก M2-Planet
  9. Compile โปรแกรม TinyCC (fork) ซึ่งสามารถใช้ compile GCC และไลบรารีอื่นๆ ที่ใช้งานใน Linux ปกติได้

(ในนี้ไม่รวมถึง tool อื่นๆ ที่ใช้ประกอบการรัน tool ข้างบน เช่น cat, shell, debug tool, linker, C standard library เป็นต้น)

หลังจากเรามี GCC และ library อื่นๆ แล้วก็สามารถ compile โปรแกรมอื่นๆ ไปเรื่อยๆ จนถึงโปรแกรมที่เราต้องการได้ เช่น Python interpreter โดยถ้าเราเชื่อว่า source code ทั้งหมดที่ใช้น่าเชื่อถือ ไม่มี backdoor แล้วกระบวนการนี้ก็ทำให้เรามั่นใจได้ว่าสามารถสร้างระบบ Linux ขึ้นมาได้โดยไม่ต้องใช้ compiler ตั้งต้น

ในปัจจุบันข้อจำกัดของระบบนี้คือต้องเชื่อได้ว่า Kernel ที่รันอยู่น่าเชื่อถือ เพราะเรารัน hex0 บน Kernel ปัจจุบัน ในอนาคตทีมงาน bootstrappable.org มีความคิดที่จะพัฒนา CPU architecture “Knight” ซึ่งสามารถออกแบบเป็นวงจร FPGA ได้เพื่อใช้สร้าง hex0 โดยไม่ต้องใช้คอมพิวเตอร์ และ builder-hex0 ที่เป็น OS พื้นฐานสามารถรัน hex0 ได้ในตัว

The original C compiler

แล้ว C compiler ตัวแรกสร้างมาจากไหน?

Dennis Richie (RIP) เขียนบันทึกไว้ว่า UNIX ตัวแรกถูกสร้างขึ้นโดย Ken Thompson (จำชื่อนี้ได้ไหม) ในปี 1968 โดยใช้โปรแกรม assembler (คล้ายๆ M0) ของเครื่อง GE-635 สร้างแถบกระดาษสำหรับเครื่อง PDP-7 จนมีโปรแกรมพื้นฐานพร้อมใช้งาน ได้แก่ Kernel, editor, shell, คำสั่ง rm, cat, cp และ assembler จากนั้นก็เริ่มพัฒนาบนเครื่อง PDP-7 ต่อ

ในปีต่อมา Doug McIlroy สร้างภาษา TMG (TransMoGrifiers) สำหรับ UNIX บน PDP-7 ขึ้นมาเป็นภาษาโปรแกรมระดับสูงตัวแรก แล้ว Ken ก็สร้างภาษา B ขึ้นโดยเขียนในภาษา TMG โดยอ้างอิงแบบมาจากภาษา BCPL ของ Martin Richards หลังจากสร้างขึ้นสำเร็จก็ได้เขียนภาษา B ใหม่อีกครั้งหนึ่งในภาษา B เอง

ในปี 1971 Dennis Richie พบว่าภาษา B เริ่มตามไม่ทันเครื่องคอมพิวเตอร์ที่อัพเกรดเป็นรุ่น PDP-11 จึงปรับปรุงภาษาขึ้นเป็นภาษา NB (New B) พร้อมกับคอมไพเลอร์ตัวใหม่ ซึ่งต่อมาก็ได้เปลี่ยนชื่อเป็น C

สำหรับ GCC ผมยังไม่เห็นประวัติว่าใช้ compiler ใดสร้างขึ้น แต่โปรแกรมส่วนมากของโครงการ GNU ถูกสร้างขึ้นบนเครื่อง UNIX ก็เป็นไปได้ว่าอาจจะใช้ C compiler ของ Dennis Richie

ใช้ 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 ข้ามกันได้