← Kursa Dön
📄 Text · 25 min

Multi-Stage Build Temelleri — Neden, Nasıl?

Docker'la bir uygulama image'ı build ettiğinde, çoğu zaman sonuç gereksiz yere büyük olur. 50MB'lık bir Node.js uygulaması için 1.2GB'lık image? Bu mantıklı değil. İçinde TypeScript compiler, devDependencies, build cache, test framework'ler — hiçbirini runtime'da kullanmıyorsun ama hepsi image'da oturuyor.

Bir araba fabrikasını düşün. Üretim hattında kaynak makineleri, boyama kabinleri, test ekipmanları var. Ama müşteriye arabayı teslim ederken fabrikayı da vermiyorsun — sadece bitmiş arabayı. Docker'da da aynı mantık geçerli: build araçlarını final image'da bırakma. Multi-stage build ile "fabrika" aşamasında build et, "teslimat" aşamasında sadece çalışan uygulamayı al.


Problemi Görelim — Şişman Image

Önce problemi somutlaştıralım. Basit bir Node.js TypeScript projesi için tek stage Dockerfile yazalım:

# ❌ Tek stage — her şey image'da kalıyor
FROM node:20
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
CMD ["node", "dist/server.js"]
docker build -t myapp:fat .
docker images myapp:fat
REPOSITORY   TAG   SIZE
myapp        fat   1.2GB

1.2GB! Bu image'ın içinde ne var?

  • Node.js runtime (150MB) — ✅ runtime'da gerekli

  • npm/yarn (50MB) — ❌ runtime'da gereksiz

  • node_modules devDependencies dahil (400MB) — ❌ çoğu gereksiz

  • TypeScript compiler (100MB) — ❌ compile bitti, artık gereksiz

  • Kaynak kodu .ts dosyaları (5MB) — ❌ compile edilmiş .js yeterli

  • Build cache, temp dosyaları — ❌ tamamen gereksiz

Yarısından fazlası gereksiz! Peki bunu nasıl çözeriz?


Multi-Stage Build — Çözüm

Multi-stage build'de birden fazla FROM talimatı kullanırsın. Her FROM yeni bir "stage" başlatır. Önceki stage'lerden sadece ihtiyacın olan dosyaları COPY --from ile alırsın. Sonuç: sadece son stage'deki katmanlar final image'a girer.

# ✅ Multi-stage — build ve runtime ayrı

# === Stage 1: Build ===
FROM node:20 AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

# === Stage 2: Production ===
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY --from=builder /app/dist ./dist
USER node
EXPOSE 3000
CMD ["node", "dist/server.js"]
docker build -t myapp:slim .
docker images myapp:slim
REPOSITORY   TAG    SIZE
myapp        slim   180MB

1.2GB → 180MB! %85 küçülme. Ve bu sadece iki basit değişiklikle oldu.

Nasıl Çalışıyor?

İlk stage'de (builder) TypeScript compile ediyoruz. Bu stage'de node:20 (1.1GB) base image kullanıyoruz çünkü build araçlarına ihtiyacımız var. Ama bu stage'in kendisi final image'a dahil edilmez.

İkinci stage'de (production) node:20-alpine (130MB) ile başlıyoruz — çok daha küçük. Sadece production dependencies yüklüyoruz ve builder stage'den compile edilmiş dist/ dizinini kopyalıyoruz.

Stage 1 (builder):                    Stage 2 (final):
┌─────────────────────┐              ┌─────────────────────┐
│ node:20 (1.1GB)     │              │ node:20-alpine(130MB)│
│ + devDependencies   │              │ + prod dependencies │
│ + TypeScript        │  COPY dist   │ + dist/ (compiled)  │
│ + Source code       │ ──────────→  │                     │
│ + Build tools       │              │ Toplam: ~180MB      │
│ Toplam: ~1.2GB      │              └─────────────────────┘
└─────────────────────┘
   ❌ Atılır                          ✅ Final image

Builder stage build bitince "atılır" — host'ta cache olarak kalır ama final image'a girmez.


COPY --from — Stage'ler Arası Transfer

COPY --from komutunun birkaç kullanım şekli var:

# Named stage'den kopyala
COPY --from=builder /app/dist ./dist

# Harici bir image'dan kopyala (çok kullanışlı!)
COPY --from=nginx:alpine /etc/nginx/nginx.conf /etc/nginx/nginx.conf

# Stage numarasından kopyala (0-indexed)
COPY --from=0 /app/output ./output

Harici image'dan kopyalama çok güçlü bir özellik. Mesela Nginx'in default config'ini alıp kendi image'ında kullanabilirsin.


Go ile Multi-Stage — Şampiyonlar Ligi

Go, multi-stage build'in en parlak örneğidir. Go statik binary oluşturur — hiçbir runtime bağımlılığı yok. Bu sayede scratch (tamamen boş) image ile kullanabilirsin:

# 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="-w -s" -o /server ./cmd/server

# Stage 2: Scratch — tamamen boş image!
FROM scratch
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=builder /server /server
EXPOSE 8080
ENTRYPOINT ["/server"]
docker images myapp
REPOSITORY   TAG     SIZE
myapp        go      12MB

12MB! scratch image tamamen boş — içinde sadece Go binary ve SSL sertifikaları var. Shell yok, paket yöneticisi yok, hiçbir şey yok. Sadece uygulaman çalışıyor.

