Skip to content

บทที่ 01 — Image เจาะลึก: Layer, Dockerfile, Multi-stage

← บทที่ 00 | สารบัญ | บทที่ 02: Networking →

(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=devRUN = "รันคำสั่งนี้ตอน 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 ci vs npm installci (clean install) จะลบ node_modules ก่อนแล้วติดตั้งตาม lockfile เป๊ะ ๆ; ส่วน install จะอัปเดต node_modules + แก้ package-lock.json ได้ — ใน build/CI เราอยากได้ผลลัพธ์เหมือนกันทุกครั้งจึงใช้ ci. ใน Lab 1 ปลายบทยังใช้ npm install อยู่เพราะ demo ไม่มี lockfile — ในงานจริงให้ commit package-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:v1

Part 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:GID

3.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 8080docker run myapp --port 9000 = python app.py --port 9000

📌 python app.py หา app.py จาก WORKDIR ปัจจุบัน — ต้องตั้ง WORKDIR และ COPY app.py เข้าไปก่อน ไม่งั้นจะ error python: 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 imagealpine, 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 ข้อ:

  1. น้อย layer — จัดการง่ายกว่า

  2. ขนาดเล็กกว่า — อันนี้สำคัญ: ใน 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 ./distcopy เฉพาะโฟลเดอร์ dist จาก stage builder เข้ามา

ผลลัพธ์: 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 แบบกว้าง ๆ จะ error multiple 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 MBLinux distro เต็ม — มีเครื่องมือครบ
*-slim~70-80 MBDebian รุ่นตัดของออก — มีของพอใช้
alpine~5-8 MBLinux distro จิ๋ว — เล็กมาก
*-distroless~20 MBGoogle ทำ — มีแค่ 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
scratch0 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 productioneclipse-temurin:25-jre-alpine, distroless/java, หรือ chainguard/jre
  • Node / Python productiondistroless, 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:v1

docker 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:v1

Registry ยอดนิยม:

  • Docker Hubdocker.io — public, มี free tier
  • GitHub Container Registryghcr.io — ดีถ้าใช้ GitHub อยู่แล้ว
  • AWS ECR / Google Artifact Registry / Azure ACR — ของ cloud แต่ละเจ้า
  • Harbor — self-host เอง

📌 วิธีขอ token ของ GitHub (สำหรับ ghcr.io)

  1. GitHub → คลิก avatar → Settings
  2. ซ้ายล่าง → Developer settings
  3. Personal access tokensTokens (classic)Generate new token (classic)
  4. ตั้งชื่อ token แล้ว ติ๊กสิทธิ์:
    • write:packages (push image)
    • read:packages (pull image)
    • delete:packages (ลบ image — optional)
  5. กด Generateคัดลอก token ทันที (โผล่ครั้งเดียว)
  6. เอา 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 ของ user node (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 install
bash
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:3000

Lab 2: เห็นพลังของ cache

  1. Build ครั้งแรก — จับเวลา
  2. แก้ server.js เปลี่ยนข้อความ — build ใหม่ — จับเวลา (ควรเร็วกว่ามาก เพราะ npm ci ใช้ cache)
  3. ลองสลับ 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

  1. ทำไม image ถึงเก็บเป็น "layer"? ได้ประโยชน์อะไร?
  2. แต่ละบรรทัดใน Dockerfile สร้างอะไร?
  3. ทำไมต้อง COPY package.json ก่อน แล้วค่อย COPY . .?
  4. CMD ต่าง ENTRYPOINT ยังไง?
  5. ทำไมต้องใช้ "exec form" (["node", "server.js"])?
  6. .dockerignore ทำอะไร?
  7. Layer cache ทำงานยังไง? "ถ้าบรรทัดหนึ่งเปลี่ยน" เกิดอะไรขึ้น?
  8. ทำไมต้องรวม apt-get update && install && rm ในบรรทัดเดียว?
  9. Multi-stage build ทำให้ image เล็กลงยังไง?
  10. alpine vs distroless vs scratch ต่างกันยังไง?

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 ทำงานยังไงจริง ๆ


← บทที่ 00 | สารบัญ | บทที่ 02: Networking →