เป้าหมาย: ใช้ Dockerfile เพียงไฟล์เดียวในการทดสอบและ Compile แอปพลิเคชัน จากนั้นย้ายเฉพาะไฟล์โปรแกรมที่จำเป็นไปยัง Docker Image สำหรับ Production

ตัวอย่างหลัก: Go HTTP API โดยใช้ golang:1.26-alpine สำหรับ Build และ scratch สำหรับ Runtime

ข้อกำหนด: Docker Engine หรือ Docker Desktop ที่มี BuildKit รองรับ Dockerfile syntax รุ่นปัจจุบัน, Command Line และโปรแกรม curl (Go SDK ไม่จำเป็นบนเครื่องผู้เรียน หาก Build ด้วย Docker)

info-dockerfile-multi-stage-build-workshop-production

#สารบัญ

  1. Multi-stage Build คืออะไร
  2. ความแตกต่างระหว่าง Single-stage และ Multi-stage
  3. โครงสร้างการทำงาน
  4. Workshop: สร้าง Go HTTP API
  5. เขียน Dockerfile แบบ Multi-stage
  6. Build และ Run
  7. เปรียบเทียบขนาด Image
  8. ใช้งานกับ Docker Compose
  9. แนวทางที่แนะนำสำหรับ Production
  10. ตัวอย่าง Multi-stage สำหรับ Node.js และ Java
  11. แก้ปัญหาที่พบบ่อย
  12. แบบฝึกหัดและสรุป

#1. Multi-stage Build คืออะไร

Multi-stage Build เป็นเทคนิคที่ใช้ FROM หลายครั้งใน Dockerfile เดียว เพื่อแยกขั้นตอนสร้างแอปพลิเคชันออกจากขั้นตอนรันแอปพลิเคชัน แต่ละ FROM จะเริ่ม Stage ใหม่ และสามารถตั้งชื่อ Stage ด้วย AS <name> ได้

FROM builder-image AS builder
# ติดตั้ง dependency, ทดสอบ และ build

FROM runtime-image AS runtime
COPY --from=builder /path/to/artifact /app/artifact
# สั่งรันแอปพลิเคชัน

ประโยชน์หลัก:

  • Image ขนาดเล็กลง: Production ไม่จำเป็นต้องมี Compiler และ Build Tools
  • ลด Attack Surface: ลดจำนวนเครื่องมือ/แพ็กเกจที่ไม่จำเป็นใน Image สุดท้าย (ไม่ได้หมายความว่าไม่มีช่องโหว่)
  • แยกหน้าที่ชัดเจน: จัดการ Build, Test, Runtime ได้ใน Dockerfile เดียว
  • ใช้ Build Cache ได้มีประสิทธิภาพ: จัดลำดับคำสั่งและเลือก Cache Mount ตามลักษณะโปรเจกต์
  • รองรับ CI/CD: Build Stage ที่ต้องการด้วย --target

Docker จะเลือก Stage สุดท้าย เป็นผลลัพธ์เริ่มต้นเมื่อไม่ระบุ --target และ Stage ก่อนหน้า ไม่ได้ถูกนำทั้งหมด ไปรวมใน Final Image โดยอัตโนมัติ แม้เครื่อง Build อาจยังเก็บ Cache หรือ Intermediate Image ไว้ในเครื่องก็ตาม [1]

#2. ความแตกต่างระหว่าง Single-stage และ Multi-stage

หัวข้อ Single-stage Multi-stage
Dockerfile ใช้ FROM หนึ่ง Stage เป็นหลัก ใช้ FROM หลาย Stage
Compiler/Build Tools ใน Final Image มักมี ถ้าติดตั้งเพื่อ Build ใน Stage เดียว สามารถตัดออกได้
Source Code ใน Final Image มักติดไปด้วย คัดลอกเฉพาะ Artifact ได้
ขนาด Image มักใหญ่กว่าเมื่อมี Build Tools มักเล็กกว่าหากเลือก Runtime Image เหมาะสม
ความปลอดภัย มีแพ็กเกจเกินความจำเป็นได้ ลดแพ็กเกจที่ไม่จำเป็นได้ แต่ยังต้องอัปเดตและสแกน
การ Debug มี Shell/เครื่องมือช่วยมากกว่า Runtime แบบ scratch ไม่มี Shell