CGO_ENABLED=0 statik binary oluşturur (C kütüphanelerine bağımlılık yok). -ldflags="-w -s" debug bilgilerini çıkararak binary boyutunu ~%30 küçültür.


Java ile Multi-Stage — Spring Boot Layer Extraction

Java uygulamaları genelde büyük olur — JDK + Maven + dependencies. Multi-stage ile JDK'yı atıp sadece JRE kalır:

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

# Stage 2: Layer extraction (Spring Boot)
FROM eclipse-temurin:21-jre-alpine AS extractor
WORKDIR /app
COPY --from=builder /app/target/*.jar app.jar
RUN java -Djarmode=layertools -jar app.jar extract

# Stage 3: Production
FROM eclipse-temurin:21-jre-alpine
RUN addgroup -S spring && adduser -S spring -G spring
USER spring
WORKDIR /app
COPY --from=extractor /app/dependencies/ ./
COPY --from=extractor /app/spring-boot-loader/ ./
COPY --from=extractor /app/snapshot-dependencies/ ./
COPY --from=extractor /app/application/ ./
EXPOSE 8080
ENTRYPOINT ["java", "org.springframework.boot.loader.launch.JarLauncher"]

Spring Boot'un layered jar özelliği çok akıllıca. Dependencies (nadiren değişir) ayrı katmanda, application kodu (sık değişir) ayrı katmanda. Sadece kodun değiştiğinde üstteki katmanlar cache'ten gelir — build çok daha hızlı.


Python ile Multi-Stage

Python'un zorluğu C extension'lı paketler — numpy, psycopg2 gibi paketler compile edilmek ister. Build araçlarını (gcc, headers) production'a taşımak istemezsin:

# Stage 1: Build
FROM python:3.12-slim AS builder
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends gcc libpq-dev
COPY requirements.txt .
RUN pip install --user --no-cache-dir -r requirements.txt

# Stage 2: Production
FROM python:3.12-slim
RUN apt-get update && apt-get install -y --no-install-recommends libpq5 && \
    rm -rf /var/lib/apt/lists/*
RUN useradd --create-home appuser
WORKDIR /app
COPY --from=builder /root/.local /home/appuser/.local
COPY . .
USER appuser
ENV PATH=/home/appuser/.local/bin:$PATH
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
EXPOSE 8000
CMD ["gunicorn", "-b", "0.0.0.0:8000", "-w", "4", "app:app"]

Builder'da gcc ve libpq-dev (header dosyaları) var — compile için gerekli. Production'da sadece libpq5 (runtime library) var — gcc ve header'lar yok.


Named Stages ve Target Build

Stage'lere isim verince çok kullanışlı şeyler yapabilirsin:

FROM node:20 AS deps
# dependency stage

FROM node:20 AS builder
# build stage

FROM node:20 AS tester
COPY --from=builder /app/dist ./dist
RUN npm test

FROM node:20-alpine AS production
COPY --from=builder /app/dist ./dist
CMD ["node", "dist/server.js"]
# Sadece test stage'e kadar build et
docker build --target tester -t myapp:test .

# Production image
docker build --target production -t myapp:prod .

CI/CD pipeline'ında: önce --target tester ile testleri çalıştır, geçerse --target production ile production image build et. Bu sayede test aşamasında production image oluşturmana gerek kalmaz — zaman kazanırsın.

Docker Compose'da:

services:
  api:
    build:
      context: ./api
      target: development  # Dev stage'i build et

Boyut Karşılaştırması

Aynı "Hello World" API uygulaması, farklı stratejilerle:

Strateji                     Node.js    Go        Java       Python
─────────────────────────────────────────────────────────────────────
Tek stage, full image        1.1 GB     850 MB    700 MB     1.0 GB
Tek stage, alpine            350 MB     250 MB    350 MB     130 MB
Multi-stage, alpine          180 MB     15 MB     250 MB     200 MB
Multi-stage, distroless      150 MB     12 MB     220 MB     150 MB
Multi-stage, scratch         —          8 MB      —          —

Go ile scratch kullanarak 850MB → 8MB! %99 küçülme. Node.js ile multi-stage+alpine ile 1.1GB → 180MB — %84 küçülme.


Yaygın Hatalar

Stage ismi büyük/küçük harf duyarlı:

FROM node:20 AS Builder    # Büyük B
COPY --from=builder ...    # Küçük b → HATA!

Alpine'da native modül sorunu: Alpine musl libc kullanır, bazı native modüller (bcrypt, sharp) sorun çıkarabilir. Çözüm: build stage'de full image, production'da alpine.

Dosya eksik kopyalama: Production stage'de dist/ kopyaladın ama node_modules unuttun → "module not found" hatası. COPY komutlarını dikkatlice kontrol et.


Bu Derste Ne Öğrendik?

  • Multi-stage build, build araçlarını final image'dan ayırarak boyutu drastik düşürür.

  • COPY --from=stage_name ile bir stage'den diğerine sadece gerekli dosyaları taşı.

  • Go → scratch (8MB), Java → JRE-alpine (250MB), Node.js → alpine (180MB), Python → slim (200MB).

  • --target ile CI/CD'de farklı stage'leri build et — test ayrı, production ayrı.

  • Her dil için multi-stage pattern farklı ama temel mantık aynı: build araçlarını production'a taşıma.

Sonraki derste dil bazlı optimizasyon detaylarına dalacağız — Node.js'te npm ci, Java'da jlink, Go'da ldflags, Python'da pip tricks.