ลดขนาดอิมเมจคอนเทนเนอร์ด้วย Docker Multi-Stage Build
(labs.iximiuz.com)- เมื่อสร้าง Docker container image หาก Dockerfile ไม่ได้ใช้โครงสร้างแบบ Multi-Stage ก็มีโอกาสสูงที่จะมีไฟล์ที่ไม่จำเป็นถูกรวมเข้าไปด้วย
- สิ่งนี้นำไปสู่การเพิ่มขึ้นของขนาดอิมเมจและความเสี่ยงด้านความปลอดภัยที่มากขึ้น
- บทความนี้วิเคราะห์สาเหตุหลักของ “ไฟล์ที่ไม่จำเป็น” ที่อาจเกิดขึ้นใน container image และอธิบายวิธีแก้ไขด้วย Multi-Stage Build
สาเหตุที่ทำให้อิมเมจมีขนาดใหญ่ขึ้น
- แอปพลิเคชันมี dependency ทั้งในช่วง build time และ runtime
- dependency ในช่วง build time มีมากกว่าช่วง runtime และมีช่องโหว่ด้านความปลอดภัย (CVEs) มากกว่า
- หากใช้อิมเมจเดียวกันทั้งสำหรับการ build และการรัน จะทำให้มี dependency ของ build time ที่ไม่จำเป็น (เช่น compiler, linter) รวมอยู่ด้วย
- ควรแยก build image และ runtime image ออกจากกัน แต่หลายกรณีก็มักมองข้ามจุดนี้
ตัวอย่างโครงสร้าง Dockerfile ที่ไม่ถูกต้อง
ตัวอย่างที่ไม่ถูกต้องสำหรับแอปพลิเคชัน Go
FROM golang:1.23
WORKDIR /app
COPY . .
RUN go build -o binary
CMD ["/app/binary"]
- อิมเมจ
golang:1.23มีไว้สำหรับการคอมไพล์ แต่หากนำไปใช้ใน production ตรง ๆ ก็จะรวมทั้ง Go compiler และ dependency ทั้งหมดเข้าไปด้วย - ขนาดอิมเมจ: มากกว่า 800MB และมีช่องโหว่ด้านความปลอดภัยมากกว่า 800 รายการ
ตัวอย่างที่ไม่ถูกต้องสำหรับแอปพลิเคชัน Node.js
FROM node:lts-slim
WORKDIR /app
COPY . .
RUN npm ci
RUN npm run build
ENV NODE_ENV=production
EXPOSE 3000
CMD ["node", "/app/.output/index.mjs"]
- โฟลเดอร์
node_modulesจะรวม dependency สำหรับการพัฒนาที่ไม่จำเป็นต่อ runtime เข้าไปด้วย - ไม่สามารถแก้ได้ด้วย
npm ci --omit=devเพราะอาจทำให้ dependency สำหรับการ build ถูกลบออกไป
วิธีสร้าง Lean image ก่อนมี Multi-Stage Build
Builder pattern
- build แอปพลิเคชันใน
Dockerfile.build:
FROM node:lts-slim
WORKDIR /app
COPY . .
RUN npm ci
RUN npm run build
- คัดลอก artifact ที่ build เสร็จแล้วออกมาที่โฮสต์:
docker cp $(docker create build:v1):/app/.output .
- สร้าง runtime image ใน
Dockerfile.run:
FROM node:lts-slim
WORKDIR /app
COPY .output .
CMD ["node", "/app/.output/index.mjs"]
• ปัญหา: ต้องเขียน Dockerfile หลายไฟล์ ต้องจัดการลำดับการ build และต้องมีสคริปต์เพิ่มเติม
ทำความเข้าใจ Multi-Stage Build
- Multi-Stage Build คือความสามารถที่นำ Builder pattern มาใช้งานภายใน Docker
- สามารถกำหนดทั้ง build stage และ runtime stage ได้ใน Dockerfile เดียว ด้วยการใช้คำสั่ง
FROMหลายครั้ง - ใช้คำสั่ง
COPY --from=<stage>เพื่อนำไฟล์ที่ build จาก stage ก่อนหน้ามาใช้
- สามารถกำหนดทั้ง build stage และ runtime stage ได้ใน Dockerfile เดียว ด้วยการใช้คำสั่ง
ตัวอย่าง Multi-Stage Dockerfile (Node.js)
# Build stage
FROM node:lts-slim AS build
WORKDIR /app
COPY . .
RUN npm ci
RUN npm run build
# Runtime stage
FROM node:lts-slim AS runtime
WORKDIR /app
COPY --from=build /app/.output .
ENV NODE_ENV=production
CMD ["node", "/app/.output/index.mjs"]
- การใช้
COPY --from=buildเพื่อคัดลอก artifact ที่ build แล้วโดยตรง ทำให้ย้ายไฟล์ได้โดยไม่ต้องผ่านโฮสต์
ตัวอย่างการใช้งาน Multi-Stage Build จริง
React แอปพลิเคชัน
# Build stage
FROM node:lts-slim AS build
WORKDIR /app
COPY . .
RUN npm ci
RUN npm run build
# Runtime stage
FROM nginx:alpine
COPY --from=build /app/build /usr/share/nginx/html
ENTRYPOINT ["nginx", "-g", "daemon off;"]
- หลังจาก build แล้ว React แอปพลิเคชันจะกลายเป็นไฟล์แบบ static และสามารถเสิร์ฟผ่าน Nginx ได้
Go แอปพลิเคชัน
# Build stage
FROM golang:1.23 AS build
WORKDIR /app
COPY . .
RUN go build -o binary
# Runtime stage
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /app/binary /app/binary
ENTRYPOINT ["/app/binary"]
- ใช้อิมเมจ distroless เพื่อให้ได้ runtime environment ที่เล็กที่สุดเท่าที่จำเป็น
Java แอปพลิเคชัน
# Build stage
FROM eclipse-temurin:21-jdk-jammy AS build
WORKDIR /build
COPY . .
RUN ./mvnw package -DskipTests
# Runtime stage
FROM eclipse-temurin:21-jre-jammy
COPY --from=build /build/target/app.jar /app.jar
CMD ["java", "-jar", "/app.jar"]
- ใช้ JDK ในขั้นตอน build และใช้ JRE ที่เบากว่าในขั้นตอน runtime
บทสรุป
- Multi-Stage Build ช่วยแยกสภาพแวดล้อมสำหรับการ build และ runtime ออกจากกัน เพื่อลดปัญหาขนาดอิมเมจที่เพิ่มขึ้นจาก dependency สำหรับการพัฒนาที่ไม่จำเป็น
- ด้วยวิธีนี้สามารถลดขนาดอิมเมจ เพิ่มความปลอดภัย และทำให้กระบวนการ build ง่ายขึ้นได้
- Multi-Stage Build เป็นแนวทางมาตรฐานในการสร้าง container image ที่มีประสิทธิภาพ และยังรองรับฟีเจอร์ขั้นสูงด้วย (เช่น เงื่อนไขการแตกแขนง, unit test ระหว่างการ build)
6 ความคิดเห็น
ในกรณีของ Java แม้ว่า
jlinkจะถูกนำมาใช้ตั้งแต่เวอร์ชัน 9 แต่การใช้งานยังไม่ค่อยสะดวกนัก เช่น ต้องหาโมดูลที่พึ่งพาด้วยjdepsแล้วระบุเอง พอเห็นว่าคนจำนวนมากไม่รู้วิธีแบบนั้นหรือยังคงมองหา JRE ก็รู้สึกว่าเครื่องมือของ Java ยังถูกประชาสัมพันธ์ไม่เพียงพอ และน่าจะต้องปรับปรุงให้สามารถสร้าง JRE ได้ด้วยคำสั่งเดียวผมก็ใช้อยู่แบบนั้นเหมือนกัน แต่ข้อเสียคือดูเหมือนว่าเวลาในการบิลด์จะนาน
เวลาในการบิลด์ไม่น่าจะต่างกันนะครับ ถ้าต่างแปลว่าตั้งค่าผิด!
อ๋อ เข้าใจแล้ว!
ขึ้นอยู่กับกลยุทธ์ เราอาจแคชทั้งสเตจได้เลยด้วยซ้ำ เลยกลายเป็นว่าสำหรับผม เวลาบิลด์กลับสั้นลงด้วย!
คงต้องไปทำความรู้จัก Docker ให้มากกว่านี้แล้วล่ะ!