#ตัวอย่าง Single-stage Dockerfile

ไฟล์ Dockerfile.single สำหรับเปรียบเทียบ:

# Single-stage example for comparison only
FROM golang:1.26-alpine
WORKDIR /src
COPY go.mod ./
COPY *.go ./
RUN go test ./...
RUN CGO_ENABLED=0 go build -o /src/server .
USER 65532:65532
EXPOSE 8080
CMD ["/src/server"]

ข้อสังเกต: Dockerfile แบบนี้ Build และ Run ได้ แต่ Runtime Image ยังคงมี Go SDK และไฟล์ Source Code จากขั้นตอน Build อยู่

#3. โครงสร้างการทำงาน

flowchart LR
    A["Go source code"] --> B["Stage 1: builder (golang:1.26-alpine)"]
    B --> C["Run unit tests"]
    C --> D["Static binary: /out/server"]
    D -->|"COPY --from=builder"| E["Stage 2: runtime (scratch)"]
    E --> F["Final Production Image: /server"]
คำสั่ง ความหมาย
FROM image AS builder เริ่ม Stage และตั้งชื่อให้เรียกอ้างอิงได้
WORKDIR /src กำหนดไดเรกทอรีทำงานใน Stage ปัจจุบัน
COPY คัดลอกไฟล์เข้า Image ตาม Build Context
RUN เรียกคำสั่งระหว่าง Build Image
COPY --from=builder คัดลอกไฟล์จากอีก Stage โดยตรง
USER กำหนดผู้ใช้สำหรับ Runtime และคำสั่งถัดไป
EXPOSE ประกาศ Port ที่แอปใช้ ไม่ได้ Publish Port ออกสู่ Host
ENTRYPOINT ระบุโปรแกรมหลักของ Container

#4. Workshop: สร้าง Go HTTP API

โครงสร้างโปรเจกต์:

docker-multistage-workshop/
├── go.mod
├── main.go
├── main_test.go
├── Dockerfile
├── Dockerfile.single
├── .dockerignore
└── compose.yaml

#ขั้นตอน 4.1: กำหนด Go module (go.mod)

module example.com/docker-multistage-workshop

go 1.23

ใน Workshop นี้ใช้ไลบรารีมาตรฐานของ Go จึงไม่จำเป็นต้องมีไฟล์ go.sum เมื่อยังไม่มี External Module Dependencies

#ขั้นตอน 4.2: เขียน API (main.go)

package main

import (
    "fmt"
    "log"
    "net/http"
    "os"
)

func routes() http.Handler {
    mux := http.NewServeMux()

    mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) {
        w.Header().Set("Content-Type", "application/json; charset=utf-8")
        fmt.Fprintln(w, `{"message":"Hello from Docker multi-stage build"}`)
    })

    mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
        w.Header().Set("Content-Type", "text/plain; charset=utf-8")
        fmt.Fprintln(w, "ok")
    })

    return mux
}

func main() {
    port := os.Getenv("PORT")
    if port == "" {
        port = "8080"
    }
    log.Printf("Listening on :%s", port)
    log.Fatal(http.ListenAndServe(":"+port, routes()))
}

API ที่ได้ (ค่าเริ่มต้นฟังที่ Port 8080 และปรับได้ด้วยตัวแปร PORT):

  • GET / คืนข้อความ JSON
  • GET /healthz คืนข้อความ ok สำหรับตรวจสถานะ

#ขั้นตอน 4.3: เพิ่ม Unit Test (main_test.go)

package main

import (
    "net/http"
    "net/http/httptest"
    "strings"
    "testing"
)

func TestHealth(t *testing.T) {
    req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
    rec := httptest.NewRecorder()
    routes().ServeHTTP(rec, req)

    if rec.Code != http.StatusOK {
        t.Fatalf("status=%d, want 200", rec.Code)
    }
    if got := strings.TrimSpace(rec.Body.String()); got != "ok" {
        t.Fatalf("body=%q, want ok", got)
    }
}

