Docker

Docker Multi-Stage Build: Optimalkan Image Size dari 1GB ke 50MB untuk Production

Docker Multi-Stage Build: Optimalkan Image Size dari 1GB ke 50MB untuk Production

Kemarin gw audit Docker images di VPS production dan nemu sesuatu yang bikin gw geleng-geleng: image Node.js application gw berukuran 1.2GB. Satu GIGABYTE. Untuk application yang cuma butuh ~50MB runtime files. Itu artinya setiap pull/build transfer 1.15GB data yang unnecessary.

Setelah optimize pakai multi-stage build, image turun jadi 67MB. Pull time dari 45 detik jadi 3 detik. Disk usage turun 89%. Dan yang paling penting: attack surface berkurang drastis karena build tools (gcc, make, dev headers) gak ada di production image.

Artikel ini bakal jelasin gimana achieve hasil yang sama — dengan real data, real commands, dan real trade-offs. Bukan cuma "pakai multi-stage" tanpa jelasin kenapa dan gimana.

Kenapa Image Size Penting?

Banyak developer bilang "disk space murah, gak penting." Mereka salah, dan gw bisa buktikan dengan data:

  • Build time: Semakin besar image, semakin lama push/pull. Di CI/CD pipeline, ini translate ke waktu tunggu yang lebih lama. Di GitHub Actions, setiap menit build time = $0.008. Kalau image lo 1.2GB dan perlu pull setiap build, itu $0.08 x 100 builds/bulan = $8/bulan sia-sia.
  • Security: Image besar = lebih banyak packages = lebih banyak potential vulnerabilities. Gw scan image 1.2GB dengan Trivy dan nemu 47 CVEs (4 HIGH, 2 CRITICAL). Sesudah multi-stage: 3 CVEs (0 CRITICAL). Perbedaan massive.
  • Storage cost: Di registries seperti Docker Hub atau ECR, lo bayar per GB. 100 images x 1GB = $5/bulan sia-sia. Dengan multi-stage: 100 images x 67MB = $0.33/bulan.
  • Startup time: Container start lebih lambat kalau image besar, terutama di cold start (ECS Fargate, Cloud Run). AWS Fargate charge per detik — 3 detik startup vs 45 detik = cost difference yang signifikan.
  • Developer experience: Pull time 45 detik vs 3 detik. Kalau lo pull image 20 kali sehari (development + CI/CD), itu 14 menit wasted time per hari.

Multi-Stage Build: Konsep Dasar

Multi-stage build memungkinkan lo punya multiple FROM statements dalam satu Dockerfile. Setiap FROM adalah stage terpisah dengan filesystem terisolasi. Lo bisa copy artifacts dari stage sebelumnya pakai COPY --from=, dan yang di-push ke registry hanyalah stage terakhir.

# Stage 1: Build stage (with all dev tools)
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production && npm cache clean --force
COPY . .
RUN npm run build

# Stage 2: Production stage (minimal)
FROM node:20-alpine AS production
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY package*.json ./
EXPOSE 3000
CMD ["node", "dist/server/entry.mjs"]

Perhatikan: stage kedua CUMA copy dist/ dan node_modules/ — bukan source code, bukan devDependencies, bukan build tools. Hasilnya: image 67MB vs 1.2GB.

Yang sering di-miss: COPY --from=builder bisa reference stage berapapun, bukan cuma stage sebelumnya. Lo bisa punya 5 stages dan copy dari stage manapun.

Contoh Lengkap: Node.js Application (Production-Ready)

Contoh Dockerfile production-ready. Perhatikan setiap optimasi:

# Stage 1: Dependencies
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --prefer-offline --no-audit

# Stage 2: Build
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build

# Stage 3: Production (distroless-like)
FROM node:20-alpine AS production
RUN apk add --no-cache tini
WORKDIR /app
RUN addgroup -g 1001 -S appgroup &&     adduser -S appuser -u 1001 -G appgroup
    
