---
title: "DinD vs DooD เลือกแบบไหนดี ตอนต้อง build image ใน CI"
description: "เทียบ Docker in Docker กับ Docker out of Docker สำหรับ self-hosted CI runner ที่ต้อง build image เอง พร้อมกับดักเรื่อง volume path และความปลอดภัยที่คนมักมองข้าม"
canonical_url: "https://iamthana.com/blogs/2026-08-03-docker-dind-vs-dood"
---

> Index of every page on this site: [llms.txt](https://iamthana.com/llms.txt)

## ที่มาของบทความนี้

สวัสดีครับ พอดีช่วงนี้ผมย้าย CI runner มารันบนเครื่องที่บ้านตัวเอง ตัว runner ก็รันเป็น container ตามปกตินี่แหละครับ ทุกอย่างก็ดูดีมาตลอดจนกระทั่งไปเจอ job ที่ต้อง `docker build` แล้วก็ได้บรรทัดนี้กลับมา

```text
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 ซะ

```sh
docker run --rm \
  -v /var/run/docker.sock:/var/run/docker.sock \
  docker:cli docker ps --format 'table {{.Names}}\t{{.Image}}'
```

```text
NAMES               IMAGE
elegant_mayer       docker:cli
demo-web            alpine
redis-cache         redis:7-alpine
app-postgres        postgres:16-alpine
```

จะเห็นว่า container ที่เพิ่งรันไปมันมองเห็น container ตัวอื่นที่ตัวเองไม่ได้สร้างด้วย แถมยังเห็นตัวเองอยู่ในลิสต์ด้วย (`elegant_mayer` นั่นแหละครับ) ลองถาม daemon ตรง ๆ อีกทีก็จะยิ่งชัด

```sh
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \
  docker:cli docker info --format 'ServerVersion={{.ServerVersion}} Containers={{.Containers}} Name={{.Name}}'
```

```text
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` ต่ออีกทีนึง

```sh
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'
```

```text
--- 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 ทันทีแทนที่จะเงียบ ๆ สร้างโฟลเดอร์เปล่ามาให้

```sh
docker run --rm --mount type=bind,source=/ไม่มีจริง,target=/work alpine ls /work
```

```text
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 ของตัวเอง

```sh
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 ให้ด้วย

```text
level=warning msg="Binding to an IP address without --tlsverify is insecure"
```

ในเครื่องตัวเองลองเล่นแบบนี้ก็พอไหวครับ แต่ถ้าจะเอาไปใช้จริงบน network ที่มีคนอื่นอยู่ด้วยอย่าปิด TLS นะครับ

ทีนี้ลองต่อเข้าไปจาก client อีกตัวดู

```sh
docker run --rm --network dind-net \
  -e DOCKER_HOST=tcp://dind-daemon:2375 \
  docker:cli docker info --format 'ServerVersion={{.ServerVersion}} Containers={{.Containers}} Name={{.Name}}'
```

```text
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 ข้างในดูอีกที

```sh
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}}"'
```

```text
demo-image:1.0
```

แล้วกลับมาถามที่ host ดูบ้าง

```sh
docker images demo-image
```

```text
(ไม่มี)
```

image ที่ build เสร็จมันอยู่แค่ใน DinD ตัวนั้นตัวเดียวครับ พอ container ตายเมื่อไหร่ก็หายไปพร้อมกันหมดเลย

### ข้อควรระวังของ DinD

เรื่องแรกคือ daemon มันใช้เวลาบูตครับ รอบแรกผมรอไป 8 วินาทีแล้วยิงคำสั่งเข้าไปเลย ได้ `Cannot connect to the Docker daemon` กลับมาเต็ม ๆ ทั้งที่ status ของ container ขึ้นเป็น `running` เรียบร้อยแล้ว (ตัว container มันขึ้นก่อน ส่วน daemon ค่อยพร้อมทีหลัง) เพราะฉะนั้นใน CI ควรมี wait loop คั่นไว้ด้วยนะครับ

```sh
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` เลยน่าจะปลอดภัยกว่า ซึ่งจริง ๆ แล้วไม่ใช่แบบนั้นเลยครับ ลองดูอันนี้

```sh
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"'
```

```text
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 ก่อน ตอนแรกผมก็เข้าใจแบบนั้นครับ แต่พอลองจริงแล้วไม่ใช่เลย

```sh
docker run --rm --privileged alpine sh -c 'mkdir -p /mnt/hostdisk; mount /dev/vdb1 /mnt/hostdisk; ls /mnt/hostdisk'
```

```text
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` จริง ๆ ลองทางนี้ก่อนน่าจะสบายใจกว่า

## สรุป

<table>
<thead>
  <tr>
    <th>
      โจทย์
    </th>
    
    <th>
      เลือก
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      runner บนเครื่องตัวเอง คนใช้เชื่อถือได้ อยากได้ cache
    </td>
    
    <td>
      DooD
    </td>
  </tr>
  
  <tr>
    <td>
      อยากให้แต่ละ job สะอาด ไม่ปน state กัน
    </td>
    
    <td>
      DinD
    </td>
  </tr>
  
  <tr>
    <td>
      แค่อยากได้ image ออกมา
    </td>
    
    <td>
      buildx rootless / Podman
    </td>
  </tr>
  
  <tr>
    <td>
      job มาจาก repo ที่คุมคนเขียนไม่ได้
    </td>
    
    <td>
      ไม่เอาทั้งคู่ แยก VM ไปเลย
    </td>
  </tr>
</tbody>
</table>

ส่วนตัวผมใช้ DooD กับ runner ที่บ้านครับ เพราะ cache มันเร็วกว่าเยอะแล้วก็ไม่ต้องมานั่งจูน wait loop ให้ปวดหัว แต่ก็ยอมรับตรง ๆ นะว่ามันคือการยกกุญแจบ้านให้ CI ถือไว้ พอเป็นเครื่องที่มีผมใช้อยู่คนเดียวก็พอแลกกันได้อยู่ ถ้าเป็นเครื่องที่มีคนอื่นเข้ามาใช้ด้วยผมคงไม่กล้าทำแบบนี้ครับ

## References

- [Docker Engine — Protect the Docker daemon socket](https://docs.docker.com/engine/security/protect-access/)
- [docker image บน Docker Hub (docker:dind, docker:cli)](https://hub.docker.com/_/docker)
- [Using Docker-in-Docker for your CI or testing environment? Think twice. — jpetazzo](https://jpetazzo.github.io/2015/09/03/do-not-use-docker-in-docker-for-ci/)
- [Rootless mode](https://docs.docker.com/engine/security/rootless/)

## Sitemap

See the full [sitemap](https://iamthana.com/sitemap.md) for all pages.