func TestRoot(t *testing.T) {
    req := httptest.NewRequest(http.MethodGet, "/", nil)
    rec := httptest.NewRecorder()
    routes().ServeHTTP(rec, req)

    if rec.Code != http.StatusOK {
        t.Fatalf("status=%d, want 200", rec.Code)
    }
    if got := rec.Header().Get("Content-Type"); !strings.HasPrefix(got, "application/json") {
        t.Fatalf("content-type=%q, want application/json", got)
    }
}

ถ้าเครื่องผู้เรียนติดตั้ง Go อยู่แล้ว สามารถตรวจสอบโดยรัน:

go test ./...
go run .

จากนั้นเปิด Terminal อีกหน้าต่างหนึ่งแล้วใช้ curl http://localhost:8080/healthz

#5. เขียน Dockerfile แบบ Multi-stage

สร้างไฟล์ชื่อ Dockerfile:

# syntax=docker/dockerfile:1

# Stage 1: compile and test
FROM golang:1.26-alpine AS builder
WORKDIR /src
COPY go.mod ./
COPY *.go ./
RUN --mount=type=cache,target=/root/.cache/go-build go test ./...
RUN --mount=type=cache,target=/root/.cache/go-build \
    CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/server .

# Stage 2: minimal production runtime
FROM scratch AS runtime
WORKDIR /
COPY --from=builder /out/server /server
USER 65532:65532
EXPOSE 8080
ENTRYPOINT ["/server"]

#อธิบาย Stage แรก: builder

  1. FROM golang:1.26-alpine AS builder ใช้ Image ที่มี Go Toolchain สำหรับ Build
  2. WORKDIR /src ตั้งตำแหน่งทำงานภายใน Container
  3. COPY go.mod ./ และ COPY *.go ./ คัดลอกไฟล์ที่จำเป็นสำหรับตัวอย่างซึ่งมี Go files อยู่ที่รากโปรเจกต์ (โปรเจกต์ที่มีไดเรกทอรีย่อยควรปรับ COPY ให้ครอบคลุม)
  4. go test ./... รัน Unit Test ระหว่าง Build หาก Test ไม่ผ่าน การ Build จะล้มเหลว
  5. CGO_ENABLED=0 ช่วยให้ตัวอย่างนี้ Build เป็น Static Binary โดยไม่ต้องพึ่ง C runtime; -trimpath และ -ldflags="-s -w" ลดข้อมูลส่วนเกินใน Binary
  6. RUN --mount=type=cache ใช้ BuildKit cache สำหรับไฟล์แคช Compile ของ Go เพื่อช่วยการ Build รอบถัดไป โดย Cache Mount นี้ไม่ถูกคัดลอกเข้า Final Image

#อธิบาย Stage สุดท้าย: runtime

  1. FROM scratch AS runtime เริ่มจาก Empty Base Image ที่ไม่มี Shell หรือ Package Manager
  2. COPY --from=builder /out/server /server นำเฉพาะ Binary มาใส่ใน Runtime Image
  3. USER 65532:65532 สั่งให้โปรเซสรันด้วย UID/GID ที่ไม่ใช่ Root แม้ Image แบบ scratch จะไม่มี /etc/passwd ก็ใช้ ID แบบตัวเลขได้
  4. EXPOSE 8080 เป็นข้อมูลเกี่ยวกับ Port เท่านั้น ต้อง -p เพื่อ Publish Port ให้เรียกจาก Host ได้
  5. ENTRYPOINT ["/server"] เริ่มโปรแกรมเมื่อสร้าง Container

ข้อจำกัดของ scratch: เหมาะกับ Static Binary ที่ไม่ต้องใช้ Shell, Dynamic Libraries หรือ Runtime Resources เพิ่มเติม หากแอปเรียก HTTPS ภายนอก ต้องตรวจสอบการมี CA Certificates; หากต้องใช้ Timezone/ไฟล์ระบบอื่น หรือ Native Dependencies อาจต้องเพิ่มไฟล์ที่จำเป็นหรือเลือก Minimal Runtime เช่น Distroless อย่างเหมาะสม