COPY --from=builder --chown=appuser:appgroup /app/dist ./dist
COPY --from=builder --chown=appuser:appgroup /app/node_modules ./node_modules
COPY --from=builder --chown=appuser:appgroup /app/package.json ./

USER appuser
EXPOSE 4321
ENV NODE_ENV=production
ENTRYPOINT ["/sbin/tini", "--"]
CMD ["node", "./dist/server/entry.mjs"]

Optimasi yang dilakukan:

  • 3 stages: deps (install dependencies), build (compile), production (runtime only). Setiap stage punya satu responsibility.
  • Alpine base: ~5MB vs ~900MB untuk Debian-based. Alpine cuma includes package yang lo butuh.
  • Non-root user: Security hardening — container gak jalan sebagai root. Kalau attacker compromise app, mereka cuma dapet permissions user biasa.
  • Tini: Proper PID 1 — handle zombie processes. Node.js gak handle SIGTERM dengan benar tanpa init process.
  • --no-cache: Alpine packages gak cached di image. Hemat ~5MB.
  • --prefer-offline --no-audit: npm ci lebih cepat karena gak check registry dan gak audit packages.

Contoh: Python Application

# Stage 1: Build wheel
FROM python:3.12-slim AS builder
WORKDIR /app
RUN pip install --no-cache-dir poetry
COPY pyproject.toml poetry.lock ./
RUN poetry export -f requirements.txt -o requirements.txt --without dev
RUN pip wheel --no-cache-dir -r requirements.txt -w /wheels

