Lecture 3 — Managing Multi-container Application
จาก Dockerfile ที่สร้าง image ซ้ำได้ ไปจนถึง Docker Compose ที่สั่งทั้งระบบขึ้นลงด้วยคำสั่งเดียว
เลกเชอร์ 3 · อ่าน 50 นาที · เรียบเรียงจากสไลด์ Lecture 3 ของ รศ.ดร.วีระ เหมืองสิน
คาบนี้ไล่แก้ปัญหาสามชั้นต่อกัน: Dockerfile ทำให้สร้าง image เดิมซ้ำได้แทนที่จะประกอบมือ Docker Compose ทำให้สั่งหลาย container พร้อมกันเป็นก้อนเดียวแทนที่จะพิมพ์ docker run ทีละตัว และเมื่อ container ล้นออกไปหลายเครื่อง ก็ถึงคิวของ container orchestration ซึ่งเป็นเรื่องของคาบถัดไป
ข้อมูลในหน้านี้มาจากไหน
เนื้อหามาจากสไลด์ Lecture 3 ทั้ง 71 หน้า คำสั่ง ไฟล์ตั้งค่า และผลลัพธ์บนหน้าจอทุกอันยกมาจากสไลด์จริง ส่วนคำอธิบาย รูป และตัวอย่างที่ระบุว่าเสริม เขียนขึ้นใหม่
คาบนี้จะพาไปไหน
คาบที่แล้วจบตรงที่เราสร้าง container ได้แล้ว ต่อเน็ตให้มันคุยกันได้แล้ว และเก็บข้อมูลไม่ให้หายได้แล้ว — แต่ทุกอย่างยังทำด้วยมือทีละคำสั่ง
คาบนี้ตอบสามคำถามที่ตามมาทันที
| คำถาม | คำตอบของคาบนี้ |
|---|---|
| จะสร้าง image เดิมซ้ำได้ยังไง ไม่ต้องจำว่ากดอะไรไปบ้าง | Dockerfile |
| แอปหนึ่งตัวใช้ 4 container จะสั่งขึ้นลงพร้อมกันยังไง | Docker Compose |
| ถ้า container ล้นเครื่องเดียวไม่พอ ต้องกระจายหลายเครื่อง | Container orchestration (คาบหน้า) |
ท้ายคาบมีแล็บ Raspberry Pi ที่พาเอาเครื่องที่สองเข้ามาจริง ๆ เพื่อให้เห็นด้วยตาว่าปัญหาข้อสามหน้าตาเป็นยังไง
ส่วนที่ 1 — Dockerfile
ปัญหาที่ Dockerfile เกิดมาแก้
คาบที่แล้วสร้าง image ด้วยวิธี "เข้าไปใน container แล้วติดตั้งของ แล้วค่อย docker commit" วิธีนี้ได้ image จริง แต่มันมีปัญหาที่แก้ไม่ได้เลยสามข้อ
- ไม่มีใครรู้ว่าข้างในมีอะไร เพราะสิ่งที่คุณพิมพ์ไปหายไปกับ shell แล้ว
- ทำซ้ำไม่ได้ อีกสามเดือนอยากได้ image เดิมแต่อัปเดตเวอร์ชันไลบรารี ต้องนั่งนึกใหม่ทั้งหมด
- เอาเข้า Git ไม่ได้ image เป็นไฟล์ไบนารีขนาดหลายร้อยเมกะไบต์ จะรีวิวว่าใครเปลี่ยนอะไรก็ทำไม่ได้
Dockerfile (ไฟล์ข้อความที่บอกวิธีสร้าง image) แก้ทั้งสามข้อพร้อมกัน เพราะมันเป็นไฟล์ข้อความธรรมดา ที่บอก Docker engine ว่าจะประกอบ image ขึ้นมายังไง
สิ่งที่เขียนสั่งได้มีสี่แบบ
- รันคำสั่ง (ติดตั้งของ ตั้งค่า)
- ก๊อปไฟล์หรือโฟลเดอร์เข้าไปใน image
- ตั้งตัวแปรสภาพแวดล้อม
- บอกว่าเวลาสตาร์ต container ให้รันอะไร
หน้าตาสั้นที่สุดที่ใช้งานได้จริงมีแค่สี่บรรทัด
FROM python:3.13-slim
WORKDIR /app
COPY hello.py .
CMD ["python", "hello.py"]Dockerfile คือสูตรอาหาร ส่วน image คืออาหารที่ทำเสร็จแล้ว และ container คือจานที่ยกมาเสิร์ฟ
docker commit เหมือนทำอาหารเสร็จแล้วถ่ายรูปไว้ — ได้ภาพ แต่ทำซ้ำไม่ได้ ส่วน Dockerfile คือจดสูตรไว้ ใครหยิบไปก็ทำได้เหมือนกัน และแก้ปริมาณน้ำตาลได้โดยไม่ต้องทำใหม่ทั้งจาน
Dockerfile ใช้ทำอะไรในงานจริง
ประโยชน์ตัวจริงโผล่ตอนที่เครื่องที่ build กับเครื่องที่รันไม่ใช่เครื่องเดียวกัน
เครื่องปลายทางไม่ต้องมี Python ไม่ต้องมี compiler ไม่ต้องมี source code เลย มันได้ image ที่ประกอบเสร็จแล้วมาทั้งก้อน
คำสั่งใน Dockerfile
สไลด์ไล่คำสั่งไว้ตามลำดับที่ใช้จริง เริ่มจากสามตัวที่แทบทุกไฟล์ต้องมี
FROM, WORKDIR, COPY
FROM <image>
WORKDIR <path>
COPY <host-path> <image-path>FROMเริ่มขั้นตอนการ build และบอกว่าใช้ image ไหนเป็นฐาน มักเป็นบรรทัดแรกของไฟล์ ฐานเป็น image ทางการอย่างubuntuหรือalpineก็ได้ หรือเป็น image ที่เราสร้างเองก็ได้WORKDIRตั้งโฟลเดอร์ทำงานให้คำสั่งที่ตามมาข้างล่าง ถ้าโฟลเดอร์ยังไม่มี Docker สร้างให้COPYก๊อปไฟล์จากเครื่องที่ build เข้าไปวางใน image ที่ตำแหน่งที่ระบุ
FROM python:3.13-slim
WORKDIR /app
COPY hello.py .COPY มองเห็นแค่ไฟล์ที่อยู่ใน build context (โฟลเดอร์ที่ส่งให้ Docker ตอน build) — ก็คือโฟลเดอร์ที่คุณใส่เป็นจุดสุดท้ายของคำสั่ง docker build -t app . (จุดนั้นแหละ)
COPY ../secrets.txt . จึงพังเสมอ ไม่ใช่เพราะไฟล์ไม่มี แต่เพราะมันอยู่นอกโฟลเดอร์ที่ Docker ได้รับมา
USER — เรื่องความปลอดภัยที่คนมองข้าม
USER <username>ตั้งว่าคำสั่งที่ตามมา (และตัวโปรแกรมตอนรัน) จะรันในนามผู้ใช้คนไหน
โดยปริยาย container รันเป็น root และ root ใน container คือ root ตัวเดียวกับบนเครื่อง host ในสายตาของ kernel เพียงแต่ถูกจำกัดด้วย namespace และตัดสิทธิ์บางส่วนออก
ถ้ามีช่องโหว่ที่ทำให้หลุดออกจาก container ได้ (เรียกว่า container escape (การหลุดออกจากกรอบของ container)) ผู้โจมตีจะได้สิทธิ์ระดับ root บนเครื่องจริง
วิธีแก้คือสร้างผู้ใช้ธรรมดาขึ้นมาแล้วสลับไปใช้
RUN useradd appuser
USER appuserRUN — รันตอน build ไม่ใช่ตอนรัน
RUN ["<command>", "<arg1>"]
RUN <command> <arg1>RUN รันคำสั่ง ตอน build และผลลัพธ์กลายเป็น layer ใหม่ใน image ใช้ติดตั้งแพ็กเกจ ตั้งค่า หรือเตรียมอะไรก็ตามที่ต้องมีอยู่แล้วก่อน container จะสตาร์ต
มันเขียนได้สองรูปแบบ และสองรูปแบบนี้ทำงานไม่เหมือนกัน
| รูปแบบ | หน้าตา | ต่างกันตรงไหน |
|---|---|---|
| exec form (แบบรายการ) | RUN ["apt-get", "update"] | รันตรง ๆ ไม่ผ่าน shell |
| shell form (แบบบรรทัดคำสั่ง) | RUN apt-get update && apt-get install -y python3 | รันผ่าน shell |
shell form ใช้ความสามารถของ shell ได้ครบ — ท่อ |, ต่อคำสั่ง &&, เปลี่ยนทิศทาง >, และแทนค่าตัวแปร $VAR ส่วน exec form ทำไม่ได้เพราะไม่มี shell มาแปลให้
RUN ["echo", "$HOME"]ได้ผลลัพธ์ตรงตัวว่า $HOME เพราะไม่มี shell มาแทนค่าให้ ถ้าอยากได้ /root ต้องใช้ shell form
RUN echo $HOMEENTRYPOINT กับ CMD — คู่ที่สับสนที่สุด
ทั้งสองตัวบอกว่า "เวลาสตาร์ต container ให้รันอะไร" แต่มันตอบสนองต่อสิ่งที่ผู้ใช้พิมพ์ต่อท้าย docker run คนละแบบ
ENTRYPOINT (คำสั่งหลักที่ตายตัว) — สิ่งที่ผู้ใช้พิมพ์ต่อท้าย docker run จะถูกเติมต่อท้าย คำสั่งนี้
CMD (คำสั่งหรืออาร์กิวเมนต์ตั้งต้น) — สิ่งที่ผู้ใช้พิมพ์ต่อท้าย docker run จะแทนที่ ค่านี้ทั้งอัน
สรุปเป็นตารางว่าใช้ตัวไหนเมื่อไร
| อยากได้แบบไหน | ใช้ | เหตุผล |
|---|---|---|
| image ที่มีหน้าที่เดียวตายตัว ห้ามใครเปลี่ยน | ENTRYPOINT | ผู้ใช้เปลี่ยนได้แค่อาร์กิวเมนต์ |
| image เครื่องมือที่ให้ผู้ใช้สั่งคำสั่งอะไรก็ได้ | CMD | ผู้ใช้พิมพ์คำสั่งใหม่ทับได้เลย |
เมื่อมีทั้งคู่ CMD จะกลายเป็นอาร์กิวเมนต์ตั้งต้นของ ENTRYPOINT
ทุกแถวคือ Dockerfile ที่ต่างกันแค่สองบรรทัดสุดท้าย
| ใน Dockerfile | สั่งว่า | ได้ผลจริงว่า |
|---|---|---|
ENTRYPOINT ["/bin/echo","ENTRYPOINT"] | docker run demo | พิมพ์ ENTRYPOINT |
ENTRYPOINT ["/bin/echo","ENTRYPOINT"] | docker run demo hello | พิมพ์ ENTRYPOINT hello |
ENTRYPOINT + CMD ["CMD-default"] | docker run demo | พิมพ์ ENTRYPOINT CMD-default |
ENTRYPOINT + CMD ["CMD-default"] | docker run demo argument | พิมพ์ ENTRYPOINT argument |
CMD ["/bin/echo","CMD-default"] | docker run demo | พิมพ์ CMD-default |
CMD ["/bin/echo","CMD-default"] | docker run demo /bin/uname -a | รัน /bin/uname -a แทนทั้งอัน |
สองแถวสุดท้ายคือหัวใจ: CMD โดนแทนที่ได้ทั้งคำสั่ง ส่วน ENTRYPOINT แถวที่สี่แค่โดนเปลี่ยนอาร์กิวเมนต์ ตัวคำสั่ง /bin/echo ยังอยู่
EXPOSE, VOLUME, ENV, LABEL
สี่ตัวนี้ไม่ได้รันอะไร มันแค่บันทึกข้อมูลไว้ใน image
EXPOSE 8080
VOLUME /data
ENV NODE_ENV=production
LABEL maintainer="John Doe <[email protected]>"EXPOSEบอกว่า container นี้จะฟังที่พอร์ตไหน — เป็นการประกาศให้คนอ่านและเครื่องมืออื่นรู้เท่านั้นVOLUMEทำจุดต่อไว้ที่ path ที่ระบุ ถ้าตอนdocker runไม่ได้ผูก volume หรือ bind mount มาให้ Docker จะสร้าง anonymous volume (volume ไม่มีชื่อ) ให้อัตโนมัติENVตั้งตัวแปรสภาพแวดล้อมที่ติดไปกับ image และมีผลตอนรันด้วยLABELแปะข้อมูลกำกับ เช่น เวอร์ชัน ผู้ดูแล วันที่ build
EXPOSE ไม่ใช่ -p สไลด์เขียนไว้ตรง ๆ ว่า expose ≠ publish
EXPOSE 8080 ไม่ได้เปิดพอร์ตให้เข้าจากข้างนอกแม้แต่นิดเดียว มันเป็นแค่หมายเหตุ ถ้าอยากเข้าถึงจากเครื่อง host ได้จริงต้องสั่ง docker run -p 8080:8080 เอง
จาก Dockerfile กลายเป็น image
คำสั่งเดียว
docker build -t my-app:1.0 .-t my-app:1.0ตั้งชื่อและ tag ให้ image เอาไว้อ้างถึงทีหลัง.บอกว่าให้หา Dockerfile และไฟล์ที่จะ copy จากโฟลเดอร์ปัจจุบัน
แต่ละคำสั่งใน Dockerfile กลายเป็นหนึ่ง layer ใน image
เรื่อง layer และ union file system อธิบายไว้ละเอียดใน Lecture 2 ตรงหัวข้อ image เป็นชั้น ๆ ถ้ายังไม่ชัดว่าทำไม image ถึงแชร์ชั้นกันได้ อ่านตรงนั้นก่อน
เปิดดูว่า image ถูกสร้างมายังไง
docker image history เล่าย้อนกลับได้ว่า image หนึ่งเกิดจากคำสั่งอะไรบ้าง
docker image history nginx:alpineบรรทัดที่บอกความจริงคือสองบรรทัดนี้
IMAGE CREATED CREATED BY SIZE
c7b4f26a7d93 3 weeks ago RUN /bin/sh -c set -x && apkArch="$(cat … 31.5MB
<missing> 3 weeks ago ENV NJS_RELEASE=1 0Bบรรทัดแรกคือ RUN ที่ติดตั้งของจริง กินไป 31.5MB ส่วนบรรทัดที่สองเป็นแค่การตั้งตัวแปร จึงมีขนาด 0B
ดูประวัติทั้งหมดของ nginx:alpine
$ docker pull nginx:alpine
$ docker image ls nginx:alpine
REPOSITORY TAG IMAGE ID CREATED SIZE
nginx alpine c7b4f26a7d93 3 weeks ago 43.2MB
$ docker image history nginx:alpine
IMAGE CREATED CREATED BY SIZE
c7b4f26a7d93 3 weeks ago RUN /bin/sh -c set -x && apkArch="$(cat … 31.5MB
<missing> 3 weeks ago ENV NJS_RELEASE=1 0B
<missing> 3 weeks ago ENV NJS_VERSION=0.8.5 0B
<missing> 3 weeks ago CMD ["nginx" "-g" "daemon off;"] 0B
<missing> 3 weeks ago STOPSIGNAL SIGQUIT 0B
<missing> 3 weeks ago EXPOSE map[80/tcp:{}] 0B
<missing> 3 weeks ago ENTRYPOINT ["/docker-entrypoint.sh"] 0B
<missing> 3 weeks ago COPY 30-tune-worker-processes.sh /docker-ent… 4.62kB
<missing> 3 weeks ago COPY 20-envsubst-on-templates.sh /docker-ent… 3.02kB
<missing> 3 weeks ago COPY 15-local-resolvers.envsh /docker-entryp… 336B
<missing> 3 weeks ago COPY 10-listen-on-ipv6-by-default.sh /docker… 2.12kB
<missing> 3 weeks ago COPY docker-entrypoint.sh / # buildkit 1.62kB
<missing> 3 weeks ago RUN /bin/sh -c set -x && addgroup -g 101… 3.97MB
<missing> 3 weeks ago ENV DYNPKG_RELEASE=2 0B
<missing> 3 weeks ago ENV PKG_RELEASE=1 0B
<missing> 3 weeks ago ENV NGINX_VERSION=1.27.1 0B
<missing> 3 weeks ago LABEL maintainer=NGINX Docker Maintainers <d… 0B
<missing> 3 weeks ago /bin/sh -c #(nop) CMD ["/bin/sh"] 0B
<missing> 3 weeks ago /bin/sh -c #(nop) ADD file:5758b97d8301c84a2… 7.8MB<missing> ในคอลัมน์ IMAGE ไม่ได้แปลว่าหาย มันแปลว่าชั้นนั้นไม่มี ID ของตัวเองบนเครื่องนี้ เพราะมันถูกดึงมาพร้อม image ไม่ได้ถูก build ที่นี่
สร้าง image แรกจริง ๆ
สร้างไฟล์ชื่อ dockerfile ที่มีแค่นี้
FROM ubuntu
CMD ["bash"]แล้วรันสามคำสั่งนี้ในโฟลเดอร์เดียวกัน
docker build -t ubuntu_bash .
docker image ls
docker run -it ubuntu_bashบรรทัดที่พิสูจน์ว่าสำเร็จคือสองบรรทัดนี้
Successfully tagged ubuntu_bash:latest
root@a002cb5b70b2:/#ตัวที่สองคือ prompt ของ bash ที่อยู่ข้างใน container — คุณเข้าไปอยู่ในนั้นแล้ว
ดูผลลัพธ์เต็มของการ build และ run
$ cat dockerfile
FROM ubuntu
CMD ["bash"]
$ docker build -t ubuntu_bash .
Sending build context to Docker daemon 2.048kB
Step 1/2 : FROM ubuntu
---> a2a15febcdf3
Step 2/2 : CMD ["bash"]
---> Running in e3bffa3d087c
Removing intermediate container e3bffa3d087c
---> c7824c8113f5
Successfully built c7824c8113f5
Successfully tagged ubuntu_bash:latest
$ docker image ls
REPOSITORY TAG IMAGE ID CREATED SIZE
ubuntu_bash latest c7824c8113f5 21 seconds ago 64.2MB
python latest 780bbecceca6 32 hours ago 919MB
ubuntu latest a2a15febcdf3 4 weeks ago 64.2MB
hello-world latest fce289e99eb9 8 months ago 1.84kB
$ docker run -it ubuntu_bash
root@a002cb5b70b2:/#สังเกตว่า ubuntu_bash กับ ubuntu มีขนาด 64.2MB เท่ากันเป๊ะ เพราะบรรทัด CMD ไม่ได้เพิ่มไฟล์อะไรเลย มันแชร์ชั้นเดียวกันทั้งหมด
เขียน Dockerfile ให้ image ไม่บวม
เครื่องมือที่คาบที่แล้วใช้ตรวจเน็ตเวิร์กใน container ไม่ได้ติดมากับ ubuntu เปล่า ๆ ต้องติดตั้งเอง
| คำสั่งที่อยากใช้ | แพ็กเกจที่ต้องลง |
|---|---|
ping | iputils-ping |
ifconfig | net-tools |
ip | iproute2 |
curl | curl |
เขียนตรง ๆ ได้แบบนี้ — แต่มันไม่ดี
FROM ubuntu
# add tools
RUN apt-get update
RUN apt-get install -y iputils-ping
RUN apt-get install -y net-tools
RUN apt-get install -y iproute2
RUN apt-get install -y curl
CMD ["bash"]RUN ห้าบรรทัด = ห้าชั้น และแคชของ apt ที่ดาวน์โหลดมาก็ค้างอยู่ในชั้นเหล่านั้นตลอดไป
FROM ubuntu
# Add tools in one RUN command to minimize the number of layers
# and clean up package manager cache to reduce image size
RUN apt-get update && \
apt-get install -y \
iputils-ping \
net-tools \
iproute2 \
curl && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
CMD ["bash"]RUN บรรทัดเดียว = หนึ่งชั้น และการลบแคชเกิดขึ้นในชั้นเดียวกัน ไฟล์แคชจึงไม่เคยถูกบันทึกลง image เลย
ทำไมต้องล้างแคชในบรรทัดเดียวกัน ไม่ใช่บรรทัดถัดไป?
เพราะ layer เป็นของถาวร ถ้าเขียนแยกเป็น
RUN apt-get install -y curl
RUN rm -rf /var/lib/apt/lists/*ชั้นแรกยังเก็บไฟล์แคชไว้ครบ ชั้นที่สองแค่บันทึกว่า "มองไม่เห็นไฟล์พวกนี้แล้ว" ขนาด image รวมไม่ได้ลดลงเลย นี่เป็นเหตุผลเดียวกับที่ทำให้เรื่องความลับในหัวข้อถัดไปเป็นปัญหา
ตัวอย่างที่ใช้จริง
ไฟล์ hello.py มีแค่ print("hello") และ dockerfile อยู่ในโฟลเดอร์ hello เดียวกัน
FROM python:3.13-slim
# Copy current folder which contains python code
# into directory /usr/src/hello of the Docker image
COPY . /usr/src/hello
# Specify working directory
WORKDIR /usr/src/hello
# Run program
CMD ["python", "hello.py"]cd hello
docker build -t hello .
docker run helloถ้าเขียน app.run(host="127.0.0.1") เว็บจะรันได้ปกติในบันทึกของ container แต่เปิดจากเครื่องคุณไม่ติดเลย เพราะ 127.0.0.1 ในสายตาของ container หมายถึงตัวมันเอง ไม่ใช่เครื่องคุณ
ต้อง 0.0.0.0 เท่านั้น มันแปลว่า "รับจากทุกหน้าต่างที่มี" รวมถึงหน้าต่างที่ต่อกับเครื่อง host
Multi-stage build — ทิ้งเครื่องมือ เก็บแค่ผลลัพธ์
บาง RUN สร้าง layer ที่ใหญ่มาก และของที่มันติดตั้งมา จำเป็นตอน build เท่านั้น เช่น Go compiler, .NET SDK, หรือ node_modules ของเครื่องมือ build
image สุดท้ายไม่ต้องใช้ของพวกนี้เลย แต่มันติดอยู่ในชั้นล่าง ๆ ลบทีหลังก็ไม่ช่วย
Multi-stage build (การ build หลายขั้นแล้วเลือกเก็บ) แบ่งการ build เป็นหลายขั้น แล้วประกอบ image สุดท้ายจากบางขั้นเท่านั้น
วิธีคิดมีสองก้าว
- ใช้ image ฐานที่มีเครื่องมือครบ build โปรแกรมออกมาเป็นไฟล์
- ใช้ image ฐานตัวเล็กที่มีแค่ของจำเป็นตอนรัน แล้ว copy เฉพาะไฟล์ผลลัพธ์จากขั้นแรกข้ามมา
FROM golang:1.23 AS build
WORKDIR /src
COPY <<EOF /src/main.go
package main
import "fmt"
func main() {
fmt.Println("hello, world")
}
EOF
RUN go build -o /bin/hello ./main.go
FROM scratch
COPY --from=build /bin/hello /bin/hello
USER 65534:65534
CMD ["/bin/hello"]อ่านทีละก้อน
- ขั้นแรกชื่อ
build(ตั้งชื่อด้วยAS build) ใช้ฐานgolangcompile โปรแกรมออกมาเป็น/bin/hello - ขั้นที่สองไม่มีชื่อ ใช้ฐาน
scratchซึ่งคือ image ว่างเปล่าจริง ๆ ไม่มีแม้แต่ shell COPY --from=buildดึงเฉพาะไฟล์/bin/helloจากขั้นแรกข้ามมา ที่เหลือถูกทิ้งทั้งหมดUSER 65534:65534ตั้งให้รันเป็นผู้ใช้nobody— ต้องใช้ตัวเลขเพราะscratchไม่มีuseraddไม่มี shell และไม่มีแม้แต่ไฟล์/etc/passwdให้แปลชื่อเป็นตัวเลข
COPY <<EOF /src/main.go เขียนไฟล์ลงไปตรง ๆ ใน Dockerfile เรียกว่า here-document รูปแบบนี้ต้องใช้ BuildKit ซึ่งเป็นตัว build มาตรฐานของ Docker รุ่นใหม่ ในงานจริงคุณจะเขียน main.go เป็นไฟล์แยกแล้ว COPY . . มากกว่า
เขียน Dockerfile ให้ปลอดภัย
สไลด์ยกไว้สองเรื่อง และทั้งสองเรื่องเป็นความผิดพลาดที่พบบ่อยมาก
เรื่องที่หนึ่ง — อย่ารันเป็น root
FROM python:3.13-slim
WORKDIR /app
COPY . .
RUN pip install -r requirements.txt
# runs as root
CMD ["python", "app.py"]ไม่ได้เขียน USER เลย แปลว่าโปรแกรมรันเป็น root ซึ่งเป็น UID 0 ตัวเดียวกับ root บนเครื่อง host
FROM python:3.13-slim
WORKDIR /app
COPY . .
RUN pip install -r requirements.txt && \
useradd -m appuser
USER appuser
CMD ["python", "app.py"]สร้างผู้ใช้ธรรมดาแล้วสลับไปใช้ก่อนบรรทัดที่รันโปรแกรมจริง
ลำดับสำคัญ USER ต้องอยู่หลัง RUN pip install เพราะการติดตั้งแพ็กเกจต้องใช้สิทธิ์ root ถ้าสลับไปเป็น appuser ก่อน การติดตั้งจะพังเพราะเขียนลงโฟลเดอร์ระบบไม่ได้
เรื่องที่สอง — อย่าฝังความลับลงใน layer
layer เป็นของถาวรและแก้ไม่ได้ ความลับที่ใส่ไว้ในชั้นหนึ่ง จะค้างอยู่ในประวัติของ image ตลอดไป แม้ชั้นถัดมาจะลบมันทิ้งแล้วก็ตาม
ใครก็ตามที่ docker pull image นั้นไป อ่านมันกลับออกมาได้ด้วย docker history หรือ docker image inspect
COPY .env .
ENV API_KEY=sk-123...
ARG DB_PASSWORDทั้งสามบรรทัดฝังความลับลง image อย่างถาวร อ่านกลับได้ด้วย
docker history IMAGE
docker image inspect IMAGE# ส่งตอนรัน
docker run --env-file .env my-app
# หรือส่งตอน build แบบไม่ทิ้งร่องรอย
docker build --secret id=db_pass,env=DB_PASS -t my-image .ในฝั่ง Dockerfile รับด้วย
RUN --mount=type=secret,id=key ...อย่าลืมใส่ .env ลงในไฟล์ .dockerignore ด้วย ไม่งั้น COPY . . จะลากมันเข้าไปให้เองโดยที่คุณไม่ได้ตั้งใจ
ลองตอบดู: Dockerfile เขียน `EXPOSE 3000` ไว้ แล้วสั่ง `docker run --detach my-app` เปิด http://localhost:3000 จะเจออะไร
เจอหน้า error ต่อไม่ติด
EXPOSE เป็นแค่การประกาศ ไม่ได้เปิดพอร์ตจริง ต้องสั่ง docker run -p 3000:3000 my-app ถึงจะเข้าถึงจากเครื่อง host ได้
ส่วนที่ 2 — Docker Compose
ปัญหาที่ Compose เกิดมาแก้
container ตัวเดียวจัดการด้วย docker run และ docker stop ก็จบ ง่ายมาก
แต่แอปจริงแทบไม่เคยมี container ตัวเดียว ระบบมอนิเตอร์จากคาบที่แล้วใช้สามตัว เว็บที่มีฐานข้อมูลใช้สองตัวเป็นอย่างน้อย พอเป็นแบบนั้นจะเจอปัญหาทันที
- ต้องจำลำดับว่าสร้าง network ก่อน แล้วค่อย volume แล้วค่อย container
- แต่ละตัวมี flag ยาวเป็นบรรทัด ต้องจำให้ครบและพิมพ์ให้ถูก
- จะปิดทั้งระบบต้องไล่
docker stopทีละตัว ลืมตัวเดียวก็เหลือค้าง - เพื่อนที่มารับงานต่อไม่มีทางรู้ว่าคุณเคยพิมพ์อะไรไป
คำถามคือ สั่งหลาย container เป็นก้อนเดียวได้ไหม?
Docker Compose (เครื่องมือสั่งหลาย container พร้อมกัน) คือคำตอบ มันเป็นเครื่องมือสำหรับนิยามและรันแอปที่ประกอบด้วยหลาย container
หลักการมีสามข้อ
- แอปหนึ่งตัวประกอบขึ้นจาก service (หน่วยบริการหนึ่งอย่าง) หลายตัว
- Compose มีคำสั่งสำหรับจัดการ service เหล่านั้นทั้งชุด
- ทุกอย่างเขียนไว้ในไฟล์ตั้งค่าที่เรียกว่า Compose file (ไฟล์อธิบายทั้งแอป) รูปแบบ YAML ชื่อโดยปริยายคือ
compose.yamlหรือcompose.yml
Compose อยู่ตรงไหนของขั้นตอนพัฒนา
สไลด์ยกภาพขั้นตอนพัฒนาแอปแบบ Docker มาเจ็ดขั้น
งานปกติที่ทำอยู่แล้ว ยังไม่เกี่ยวกับ Docker
ติดตั้ง Compose
เช็กก่อนว่ามีอยู่แล้วหรือยัง
docker compose version- Windows และ Mac — Docker Desktop แถม Compose มาให้แล้ว ไม่ต้องทำอะไร
- Linux — ติดตั้งเพิ่ม
sudo apt-get install docker-compose-pluginProject — กล่องที่ครอบทุกอย่างไว้
ถ้าคุณและเพื่อนใช้ compose file หน้าตาเหมือนกันบนเครื่องเดียวกัน จะเกิดอะไรขึ้น? network ชื่อเดียวกัน volume ชื่อเดียวกัน container ชื่อเดียวกัน — ชนกันหมด
Compose แก้ด้วยการห่อทุกอย่างไว้ในกล่องที่มีชื่อ
Project (กลุ่มของ container เครือข่าย และที่เก็บข้อมูลที่ถูกจัดการเป็นก้อนเดียว) คือการรวม service, network และ volume ที่เกี่ยวข้องกันไว้เป็นหน่วยเดียว สร้างขึ้นจาก compose file หนึ่งไฟล์
การแยกขาดจากกัน — แต่ละ project สร้างทรัพยากรของตัวเองแยกกัน ต่อให้ใช้ compose file ไฟล์เดียวกันเป๊ะ และโดยปริยาย service คุยกันได้เฉพาะภายใน project เดียวกันเท่านั้น
ชื่อ project มาจากไหน มีสี่ทาง เรียงจากที่ใช้บ่อยที่สุด
| วิธี | ทำยังไง |
|---|---|
| โดยปริยาย | ชื่อโฟลเดอร์ที่มี compose file อยู่ |
| ตอนสั่ง | ใส่ flag -p <ชื่อ> |
| ตัวแปรสภาพแวดล้อม | ตั้ง COMPOSE_PROJECT_NAME |
| ในไฟล์ | ใส่ name: ที่ระดับบนสุดของ compose file |
ชื่อของแต่ละอย่างถูกประกอบขึ้นตามแบบ
| ประเภท | รูปแบบ | ตัวอย่าง |
|---|---|---|
| Container | {project}-{service}-{replica} | webapp-frontend-1 |
| Network | {project}_{network} | webapp_app-network |
| Volume | {project}_{volume} | webapp_db-data |
สังเกตว่า container ใช้ขีดกลาง - คั่น แต่ network กับ volume ใช้ขีดล่าง _ คั่น ไม่มีเหตุผลลึกซึ้ง เป็นแค่ธรรมเนียมของ Compose แต่มันทำให้คนพิมพ์ชื่อผิดบ่อยมากตอนไปอ้างถึงในคำสั่ง docker ธรรมดา
Service — หนึ่งบริการ
project หนึ่งประกอบด้วย service ตั้งแต่หนึ่งตัวขึ้นไป แต่ละ service นิยามด้วย
- image ที่จะใช้
- คำสั่งที่จะรัน
- เครือข่ายที่จะเข้าร่วม
- พอร์ต
- ขีดจำกัด CPU และหน่วยความจำ
- จำนวนสำเนา
service ไม่เท่ากับ container service คือคำอธิบายว่า "ต้องมีของแบบนี้กี่ตัว" ส่วน container คือของจริงที่ถูกสร้างขึ้นตามคำอธิบายนั้น service เดียวขยายเป็น 5 container ได้ — เดี๋ยวได้เห็นตอนหัวข้อ scaling
หน้าตาของ compose file
องค์ประกอบระดับบนสุดมีห้าอย่าง
| ระดับบนสุด | หน้าที่ |
|---|---|
name | ชื่อ project — ถ้าไม่ใส่ Compose ใช้ชื่อโฟลเดอร์ |
services | นิยาม container และการตั้งค่าทั้งหมด |
networks | เครือข่ายที่จะสร้างเอง |
volumes | ที่เก็บข้อมูลถาวร |
configs และ secrets | ข้อมูลตั้งค่าและความลับ (ใส่หรือไม่ใส่ก็ได้) |
YAML เขียนได้สองแบบ และห้ามผสมกัน
นี่คือจุดที่คนเขียน compose file ครั้งแรกพลาดบ่อยที่สุด
| หัวข้อ | มักใช้แบบ | ตัวอย่าง |
|---|---|---|
ports: | รายการ | - "8080:80" |
environment: | ได้ทั้งสองแบบ | - KEY=value หรือ KEY: value |
volumes: | รายการ | - "./app:/var/www" |
deploy: | คู่คีย์-ค่า | replicas: 3 |
labels: | ได้ทั้งสองแบบ | - "key=value" หรือ key: value |
กฎที่ใช้ตัดสิน
- ชื่อ service ชื่อ network ชื่อ volume เป็นคู่คีย์-ค่าเสมอ เพราะมันคือคีย์
- ของหลายชิ้นชนิดเดียวกัน ใช้รายการ
- ค่าเดี่ยว ๆ ใช้คู่คีย์-ค่า
- ห้ามผสมสองแบบในหัวข้อเดียวกัน เลือกอย่างใดอย่างหนึ่ง
project ที่เล็กที่สุดที่เป็นไปได้
services:
node-exporter:
image: prom/node-exporter
ports:
- 9100:9100ทั้งหมดนี้แทนคำสั่งเดียว
docker run --rm -d -p 9100:9100 --network mynet --name node-exporter prom/node-exporterอ่านทีละส่วน
services:คือองค์ประกอบระดับบนสุดnode-exporter:คือชื่อ service ที่เราตั้งเองimage:คือ image ที่จะใช้ports:คือการเปิดพอร์ตออกมาที่เครื่อง host
สั่ง Compose
Compose ทำงานกับ project ทีละอัน และเลือก project ได้สามทาง
# 1. เข้าไปในโฟลเดอร์ที่มี compose file
cd <project_directory>
docker compose COMMAND
# 2. ระบุด้วยชื่อ project
docker compose -p <project_name> COMMAND
# 3. ระบุด้วย path เต็มของ compose file
docker compose -f <compose_file> COMMANDดูรายการคำสั่งทั้งหมดได้ด้วย docker compose เปล่า ๆ และดูวิธีใช้ของแต่ละคำสั่งด้วย docker compose COMMAND --help
คำสั่งพื้นฐาน
## สร้าง image ให้ service
docker compose build [SERVICE...]
## สั่งขึ้น
docker compose up -d [SERVICE...]
docker compose up --build -d [SERVICE...]
## สั่งลง
docker compose down # ลบ container และเครือข่ายด้วย
docker compose stop # หยุดเฉย ๆ ไม่ลบ
## ดูสถานะ
docker compose ls # รายการ project ที่รันอยู่
docker compose ps # รายการ container ของ project นี้
docker compose top # รายการ process ข้างใน-dรันแบบเบื้องหลัง ไม่ยึด terminal--buildสร้าง image ใหม่ก่อนสตาร์ต
down กับ stop ต่างกันมาก
down ลบ container และลบเครือข่ายที่สร้างไว้ด้วย (และลบ volume ด้วยถ้าใส่ -v) ส่วน stop แค่หยุด ทุกอย่างยังอยู่ครบ สั่ง start กลับมาได้ทันทีโดยไม่เสียข้อมูลใน container
ใช้ stop เมื่อยังไม่อยากลบ
ลองสั่งจริง
รันในโฟลเดอร์ที่มี compose.yml
$ docker compose up -d
[+] Running 2/2
✔ Network node-exporter_default Created
✔ Container node-exporter-node-exporter-1 Startedสองบรรทัดนี้บอกความจริงทั้งหมด — Compose สร้างเครือข่ายให้เองโดยไม่ได้สั่ง และตั้งชื่อ container ตามแบบที่ว่าไว้
ชื่อสามชั้นที่ต้องแยกให้ออก
| เห็นตรงไหน | ค่าที่ได้ | มาจาก |
|---|---|---|
docker compose ls | node-exporter | ชื่อ project = ชื่อโฟลเดอร์ |
docker compose ps คอลัมน์ SERVICE | node-exporter | ชื่อ service ใน compose file |
docker compose ps คอลัมน์ NAME | node-exporter-node-exporter-1 | ชื่อ container ที่ประกอบขึ้น |
เปิด http://localhost:9100/metrics ก็เห็นตัวเลขจากเครื่อง
สั่งลง
$ docker compose down
[+] Running 2/2
✔ Container node-exporter-node-exporter-1 Removed
✔ Network node-exporter_default Removedทั้ง container และเครือข่ายหายไปพร้อมกัน
ในตัวอย่างนี้ชื่อ project กับชื่อ service บังเอิญเหมือนกัน เพราะโฟลเดอร์ชื่อ node-exporter และ service ก็ชื่อ node-exporter ชื่อ container เลยกลายเป็น node-exporter-node-exporter-1 ที่อ่านแล้วงง ในงานจริงตั้งชื่อโฟลเดอร์ให้ต่างจากชื่อ service จะอ่านง่ายกว่ามาก
ตัวอย่างใหญ่ที่หนึ่ง — ระบบมอนิเตอร์
นี่คือระบบเดียวกับที่คาบที่แล้วสร้างด้วยมือ คราวนี้เอามาเขียนเป็น compose file
เดิมต้องพิมพ์ห้าคำสั่ง
docker volume create grafana-vol
docker network create mynet
docker run --rm -d -p 9100:9100 --network mynet --name node-exporter prom/node-exporter:v1.12.0
docker run --rm -d -p 9090:9090 --network mynet --name prometheus -v ./prometheus.yml:/etc/prometheus/prometheus.yml prom/prometheus:v3.0.1
docker run --rm -d -p 3000:3000 --network mynet -v grafana-vol:/var/lib/grafana --name grafana grafana/grafana:11.4.0ห้าบรรทัดนี้ต้องรันตามลำดับ ต้องจำ flag ให้ครบ และไม่มีที่ไหนบันทึกไว้ว่าคุณเคยรันอะไรไป
เขียนใหม่เป็น compose file
networks:
monitoring:
driver: bridge
volumes:
grafana-vol:
external: true
services:
node-exporter:
image: prom/node-exporter:v1.12.0
container_name: node-exporter
expose:
- 9100
networks:
- monitoring
prometheus:
image: prom/prometheus:v3.0.1
container_name: prometheus
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml
expose:
- 9090
networks:
- monitoring
grafana:
image: grafana/grafana:11.4.0
container_name: grafana
volumes:
- grafana-vol:/var/lib/grafana
ports:
- 3000:3000
networks:
- monitoringอ่านทีละจุดที่สไลด์ชี้ไว้
| บรรทัด | หมายความว่า |
|---|---|
networks: monitoring: driver: bridge | สร้างเครือข่ายแบบ bridge ชื่อ monitoring |
volumes: grafana-vol: external: true | volume นี้มีอยู่แล้ว Compose จะไม่สร้างให้ และจะฟ้อง error ถ้าหาไม่เจอ |
container_name: | ตั้งชื่อ container ตายตัว ทับแบบ {project}-{service}-{n} |
expose: - 9100 | ประกาศว่าเพื่อนบนเครือข่ายเดียวกันเรียกพอร์ตนี้ได้ แต่ไม่เปิดออกไปที่เครื่อง host |
ports: - 3000:3000 | เปิดออกไปที่เครื่อง host จริง ๆ |
volumes: - ./prometheus.yml:/etc/... | bind mount (ผูกไฟล์จริงบนเครื่องเข้าไป) — เอาไฟล์บนเครื่องยัดเข้าไปในตำแหน่งนั้น |
volumes: - grafana-vol:/var/lib/grafana | volume mapping (ผูก volume ของ Docker เข้าไป) — เอา volume ที่ Docker ดูแลมาแปะ |
external: true มีผลสองอย่างที่ต้องรู้
มี — Compose ไม่สร้างและไม่ลบ volume นี้ ถ้ายังไม่มีอยู่ก่อน docker compose up จะ error ทันที ต้องสร้างเองด้วย docker volume create grafana-vol ก่อน
ไม่มี — Compose สร้าง volume ให้เองในการรันครั้งแรก แล้วใช้ตัวเดิมในครั้งถัดไป และชื่อจะกลายเป็น monitoring_grafana-vol ตามแบบของ project
เลือก external: true เมื่อข้อมูลนั้นสำคัญกว่า project — เช่นแดชบอร์ด Grafana ที่คุณตั้งไว้ ไม่อยากให้หายไปพร้อมกับ docker compose down -v
สั่งขึ้นแล้วดูว่าได้อะไรมา
$ docker compose up -d
[+] Running 4/4
✔ Network monitoring_monitoring Created
✔ Container prometheus Started
✔ Container grafana Started
✔ Container node-exporter Startedสามบรรทัดล่างใช้ชื่อสั้น เพราะเราตั้ง container_name ไว้เอง ส่วนเครือข่ายยังใช้แบบมาตรฐาน {project}_{network} = monitoring_monitoring
ไม่มีบรรทัดที่บอกว่าสร้าง volume เพราะเราสั่ง external: true ไว้ มันไปใช้ grafana-vol ตัวที่มีอยู่แล้ว
ดูผลของคำสั่งตรวจสอบทั้งสี่คำสั่ง
$ docker compose ls
NAME STATUS CONFIG FILES
monitoring running(3) /home/veera/docker/monitoring/compose.yml
$ docker compose ps
NAME IMAGE COMMAND SERVICE STATUS PORTS
grafana grafana/grafana "/run.sh" grafana Up 0.0.0.0:3000->3000/tcp
node-exporter prom/node-exporter "/bin/node_exporter" node-exporter Up 9100/tcp
prometheus prom/prometheus "/bin/prometheus --c…" prometheus Up 9090/tcp
$ docker compose top
grafana
UID PID PPID C STIME TTY TIME CMD
472 6601 6566 0 01:00 ? 00:00:01 grafana server --homepath=/usr/share/grafana …
node-exporter
UID PID PPID C STIME TTY TIME CMD
nobody 6550 6531 0 01:00 ? 00:00:00 /bin/node_exporter
prometheus
UID PID PPID C STIME TTY TIME CMD
nobody 6637 6597 0 01:00 ? 00:00:00 /bin/prometheus --config.file=/etc/prometheus/prometheus.yml …
$ docker network ls
NETWORK ID NAME DRIVER SCOPE
d2b832013cbc bridge bridge local
5583bf467cc2 host host local
10287c1b3112 monitoring_monitoring bridge local
d03f7c53291d mynet bridge local
114d0b600a15 none null local
$ docker volume ls
DRIVER VOLUME NAME
local e036ac688866b6631b8cb539ee610bc52cfb73eb7c4e8f9e4430c79bf2ac8f08
local grafana-volจุดที่ควรสังเกต — docker compose ps แสดง 9100/tcp เฉย ๆ ให้ node-exporter (แค่ expose) แต่แสดง 0.0.0.0:3000->3000/tcp ให้ grafana (publish จริง) ความต่างของ expose กับ ports อยู่ตรงนี้
docker compose ls และ ps ใช้ compose file ในโฟลเดอร์ปัจจุบันโดยปริยาย ถ้าสั่งจากที่อื่นต้องใส่ -f หรือ -p ระบุ project ให้ชัด ไม่งั้นจะได้ผลว่าง ๆ แล้วนึกว่าไม่มีอะไรรันอยู่
สั่งลงก็จบทุกอย่างในคำสั่งเดียว
$ docker compose down
[+] Running 4/4
✔ Container node-exporter Removed
✔ Container prometheus Removed
✔ Container grafana Removed
✔ Network monitoring_monitoring Removeddocker compose up สั่งซ้ำได้ไม่พัง
Idempotent (สั่งซ้ำกี่ครั้งผลเท่าเดิม) แปลว่าสั่งหลายครั้งได้ผลเหมือนสั่งครั้งเดียว ปลอดภัยที่จะทำซ้ำ
docker compose up เป็นแบบนั้น
- ครั้งแรก — สร้าง service, network, volume ทั้งหมด
- ครั้งต่อไป (ก่อนสั่ง
down) — Compose เทียบ compose file กับของที่รันอยู่จริง- ถ้าตรงกันหมด ไม่เกิดอะไรขึ้นเลย
- ถ้ามีอะไรไม่ตรง เฉพาะส่วนนั้นถูกสร้างใหม่หรือลบทิ้งให้ตรงกับไฟล์
สมมติแก้ compose.yml เปลี่ยน grafana/grafana:11.4.0 เป็น 11.5.0 แล้วสั่ง docker compose up -d ใหม่
Compose จะลบ container grafana ตัวเก่าแล้วสร้างใหม่ด้วย image ใหม่ ส่วน prometheus และ node-exporter ไม่ถูกแตะเลย เพราะนิยามของสองตัวนั้นไม่ได้เปลี่ยน
นี่คือสาเหตุที่ compose file เป็นเครื่องมือที่ดีต่อการดูแลระบบ — คุณแก้ไฟล์แล้วสั่งซ้ำ ไม่ต้องคิดเองว่าต้องหยุดตัวไหนบ้าง
คำสั่งอื่นที่ใช้บ่อย
## ขยายจำนวนสำเนาของ service
docker compose up --scale [SERVICE=NUM] -d
## หยุดโดยไม่ลบ / สตาร์ตกลับ
docker compose stop [SERVICE...]
docker compose start [SERVICE...]
## ลบ container ที่หยุดแล้ว
docker compose rm
## สั่งคำสั่งเข้าไปใน container ที่รันอยู่
docker compose exec [SERVICE] [COMMAND]
## ดู log
docker compose logs [SERVICE...]ตัวอย่างใหญ่ที่สอง — เว็บนับจำนวนผู้เข้าชม
เว็บง่าย ๆ ที่นับว่ามีคนเข้ามากี่ครั้ง ใช้สอง service คือเว็บกับฐานข้อมูล
โครงไฟล์
.
├── compose.yml
└── web
├── Dockerfile
├── app.py
└── requirements.txtfrom flask import Flask
from pymongo import MongoClient, ReturnDocument
app = Flask(__name__)
# Connect to MongoDB
client = MongoClient('mongo', 27017)
db = client['mydb']
visitors = db.visitors
@app.route('/')
def hello():
visitor = visitors.find_one_and_update(
{"_id": "visit-counter"},
{"$inc": {"count": 1}},
upsert=True,
return_document=ReturnDocument.AFTER,
)
return f"Visit count: {visitor['count']}"
if __name__ == "__main__":
app.run(host="0.0.0.0", port=5000)จุดสำคัญคือบรรทัด MongoClient('mongo', 27017) — มันต่อไปที่ชื่อ mongo ตรง ๆ ไม่ใช่หมายเลข IP ชื่อนี้คือ ชื่อ service ใน compose file และ Compose มี DNS ในตัวที่แปลงชื่อ service เป็น IP ให้เอง
depends_on รับประกันแค่ว่า mongo สตาร์ตแล้ว ไม่ได้รับประกันว่า mongo พร้อมรับงานแล้ว ฐานข้อมูลใช้เวลาอีกหลายวินาทีหลังจาก process เริ่มทำงาน กว่าจะรับ connection ได้จริง
ถ้าอยากได้ของจริง ต้องเพิ่มสองอย่าง — healthcheck ในฝั่ง mongo และ condition: service_healthy ในฝั่ง web
services:
web:
depends_on:
mongo:
condition: service_healthy
mongo:
healthcheck:
test: ["CMD", "mongosh", "--eval", "db.adminCommand('ping')"]
interval: 5s
retries: 5สังเกตว่ารูปแบบเปลี่ยนจากรายการเป็นคู่คีย์-ค่า ตามกฎ YAML ที่ว่าไว้ข้างบน
สั่งขึ้น
$ docker compose up
✔ web Built
✔ Network web_counting_default Created
✔ Container web_counting-mongo-1 Started
✔ Container web_counting-web-1 StartedCompose ทำสี่อย่างตามลำดับ — build image, สร้างเครือข่าย, สตาร์ต mongo, แล้วค่อยสตาร์ต web (ตาม depends_on)
พอร์ตที่ได้มาเป็นแบบสุ่ม
$ docker compose ps
NAME IMAGE SERVICE PORTS
web_counting-mongo-1 mongo mongo 27017/tcp
web_counting-web-1 web_counting-web web 0.0.0.0:65233->5000/tcpเปิด http://localhost:65233 แล้วรีเฟรชสามครั้ง ได้ Visit count: 1, 2, 3 ตามลำดับ
ดูว่าเครือข่ายและ volume ถูกสร้างชื่ออะไร
$ docker network ls
NETWORK ID NAME DRIVER SCOPE
d2b832013cbc bridge bridge local
5583bf467cc2 host host local
d03f7c53291d mynet bridge local
114d0b600a15 none null local
65cca95c5723 web_counting_default bridge local
$ docker volume ls
DRIVER VOLUME NAME
local 262714c1cf0136099445c01e552e3fd1e1fa75daf8c118f9d5873bfb334b4f4b
local grafana-vol
local web_counting_mongo-dataweb_counting_default คือเครือข่ายที่ Compose สร้างให้เอง เพราะ compose file ไม่ได้ประกาศ networks: ไว้ ส่วน web_counting_mongo-data คือ volume ที่ประกาศไว้ในไฟล์ ได้ชื่อขึ้นต้นด้วยชื่อ project ตามแบบ
ขยายเป็นหลายสำเนา
เว็บตัวเดียวรับคนได้จำกัด ถ้าอยากรับมากขึ้นก็เพิ่มสำเนาของ service เดิม แต่ถ้าเพิ่มด้วย docker run ต้องตั้งชื่อใหม่ทุกตัวและเลือกพอร์ตเองทุกตัว
docker compose up --scale web=3 -dผลลัพธ์ต่างกันตามว่ามี depends_on หรือไม่
$ docker compose up --scale web=3 -d
[+] Running 6/6
✔ Network web_counting_default Created
✔ Volume "web_counting_mongo-data" Created
✔ Container web_counting-web-3 Started
✔ Container web_counting-web-1 Started
✔ Container web_counting-mongo-1 Started
✔ Container web_counting-web-2 Startedweb ทั้งสามตัวสตาร์ตพร้อม ๆ กับ mongo ไม่ได้รอ ถ้า mongo ยังไม่พร้อม web จะต่อฐานข้อมูลไม่ติดในวินาทีแรก ๆ
$ docker compose up --scale web=3 -d
[+] Running 5/5
✔ Network web_counting_default Created
✔ Container web_counting-mongo-1 Started
✔ Container web_counting-web-3 Started
✔ Container web_counting-web-1 Started
✔ Container web_counting-web-2 Startedmongo ขึ้นเป็นตัวแรกเสมอ และไม่มีบรรทัด Volume Created เพราะรันครั้งที่สองแล้ว volume ตัวเดิมยังอยู่
ดูพอร์ตที่แต่ละสำเนาได้
$ docker compose ps
NAME IMAGE SERVICE PORTS
web_counting-mongo-1 mongo mongo 27017/tcp
web_counting-web-1 web_counting-web web 0.0.0.0:53468->5000/tcp
web_counting-web-2 web_counting-web web 0.0.0.0:53467->5000/tcp
web_counting-web-3 web_counting-web web 0.0.0.0:53469->5000/tcpนี่คือเหตุผลที่ compose file เขียน ports: - "5000" เฉย ๆ ถ้าเขียน "5000:5000" สำเนาที่สองจะสตาร์ตไม่ขึ้น เพราะพอร์ต 5000 บนเครื่อง host ถูกจองไปแล้ว
เข้าเว็บสำเนาไหนก็ได้ ตัวนับเดินต่อจากกัน — 53467 ได้ 1, 53468 ได้ 2, 53469 ได้ 3, กลับมา 53467 ได้ 4 เพราะทั้งสามอ่านเขียน mongo ตัวเดียวกัน
ลดเหลือ 1 — สั่ง docker compose up -d เฉย ๆ (ไม่ใส่ --scale แปลว่ากลับไปค่าปริยายคือ 1)
[+] Running 4/4
✔ Container web_counting-mongo-1 Running
✔ Container web_counting-web-2 Removed
✔ Container web_counting-web-1 Removed
✔ Container web_counting-web-3 Startedweb-1 และ web-2 ถูกลบ เหลือ web-3
เพิ่มเป็น 5
$ docker compose up --scale web=5 -d
[+] Running 6/6
✔ Container web_counting-web-3 Running
✔ Container web_counting-web-7 Started
✔ Container web_counting-web-4 Started
✔ Container web_counting-web-5 Started
✔ Container web_counting-mongo-1 Running
✔ Container web_counting-web-6 Startedได้ web-4 ถึง web-7 เพิ่มมา ส่วน web-3 ยังเป็นตัวเดิมที่ไม่ถูกแตะ (สถานะ Running ไม่ใช่ Started)
หมายเลขไม่ได้เริ่มนับใหม่ มันเดินต่อจากเลขสูงสุดที่เคยใช้ไป อย่าไปคาดหวังว่าจะได้ web-1 ถึง web-5
กำหนดจำนวนสำเนาและขีดจำกัดทรัพยากรในไฟล์
แทนที่จะใส่ --scale ตอนสั่งทุกครั้ง เขียนไว้ในไฟล์ได้เลย
services:
web:
build: ./web
ports:
- "5000"
environment:
- MONGO_URL=mongodb://mongo:27017/mydb
restart: always
depends_on:
- mongo
deploy:
replicas: 3 # Create 3 replicas of web service
resources:
limits:
memory: 512M # Maximum memory usage
cpus: '0.5' # Maximum 50% of one CPU core
reservations:
memory: 256M # Guaranteed minimum memory
cpus: '0.25' # Guaranteed minimum 25% of one CPU core
mongo:
image: mongo:7
volumes:
- mongo-data:/data/db
volumes:
mongo-data:ของใหม่สามอย่าง
| คีย์ | ทำอะไร |
|---|---|
restart: always | ถ้า container ตาย ให้สตาร์ตใหม่ให้อัตโนมัติ |
limits | เพดาน — ใช้ได้ไม่เกินนี้ |
reservations | พื้น — รับประกันว่าจะได้อย่างน้อยเท่านี้ |
cpus: '0.5' ไม่ได้แปลว่าครึ่งหนึ่งของทั้งเครื่อง แต่แปลว่า ครึ่งหนึ่งของหนึ่งแกน ถ้าเครื่องมี 8 แกน 1.0 ก็ยังใช้ได้แค่แกนเดียวเต็ม ๆ
หลายเครือข่ายใน project เดียว
ในระบบจริงไม่ใช่ทุก container ควรคุยกันได้หมด ฐานข้อมูลไม่ควรถูกเรียกจากอินเทอร์เน็ต และไม่ควรมีสิทธิ์ต่อออกอินเทอร์เน็ตด้วยซ้ำ
ทางที่ปลอดภัยกว่าคือแยกเครือข่ายตามระดับความเชื่อใจ แล้วให้ service อยู่เฉพาะเครือข่ายที่มันต้องอยู่จริง ๆ
networks:
frontend: # Public-facing services
driver: bridge
backend: # Internal services only
driver: bridge
internal: true # No external access
services:
nginx: # Load balancer
image: nginx
networks:
- frontend
ports:
- "80:80"
web: # Application server
image: my-app
networks:
- frontend # Can receive requests from nginx
- backend # Can connect to database
db: # Database
image: postgres:16
networks:
- backend # Only accessible from backend network
# No ports exposed to hostสามจุดที่ทำให้ปลอดภัย
dbไม่มีports:เลย จึงไม่มีพอร์ตไหนบนเครื่อง host ที่ชี้ไปหามันdbอยู่แค่backendส่วนnginxอยู่แค่frontendทั้งสองไม่มีทางเจอกันinternal: trueตัดทางออกสู่ภายนอกของทั้งเครือข่ายbackendถ้าฐานข้อมูลถูกเจาะ มันก็ส่งข้อมูลออกไปข้างนอกไม่ได้
web เป็นตัวเดียวที่อยู่ทั้งสองเครือข่าย มันจึงเป็นทางผ่านเดียวจากโลกภายนอกไปหาฐานข้อมูล
ลองตอบดู: ถ้าอยากเข้า postgres จากเครื่องตัวเองเพื่อดีบัก ตั้งค่ายังไงให้ยังปลอดภัยพอควร
เพิ่ม ports: - "127.0.0.1:5432:5432" ให้ db ชั่วคราว
การใส่ 127.0.0.1: ข้างหน้าทำให้พอร์ตเปิดเฉพาะกับตัวเครื่องเอง ไม่ใช่ทุกหน้าต่างเครือข่าย เครื่องอื่นในวงแลนจึงยังต่อไม่ได้ แต่ก็ยังไม่ควรทิ้งไว้ตอนขึ้นระบบจริง
ทางที่สะอาดกว่าคือใช้ docker compose exec db psql เข้าไปข้างในเลย โดยไม่ต้องเปิดพอร์ตอะไรเพิ่ม
ส่วนที่ 3 — เมื่อเครื่องเดียวไม่พอ
ปัญหาถัดไป
Compose แก้ปัญหา "หลาย container" ได้หมดจด แต่มันมีขอบเขตที่ชัดมาก — ทุกอย่างอยู่บนเครื่องเดียว
พอระบบโตขึ้น เครื่องเดียวไม่พอ ทั้งเรื่องแรงและเรื่องความทนทาน ถ้าเครื่องนั้นดับ ทุก container ดับพร้อมกัน
คำถามคือ จะจัดการ container หลายตัวบนเครื่องหลายเครื่องยังไง?
Container orchestration
Container orchestration (ระบบจัดวางและดูแล container ข้ามเครื่อง) คือแพลตฟอร์มที่ทำให้การ deploy จัดการ และขยายแอปแบบ container บนคลัสเตอร์ใหญ่ ๆ ทำได้ง่ายขึ้น
พูดอีกแบบคือ การทำ cluster computing ด้วย container
เครื่องเดียวเหมือนร้านอาหารร้านเดียว เจ้าของจัดโต๊ะเองได้หมด
คลัสเตอร์เหมือนสายการบิน มีเครื่องบินร้อยลำและผู้โดยสารหมื่นคน ต้องมีระบบกลางที่ตัดสินว่าใครขึ้นลำไหน ลำไหนซ่อมอยู่ ถ้าลำหนึ่งเสียต้องย้ายคนไปลำอื่นให้อัตโนมัติ — orchestrator ทำหน้าที่นั้นให้ container
Compose กับ Kubernetes ต่างกันตรงไหน
| ด้าน | Docker Compose | Kubernetes |
|---|---|---|
| ขอบเขต | พัฒนาบนเครื่องตัวเอง งานเล็ก | ระบบจริงขนาดใหญ่ คลัสเตอร์หลายเครื่อง |
| การติดตั้ง | ง่ายมาก ไฟล์ YAML เดียวกับ docker compose up | ซับซ้อน มี control plane, kubelet, ปลั๊กอินเครือข่าย |
| ความยากในการเรียน | ต่ำ เหมาะกับคนเริ่มต้นและงานต้นแบบ | สูง มีแนวคิดให้เรียนเยอะ (Pod, Service, Deployment, Ingress) |
| ขนาดที่รองรับ | เครื่องเดียว container ไม่กี่ตัว | container หลายพันตัวข้ามหลายเครื่อง |
| เครือข่าย | เครือข่ายในเครื่องเดียว หากันด้วยชื่อ | เครือข่ายทั้งคลัสเตอร์ มี DNS และ load balancing ในตัว |
| ที่เก็บข้อมูล | volume แบบง่าย | persistent volume และการจัดการที่เก็บข้อมูลขั้นสูง |
| ความทนทาน | ไม่มีตัวจัดวางข้ามเครื่อง ไม่ย้าย container ให้อัตโนมัติ | ซ่อมตัวเองได้ รีสตาร์ตอัตโนมัติ อัปเดตแบบไม่ดับ ขยายอัตโนมัติ |
| เหมาะกับ | สภาพแวดล้อมทดสอบ สอนพื้นฐาน โปรเจกต์เล็ก | microservices ที่ขึ้นจริง ระบบ cloud-native แอปที่ห้ามล่ม |
Compose ไม่ได้แย่กว่า Kubernetes มันแก้คนละปัญหา Compose แก้ "หลาย container หนึ่งเครื่อง" ส่วน Kubernetes แก้ "หลาย container หลายเครื่อง"
ใช้ Kubernetes กับงานที่ Compose พอ คือการจ่ายความซับซ้อนไปฟรี ๆ
เรื่อง Kubernetes ต่อในคาบถัดไป
ส่วนที่ 4 — แล็บ Raspberry Pi
ทุกอย่างในหน้านี้ยังอยู่บนเครื่องเดียว การจะเข้าใจว่าทำไมถึงต้องมี orchestrator ต้องเห็นด้วยตาก่อนว่า เครื่องที่สองทำให้อะไรพัง
แล็บนี้เอา Raspberry Pi มาเป็นเครื่องที่สอง แล้วลองย้าย container ตัวหนึ่งไปอยู่บนนั้น
ขั้นตอนทั้งหมดมีหกก้าว
- เตรียมฮาร์ดแวร์
- ลงระบบปฏิบัติการ
- เลือกวิธีใช้งาน
- ต่อเครือข่าย
- ติดตั้ง Docker
- รัน container
เตรียมฮาร์ดแวร์
ใช้ได้ทั้ง Raspberry Pi 3 Model B+ และ Raspberry Pi 4 Model B เช็กว่ามี SD card มาด้วย เพราะ Pi ไม่มีที่เก็บข้อมูลในตัว ระบบปฏิบัติการทั้งหมดอยู่บน SD card
ลงระบบปฏิบัติการ
ใช้โปรแกรม Raspberry Pi Imager เขียน Raspberry Pi OS ลง SD card ดาวน์โหลดจาก raspberrypi.com/software
ตั้งค่าใน Imager ก่อนเขียนลงการ์ด
| ตั้งค่า | ค่าที่ใช้ในแล็บ |
|---|---|
| Hostname | raspberrypi |
| Username | pi |
| Password | raspberry |
| Wi-Fi SSID และรหัส | ของ mobile hotspot ตัวเอง |
ตั้งชื่อ hotspot ให้ไม่ซ้ำกับเพื่อน ไม่งั้น Pi อาจไปเกาะ hotspot ของคนอื่นแล้วหาไม่เจอ
เรื่องเครือข่าย — mobile hotspot ดีที่สุด Wi-Fi ที่บ้านก็ใช้ได้ แต่ Wi-Fi สาธารณะอย่าง ChulaWiFi ใช้ไม่ได้ เพราะมีหน้าล็อกอินและมักกันไม่ให้อุปกรณ์ในวงเดียวกันคุยกัน
และควรใช้คลื่น 2.4 GHz เพราะ Pi 3 รองรับแค่นั้น
เลือกวิธีใช้งาน
มีสองทาง
| ทาง | ต้องมีอะไร | เหมาะเมื่อ |
|---|---|---|
| หน้าจอ Desktop | จอ คีย์บอร์ด เมาส์ | อยากเห็นหน้าตาระบบ ต่อ Wi-Fi ผ่าน GUI ได้ |
| SSH จากคอมพิวเตอร์ | ไม่ต้องมีจออะไรเลย | ก๊อปวางคำสั่งได้ สะดวกกว่ามาก |
แบบที่สองเรียกว่า headless (ไม่ต้องต่อจอ) และเป็นวิธีที่แล็บแนะนำ
ต่อด้วย SSH
Raspberry Pi รองรับ multicast DNS (ระบบชื่อในวงแลนที่ไม่ต้องมีเซิร์ฟเวอร์) เครื่องอื่นในวงเดียวกันจึงเรียกมันด้วยชื่อ raspberrypi หรือ raspberrypi.local ได้เลย ไม่ต้องรู้หมายเลข IP
ใช้ SSID และรหัสตัวเดียวกับที่ตั้งไว้ใน Imager
ถ้าเจอข้อความ
Host key verification failed.แปลว่าคอมพิวเตอร์เคยจำกุญแจของเครื่องชื่อ raspberrypi ตัวเก่าไว้ แล้วคราวนี้เจอกุญแจไม่ตรง (เพราะเขียน SD card ใหม่ หรือเป็น Pi คนละตัว)
แก้ด้วยการลบไฟล์ที่จำไว้แล้วต่อใหม่
rm ~/.ssh/known_hostsการเช็กนี้มีไว้กันการถูกดักกลางทาง ในแล็บที่รู้แน่ว่าเราเขียนการ์ดใหม่เอง การลบทิ้งไม่มีปัญหา แต่บนเซิร์ฟเวอร์จริงถ้าเจอข้อความนี้แบบไม่มีสาเหตุ ให้สงสัยไว้ก่อน
เปลี่ยนชื่อเครื่อง
ถ้ามี Pi หลายตัวในวงเดียวกัน ชื่อ raspberrypi เหมือนกันหมดจะชนกัน เปลี่ยนได้ทีหลังโดยแก้สองไฟล์
sudo nano /etc/hostname
sudo nano /etc/hostsเปลี่ยนชื่อเดิมเป็นชื่อใหม่ในทั้งสองไฟล์ แล้ว reboot
ติดตั้ง Docker บน Pi
sudo apt-get update
curl -fsSL https://get.docker.com -o get-docker.sh
sudo sh get-docker.shแล้วเพิ่มผู้ใช้ pi เข้ากลุ่ม docker เพื่อจะได้ไม่ต้องพิมพ์ sudo ทุกครั้ง
sudo usermod -aG docker piต้อง ออกจากระบบแล้วเข้าใหม่ สิทธิ์กลุ่มถึงจะมีผล
เช็กว่าใช้ได้
docker version
docker infoลองรัน container
docker run -d -p 4000:80 --name nginx nginxเปิด http://raspberrypi:4000 จากเบราว์เซอร์บนคอมพิวเตอร์ ไม่ใช่บน Pi — ถ้าเห็นหน้าต้อนรับของ nginx แปลว่าทุกอย่างครบแล้ว
สังเกตว่าเรียกด้วยชื่อ raspberrypi ได้เลยเพราะ multicast DNS ทำงานอยู่ ไม่ต้องรู้ IP ของ Pi
ปัญหาที่โผล่มาทันทีที่มีสองเครื่อง
ระบบมอนิเตอร์สามตัวจากคาบที่แล้ว อยู่บนเครื่องเดียวแล้วทำงานได้ปกติ เพราะทั้งสามอยู่บนเครือข่าย mynet เดียวกัน เรียกกันด้วยชื่อได้
ทีนี้ลองย้าย node-exporter ไปอยู่บน Raspberry Pi
มันไม่ทำงาน เพราะ user-defined network เป็นของเฉพาะเครื่องนั้น เครือข่ายชื่อ mynet บนคอมพิวเตอร์ กับเครือข่ายชื่อ mynet บน Pi เป็นคนละเครือข่ายกันสนิท ไม่รู้จักกัน
prometheus จึงหาชื่อ node-exporter ไม่เจอ
ทางออกเฉพาะหน้าของแล็บ
สไลด์ให้ทำสามอย่าง
- เขียน
compose.ymlตัวหนึ่งสำหรับคอมพิวเตอร์ ให้รัน Prometheus กับ Grafana - เขียน
compose.ymlอีกตัวสำหรับ Raspberry Pi ให้รัน node-exporter - แก้
prometheus.ymlให้ชี้ target (เป้าที่ Prometheus ไปดึงตัวเลข) ไปที่ หมายเลข IP ของ Raspberry Pi
scrape_configs:
- job_name: 'node'
static_configs:
- targets: ['192.168.43.51:9100']แทนที่จะเป็น ['node-exporter:9100'] แบบตอนอยู่เครื่องเดียวกัน
และฝั่ง Pi ต้องเปลี่ยนจาก expose: - 9100 เป็น ports: - 9100:9100 ด้วย ไม่งั้นพอร์ตไม่โผล่ออกมาให้เครื่องอื่นเรียก
วิธีนี้ใช้ได้ แต่เปราะมาก และนี่คือเหตุผลทั้งหมดที่ orchestrator มีอยู่
- IP เปลี่ยนได้ hotspot แจก IP ใหม่ทุกครั้งที่ต่อใหม่ ต้องไปแก้
prometheus.ymlทุกรอบ - ไม่ขยายตัว มีสิบเครื่องก็ต้องเขียนสิบบรรทัดและตามแก้ทั้งสิบ
- ไม่มีใครกู้ให้ ถ้า Pi ดับ ไม่มีอะไรย้าย node-exporter ไปเครื่องอื่นให้
- พอร์ตเปิดโล่ง การเอา 9100 ออกมาที่เครื่อง host แปลว่าใครในวงเดียวกันก็ยิงเข้ามาได้
Kubernetes แก้ทั้งสี่ข้อนี้ด้วยเครือข่ายระดับคลัสเตอร์ที่ทำให้ container ทุกตัวบนทุกเครื่องเรียกกันด้วยชื่อได้เหมือนอยู่เครื่องเดียวกัน
Dockerfile
- Dockerfile คือไฟล์ข้อความที่บอกวิธีสร้าง image — ทำซ้ำได้ อ่านรู้เรื่อง เก็บใน Git ได้ ต่างจาก
docker commitที่ทำได้แค่ครั้งเดียว FROMเลือกฐาน ·WORKDIRตั้งโฟลเดอร์ ·COPYก๊อปไฟล์เข้า image ·RUNรันตอน build ·USERเปลี่ยนผู้ใช้RUNมีสองรูปแบบ — exec form ไม่ผ่าน shell จึงใช้&&และ$VARไม่ได้ ส่วน shell form ใช้ได้ENTRYPOINTให้ผู้ใช้เติมอาร์กิวเมนต์ต่อท้าย ส่วนCMDให้ผู้ใช้แทนที่ทั้งคำสั่ง มีทั้งคู่ =CMDเป็นอาร์กิวเมนต์ตั้งต้นของENTRYPOINTEXPOSEเป็นแค่การประกาศ ไม่ได้เปิดพอร์ต ต้อง-pตอนdocker runเท่านั้น- แต่ละบรรทัดกลายเป็นหนึ่ง layer ·
docker image historyเปิดดูได้ · ขนาด0Bแปลว่าชั้นนั้นเพิ่มแค่ข้อมูลกำกับ - รวม
RUNหลายบรรทัดเป็นบรรทัดเดียวแล้วล้างแคชในบรรทัดเดียวกัน ถ้าล้างคนละบรรทัด ขนาด image ไม่ลด - multi-stage build — build ในขั้นที่มีเครื่องมือครบ แล้ว
COPY --from=เอาเฉพาะผลลัพธ์มาขั้นสุดท้าย - ความปลอดภัยสองข้อ — อย่ารันเป็น root (ใช้
USER) และอย่าฝังความลับลง layer (ใช้--env-fileหรือ--secretและใส่.envใน.dockerignore)
Docker Compose
- Compose นิยามและรันแอปหลาย container จากไฟล์ YAML ไฟล์เดียว
- project คือกล่องที่ครอบ service, network, volume ไว้ ชื่อโดยปริยายมาจากชื่อโฟลเดอร์
- ชื่อทุกอย่างขึ้นต้นด้วยชื่อ project — container ใช้
-คั่น network และ volume ใช้_คั่น - ระดับบนสุดมีห้าอย่าง —
name,services,networks,volumes,configs/secrets - YAML เขียนได้สองแบบ (รายการกับคู่คีย์-ค่า) แต่ห้ามผสมกันในหัวข้อเดียว
- คำสั่งหลัก —
up -dขึ้น ·downลบทั้งชุด ·stopหยุดแต่ไม่ลบ ·lspstopดูสถานะ ·logsexecเข้าไปดูข้างใน exposeให้เพื่อนบนเครือข่ายเดียวกันเรียกได้ ·portsเปิดออกไปที่เครื่อง host จริง ๆexternal: trueแปลว่า volume มีอยู่แล้ว Compose ไม่สร้างและไม่ลบให้upเป็น idempotent — สั่งซ้ำแล้วเทียบกับของที่รันอยู่ เปลี่ยนเฉพาะส่วนที่ไม่ตรง--scale web=3สร้างสามสำเนา ต้องเขียนports: - "5000"ไม่ผูกพอร์ต host ไม่งั้นสำเนาที่สองชนพอร์ตdepends_onรับประกันแค่ว่าสตาร์ตแล้ว ไม่ใช่พร้อมใช้ ถ้าต้องการของจริงใช้healthcheckคู่กับcondition: service_healthydeploy.replicasตั้งจำนวนในไฟล์ ·limitsเป็นเพดาน ·reservationsเป็นพื้น ·cpus: '0.5'= ครึ่งแกน ไม่ใช่ครึ่งเครื่อง- แยกเครือข่ายตามระดับความเชื่อใจ และ
internal: trueตัดทางออกภายนอกให้ทั้งเครือข่าย
เมื่อเครื่องเดียวไม่พอ
- Compose มีขอบเขตที่เครื่องเดียว container orchestration แก้เรื่องหลาย container บนหลายเครื่อง
- Compose กับ Kubernetes ไม่ได้แข่งกัน — Compose เหมาะกับงานพัฒนาและระบบเล็ก Kubernetes เหมาะกับระบบจริงขนาดใหญ่ที่ต้องซ่อมตัวเองได้
- แล็บ Raspberry Pi พิสูจน์ข้อจำกัด — user-defined network เป็นของเฉพาะเครื่อง เครือข่ายชื่อ
mynetบนสองเครื่องคือคนละเครือข่าย เรียกกันด้วยชื่อไม่ได้ ต้องกลับไปใช้ IP ซึ่งเปราะและไม่ขยายตัว