#เพิ่มไฟล์ .dockerignore

.git
.github
.env
.env.*
*.log
.DS_Store
node_modules
bin
dist
tmp

การลด Build Context ช่วยให้ Build มีประสิทธิภาพขึ้น และลดโอกาสนำไฟล์ที่ไม่ควรใช้งาน (เช่น .env) เข้ากระบวนการ Build โดยยังต้องตรวจสอบ Context และ Secret อย่างรอบคอบ

#6. Build และ Run

#6.1 Build Final Image

เปิด Terminal ในโฟลเดอร์โปรเจกต์แล้วรัน:

docker build -t docker-multistage-go:multi .

หรือกำหนด Stage ปลายทางให้ชัดเจน:

docker build --target runtime -t docker-multistage-go:multi .

#6.2 เริ่ม Container

docker run -d --name multi-api -p 8080:8080 docker-multistage-go:multi

#6.3 ทดลอง API

curl http://localhost:8080/
curl http://localhost:8080/healthz

ผลลัพธ์ที่คาดหวัง:

{"message":"Hello from Docker multi-stage build"}

และคำสั่ง /healthz ตอบ ok

#6.4 ดู Log และหยุด Container

docker logs multi-api
docker stop multi-api
docker rm multi-api

#7. เปรียบเทียบขนาด Image

สร้าง Image 2 รูปแบบจาก Source Code ชุดเดียวกัน:

# Single-stage
docker build -f Dockerfile.single -t docker-multistage-go:single .

# Multi-stage
docker build -f Dockerfile -t docker-multistage-go:multi .

# ดูขนาดที่ Docker แสดง
docker image ls docker-multistage-go

# ดูขนาดเป็น Byte
docker image inspect docker-multistage-go:single --format '{{.Size}}'
docker image inspect docker-multistage-go:multi --format '{{.Size}}'

การตีความ: คาดว่าแบบ Multi-stage ในตัวอย่างจะมีขนาดน้อยกว่า เพราะไม่ได้บรรจุ Go SDK เข้า Image สุดท้าย แต่ขนาดจริงขึ้นกับ Base Image, Architecture, Docker Version, Build Options และ Artifact หลีกเลี่ยงการรายงานตัวเลขประหยัดพื้นที่เป็นเปอร์เซ็นต์หากยังไม่ได้วัดบนระบบจริง

นอกจากนี้ยังดูแต่ละ Layer ได้ด้วย:

docker history docker-multistage-go:single
docker history docker-multistage-go:multi

หากต้องการ Build เฉพาะ Builder Stage เพื่อตรวจสอบ:

docker build --target builder -t docker-multistage-go:builder .

#8. ใช้งานกับ Docker Compose

สร้าง compose.yaml:

services:
  api:
    build:
      context: .
      target: runtime
    image: docker-multistage-go:local
    ports:
      - "8080:8080"
    read_only: true
    security_opt:
      - no-new-privileges:true

คำสั่ง:

docker compose up -d --build
docker compose ps
docker compose logs -f api
curl http://localhost:8080/healthz
docker compose down

read_only: true กำหนดให้ Filesystem หลักของ Container เป็น Read-only เหมาะกับ API ตัวอย่างนี้ที่ไม่เขียนไฟล์ภายใน Container หากแอปของจริงต้องเขียนข้อมูล ควรจัด Volume หรือ Writable Mount ให้เหมาะสม

ข้อควรระวัง: ก่อนใช้ Compose ให้แน่ใจว่า Container ชื่อ multi-api จากขั้นตอนก่อนหน้าหยุดและถูกลบแล้ว เพื่อไม่ให้ Port 8080 ชนกัน

