
/blogs
DinD vs DooD เลือกแบบไหนดี ตอนต้อง build image ใน CI
ที่มาของบทความนี้
สวัสดีครับ พอดีช่วงนี้ผมย้าย CI runner มารันบนเครื่องที่บ้านตัวเอง ตัว runner ก็รันเป็น container ตามปกตินี่แหละครับ ทุกอย่างก็ดูดีมาตลอดจนกระทั่งไปเจอ job ที่ต้อง docker build แล้วก็ได้บรรทัดนี้กลับมา
Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running?
ตอนแรกก็งงอยู่พักนึงครับ เพราะในตัว image ของ runner ก็มี docker ติดมาให้แล้ว ที่จริงเหตุผลมันง่ายมากเลย คือใน container มีแต่ตัว client ไม่ได้มี Docker daemon ให้คุยด้วยนั่นเอง
ทางแก้ของเรื่องนี้มีอยู่สองสายที่คนเถียงกันมาสิบกว่าปีแล้ว สายแรกคือ DooD (Docker out of Docker) เป็นการไปยืม daemon ของเครื่อง host มาใช้ ด้วยการ mount docker.sock เข้าไปใน container ส่วนอีกสายคือ DinD (Docker in Docker) ซึ่งจะรัน daemon ตัวใหม่ขึ้นมาใน container เลย โดยต้องเปิด --privileged ให้ด้วย
ผมเลยลองรันเทียบกันดูจริง ๆ ทั้งสองแบบ output ที่เอามาแปะในบทความนี้มาจากเครื่องผมทั้งหมดนะครับ (Docker 29.4.0 บน OrbStack) ไม่ได้พิมพ์ขึ้นมาเอง
DooD คืออะไร
เรื่องที่ต้องเข้าใจก่อนคือคำสั่ง docker ที่เราพิมพ์กันอยู่ทุกวันมันเป็นแค่ client นะครับ ตัวมันเองไม่ได้สร้าง container แต่จะยิงคำสั่งผ่าน unix socket ที่ /var/run/docker.sock ไปให้ daemon ทำงานอีกที พอรู้ตรงนี้แล้ววิธีแก้ก็ตรงไปตรงมาเลยครับ คือโยน socket ตัวนั้นเข้าไปใน container ซะ
docker run --rm \
-v /var/run/docker.sock:/var/run/docker.sock \
docker:cli docker ps --format 'table {{.Names}}\t{{.Image}}'
NAMES IMAGE
elegant_mayer docker:cli
demo-web alpine
redis-cache redis:7-alpine
app-postgres postgres:16-alpine
จะเห็นว่า container ที่เพิ่งรันไปมันมองเห็น container ตัวอื่นที่ตัวเองไม่ได้สร้างด้วย แถมยังเห็นตัวเองอยู่ในลิสต์ด้วย (elegant_mayer นั่นแหละครับ) ลองถาม daemon ตรง ๆ อีกทีก็จะยิ่งชัด
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \
docker:cli docker info --format 'ServerVersion={{.ServerVersion}} Containers={{.Containers}} Name={{.Name}}'
ServerVersion=29.4.0 Containers=23 Name=orbstack
เวอร์ชันตรงกับ host เป๊ะ ๆ เลยครับ เพราะมันคือ daemon ตัวเดียวกันนั่นแหละ ซึ่งแปลว่า container ที่ job ของเราสั่งสร้างออกมามันไม่ใช่ child ของ runner นะครับ แต่เป็น sibling ที่รันอยู่บน host ในระดับเดียวกันต่างหาก
กับดักเรื่อง path ใน -v
อันนี้เป็นตัวที่กินเวลา debug ผมนานที่สุดครับ เพราะไม่มี error โผล่ขึ้นมาให้เห็นเลยสักบรรทัด เลยยิ่งหาสาเหตุไม่เจอ
ผมลองสร้างไฟล์ไว้ในโฟลเดอร์ที่ mount เข้า container ชั้นนอก แล้วให้ container ชั้นนอกสั่ง docker run -v /work:/work ต่ออีกทีนึง
docker run --rm \
-v /var/run/docker.sock:/var/run/docker.sock \
-v /tmp/hostdir:/work \
docker:cli sh -c 'ls /work; docker run --rm -v /work:/work alpine ls -la /work'
--- ls /work จากคอนเทนเนอร์ชั้นนอก:
hello.txt
--- ls /work จากคอนเทนเนอร์ที่ spawn ออกมา:
total 0
drwxr-xr-x 2 root root 40 Aug 3 04:35 .
drwxr-xr-x 1 root root 20 Aug 3 04:35 ..
ไฟล์หายไปเฉย ๆ ครับ ตอนเจอครั้งแรกผมนึกว่า mount ไม่ติดหรือพิมพ์ path ผิดอยู่ตั้งนาน
สาเหตุจริง ๆ คือคนที่แปล -v /work:/work ให้เราคือ daemon ครับ ซึ่ง daemon มันอยู่บน host ไม่ได้อยู่ใน container ชั้นนอกกับเรา พอบน host ไม่มีโฟลเดอร์ชื่อ /work มันก็เลยสร้างโฟลเดอร์เปล่าขึ้นมาให้ตามน้ำ ไม่บ่นอะไรสักคำ
พอมาอยู่ในบริบทของ CI ก็แปลว่า job ที่พยายาม mount workspace ของตัวเองเข้าไปใน container ที่สั่งสร้างออกมา จะได้โฟลเดอร์ว่างเปล่ากลับมาแทน ทางออกคือต้องส่ง path ฝั่ง host เข้าไปเสมอครับ ซึ่ง runner ส่วนใหญ่ก็จะมี environment variable เตรียมไว้ให้อยู่แล้ว หรือถ้าไม่อยากปวดหัวก็เปลี่ยนไปใช้ named volume แทน bind mount ไปเลยก็ได้
อีกวิธีที่ผมชอบคือเปลี่ยนจาก -v ไปใช้ --mount type=bind แทนครับ พอ source ไม่มีอยู่บน host มันจะ fail ทันทีแทนที่จะเงียบ ๆ สร้างโฟลเดอร์เปล่ามาให้
docker run --rm --mount type=bind,source=/ไม่มีจริง,target=/work alpine ls /work
docker: Error response from daemon: invalid mount config for type "bind": bind source path does not exist: /ไม่มีจริง
ใน CI ผมว่าให้มันเด้ง error ตั้งแต่บรรทัดแรกไปเลยดีกว่าได้โฟลเดอร์ว่างมาแล้วต้องไปงงเอาตอน build เสร็จเยอะครับ
DinD คืออะไร
ฝั่งนี้จะเป็นการเอา daemon ยัดเข้าไปใน container เลยครับ ซึ่งจำเป็นต้องใช้ --privileged เพราะตัว daemon ต้องจัดการ cgroup, iptables แล้วก็ overlay filesystem ของตัวเอง
docker network create dind-net
docker run -d --name dind-daemon \
--privileged \
--network dind-net \
-e DOCKER_TLS_CERTDIR= \
docker:dind
ตรง DOCKER_TLS_CERTDIR= ที่ปล่อยเป็นค่าว่างไว้คือการปิด TLS เพื่อให้ต่อผ่าน port 2375 ตรง ๆ ได้ครับ ถ้าไม่ตั้งไว้ image มันจะ generate cert ให้เองแล้วไปเปิดที่ port 2376 แทน ซึ่งตัว daemon ก็จะเตือนไว้ใน log ให้ด้วย
level=warning msg="Binding to an IP address without --tlsverify is insecure"
ในเครื่องตัวเองลองเล่นแบบนี้ก็พอไหวครับ แต่ถ้าจะเอาไปใช้จริงบน network ที่มีคนอื่นอยู่ด้วยอย่าปิด TLS นะครับ
ทีนี้ลองต่อเข้าไปจาก client อีกตัวดู
docker run --rm --network dind-net \
-e DOCKER_HOST=tcp://dind-daemon:2375 \
docker:cli docker info --format 'ServerVersion={{.ServerVersion}} Containers={{.Containers}} Name={{.Name}}'
ServerVersion=29.7.0 Containers=0 Name=dc782fdd39de
อันนี้ต่างจาก DooD คนละเรื่องเลยครับ เวอร์ชันขึ้นเป็น 29.7.0 ทั้งที่ host ของผมเป็น 29.4.0 ชื่อ daemon ก็กลายเป็น container id ไปแล้ว ส่วน container ทั้ง 23 ตัวที่รันอยู่บน host ก็หายไปหมดเหลือ 0 เพราะมันคนละ daemon กันจริง ๆ
ลอง build image ข้างในดูอีกที
docker run --rm --network dind-net -e DOCKER_HOST=tcp://dind-daemon:2375 docker:cli \
sh -c 'mkdir -p /ctx && printf "FROM alpine\nRUN echo hi > /hi.txt\n" > /ctx/Dockerfile
docker build -q -t demo-image:1.0 /ctx > /dev/null
docker images --format "{{.Repository}}:{{.Tag}}"'
demo-image:1.0
แล้วกลับมาถามที่ host ดูบ้าง
docker images demo-image
(ไม่มี)
image ที่ build เสร็จมันอยู่แค่ใน DinD ตัวนั้นตัวเดียวครับ พอ container ตายเมื่อไหร่ก็หายไปพร้อมกันหมดเลย
ข้อควรระวังของ DinD
เรื่องแรกคือ daemon มันใช้เวลาบูตครับ รอบแรกผมรอไป 8 วินาทีแล้วยิงคำสั่งเข้าไปเลย ได้ Cannot connect to the Docker daemon กลับมาเต็ม ๆ ทั้งที่ status ของ container ขึ้นเป็น running เรียบร้อยแล้ว (ตัว container มันขึ้นก่อน ส่วน daemon ค่อยพร้อมทีหลัง) เพราะฉะนั้นใน CI ควรมี wait loop คั่นไว้ด้วยนะครับ
until docker -H tcp://dind-daemon:2375 info > /dev/null 2>&1; do
echo "รอ dind..."
sleep 1
done
อีกเรื่องคือมันไม่มี layer cache ติดมาให้เลย daemon เกิดใหม่ก็เริ่มจากศูนย์ ทุก job เลยต้องดึง base image ใหม่หมดทุกรอบ ถ้าไม่อยากให้ CI ช้าลงเป็นเท่าตัวก็ต้องไป mount volume ไว้ที่ /var/lib/docker ของ DinD ครับ แต่พอทำแบบนั้นแล้ว job ก็จะเริ่มแชร์ state กัน ความสะอาดที่เป็นข้อดีข้อเดียวของ DinD ก็หายไปพอสมควรเหมือนกันนะ
ที่สำคัญกว่านั้นคือ ห้ามให้ DinD หลายตัวชี้มาที่ volume ก้อนเดียวกันเด็ดขาดครับ เพราะ Docker daemon ถูกออกแบบมาให้ครอบครอง /var/lib/docker อยู่ตัวเดียว ถ้ามีสอง daemon มาแย่งกันใช้ไม่ใช่แค่แชร์ cache กันนะ แต่ข้างในจะพังเลย (jpetazzo เขียนเตือนเรื่องนี้ไว้ตั้งแต่ปี 2015 แล้ว ลิงก์อยู่ท้ายบทความ) ถ้าจะใช้ cache volume จริง ๆ ก็ต้องแยก volume ให้แต่ละ job หรือยอมรันทีละ job ไปเลย
เรื่องความปลอดภัย
ผมเชื่อว่าหลายคนเลือก DooD เพราะรู้สึกว่ามันเบากว่า ไม่ต้องใช้ --privileged เลยน่าจะปลอดภัยกว่า ซึ่งจริง ๆ แล้วไม่ใช่แบบนั้นเลยครับ ลองดูอันนี้
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock docker:cli \
sh -c 'docker run --rm -v /:/host:ro alpine sh -c "id -un; cat /host/etc/hostname"'
whoami-ในคอนเทนเนอร์ที่ spawn: root
อ่าน /host/etc/hostname ของ host ได้:
orbstack
จาก container ที่ไม่ได้เปิด privileged เลยสักนิด ผมสั่ง mount root filesystem ของ host เข้ามาอ่านได้สบาย ๆ ในฐานะ root แล้วถ้าไม่ใส่ :ro ไว้ก็เขียนทับได้ด้วยนะครับ
พูดง่าย ๆ คือการ mount docker.sock บน Docker แบบปกติมันเท่ากับการให้สิทธิ์ root บนเครื่อง host ไปเลยครับ ใครก็ตามที่แก้ไฟล์ CI ได้ ก็เท่ากับเป็นเจ้าของเครื่องนั้นไปโดยปริยาย (จะลดขอบเขตลงได้ก็ต่อเมื่อรัน Docker แบบ rootless หรือมี socket proxy คั่นไว้เท่านั้น)
ทีนี้หลายคนน่าจะคิดว่าถ้างั้น DinD ที่ต้องเปิด --privileged ก็คงปลอดภัยกว่าอยู่ดี เพราะกว่าจะหลุดออกมาได้ต้องไปหาช่องโหว่ kernel ก่อน ตอนแรกผมก็เข้าใจแบบนั้นครับ แต่พอลองจริงแล้วไม่ใช่เลย
docker run --rm --privileged alpine sh -c 'mkdir -p /mnt/hostdisk; mount /dev/vdb1 /mnt/hostdisk; ls /mnt/hostdisk'
docker
guest-state
k8s
scon
version
นั่นคือไส้ในของดิสก์ host ที่โผล่มาให้เห็นเฉย ๆ ครับ ต่อจากนี้แค่ chroot เข้าไปก็ได้ shell ที่เป็น root บน host แล้ว ไม่ได้ใช้ช่องโหว่อะไรเลยสักอย่าง เป็นแค่การใช้ความสามารถที่ --privileged เปิดให้ตามปกติ (ยังมีท่า cgroup release_agent อีกท่าที่ดังกว่านี้ แต่ใช้ได้เฉพาะ host ที่ยังเป็น cgroup v1)
สรุปคือทั้งสองแบบให้ root บน host ได้เหมือนกันครับ ต่างกันแค่ DooD พิมพ์คำสั่งเดียวก็ถึง ส่วน DinD พิมพ์เพิ่มอีกบรรทัดสองบรรทัด ไม่มีอันไหนเป็นกำแพงกันคนที่ตั้งใจจะเข้ามาจริง ๆ
ข้อสรุปของทั้งคู่เลยเหมือนกันครับ คืออย่าเอาไปรัน job ที่มาจาก repo ที่เราคุมคนเขียนไม่ได้
ทางเลือกอื่น
หลายเคสที่คนไปจนถึงขั้นต้องเลือกระหว่าง DinD กับ DooD จริง ๆ แล้วต้องการแค่ "ได้ image ออกมาสัก tag นึง" เท่านั้นเองครับ ซึ่งงานแค่นี้มีทางที่เบากว่าอยู่
ตัวแรกคือ BuildKit ที่รันแบบ rootless ได้ ไม่ต้องเปิด privileged เต็ม ๆ แต่ต้องบอกไว้ก่อนว่ามันก็มีเงื่อนไขของมันอยู่ดีนะครับ ยังต้องปลด seccomp กับ AppArmor ต่อ /dev/fuse แล้วก็ตั้ง subuid/subgid ให้ถูกอยู่ดี แล้วถ้าใช้ผ่าน docker buildx เฉย ๆ ตัว driver ปกติของมันยังคุยผ่าน daemon อยู่ ถ้าจะเลี่ยง daemon จริง ๆ ต้องไปใช้ driver แบบ remote หรือ kubernetes หรือเรียก buildctl ตรง ๆ
อีกตัวคือ Podman ที่ไม่มี daemon มาตั้งแต่แรก รันเป็น rootless ได้ถ้าเรียกด้วย user ธรรมดา คำสั่งก็เกือบจะเหมือน docker หมด (แต่พอเอาไปซ้อนใน CI container อีกทีก็ยังต้องจัด subuid/subgid กับ /dev/fuse ให้มันเหมือนกัน)
สองตัวนี้ไม่ได้แปลว่าไม่ต้องตั้งค่าอะไรเลยนะครับ แต่อย่างน้อยก็ไม่ต้องยกสิทธิ์ root ของทั้งเครื่องให้ CI ถือไว้ ถ้า pipeline ของเราไม่ได้ต้อง docker run หรือ docker compose up จริง ๆ ลองทางนี้ก่อนน่าจะสบายใจกว่า
สรุป
| โจทย์ | เลือก |
|---|---|
| runner บนเครื่องตัวเอง คนใช้เชื่อถือได้ อยากได้ cache | DooD |
| อยากให้แต่ละ job สะอาด ไม่ปน state กัน | DinD |
| แค่อยากได้ image ออกมา | buildx rootless / Podman |
| job มาจาก repo ที่คุมคนเขียนไม่ได้ | ไม่เอาทั้งคู่ แยก VM ไปเลย |
ส่วนตัวผมใช้ DooD กับ runner ที่บ้านครับ เพราะ cache มันเร็วกว่าเยอะแล้วก็ไม่ต้องมานั่งจูน wait loop ให้ปวดหัว แต่ก็ยอมรับตรง ๆ นะว่ามันคือการยกกุญแจบ้านให้ CI ถือไว้ พอเป็นเครื่องที่มีผมใช้อยู่คนเดียวก็พอแลกกันได้อยู่ ถ้าเป็นเครื่องที่มีคนอื่นเข้ามาใช้ด้วยผมคงไม่กล้าทำแบบนี้ครับ
References
Related Articles



