ข้ามไปยังเนื้อหา

Multi-Stage Builds

application ทั่วไปต้องการเครื่องมือที่แตกต่างกันมากในสองช่วง:

  • ช่วง Build: compiler, test runner, type checker, build toolchain (tsc, go build, maven ฯลฯ)
  • ช่วง Runtime: เฉพาะ output ที่ compile แล้วและ dependency ที่จำเป็นสำหรับการรัน

ใน Dockerfile แบบ single-stage ทั่วไป เราติดตั้งทุกอย่างใน image เดียว compiler ที่จำเป็นในช่วง build ตอนนี้นั่งว่างอยู่ใน production โดยเพิ่มขนาดหลายร้อย MB และ attack surface ที่ไม่จำเป็น

# Naive single-stage — compiler ends up in the final image
FROM node:22
WORKDIR /app
COPY . .
RUN npm ci
RUN npm run build
CMD ["node", "dist/server.js"]

ผลลัพธ์ที่พบบ่อย:

myapp:naive 1.21GB

Docker BuildKit รองรับ multi-stage builds: Dockerfile เดียวที่มี instruction FROM หลายอัน แต่ละ FROM เริ่ม stage ใหม่ คุณสามารถ copy artifact จาก stage ก่อนหน้าไปยัง stage ถัดไปโดยใช้ COPY --from=<stage>

สิ่งสำคัญ: เฉพาะ stage สุดท้ายเท่านั้นที่จะถูก export เป็น final image stage กลางทั้งหมด รวมถึงทุก tool ที่ติดตั้งใน stage เหล่านั้น จะถูกทิ้งโดยอัตโนมัติ

# syntax=docker/dockerfile:1
# --- Stage 1: build ---
FROM node:22-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY src/ ./src/
RUN npm run build # produces dist/
# --- Stage 2: runtime ---
FROM node:22-alpine AS runtime
WORKDIR /app
COPY --from=build /app/dist ./dist
COPY --from=build /app/node_modules ./node_modules
ENV NODE_ENV=production
EXPOSE 3000
CMD ["node", "dist/server.js"]

สิ่งที่เปลี่ยนไป:

Single-stageMulti-stage
Final image มีSource + devDeps + distเฉพาะ dist + prodDeps
ขนาดโดยประมาณ (Node.js)~1.2 GB~110 MB
Build tools ใน prodมีไม่มี

ตั้งชื่อที่มีความหมายให้แต่ละ stage ด้วย AS <name> จากนั้นสามารถ reference ได้ใน COPY --from=<name> และยัง build ได้เฉพาะ stage ที่ต้องการระหว่างพัฒนา:

Terminal window
# Build เฉพาะ build stage (เหมาะสำหรับรัน test บน CI)
docker build --target build -t myapp:ci .
# Build image เต็มสำหรับ production
docker build -t myapp:prod .

Go เหมาะอย่างยิ่งสำหรับ multi-stage builds: compiler สร้าง binary เดี่ยวแบบ static ที่ไม่ต้องการ runtime ใดๆ

# syntax=docker/dockerfile:1
FROM golang:1.23-alpine AS build
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o server ./cmd/server
FROM scratch AS runtime
COPY --from=build /app/server /server
EXPOSE 8080
ENTRYPOINT ["/server"]

FROM scratch คือ base ขั้นต่ำสุด — filesystem ว่างเปล่าสมบูรณ์ ไฟล์เดียวใน final image คือ binary ที่ compile แล้ว ผลลัพธ์มักจะต่ำกว่า 10 MB

snippet ด้านล่างรัน two-stage build ใน Play with Docker และแสดงขนาดของ final image

# syntax=docker/dockerfile:1

# Stage 1 — build
FROM node:22-alpine AS build
WORKDIR /app
RUN echo '{"name":"demo","version":"1.0.0","scripts":{"build":"echo built"}}' > package.json
RUN npm run build
RUN echo "console.log('Hello from optimized image!');" > dist/server.js

# Stage 2 — runtime (only dist/ lands here)
FROM node:22-alpine AS runtime
WORKDIR /app
COPY --from=build /app/dist ./dist
ENV NODE_ENV=production
EXPOSE 3000
CMD ["node","dist/server.js"]

# --- build & run ---
# docker build -t myapp:multi .
# docker run --rm myapp:multi
# docker images myapp:multi
ตัวเลือกBenefitCost
Multi-stage buildfinal image เล็กมาก มีเฉพาะ runtime artifact ไม่มี build tool ตกค้างDockerfile ซับซ้อนขึ้น ต้องดูแลสอง stage ขึ้นไปให้ sync กันตลอด
Runtime stage เป็น alpineเล็ก มี shell ให้ exec เข้าไป debug ได้สะดวกยังมี package manager และ shell ซึ่งเพิ่ม attack surface เทียบกับ distroless
Runtime stage เป็น distrolessเล็กกว่า alpine อีกขั้น ไม่มี shell จึงปลอดภัยกว่าไม่มี shell ทำให้ exec เข้าไป debug container ที่กำลังรันแบบ interactive ไม่ได้เลย
  • ใช้ base image เดียวกันทั้ง build stage และ runtime stage ทั้งที่ runtime ไม่ต้องการ compiler เลย ทำให้พลาดโอกาสลดขนาด image ไปมาก
  • COPY --from=<stage> ทั้งไดเรกทอรีของ build stage แทนที่จะเลือกเฉพาะไฟล์ artifact ที่จำเป็น ทำให้ final image ใหญ่เกินความจำเป็น
  • ไม่ pin version ของ base image ในแต่ละ stage (เช่นใช้ latest) ทำให้ build ไม่ reproducible ระหว่าง environment

💡 ตัวอย่างจากของจริง

Binary ที่ compile แบบ static ด้วย Go หรือ Rust แล้วรันบน distroless หรือ scratch base image มักได้ production image สุดท้ายต่ำกว่า 20MB เป็นประจำ ที่เป็น pattern ที่ Google แนะนำและ publish เองผ่าน gcr.io/distroless

ใน multi-stage Dockerfile stage ใดที่จะกลายเป็น final image?
`COPY --from=build /app/dist ./dist` ทำอะไร?
ทำไม `FROM scratch` จึงเหมาะสำหรับ compiled Go binary?
flag ใดที่ให้เราสามารถ build ได้เฉพาะถึง stage ที่กำหนด?