#9. แนวทางที่แนะนำสำหรับ Production

  1. แยก Build กับ Runtime: นำ Artifact เท่าที่จำเป็นเข้า Final Image
  2. ใช้ Base Image ที่เชื่อถือได้: เลือกเวอร์ชันที่ยังได้รับการดูแล ตรวจสอบ Security Advisories และปรับรุ่นสม่ำเสมอ
  3. Pin เวอร์ชันหรือ Digest: สำหรับการผลิตซ้ำอย่างแน่นอนควร Pin ไปยัง Digest; Tag เช่น 1.26-alpine อาจถูกอัปเดตได้
  4. รันเป็น Non-root: ใช้ USER หรือ Runtime Image แบบ Non-root เมื่อแอปรองรับ
  5. ใช้ .dockerignore: ไม่คัดลอก .git, Dependency Caches, Logs หรือ Secrets ไปโดยไม่จำเป็น
  6. Build แบบทดสอบได้: รัน Unit Test ใน Builder Stage หรือทำ Test เป็น Job ก่อน Build/Push Production Image
  7. Cache อย่างมีเหตุผล: คัดลอก Dependency Manifest ก่อน Source Code สำหรับแอปที่มี Dependency มาก; ใช้ BuildKit Cache Mount เมื่อเหมาะสม
  8. ไม่ฝัง Secrets: หลีกเลี่ยงการใช้ ARG/ENV ใส่รหัสผ่านหรือ Token ระหว่าง Build ควรใช้ BuildKit Secret Mount ในกรณีต้องใช้ Secret ระหว่าง Build
  9. สแกนและอัปเดต Image: Image ขนาดเล็กไม่ได้ปลอดภัยโดยอัตโนมัติ; ควรสแกน Vulnerabilities, ทำ SBOM และใช้ Patch/Upgrade ตามรอบ
  10. ตรวจสอบ Runtime Dependencies: scratch อาจขาด CA Certificates, Timezone Files และ Shared Libraries ที่แอปต้องการ

#ตัวอย่างรองรับ Multi-platform (ขั้นสูง)

ตัวอย่างส่วนของ Builder สำหรับการ Cross-compile Go Binary ที่ไม่พึ่ง CGO:

FROM --platform=$BUILDPLATFORM golang:1.26-alpine AS builder
WORKDIR /src
COPY go.mod ./
COPY *.go ./
RUN go test ./...
ARG TARGETOS
ARG TARGETARCH
RUN CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH \
    go build -trimpath -ldflags="-s -w" -o /out/server .

ใช้คู่กับ Runtime Stage เดิมได้ และสั่ง Build สำหรับสองสถาปัตยกรรมด้วย Buildx เมื่อ Builder รองรับ:

docker buildx build \
  --platform linux/amd64,linux/arm64 \
  -t your-registry/your-image:1.0 \
  --push .

คำสั่งนี้จะ Push ไปยัง Registry จริง จึงต้องเปลี่ยนชื่อ Repository ให้ถูกต้องและ Login ก่อนใช้งาน

#10. ตัวอย่าง Multi-stage สำหรับ Node.js และ Java

#10.1 Node.js + Vite: Build Frontend แล้ว Serve ด้วย Nginx

สมมติว่าโปรเจกต์มี package.json, package-lock.json และ npm run build สร้างไฟล์ใน dist/

# syntax=docker/dockerfile:1
FROM node:24-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm npm ci
COPY . .
RUN npm run build

FROM nginx:stable-alpine AS runtime
COPY --from=builder /app/dist/ /usr/share/nginx/html/
EXPOSE 80

สำหรับ Single-page Application ที่ใช้ Client-side Routing อาจต้องเพิ่ม Nginx Config เพื่อให้ Fallback ไป index.html และควร Pin Runtime Image เวอร์ชัน/Digest สำหรับ Production

#10.2 Node.js Backend ที่ Compile TypeScript

สมมติว่า npm run build สร้าง dist/server.js และมี package-lock.json

# syntax=docker/dockerfile:1
FROM node:24-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM node:24-alpine AS runtime
ENV NODE_ENV=production
WORKDIR /app
COPY --from=builder /app/package.json /app/package-lock.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY --from=builder --chown=node:node /app/dist/ ./dist/
USER node
EXPOSE 3000
CMD ["node", "dist/server.js"]

