โหมดมืด
บทที่ 01 — Image เจาะลึก: Layer, Dockerfile, Multi-stage
(last reviewed: 2026-06 — ตรวจ release notes ของ Docker / BuildKit / ภาษา (Node/Go/Java) ก่อนยึดเป็นเวอร์ชันปัจจุบัน)
บทที่แล้วเรารู้จัก image แบบผิวเผิน — บทนี้เจาะลึก: image สร้างยังไง, layer ทำงานยังไงจริง ๆ, เขียน Dockerfile เป็น, ทำ image ให้เล็กและ build เร็ว
อ่านจบจะ:
- เขียน Dockerfile เองได้ พร้อมเข้าใจทุกบรรทัด
- เข้าใจ "layer cache" — ทำไม build ครั้งที่สองเร็วกว่า และจัดลำดับยังไงให้เร็วที่สุด
- ทำ image จาก 1GB ให้เหลือ 50MB ด้วย multi-stage build
- เลือก base image (image ฐาน/image แม่ ที่เราเอามาต่อยอด) เป็น (alpine, distroless, scratch ต่างกันยังไง)
ใช้เวลา 3-4 ชั่วโมง
📚 ไม่ต้องอ่านรวดเดียวจบ — แบ่งอ่านเป็นหลายรอบได้ ลองทำ Lab ท้ายบทไปด้วยจะจำได้ดีกว่าอ่านยาวรวดเดียว
Part 1: Image คือ "ชั้นของไฟล์" — ทบทวนและขยาย
บทที่แล้วเราพูดถึง Union Filesystem — image คือไฟล์ที่ซ้อนเป็นชั้น ๆ — มาทำความเข้าใจให้ลึกขึ้น
1.1 ทำไมต้องเป็นชั้น (layers)?
ลองคิด: สมมติคุณมี 3 image
- image A = Ubuntu + Node.js + แอป A
- image B = Ubuntu + Node.js + แอป B
- image C = Ubuntu + Python + แอป C
ถ้าเก็บแต่ละ image เป็น "ก้อนเดียว" → Ubuntu ถูกเก็บ 3 ชุด, Node.js เก็บ 2 ชุด — เปลือง disk มหาศาล
Docker ฉลาดกว่านั้น — มันเก็บเป็น layer แล้ว layer ที่เหมือนกัน ใช้ร่วมกัน:
image A: [Ubuntu] → [Node.js] → [แอป A]
image B: [Ubuntu] → [Node.js] → [แอป B]
image C: [Ubuntu] → [Python] → [แอป C]
↑ ↑
เก็บชุดเดียว Node.js เก็บชุดเดียว
ใช้ทั้ง A,B,C ใช้ทั้ง A,B→ [Ubuntu] เก็บใน disk แค่ครั้งเดียว ทั้ง 3 image ชี้มาที่ก้อนเดียวกัน
ผลลัพธ์:
- ประหยัด disk — layer ซ้ำไม่เก็บซ้ำ
- ดาวน์โหลดเร็ว — ถ้าเครื่องมี layer นั้นอยู่แล้ว ไม่ต้องโหลดซ้ำ
- build เร็ว — layer ที่ไม่เปลี่ยน ใช้ของเดิม (เรื่อง cache — Part 5)
1.2 แต่ละ layer คืออะไรกันแน่
แต่ละ layer คือ "ชุดของการเปลี่ยนแปลงไฟล์" — ไม่ใช่ filesystem เต็ม ๆ
ตัวอย่าง:
- layer 1: "เพิ่มไฟล์ระบบของ Ubuntu ทั้งหมด"
- layer 2: "เพิ่มไฟล์ของ Node.js ลงไปใน /usr/local"
- layer 3: "เพิ่มไฟล์โค้ดแอปเราลงใน /app"
เวลา container รัน — Docker เอาทุก layer มา "ซ้อนกัน" → ได้ filesystem สมบูรณ์ 1 ชุด
แต่ละ layer เป็น read-only (อ่านอย่างเดียว) — ยกเว้นตอน container รัน Docker จะเพิ่ม "container layer" ที่เขียนได้ ทับบนสุด:
นี่อธิบายว่าทำไม "ข้อมูลใน container หายเมื่อลบ container" — เพราะข้อมูลที่เขียนตอนรันอยู่ใน "container layer" ซึ่งถูกลบไปพร้อม container
Part 2: Dockerfile — พิมพ์เขียวสร้าง Image
Dockerfile = ไฟล์ข้อความ (ตั้งชื่อว่า Dockerfile เป๊ะ ๆ ไม่มีนามสกุล — D ตัวใหญ่เสมอ เพราะบน Linux ชื่อไฟล์เป็น case-sensitive ถ้าตั้งว่า dockerfile ด้วย d เล็ก Docker บน Linux จะหาไม่เจอ) ที่เขียน "ขั้นตอนสร้าง image"
2.1 Dockerfile แรก — อธิบายทุกบรรทัด
มาดูตัวอย่างแอป Node.js ง่าย ๆ:
💡 ตัวอย่างนี้ใช้ Node — ผู้อ่านสาย Java/Python/Go ดูตัวอย่างของภาษาตัวเองได้ที่ Part 6 (Multi-stage Build) ซึ่งมีทั้ง Go และ Java ครบ — แนวคิด (FROM → WORKDIR → COPY → RUN → CMD) เหมือนกันทุกภาษา
dockerfile
# syntax=docker/dockerfile:1
# ↑ บรรทัดนี้ดูเหมือน comment แต่จริง ๆ เป็นคำสั่งบอก BuildKit ให้เปิด syntax ใหม่
# (HEREDOC, --mount=type=cache, ฯลฯ) — ห้ามลบ! ใส่ไว้บนสุดของ Dockerfile เสมอ
# บรรทัดที่ขึ้นต้นด้วย # คือ comment (ยกเว้น # syntax= ด้านบน)
# 1. เริ่มจาก base image — image ฐานที่เราต่อยอด
FROM node:22-alpine
# 2. ตั้ง "current directory" ภายใน image
WORKDIR /app
# 3. copy ไฟล์ package.json เข้าไปก่อน
# ใช้ --chown=node:node เพื่อให้ user `node` เป็นเจ้าของไฟล์ตั้งแต่ตอน copy
COPY --chown=node:node package*.json ./
# 4. ติดตั้ง dependencies (รันเป็น root ตรงนี้เพื่อให้เขียน node_modules ได้ปกติ)
RUN npm ci --omit=dev
# 5. copy โค้ดที่เหลือทั้งหมดเข้าไป (ระบุ --chown เพื่อให้ user `node` เป็นเจ้าของ)
COPY --chown=node:node . .
# 6. บอกว่า container นี้จะฟัง port 3000
EXPOSE 3000
# 7. ตรวจสุขภาพ container เป็นระยะ (จะอธิบายละเอียดใน 3.12)
# ใช้ wget เพราะ node:22-alpine ไม่มี curl — ดูเหตุผลเพิ่มเติมใน Part 3.12
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
CMD wget -qO- http://localhost:3000/health || exit 1
# 8. เปลี่ยนเป็น user ชื่อ node (ไม่ใช่ root) ก่อนรัน container
USER node
# 9. คำสั่งที่จะรันเมื่อ container เริ่ม
CMD ["node", "server.js"]อธิบายทีละบรรทัด:
FROM node:22-alpine — ทุก Dockerfile ต้องเริ่มด้วย FROM — มันบอกว่า "ใช้ image นี้เป็นฐาน" — node:22-alpine คือ image ที่มี Node.js 22 ติดตั้งมาแล้ว (บน Alpine Linux — distro เล็ก ๆ). เราไม่ต้องติดตั้ง Node.js เอง
WORKDIR /app — ตั้ง "directory ปัจจุบัน" ภายใน image เป็น /app — ถ้า folder นี้ยังไม่มี Docker สร้างให้. คำสั่งหลังจากนี้ (COPY, RUN) จะทำงานใน /app
COPY --chown=node:node package*.json ./ — copy ไฟล์ package.json (และ package-lock.json) จากเครื่องเรา เข้าไปใน image ที่ /app (./ หมายถึง WORKDIR ปัจจุบัน). --chown=node:node = ตั้งให้ user node เป็นเจ้าของไฟล์ตั้งแต่ตอน copy เลย (ถ้าไม่ใส่ ไฟล์จะเป็นของ root ซึ่ง user node แก้ไม่ได้). ทำไม copy แค่ package.json ก่อน ไม่ copy ทั้งหมดเลย? — เป็นเทคนิค cache สำคัญมาก เดี๋ยวอธิบายใน Part 5
RUN npm ci --omit=dev — RUN = "รันคำสั่งนี้ตอน build" — ที่นี่คือติดตั้ง dependencies. npm ci = ติดตั้ง dependencies ให้เป๊ะตาม package-lock.json (เร็วกว่า + reproducible (ผลลัพธ์เหมือนกันทุกครั้ง ไม่ขึ้นกับเครื่องหรือเวลา) กว่า npm install — เหมาะกับ CI/build); ข้อแม้คือ ต้องมี package-lock.json ใน build context ไม่งั้น npm ci จะ error. ส่วน --omit=dev = ไม่เอา dev dependencies (พวก test tool ที่ production ไม่ต้องใช้)
💡
npm civsnpm install—ci(clean install) จะลบnode_modulesก่อนแล้วติดตั้งตาม lockfile เป๊ะ ๆ; ส่วนinstallจะอัปเดตnode_modules+ แก้package-lock.jsonได้ — ใน build/CI เราอยากได้ผลลัพธ์เหมือนกันทุกครั้งจึงใช้ci. ใน Lab 1 ปลายบทยังใช้npm installอยู่เพราะ demo ไม่มี lockfile — ในงานจริงให้ commitpackage-lock.jsonแล้วใช้npm ci
COPY --chown=node:node . . — ตอนนี้ค่อย copy โค้ดที่เหลือทั้งหมด (. แรก = โฟลเดอร์ปัจจุบันบนเครื่องเรา, . ที่สอง = WORKDIR ใน image). ใส่ --chown=node:node เหมือนเดิมเพื่อให้ user node เป็นเจ้าของ — สำคัญมาก: ถ้าใส่ COPY . . เฉย ๆ แล้วค่อย USER node ทีหลัง ไฟล์ทั้งหมดจะเป็นของ root และ user node จะแก้ไม่ได้
EXPOSE 3000 — บอกว่า "container นี้ตั้งใจจะฟังที่ port 3000" — หมายเหตุสำคัญ: EXPOSE ไม่ได้เปิด port จริง ๆ! มันเป็นแค่ "เอกสาร" บอกคนอ่านว่า container ใช้ port ไหน — การเปิด port จริงต้องใช้ -p ตอน docker run
HEALTHCHECK ... CMD wget -qO- http://localhost:3000/health — บอก Docker ให้ลองยิง /health ทุก 30 วินาที ถ้าไม่ตอบ 3 ครั้งติด ถือว่า container ป่วย → orchestrator (Docker / Kubernetes) จะ restart ให้. ใช้ wget แทน curl เพราะ node:22-alpine มี wget ติดมาอยู่แล้วแต่ไม่มี curl — รายละเอียดเพิ่มเติมใน 3.12
USER node — เปลี่ยนให้คำสั่งหลังจากนี้ (และตัว container ตอนรัน) ทำงานในนาม user ชื่อ node — ไม่ใช่ root (เพื่อความปลอดภัย — เรียนบท 05). image node:* ทางการมี user node มาให้แล้ว ไม่ต้อง RUN useradd เอง
CMD ["node", "server.js"] — คำสั่งที่จะรันเมื่อ container เริ่มทำงาน — ที่นี่คือ "รัน node server.js"
2.2 Build image จาก Dockerfile
bash
docker build -t myapp:v1 .อธิบาย:
docker build— สั่ง build image-t myapp:v1—-t= tag = ตั้งชื่อให้ image ว่าmyappเวอร์ชันv1.(จุด) — "build context" = โฟลเดอร์ที่ Docker จะส่งให้ build (โฟลเดอร์ปัจจุบัน) — ไฟล์ทั้งหมดในโฟลเดอร์นี้จะถูกส่งให้ Docker daemon
รันแล้วได้ image — ลองรัน:
bash
docker run -d -p 3000:3000 myapp:v1Part 3: คำสั่งใน Dockerfile ทั้งหมด
มาดูคำสั่ง (instruction) ทุกตัว พร้อมอธิบายว่าใช้ตอนไหน:
3.1 FROM — image ฐาน
dockerfile
FROM node:22-alpine
FROM ubuntu:24.04
FROM python:3.12-slim
FROM scratch # ฐานว่างเปล่า (ไม่มีอะไรเลย)ต้องเป็นบรรทัดแรกเสมอ (ยกเว้นมี ARG ก่อน หรือ comment เช่น # syntax=...)
⚠️
FROM scratchใช้ได้กับภาษาที่ compile เป็น static binary เท่านั้น (Go, Rust, C/C++ แบบ static link — static link คือการรวม library ทุกอย่างไว้ในไฟล์โปรแกรมไฟล์เดียว ไม่ต้องพึ่ง library ของ OS เลย — จะอธิบายละเอียดในตัวอย่าง Go ที่ Part 6.3) — อย่าใช้กับ Node.js / Python / Java / Ruby เพราะภาษาเหล่านี้ต้องการ runtime (Node, CPython, JVM) ที่ติดมากับ image ฐาน — ใส่FROM scratchแล้วโปรแกรมจะรันไม่ขึ้น ขึ้น error ประมาณexec: "node": executable file not found
3.2 WORKDIR — โฟลเดอร์ทำงาน
dockerfile
WORKDIR /appถ้าไม่มี → คำสั่งจะทำงานที่ / (root) ซึ่งไม่ดี — ตั้ง WORKDIR เสมอ
3.3 COPY — คัดลอกไฟล์เข้า image
COPY คัดลอกไฟล์/โฟลเดอร์จากเครื่องเรา (build context) เข้าไปใน image — ระบุได้ทั้งไฟล์เดียว ทั้งโฟลเดอร์ หรือทุกอย่าง และตั้งเจ้าของไฟล์ด้วย --chown ไปพร้อมกันได้:
dockerfile
COPY package.json ./ # copy 1 ไฟล์
COPY src/ ./src/ # copy ทั้งโฟลเดอร์
COPY . . # copy ทุกอย่าง
COPY --chown=node:node . . # copy + ตั้งเจ้าของไฟล์3.4 ADD — เหมือน COPY แต่มีลูกเล่น (ใช้ COPY ดีกว่า)
dockerfile
ADD https://example.com/file.tar.gz /tmp/ # ดาวน์โหลดจาก URL ได้
ADD archive.tar.gz /app/ # แตก tar อัตโนมัติ⚠️ แนะนำ: ใช้
COPYเกือบทุกครั้ง —ADDมีลูกเล่นที่ทำให้สับสน ใช้เมื่อต้องการแตก tar เท่านั้น
3.5 RUN — รันคำสั่งตอน build
RUN ใช้สั่งให้คำสั่งทำงาน "ตอน build image" (เช่น ติดตั้ง dependency, สร้างโฟลเดอร์) ผลลัพธ์จะถูกอบเข้าไปใน image — จำไว้ว่าแต่ละ RUN สร้าง layer ใหม่หนึ่งชั้น จึงควรรวมคำสั่งที่เกี่ยวกันไว้บรรทัดเดียว:
dockerfile
RUN npm ci
RUN apt-get update && apt-get install -y curl
RUN mkdir -p /app/dataแต่ละ RUN สร้าง 1 layer
💡 HEREDOC syntax (BuildKit) — ถ้าใส่
# syntax=docker/dockerfile:1บนสุด สามารถใช้ HEREDOC ให้ Dockerfile อ่านง่ายขึ้นมาก:dockerfile# syntax=docker/dockerfile:1 RUN <<EOF apt-get update apt-get install -y curl ca-certificates rm -rf /var/lib/apt/lists/* EOFได้ผลเหมือน
RUN apt-get update && apt-get install ... && rm ...แต่ไม่ต้องใส่\ท้ายบรรทัด — เป็น 1 layer เหมือนกัน
3.6 ENV — ตั้ง environment variable
dockerfile
ENV NODE_ENV=production
ENV PORT=3000
ENV APP_HOME=/appค่านี้จะมีตอน container รัน (และตอน build หลังบรรทัดนี้)
3.7 ARG — ตัวแปรตอน build (ไม่อยู่ตอนรัน)
dockerfile
ARG VERSION=1.0
ARG NODE_ENV=production
RUN echo "Building version $VERSION"bash
docker build --build-arg VERSION=2.0 -t myapp .ความต่าง ARG vs ENV:
ARG— มีแค่ตอน build เท่านั้น หายไปตอนรันENV— มีทั้งตอน build และตอน รัน
3.8 EXPOSE — บอก port (เป็นเอกสาร)
EXPOSE เป็นแค่ "เอกสาร" บอกว่า container ตั้งใจฟัง port ไหน — มันไม่ได้เปิด port จริงให้เข้าถึงจากนอก (ต้องใช้ -p ตอน run) ประโยชน์คือให้คนอ่าน Dockerfile รู้ว่าควร map port อะไร:
dockerfile
EXPOSE 3000
EXPOSE 8080/tcp
EXPOSE 53/udpย้ำ: ไม่เปิด port จริง — แค่บอกเฉย ๆ
3.9 USER — ตั้ง user
USER เปลี่ยน user ที่จะรันคำสั่งหลังจากบรรทัดนี้ (และตอน container รัน) — ใส่เพื่อเลิกรันเป็น root ซึ่งเป็น best practice ด้านความปลอดภัยที่สำคัญ ระบุเป็นชื่อ user หรือ UID:GID ก็ได้:
dockerfile
USER node
USER 1000:1000 # UID:GID3.10 CMD vs ENTRYPOINT — 2 ตัวที่งงที่สุด
ทั้งคู่บอกว่า "container เริ่มแล้วรันอะไร" — แต่ต่างกัน:
CMD = "คำสั่ง default" — override ได้ง่าย
dockerfile
CMD ["node", "server.js"]bash
docker run myapp # รัน: node server.js
docker run myapp node test.js # รัน: node test.js (override CMD!)ENTRYPOINT = "คำสั่งหลักที่ตายตัว" — override ยากกว่า
dockerfile
ENTRYPOINT ["node"]
CMD ["server.js"]bash
docker run myapp # รัน: node server.js
docker run myapp test.js # รัน: node test.js (override แค่ CMD)ใช้คู่กัน — ENTRYPOINT = "โปรแกรมหลัก", CMD = "argument default ที่ override ได้"
ตัวอย่างที่ดี:
dockerfile
WORKDIR /app
COPY app.py .
ENTRYPOINT ["python", "app.py"]
CMD ["--port", "8080"]→ docker run myapp = python app.py --port 8080 → docker run myapp --port 9000 = python app.py --port 9000
📌
python app.pyหาapp.pyจาก WORKDIR ปัจจุบัน — ต้องตั้งWORKDIRและCOPY app.pyเข้าไปก่อน ไม่งั้นจะ errorpython: can't open file 'app.py'
3.11 ⚠️ Exec form vs Shell form
มี 2 วิธีเขียน CMD/RUN/ENTRYPOINT:
dockerfile
# Shell form — เขียนเป็นข้อความ
CMD node server.js
# Exec form — เขียนเป็น JSON array
CMD ["node", "server.js"]ใช้ exec form (JSON array) เสมอ — เพราะ:
- Shell form จริง ๆ รันเป็น
sh -c "node server.js"→ โปรแกรมเรากลายเป็น "ลูก" ของ shell - เมื่อ Docker ส่งสัญญาณ "stop" (SIGTERM = signal terminate — สัญญาณบอกให้โปรแกรมหยุดทำงานอย่างสุภาพ ปิดไฟล์ที่เปิดอยู่ flush log ฯลฯ) → สัญญาณไปถึง shell ไม่ถึงโปรแกรมเรา → โปรแกรมไม่ได้ปิดตัวอย่างสวยงาม → Docker รอ 10 วินาทีแล้วยิง SIGKILL (force kill — บังคับฆ่า); ค่า 10 วินาทีนี้คือ default ของ
docker stop— ใน Docker Compose ตั้งได้ด้วยstop_grace_periodและใน Kubernetes ตั้งได้ด้วยterminationGracePeriodSeconds(default 30 วินาที) - Exec form → โปรแกรมเรารับสัญญาณตรง ๆ → ปิดตัวสวยงาม
3.12 HEALTHCHECK — ตรวจสุขภาพ container
dockerfile
# แนะนำ: ใช้ wget เพราะ alpine-based image (เช่น node:22-alpine) มี wget ติดมาแต่ไม่มี curl
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
CMD wget -qO- http://localhost:3000/health || exit 1
# ถ้า image มี curl (เช่น ติดตั้งด้วย RUN apk add --no-cache curl):
# HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
# CMD curl -f http://localhost:3000/health || exit 1บอก Docker ว่า "ทุก 30 วินาที ลองเรียก /health — ถ้าไม่ตอบ 3 ครั้งติด ถือว่า container ป่วย"
→ Docker (และ Kubernetes) เอาข้อมูลนี้ไปตัดสินใจว่าจะ restart container ไหม
⚠️
curlไม่ได้ติดมาทุก base image —alpine,distroless,scratchไม่มีcurlติดมา ใช้wgetแทน (Alpine มีwgetติดมาเสมอ) เหมือนตัวอย่างข้างบน. ถ้าต้องการใช้curlให้ติดตั้งก่อน:RUN apk add --no-cache curl(Alpine)
- หรือถ้า Kubernetes อยู่แล้ว — ใช้ liveness/readiness probe ของ K8s แทน healthcheck ของ Docker (เรียนในบท Kubernetes)
3.13 VOLUME — จุด mount
dockerfile
VOLUME ["/data"]บอกว่า "โฟลเดอร์นี้ควรเก็บข้อมูลถาวร" (เรียนบท 03)
Part 4: .dockerignore — อย่าส่งขยะเข้า build
ตอน docker build . — Docker ส่ง "ทุกไฟล์ในโฟลเดอร์" ให้ daemon (เรียกว่า build context)
ถ้าโฟลเดอร์มี node_modules (ใหญ่มาก), .git (ประวัติทั้งหมด), ไฟล์ log — มันถูกส่งไปด้วย → ช้า + เปลือง + อาจ leak ความลับ
แก้ด้วยไฟล์ .dockerignore (อยู่ข้าง ๆ Dockerfile):
node_modules
.git
.env
.env.local
*.log
dist
coverage
.DS_Store
.vscode
.idea
README.md
Dockerfile
.dockerignore→ ไฟล์/โฟลเดอร์ที่ list ไว้ จะไม่ถูกส่งเข้า build — เร็วขึ้น + ปลอดภัยขึ้น
💡 หลักการ:
.dockerignoreทำงานเหมือน.gitignore— แต่สำหรับ Docker build
Part 5: Layer Cache — เวทมนตร์ที่ทำให้ build เร็ว
นี่คือหัวข้อสำคัญที่สุดของบท — เข้าใจแล้ว build จะเร็วขึ้นหลายเท่า
5.1 Cache ทำงานยังไง
แต่ละบรรทัดใน Dockerfile = 1 layer. ตอน build Docker ทำแบบนี้กับแต่ละบรรทัด:
"บรรทัดนี้ + ทุกบรรทัดก่อนหน้า เหมือนครั้งที่แล้วไหม? — ถ้าเหมือน ใช้ layer เก่า (cache) ไม่ต้องทำใหม่ — ถ้าต่าง ทำใหม่ + ทุกบรรทัดหลังจากนี้ก็ต้องทำใหม่หมด"
ประโยคสุดท้ายสำคัญ: ถ้าบรรทัดหนึ่งเปลี่ยน → ทุกบรรทัดหลังจากนั้นต้อง build ใหม่ทั้งหมด
5.2 ตัวอย่างที่ทำให้เห็นภาพ
ดู Dockerfile ที่ เขียนผิด:
dockerfile
FROM node:22-alpine
WORKDIR /app
COPY . . # ← copy ทุกอย่างก่อน
RUN npm ci # ← ติดตั้ง dependencies
CMD ["node", "server.js"]ปัญหา: ทุกครั้งที่คุณแก้โค้ด แม้แค่ 1 ตัวอักษร → บรรทัด COPY . . เปลี่ยน (เพราะไฟล์เปลี่ยน) → บรรทัด RUN npm ci ต้องทำใหม่ → ติดตั้ง dependencies ใหม่ทุกครั้ง (ช้ามาก อาจ 2-5 นาที)
ดู Dockerfile ที่ เขียนถูก:
dockerfile
FROM node:22-alpine
WORKDIR /app
COPY package*.json ./ # ← copy แค่ package.json ก่อน
RUN npm ci # ← ติดตั้ง dependencies
COPY . . # ← ค่อย copy โค้ดที่เหลือ
CMD ["node", "server.js"]ทำไมถึงเร็ว: ถ้าคุณแก้แค่โค้ด (ไม่แตะ package.json) →
- บรรทัด
COPY package*.json ./—package.jsonไม่เปลี่ยน → ใช้ cache - บรรทัด
RUN npm ci— บรรทัดก่อนหน้า cache → ใช้ cache ด้วย ← ไม่ต้องติดตั้งใหม่! - บรรทัด
COPY . .— โค้ดเปลี่ยน → ทำใหม่ (แต่อันนี้เร็ว แค่ copy ไฟล์)
→ build จาก 3 นาที เหลือ 5 วินาที
5.3 หลักการทอง: เรียงจาก "เปลี่ยนน้อย" ไป "เปลี่ยนบ่อย"
dockerfile
FROM node:22-alpine # ← แทบไม่เปลี่ยน
WORKDIR /app
COPY package*.json ./ # ← เปลี่ยนนาน ๆ ครั้ง (ตอนเพิ่ม library)
RUN npm ci # ← cache ตาม package.json
COPY . . # ← เปลี่ยนบ่อยมาก (ทุกครั้งที่แก้โค้ด)
CMD ["node", "server.js"]จำง่าย ๆ: "ของที่ไม่ค่อยเปลี่ยน เอาไว้บน — ของที่เปลี่ยนบ่อย เอาไว้ล่าง"
5.4 รวม RUN เพื่อลด layer + ลดขนาด
ดู 2 แบบนี้:
dockerfile
# ❌ แบบไม่ดี — 3 layer
RUN apt-get update
RUN apt-get install -y curl
RUN rm -rf /var/lib/apt/lists/*dockerfile
# ✅ แบบดี — 1 layer
RUN apt-get update \
&& apt-get install -y curl \
&& rm -rf /var/lib/apt/lists/*ทำไมแบบดีถึงดีกว่า — มีเหตุผล 2 ข้อ:
น้อย layer — จัดการง่ายกว่า
ขนาดเล็กกว่า — อันนี้สำคัญ: ใน Union Filesystem แต่ละ layer "เพิ่มได้แต่ลบจริง ๆ ไม่ได้". ถ้าคุณ
apt-get updateใน layer หนึ่ง (สร้าง cache ~50MB) แล้วrmใน layer ถัดไป — layer ถัดไปแค่ "ทำเครื่องหมายว่าลบ" แต่ไฟล์ 50MB ยังอยู่ใน layer เก่า → image ยังใหญ่. ถ้าทำในบรรทัดRUNเดียวกัน — สร้างและลบใน layer เดียว → ไฟล์หายจริง → image เล็ก
\ ท้ายบรรทัด = "ขึ้นบรรทัดใหม่แต่ยังเป็นคำสั่งเดียวกัน" (อ่านง่ายขึ้น)
Part 6: Multi-Stage Build — ทำ image ให้เล็กลง 10-50 เท่า
นี่คือเทคนิคที่เปลี่ยน image จาก 1GB เหลือ 50MB
6.1 ปัญหา
ตอน build แอป — เราต้องใช้ "เครื่องมือ build" เยอะ:
- compiler
- dependencies สำหรับ build
- source code
แต่ตอน รัน จริง ๆ — เราต้องการแค่ "ผลลัพธ์ที่ build เสร็จแล้ว" — ไม่ต้องการเครื่องมือ build เลย
ถ้าเราใส่ทุกอย่างใน image เดียว → image ใหญ่ + มีของไม่จำเป็น + เสี่ยงความปลอดภัย (มีเครื่องมือเยอะ = attacker ใช้ได้เยอะ)
6.2 ทางแก้: Multi-Stage Build
ไอเดีย: ใช้หลาย FROM ใน Dockerfile เดียว — stage แรก "build", stage สุดท้าย "เอาแค่ผลลัพธ์"
dockerfile
# syntax=docker/dockerfile:1
# ===== Stage 1: Build =====
FROM node:22-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci # ติดตั้งทุกอย่าง รวม dev dependencies
COPY . .
RUN npm run build # build → ได้ผลลัพธ์ใน /app/dist
# ===== Stage 2: Runtime =====
FROM node:22-alpine
WORKDIR /app
COPY --chown=node:node package*.json ./
RUN npm ci --omit=dev # ติดตั้งแค่ production dependencies
COPY --from=builder --chown=node:node /app/dist ./dist # ← copy แค่ผลลัพธ์ + ตั้งเจ้าของ
USER node
CMD ["node", "dist/server.js"]อธิบาย:
FROM node:22-alpine AS builder— stage แรก ตั้งชื่อว่าbuilder- ใน stage นี้ติดตั้งทุกอย่าง + build
FROM node:22-alpine(อีกครั้ง) — เริ่ม stage ใหม่ (stage สุดท้าย)COPY --from=builder /app/dist ./dist— copy เฉพาะโฟลเดอร์distจาก stagebuilderเข้ามา
ผลลัพธ์: image สุดท้ายมีแค่ — Node.js runtime + production dependencies + ผลลัพธ์ที่ build เสร็จ. ไม่มี source code, ไม่มี dev tools, ไม่มี build cache → เล็กลงมาก
6.3 ตัวอย่าง Go — เห็นภาพชัดสุด
Go compile เป็น "binary ไฟล์เดียว" — multi-stage จะทรงพลังมาก:
💡 ตัวแปร
$BUILDPLATFORM,$TARGETOS,$TARGETARCHเป็น built-in variables ของ BuildKit — กำหนดอัตโนมัติตอน build ไม่ต้องกำหนดเอง:$BUILDPLATFORM= platform ของเครื่องที่กำลัง build (เช่นlinux/amd64);$TARGETOS/$TARGETARCH= OS และ CPU ที่ต้องการ build ไปให้ (เช่นlinux/arm64เมื่อใช้--platform=linux/arm64)
dockerfile
# syntax=docker/dockerfile:1
# ===== Stage 1: Build (ขั้นตอนที่ 1: คอมไพล์) =====
# --platform=$BUILDPLATFORM = ให้ stage นี้รันบน CPU ของเครื่อง build เอง
# (เร็วกว่า — ไม่ต้อง emulate) แล้วค่อย cross-compile ไปยัง target ภายหลัง
FROM --platform=$BUILDPLATFORM golang:1.24-alpine AS builder
ARG TARGETOS
ARG TARGETARCH
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download # ดาวน์โหลด dependencies
COPY . .
# cross-compile ไปยัง target ที่ buildx บอก (เช่น linux/arm64)
# -ldflags='-s -w' = ตัด symbol table + debug info → binary เล็กลง ~20-30%
RUN CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH \
go build -ldflags='-s -w' -o /out/app
# ===== Stage 2: Runtime (ขั้นตอนที่ 2: รันจริง) =====
FROM scratch # ← ฐานว่างเปล่า ไม่มีอะไรเลย!
COPY --from=builder /out/app /app
ENTRYPOINT ["/app"]⚠️ Go เวอร์ชัน — Go มี policy support เฉพาะ 2 minor version ล่าสุด (minor version = เลขหลักที่สอง เช่น 1.24 หรือ 1.25; ณ 2026-06 คือ 1.24 และ 1.25); Go 1.23 และเก่ากว่าหมด security support แล้ว — ตัวอย่างเก่าในเน็ตที่ใช้
golang:1.22-*หรือgolang:1.23-*ควรอัปเป็น 1.24 หรือ 1.25
อธิบายศัพท์ในตัวอย่างนี้:
- static binary (ไฟล์โปรแกรมแบบรวมทุกอย่างในตัว) = ไฟล์โปรแกรมที่รวม library ทุกอย่างที่ต้องใช้ไว้ในไฟล์เดียว ไม่ต้องพึ่ง library ภายนอกของ OS เลย → จึงเอาไปวางบน
scratch(ที่ว่างเปล่าสนิท) แล้วรันได้ CGO_ENABLED=0= สั่ง Go ว่า "อย่าใช้ C library ของระบบ" → ผลคือได้ static binary แท้ ๆ (ถ้าไม่ใส่ บางทีโปรแกรมจะไปผูกกับ C library ของ OS แล้วรันบนscratchไม่ได้ เพราะscratchไม่มี library นั้น)
stage 1 ใช้ image golang (ใหญ่ ~300MB มี compiler ครบ) stage 2 ใช้ scratch (image ว่างเปล่า ขนาด 0 byte!) แล้ว copy แค่ binary เข้าไป
→ image สุดท้ายขนาด ~10MB (แค่ขนาด binary) เทียบกับถ้าไม่ใช้ multi-stage จะ ~300MB+
6.4 ตัวอย่าง Java
dockerfile
# syntax=docker/dockerfile:1
# ===== Stage 1: Build =====
# ใช้ Java 25 (LTS ใหม่สุด ออก ก.ย. 2025) — ถ้าโปรเจกต์ยังติด Java 21 LTS ก็เปลี่ยนเลขให้ตรงได้
FROM maven:3.9-eclipse-temurin-25 AS builder
WORKDIR /build
COPY pom.xml .
RUN mvn dependency:go-offline # ดาวน์โหลด dependencies ก่อน (cache!)
# หมายเหตุ: go-offline อาจ miss บาง artifact ในโปรเจกต์ซับซ้อน
# ทางเลือก: ใช้ BuildKit cache mount แทน:
# RUN --mount=type=cache,target=/root/.m2 mvn package -DskipTests
# (ถ้าใช้ Gradle ใช้: COPY build.gradle settings.gradle ./ แล้ว
# RUN gradle --no-daemon dependencies แทน)
COPY src ./src
RUN mvn package -DskipTests
# ===== Stage 2: Runtime =====
# eclipse-temurin มี tag jre-alpine สำหรับ Java 21+
# ถ้า tag eclipse-temurin:25-jre-alpine ไม่มี ให้ใช้ eclipse-temurin:25-jre แทน
# (ใช้ Ubuntu/UBI เป็นฐาน — ใหญ่กว่าเล็กน้อยแต่ stable กว่าและมีอยู่เสมอ)
FROM eclipse-temurin:25-jre-alpine
WORKDIR /app
# Spring Boot สร้าง jar 2 ไฟล์ (original + repackaged) — ระบุชื่อให้ชัดเพื่อกัน "multiple matches"
# วิธีที่ 1: กำหนด <finalName> ใน pom.xml ให้ชื่อชัดเจน เช่น myapp.jar แล้ว COPY ตรง ๆ
# วิธีที่ 2: rename ใน builder stage ก่อน copy:
# RUN mv $(ls /build/target/*.jar | grep -v original | head -1) /build/app.jar
# แล้วค่อย: COPY --from=builder /build/app.jar app.jar
COPY --from=builder /build/target/myapp-*.jar app.jar
EXPOSE 8080
# ⚠️ production: ควรตั้ง JVM heap ให้สอดคล้องกับ container memory limit เช่น:
# ENTRYPOINT ["java", "-XX:MaxRAMPercentage=75.0", "-jar", "/app/app.jar"]
# → ป้องกัน OOMKill เพราะ JVM อาจคำนวณ heap จาก RAM จริงของเครื่องแทน cgroup limit
# (UseContainerSupport เปิดอยู่ default ตั้งแต่ JDK 11 แต่ MaxRAMPercentage ยังต้องตั้งเอง)
ENTRYPOINT ["java", "-jar", "/app/app.jar"]stage 1 ใช้ image ที่มี Maven + JDK (ใหญ่) stage 2 ใช้แค่ JRE (รัน Java ได้แต่ build ไม่ได้ — เล็กกว่ามาก)
📌 Java version ปี 2026 — Java LTS ที่นิยมในปี 2026 คือ Java 21 (ก.ย. 2023) และ Java 25 (ก.ย. 2025); Java 17 ยังใช้ได้แต่ใกล้หมด premier support แล้ว — โปรเจกต์ใหม่ให้เริ่มที่ 21 หรือ 25
📌
target/*.jar— ระวัง: Spring Boot build จะได้ทั้งmyapp-1.0.jar(repackaged) และmyapp-1.0.jar.originalหรือบางครั้งมี*-sources.jarด้วย — ถ้าเขียนtarget/*.jarแบบกว้าง ๆ จะ errormultiple matches; ระบุ pattern ชัดเจน เช่นtarget/myapp-*.jarหรือกำหนด<finalName>ในpom.xml
Part 7: เลือก Base Image — alpine, slim, distroless, scratch
base image (บรรทัด FROM) มีผลกับขนาดและความปลอดภัยมาก — มาเปรียบเทียบ
7.1 ตัวเลือกหลัก
| Base | ขนาดประมาณ | คำอธิบาย |
|---|---|---|
ubuntu / debian | ~70-120 MB | Linux distro เต็ม — มีเครื่องมือครบ |
*-slim | ~70-80 MB | Debian รุ่นตัดของออก — มีของพอใช้ |
alpine | ~5-8 MB | Linux distro จิ๋ว — เล็กมาก |
*-distroless | ~20 MB | Google ทำ — มีแค่ runtime ของภาษา ไม่มี shell |
chainguard/wolfi | ~1-100 MB (แล้วแต่ image) | Wolfi (distro ของ Chainguard) — rebuild ทุกวัน, CVE น้อย (CVE = รหัสช่องโหว่ที่ประกาศต่อสาธารณะ), แนบ SBOM + provenance attestation (หลักฐานว่า image สร้างจากที่ไหน) มาให้ รองรับ SLSA (มาตรฐานความปลอดภัยของ supply chain) — ดูรายละเอียดใน Part 10; chainguard/static ~1-5 MB, chainguard/node/python ~40-100 MB |
scratch | 0 byte | ว่างเปล่าสนิท — ไม่มีอะไรเลย |
💡 ศัพท์: attack surface (พื้นที่ที่โดนโจมตีได้) = จำนวน "ช่อง" ที่ attacker เข้ามาเจอแล้วใช้งานได้ — เช่น shell, package manager, binary แปลก ๆ ใน image — ยิ่งมีของในเลกซ์เยอะ → attack surface ยิ่งกว้าง → ยิ่งเสี่ยง
7.2 อธิบายแต่ละตัว
ubuntu / debian — Linux distro เต็มตัว มี shell, package manager, เครื่องมือครบ. ดีตอน dev/debug แต่ใหญ่และมี attack surface (พื้นที่ที่โดนโจมตีได้) เยอะสำหรับ production
alpine — Linux distro จิ๋วมาก (~5MB). ⚠️ แต่มีกับดัก — ดูกล่องด้านล่าง
⚠️ กับดัก: Alpine ใช้
muslไม่ใช่glibc
- C library = ชุดฟังก์ชันพื้นฐานที่โปรแกรมเรียกใช้คุยกับ OS (เปิดไฟล์ จัดสรร memory ฯลฯ) — โปรแกรมเกือบทุกตัวต้องพึ่งตัวใดตัวหนึ่ง
glibc(GNU C Library — อ่านว่า "จี-ลิบ-ซี") = ตัวมาตรฐานที่ Ubuntu/Debian/RHEL ใช้musl(อ่านว่า "มัส-เซิล") = ตัวเล็ก/เร็วที่ Alpine เลือกใช้แทน- ปัญหา: โปรแกรมที่มี native code (โค้ดที่ compile ผูกกับระบบ เช่น
*.so(shared library — ไฟล์ library ที่โปรแกรมต้องโหลดตอนรัน คล้าย .dll บน Windows) ของ Python/Node) มักถูก build ผูกกับglibc— เอามารันบนmuslอาจ crash หรือ behave แปลก- อาการที่เจอบ่อย: build เสร็จ image สร้างผ่าน แต่ container start ไม่ขึ้น ขึ้น error เช่น
Error loading shared library ... No such file or directoryหรือError relocating ...- ทางออก: ถ้าเจอปัญหานี้ ย้ายไปใช้
*-slim(Debian-based) แทน
*-distroless (อ่านว่า "ดิส-โทร-เลส" — เช่น gcr.io/distroless/nodejs22) — Google ทำขึ้น — มีแค่ "runtime ของภาษา + dependencies ที่จำเป็น" — ไม่มี shell, ไม่มี package manager → attacker เข้ามาก็ทำอะไรไม่ได้มาก. แต่ debug ยากขึ้น (เข้า shell ไม่ได้ — ต้องใช้ :debug variant ตอน debug)
chainguard/wolfi (อ่านว่า "เชน-การ์ด / โวล-ฟี") — ตัวเลือก minimal-base รุ่นใหม่ที่นิยมตั้งแต่ปี 2024 — เด่นกว่า distroless ตรงที่: rebuild ทุกวัน (patch ใหม่เข้าทุก 24 ชม.), CVE เป็น 0 หรือใกล้ 0, แนบ SBOM + provenance attestation มาให้ในตัว (เห็น Part 10) → ดีสำหรับ supply-chain security / SLSA compliance. ใช้คำสั่ง pull ปกติ: FROM cgr.dev/chainguard/node:latest
scratch — image ว่างเปล่าสนิท ไม่มีแม้แต่ shell หรือ ls — ใช้กับภาษาที่ compile เป็น static binary (Go, Rust). image เล็กที่สุดเท่าที่เป็นไปได้
7.3 เลือกยังไง
- Go / Rust (static binary) →
scratchหรือdistroless/staticหรือchainguard/static - Java production →
eclipse-temurin:25-jre-alpine,distroless/java, หรือchainguard/jre - Node / Python production →
distroless,chainguard/node/chainguard/python,*-slim, หรือ*-alpine(ทดสอบให้ดี) - Dev / debug → distro เต็ม (
debian,ubuntu) หรือ distroless variant:debug
หลักการ: production ใช้ image เล็กที่สุดที่ยังทำงานได้ — เล็ก = ดาวน์โหลดเร็ว + attack surface น้อย
Part 8: คำสั่งจัดการ Image
bash
# ดู image ในเครื่อง
docker images
# ดูประวัติ layer ของ image
docker history myapp:v1
# ดูรายละเอียด image (JSON)
docker inspect myapp:v1
# ลบ image
docker rmi myapp:v1
# ลบ image ที่ไม่มี container ใช้
docker image prune
# ตั้ง tag ใหม่ให้ image (สร้างชื่อ alias)
docker tag myapp:v1 myapp:latest
docker tag myapp:v1 ghcr.io/acme/myapp:v1docker history มีประโยชน์มาก — แสดงว่าแต่ละ layer มาจากบรรทัดไหนใน Dockerfile และใหญ่เท่าไหร่ → ใช้หา layer ที่ทำให้ image ใหญ่
Part 9: ส่ง Image ขึ้น Registry
หลัง build แล้ว — ส่ง image ขึ้น registry เพื่อให้เครื่องอื่น (เช่น server) ดึงไปใช้
bash
# 1. login เข้า registry
docker login ghcr.io
# (ใส่ username = GitHub username, password = Personal Access Token — ดูวิธีขอด้านล่าง)
# 2. ตั้งชื่อ image ให้ตรงกับ registry
docker tag myapp:v1 ghcr.io/acme/myapp:v1
# 3. push ขึ้น registry
docker push ghcr.io/acme/myapp:v1
# 4. เครื่องอื่นดึงมาใช้
docker pull ghcr.io/acme/myapp:v1Registry ยอดนิยม:
- Docker Hub —
docker.io— public, มี free tier - GitHub Container Registry —
ghcr.io— ดีถ้าใช้ GitHub อยู่แล้ว - AWS ECR / Google Artifact Registry / Azure ACR — ของ cloud แต่ละเจ้า
- Harbor — self-host เอง
📌 วิธีขอ token ของ GitHub (สำหรับ
ghcr.io)
- GitHub → คลิก avatar → Settings
- ซ้ายล่าง → Developer settings
- Personal access tokens → Tokens (classic) → Generate new token (classic)
- ตั้งชื่อ token แล้ว ติ๊กสิทธิ์:
write:packages(push image)read:packages(pull image)delete:packages(ลบ image — optional)- กด Generate → คัดลอก token ทันที (โผล่ครั้งเดียว)
- เอา token ที่ได้ไปใส่เป็น password ตอน
docker login ghcr.io(username ใช้ GitHub username ตามปกติ)
Part 10: BuildKit — Build engine รุ่นใหม่
🚀 โซนขั้นสูง — ข้ามได้ Part 10 นี้ (cache mount, secret mount, multi-platform) เป็นเทคนิคขั้นสูงที่ใช้ตอนทำ CI/CD หรือทีมจริง — มือใหม่ที่เพิ่งเริ่ม ข้ามไป Part 11 (Lab) ก่อนได้ แล้วค่อยกลับมาอ่านตอนเจอปัญหา build ช้าหรือต้องจัดการความลับตอน build
Docker รุ่นใหม่ใช้ engine ชื่อ BuildKit ในการ build (เปิดเป็น default แล้ว) — มันเร็วกว่าและมีลูกเล่นเพิ่ม
10.1 Cache Mount — cache ที่อยู่ข้าม build
ปัญหา: npm ci ดาวน์โหลด package ทุกครั้งที่ cache เสีย
BuildKit แก้ด้วย "cache mount" — โฟลเดอร์ cache ที่อยู่ถาวร ข้าม build:
dockerfile
# syntax=docker/dockerfile:1
FROM node:22-alpine
WORKDIR /app
COPY package*.json ./
# uid=1000,gid=1000 = user "node" ในตัว image (ดูได้จาก `id node` ใน base image)
RUN --mount=type=cache,target=/home/node/.npm,uid=1000,gid=1000 \
npm ci --cache /home/node/.npm--mount=type=cache,target=... — บอกว่า "โฟลเดอร์นี้ให้เก็บถาวร ข้าม build" → build ครั้งหน้า npm ใช้ cache เดิม ไม่ต้องดาวน์โหลดใหม่
⚠️ path ของ cache ต้องตรงกับ user ที่รันคำสั่ง — default ของ npm คือ
/root/.npmซึ่งใช้ได้ตอน RUN เป็น root; ถ้าUSER nodeแล้วใช้/root/.npmจะ permission denied → ตัวอย่างข้างบนใช้/home/node/.npm+uid/gidของ usernode(UID 1000)
💡 ภาษาอื่นใช้ cache path คนละที่:
- Maven:
target=/root/.m2- Gradle:
target=/root/.gradle/caches- Go:
target=/root/.cache/go-build+target=/go/pkg/mod- pip:
target=/root/.cache/pip
10.2 Secret Mount — ใส่ความลับตอน build โดยไม่ leak
ปัญหา: ถ้าต้องใช้ token ตอน build (เช่นดึง private package) — ใส่ใน Dockerfile ตรง ๆ = token ติดอยู่ใน image ตลอดไป (ใครก็ดูได้)
dockerfile
RUN --mount=type=secret,id=npm_token \
NPM_TOKEN=$(cat /run/secrets/npm_token) npm installbash
docker build --secret id=npm_token,src=./token.txt .→ token ถูกใช้ตอน build แต่ไม่ติดอยู่ใน image — ปลอดภัย
10.3 Multi-platform Build
buildx = ส่วนเสริมของ docker build ที่ทำ multi-platform ได้ — มากับ Docker Desktop / Docker Engine รุ่นใหม่อยู่แล้วใช้ได้เลย ไม่ต้องลงเพิ่ม
ก่อน build ครั้งแรกต้องสร้าง builder ที่รองรับ multi-platform driver (docker-container) — default builder บางตัวรองรับแค่ platform เดียว:
bash
# สร้าง builder ใหม่ชื่อ multiarch และตั้งเป็น builder ปัจจุบัน
docker buildx create --name multiarch --driver docker-container --use
docker buildx inspect --bootstrap # เช็คว่า builder พร้อมจากนั้น build:
bash
docker buildx build --platform linux/amd64,linux/arm64 -t ghcr.io/acme/myapp:v1 --push .build image ให้รันได้ทั้ง CPU แบบ Intel/AMD (amd64) และ ARM (arm64 — เช่น Apple Silicon, AWS Graviton) จากเครื่องเดียว
📌 multi-platform build ต้อง
--push(หรือ--output) — โหลดเข้า local image store ไม่ได้ เพราะ Docker ในเครื่องเก็บ image ได้แค่ platform เดียวต่อ tag
10.4 Supply-chain: SBOM, Provenance, Cosign
ปี 2026 supply-chain security เป็นเรื่องที่ต้องคิดตั้งแต่ตอน build — BuildKit / buildx ฝัง SBOM (Software Bill of Materials — รายการ "ของ" ทั้งหมดที่อยู่ใน image) และ provenance attestation (หลักฐานว่า image นี้ build จากที่ไหน ด้วยอะไร) ลงใน image manifest ได้:
bash
docker buildx build \
--platform linux/amd64,linux/arm64 \
--sbom=true \
--provenance=mode=max \
-t ghcr.io/acme/myapp:v1 \
--push .--sbom=true— สร้าง SBOM (SPDX format = รูปแบบมาตรฐานการเขียน SBOM ที่เป็นที่ยอมรับ) แล้วฝังเป็น attestation--provenance=mode=max— บันทึก provenance ละเอียดสุด (รวมถึง Dockerfile, build args, source) — มาตรฐาน SLSA Level 2+ (SLSA = Supply chain Levels for Software Artifacts = ระดับความน่าเชื่อถือของ build pipeline)
หลัง push ขึ้น registry แล้ว ขั้นต่อไปคือ เซ็น (sign) image ด้วย cosign เพื่อให้ pipeline ปลายทาง verify ได้ว่า image ไม่ถูกแก้ระหว่างทาง:
bash
# ติดตั้ง cosign ก่อน (https://docs.sigstore.dev/cosign/installation)
cosign sign --yes ghcr.io/acme/myapp:v1
# ฝั่ง consumer verify
cosign verify ghcr.io/acme/myapp:v1 \
--certificate-identity-regexp='...' \
--certificate-oidc-issuer='https://token.actions.githubusercontent.com'💡 ใช้ร่วมกับ Kubernetes policy (
Kyverno/Sigstore Policy Controller) เพื่อ บล็อก deployment ของ image ที่ไม่ได้เซ็น — กันยิงของแปลกเข้า cluster (เรียนในบทถัด ๆ ไป)
Part 11: Lab — ลงมือทำ
Lab 1: เขียน Dockerfile แรก
แล็บแรกให้ลงมือเขียน Dockerfile ตั้งแต่ศูนย์ — เริ่มจากแอป Node.js ง่าย ๆ แล้วเขียน Dockerfile ครอบ (FROM → WORKDIR → COPY → RUN → CMD) จากนั้น build เป็น image แล้ว run เป็น container จริง เพื่อร้อยทุก instruction ในบทนี้เข้าด้วยกัน:
สร้างแอป Node.js ง่าย ๆ:
server.js:
javascript
const http = require('http');
http.createServer((req, res) => {
res.end('Hello from Docker!\n');
}).listen(3000);
console.log('Server on port 3000');package.json:
json
{ "name": "demo", "version": "1.0.0" }Dockerfile:
dockerfile
# syntax=docker/dockerfile:1
FROM node:22-alpine
WORKDIR /app
COPY --chown=node:node package*.json ./
# ⚠️ ตัวอย่าง demo นี้ใช้ `npm install` (ไม่ใช่ `npm ci`) เพราะยังไม่มี package-lock.json
# ในงานจริง: commit package-lock.json แล้วเปลี่ยนเป็น `RUN npm ci --omit=dev` ตามที่
# Part 2 ใช้ — จะได้ผลลัพธ์เหมือนกันทุกครั้ง (reproducible)
RUN npm install --omit=dev
COPY --chown=node:node . .
EXPOSE 3000
USER node
CMD ["node", "server.js"]bash
docker build -t mydemo:v1 .
docker run -d -p 3000:3000 mydemo:v1
curl http://localhost:3000Lab 2: เห็นพลังของ cache
- Build ครั้งแรก — จับเวลา
- แก้
server.jsเปลี่ยนข้อความ — build ใหม่ — จับเวลา (ควรเร็วกว่ามาก เพราะnpm ciใช้ cache) - ลองสลับ
COPY . .ขึ้นไปก่อนRUN npm ci— build ใหม่ — สังเกตว่าช้าลง
Lab 3: Multi-stage
แปลง Lab 1 เป็น multi-stage — เทียบขนาด docker images ก่อน/หลัง
Part 12: Glossary — ศัพท์สรุปท้ายบท
ทบทวนศัพท์ทั้งหมดในบทนี้ พร้อมคำอ่าน/คำแปลไทย:
| ศัพท์ | คำอ่าน | ความหมาย |
|---|---|---|
| base image | เบส อิ-เมจ | image ฐาน/image แม่ ที่เราใช้บรรทัด FROM ต่อยอด |
| layer | เลเยอร์ | "ชั้น" ของการเปลี่ยนแปลงไฟล์ — แต่ละบรรทัด Dockerfile สร้าง 1 layer |
| layer cache | เลเยอร์ แคช | กลไกที่ใช้ layer เดิมถ้าบรรทัดนั้นไม่เปลี่ยน → build เร็วขึ้นมาก |
| build context | บิลด์ คอนเท็กซ์ | ไฟล์ทั้งหมดที่ Docker ส่งให้ daemon ตอน build (โฟลเดอร์ที่ระบุท้าย docker build) |
| multi-stage build | มัลติ-สเตจ บิลด์ | ใช้หลาย FROM ใน Dockerfile เดียว — stage แรก build, stage สุดท้ายเอาแค่ผลลัพธ์ |
| static binary | สแตติก ไบ-นา-รี | ไฟล์โปรแกรมแบบรวม library ทุกอย่างในตัว ไม่ต้องพึ่ง library นอก |
| glibc | จี-ลิบ-ซี | GNU C Library — C library มาตรฐานของ Linux ส่วนใหญ่ |
| musl | มัส-เซิล | C library รุ่นเล็กที่ Alpine ใช้แทน glibc |
| distroless | ดิส-โทร-เลส | image ที่มีแค่ runtime ของภาษา ไม่มี shell/package manager |
| chainguard / wolfi | เชน-การ์ด / โวล-ฟี | base image รุ่นใหม่ rebuild ทุกวัน, low CVE, แนบ SBOM |
| scratch | สแครช | image ว่างเปล่าสนิท 0 byte — ใช้กับ static binary เท่านั้น |
| attack surface | แอ-แทค เซอร์-เฟส | พื้นที่ที่โดนโจมตีได้ — ยิ่งมีของในเลกซ์เยอะยิ่งเสี่ยง |
| SIGTERM | ซิก-เทอม | Signal terminate — สัญญาณบอกให้โปรแกรมหยุดทำงานอย่างสุภาพ |
| SIGKILL | ซิก-คิล | Signal kill — สัญญาณบังคับฆ่าโปรแกรมทันที (override ไม่ได้) |
| HEALTHCHECK | เฮลธ์-เช็ค | คำสั่งที่ Docker ใช้ตรวจสุขภาพ container เป็นระยะ |
| exec form | เอ็ก-เซค ฟอร์ม | เขียน CMD/ENTRYPOINT เป็น JSON array ["...", "..."] — แนะนำ |
| shell form | เชล ฟอร์ม | เขียน CMD/ENTRYPOINT เป็นข้อความปกติ — ไม่แนะนำ (signal ไม่ถึง) |
| BuildKit | บิลด์-คิท | Build engine รุ่นใหม่ของ Docker (default ตั้งแต่ Docker 23+) |
| buildx | บิลด์-เอ็กซ์ | ปลั๊กอินของ docker ที่ทำ multi-platform / multi-builder ได้ |
| cache mount | แคช เมาท์ | โฟลเดอร์ที่ BuildKit เก็บถาวรข้าม build (RUN --mount=type=cache) |
| secret mount | ซีเครต เมาท์ | วิธีใส่ความลับตอน build โดยไม่ติดอยู่ใน image (RUN --mount=type=secret) |
| SBOM | เอส-บอม | Software Bill of Materials — รายการของทั้งหมดใน image |
| provenance | โพร-เว-แนนซ์ | หลักฐานว่า image นี้ build จากที่ไหน ด้วยอะไร (มาตรฐาน SLSA) |
| cosign | โค-ไซน์ | เครื่องมือเซ็นและ verify container image (จาก Sigstore) |
| registry | เร-จิส-ทรี | "คลัง" เก็บ image ส่วนกลาง (Docker Hub, ghcr.io, ECR ฯลฯ) |
Part 13: Checkpoint
- ทำไม image ถึงเก็บเป็น "layer"? ได้ประโยชน์อะไร?
- แต่ละบรรทัดใน Dockerfile สร้างอะไร?
- ทำไมต้อง
COPY package.jsonก่อน แล้วค่อยCOPY . .? CMDต่างENTRYPOINTยังไง?- ทำไมต้องใช้ "exec form" (
["node", "server.js"])? .dockerignoreทำอะไร?- Layer cache ทำงานยังไง? "ถ้าบรรทัดหนึ่งเปลี่ยน" เกิดอะไรขึ้น?
- ทำไมต้องรวม
apt-get update && install && rmในบรรทัดเดียว? - Multi-stage build ทำให้ image เล็กลงยังไง?
alpinevsdistrolessvsscratchต่างกันยังไง?
Part 14: สรุปบทนี้
- Image = layer ซ้อนกัน — layer ที่เหมือนกันใช้ร่วมกัน → ประหยัด disk + เร็ว
- Dockerfile = พิมพ์เขียวสร้าง image — แต่ละบรรทัดสร้าง 1 layer
- Layer cache: เรียง "เปลี่ยนน้อย → เปลี่ยนบ่อย" —
COPY package.jsonก่อนRUN installก่อนCOPY . . - รวม RUN ในบรรทัดเดียว (หรือใช้ HEREDOC) → layer น้อย + image เล็ก
- Multi-stage build = build ใน stage แรก, เอาแค่ผลลัพธ์ไป stage สุดท้าย → image เล็กลง 10-50 เท่า
- Base image: production ใช้เล็กที่สุดที่ทำงานได้ (distroless / chainguard-wolfi / alpine / scratch)
- BuildKit = cache mount + secret mount + multi-platform + SBOM/provenance attestation
- Supply chain:
--sbom=true --provenance=mode=max+cosign sign→ image ที่ verify ได้ - ใช้ tag เวอร์ชันชัดเจน + push ขึ้น registry
บทต่อไป — Networking: container คุยกันยังไง, port mapping ทำงานยังไงจริง ๆ