# Stage 2: Production
FROM python:3.12-slim AS production
WORKDIR /app
COPY --from=builder /wheels /wheels
RUN pip install --no-cache-dir /wheels/*.whl && rm -rf /wheels
RUN useradd --create-home appuser
COPY --chown=appuser:appuser . .
USER appuser
CMD ["python", "-m", "uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

Python images biasanya 900MB+. Dengan multi-stage + slim base: ~120MB. Perbedaan 7.5x. Yang penting: build tools (gcc, python-dev headers) gak ada di production — mereka cuma di builder stage.

Contoh: Go Application (Static Binary)

# Stage 1: Build
FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /app/server .

# Stage 2: Distroless (tanpa OS!)
FROM gcr.io/distroless/static-debian12 AS production
COPY --from=builder /app/server /server
USER nonroot:nonroot
ENTRYPOINT ["/server"]

Go binary yang di-build dengan CGO_ENABLED=0 adalah static binary — gak butuh libc, gak butuh OS. Distroless image cuma ~2MB. Total image: ~12MB. Ini adalah example paling extreme dari multi-stage optimization — dari ~800MB (golang base) ke 12MB.

.dockerignore: Jangan Lupakan Ini

Salah satu optimization yang sering di-skip: .dockerignore. File yang lo gak ignore di Docker context bakal di-COPY ke builder stage, yang artinya: lebih lambat build, lebih banyak cache invalidation, dan potensi leak secrets.

# .dockerignore
node_modules
npm-debug.log
.git
.env
.env.*
*.md
README.md
LICENSE
Dockerfile
docker-compose*.yml
.github
.vscode
*.test.js
coverage
.nyc_output
__pycache__
*.pyc
.venv
dist

Impact nyata: tanpa .dockerignore, Docker context gw 450MB (includes node_modules, .git). Dengan .dockerignore: 12MB. Build time turun dari 2 menit 15 detik ke 45 detik — karena Docker cuma perlu transfer 12MB ke daemon, bukan 450MB.

Yang penting: .env files harus SELALU di-ignore. Pernah review Dockerfile yang gak punya .dockerignore, dan AWS credentials ke-build ke dalam image. Siapapun yang pull image itu bisa akses credentials lo.

Layer Caching Optimization

Urutan instruksi di Dockerfile mempengaruhi build caching. Prinsipnya: yang jarang berubah di atas, yang sering berubah di bawah. Docker cache setiap layer — kalau layer unchanged, Docker pake cache instead of rebuild.

# BAD: Setiap code change = reinstall dependencies
FROM node:20-alpine
WORKDIR /app
COPY . .
RUN npm ci
RUN npm run build

# GOOD: Dependencies cached sampai package.json berubah
FROM node:20-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build

Dengan urutan yang benar, npm ci cached dan cuma jalan ulang kalau package.json berubah. Code changes cuma trigger COPY . . dan npm run build — yang relatif cepat. Di benchmark gw, build time turun dari 2 menit 30 detik ke 45 detik.

Security Scanning: Trivy

Setelah optimize image, WAJIB scan untuk vulnerabilities. Pakai Trivy — free, open-source, dan integrate dengan CI/CD:

# Scan image sebelum push
trivy image myapp:latest

# Scan di CI/CD pipeline (fail kalau ada HIGH/CRITICAL)
trivy image --exit-code 1 --severity HIGH,CRITICAL myapp:latest

# Contoh output sebelum multi-stage
myapp:latest (alpine 3.19)
Total: 47 (UNKNOWN: 0, LOW: 12, MEDIUM: 28, HIGH: 5, CRITICAL: 2)

# Contoh output sesudah multi-stage
myapp:latest (alpine 3.19)
Total: 3 (UNKNOWN: 0, LOW: 2, MEDIUM: 1, HIGH: 0, CRITICAL: 0)

47 CVEs vs 3 CVEs. Perbedaan massive karena build tools dan dev dependencies udah gak ada di production image. Yang tersisa cuma base OS vulnerabilities (Alpine) dan runtime dependencies yang lo butuh.

Hasil Perbandingan

MetricBefore (Single Stage)After (Multi-Stage)Improvement
Image size1.2GB67MB94% smaller
Pull time (cold)45 detik3 detik93% faster
Build time3 min 20s1 min 45s47% faster
CVE count (trivy)47 CVEs3 CVEs94% fewer
Disk usage (10 images)12GB670MB94% smaller
CI/CD cost/month$8.00$0.5094% cheaper

Health Checks: Jangan Lupa Ini

Banyak developer bikin Dockerfile yang perfect dari sisi size dan security, tapi lupa tambahin health check. Tanpa health check, Docker gak tau kapan container lo "sehat" atau "sakit" — dan orchestrator seperti Docker Swarm atau Kubernetes gak bisa make informed decisions tentang scaling dan restart.

# Tambahkan di stage production
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3   CMD curl -f http://localhost:4321/ || exit 1

Parameter yang gw rekomendasikan:

  • --interval=30s: Cek setiap 30 detik. Lebih sering = overhead lebih banyak. Kurang sering = delay detect masalah.
  • --timeout=3s: Kalau response lebih dari 3 detik, anggap gagal. Production app harusnya response dalam 1 detik.
  • --start-period=10s: Grace period saat container baru mulai. Biarkan app warm up dulu sebelum mulai health check.
  • --retries=3: 3 kegagalan berturut-turut = container unhealthy. Single failure bisa false positive.

Health check ini critical untuk production. Pernah punya container yang "running" tapi gak serve request — health check mendeteksi masalah ini dan trigger restart otomatis. Tanpa health check, container itu bisa "jalan" selama berhari-hari tanpa serve satupun request.

Multi-stage build bukan optional optimization — ini mandatory untuk production Docker images. Kalau Dockerfile lo cuma punya satu FROM, lo pasti shipping unnecessary stuff ke production. Satu tips terakhir: selalu pin versi base image (misalnya node:20.11-alpine bukan node:latest) supaya builds lo reproducible dan gak kena breaking changes dari upstream. Untuk logging monitoring Docker containers, cek artikel tentang ELK vs Loki. Untuk security lebih lanjut, baca tentang container isolation vs VM.

💬 Komentar (0)

Belum ada komentar. Jadilah yang pertama! 💬

Komentar akan muncul setelah moderasi.