ตัวอย่างนี้ติดตั้งเฉพาะ Production Dependencies ใน Runtime Stage แทนการ Copy node_modules ทั้งหมดจาก Builder Stage; หากมี Native Dependencies ต้องตรวจสอบความเข้ากันได้ของแพลตฟอร์มและ Alpine/musl

#10.3 Java + Maven: Build JAR และรันบน JRE

สมมติว่า Maven Project สร้าง JAR ไฟล์เดียวใน target/

FROM maven:3.9-eclipse-temurin-21 AS builder
WORKDIR /app
COPY pom.xml ./
RUN mvn -B dependency:go-offline
COPY src ./src
RUN mvn -B clean package

FROM eclipse-temurin:21-jre AS runtime
WORKDIR /app
COPY --from=builder /app/target/*.jar /app/app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "/app/app.jar"]

หมายเหตุ: หาก target/ มีหลาย JAR ต้องกำหนดชื่อ Artifact ให้แน่นอนก่อน COPY; สามารถเลือก JRE Base Image ที่เล็กลงและตั้ง Non-root User ได้ตามข้อกำหนดของแอป

#11. แก้ปัญหาที่พบบ่อย

อาการ สาเหตุที่ควรตรวจสอบ วิธีแก้
COPY --from=builder หาไฟล์ไม่พบ Build ไม่ได้สร้าง Artifact ใน Path ตามที่กำหนด ตรวจสอบ -o /out/server และ Path ใน COPY
exec format error Binary ไม่ตรงกับ CPU Architecture ของ Runtime ตรวจสอบ GOARCH, TARGETARCH และ --platform
no such file or directory ตอนรัน Binary Binary อาจต้องพึ่ง Dynamic Loader หรือไฟล์ที่ไม่มีใน scratch ใช้ CGO_ENABLED=0 เมื่อเหมาะสมหรือเปลี่ยน Runtime Base
Container รันได้แต่เรียกไม่ได้ ไม่ได้ Publish Port หรือแอปไม่ฟังที่ Interface ถูกต้อง ใช้ -p 8080:8080 และให้แอป Listen ที่ :8080
Docker Build ช้า Cache ถูก Invalidate บ่อย, Context ใหญ่ เพิ่ม .dockerignore, จัดลำดับ COPY, ใช้ Cache Mount
ออก HTTPS ไม่ได้จาก scratch ขาด CA Root Certificates เพิ่ม Certificates ที่จำเป็นหรือเลือก Runtime Image ที่มีอยู่แล้ว
sh: not found ตอน docker exec scratch ไม่มี Shell ใช้ docker logs, Ephemeral Debug Container หรือเครื่องมือภายนอก
Port 8080 ถูกใช้อยู่ มี Container/Process อื่นครอบครอง Port หยุดตัวเดิม หรือ Map Host Port อื่น เช่น -p 8081:8080

#12. แบบฝึกหัดและสรุป

แบบฝึกหัด

  • ปรับ API เพิ่ม GET /version แล้วเขียน Unit Test
  • สร้าง Single-stage และ Multi-stage Image เพื่อวัดขนาดจริง แล้วบันทึกผลเป็นตาราง
  • สร้าง Build Pipeline ที่รัน Test และ Build ด้วย Docker
  • ทดลอง Build ผ่าน docker buildx สำหรับ linux/amd64 และ linux/arm64
  • เปรียบเทียบการใช้ scratch, Distroless และ Alpine ในแง่ขนาด Security และ Debuggability

สรุป: Multi-stage Build ช่วยให้นักพัฒนากำหนด Workflow การ Build และ Runtime ใน Dockerfile เดียว โดยควบคุมได้ว่า Artifact ใดจะไปอยู่ใน Final Image แนวทางนี้เหมาะกับ Go, Java, Node.js, Python และภาษาอื่น ๆ แต่ผลด้านขนาด ความเร็วและความปลอดภัยจะขึ้นอยู่กับการออกแบบ Stage, Dependencies และ Base Image ที่เลือก

#แหล่งอ้างอิง

  1. Docker Docs — Multi-stage builds
  2. Docker Docs — Building best practices
  3. Docker Docs — Dockerfile reference
  4. Docker Docs — Build cache optimization