Bir Go uygulamasını Docker'a taşıdığınızda ortaya çıkan ilk imajın 900 MB olması, hemen herkesin yaşadığı bir şaşkınlıktır. Oysa derlenmiş ikili dosya sadece 12 MB'tır. Aradaki 888 MB, derleyicinin kendisi, standart kütüphane kaynakları, apt önbelleği, git, make ve uygulamanın çalışması için hiçbir şekilde gerekli olmayan onlarca araçtır. Aynı hikâye Node.js'te node_modules ve derleme bağımlılıkları, Java'da Maven deposu, PHP'de Composer önbelleği ile tekrarlanır. Bu imajı her dağıtımda çekmek zorunda olan sunucularınız da faturayı sizinle birlikte öder.
Multi-stage build, yani çok aşamalı derleme, bu sorunu tek bir Dockerfile içinde ve ek araç kullanmadan çözer: uygulamayı ağır bir "derleyici" imajında derlersiniz, sonra sadece ortaya çıkan çıktıyı ince bir "çalışma zamanı" imajına kopyalarsınız. Derleyici aşaması nihai imaja hiç girmez, dolayısıyla ne boyutu ne de güvenlik yüzeyini artırır. Bu rehberde çok aşamalı derlemenin nasıl çalıştığını, Go ve Node.js için uçtan uca gerçek örnekleri, --target ile aynı dosyadan farklı imajlar üretmeyi, katman önbelleğini bozmadan derleme sürelerini kısaltmayı ve en sık düşülen tuzakları anlatacağım.
Tek Aşamalı Dockerfile'ın Üç Bedeli#
Klasik tek aşamalı bir Dockerfile şuna benzer ve teknik olarak sorunsuz çalışır:
FROM golang:1.22
WORKDIR /app
COPY . .
RUN go build -o /app/server ./cmd/server
CMD ["/app/server"]
Bu dosyanın ürettiği imaj üç ayrı bedel taşır. Birincisi boyut: golang temel imajı derleyici zinciriyle birlikte gelir ve tek başına yüzlerce megabayttır. Her dağıtımda bu katmanlar registry'den çekilir; on sunucunuz varsa on kez. İkincisi güvenlik yüzeyi: imajın içinde bash, git, curl, derleyici ve paket yöneticisi vardır. Uygulamanızda uzaktan kod çalıştırma açığı bulan bir saldırgan, hazır bir araç kutusunun ortasına düşer. Üçüncüsü ve en sinsisi sızıntı riski: derleme sırasında kullandığınız özel depo anahtarı, .npmrc token'ı ya da .git klasörü, siz farkında olmasanız da nihai imajın bir katmanında durur. Katmanlar salt okunurdur; sonraki bir RUN rm komutu dosyayı önceki katmandan silmez, sadece üstünü örter.
Bu üç bedelin ortak sebebi aynıdır: derleme ile çalıştırma aynı dosya sisteminde yapılmaktadır. Multi-stage build tam olarak bu ikisini ayırır. İmaj boyutunu düşürmenin diğer yöntemlerini de görmek isterseniz Docker imaj boyutu optimizasyonu yazısı bu rehberin doğal devamıdır.
Multi-Stage Build Nasıl Çalışır#
Mekanizma şaşırtıcı derecede basittir: bir Dockerfile içinde birden fazla FROM satırı yazarsınız. Her FROM yeni bir aşama başlatır ve önceki aşamanın dosya sistemini sıfırlar. Aşamalara AS isim ile ad verir, sonraki aşamada COPY --from=isim ile önceki aşamadan yalnızca istediğiniz dosyaları alırsınız. Nihai imaj yalnızca son aşamadır; ondan önceki her şey derleme sırasında var olur, sonra kaybolur.
# 1. asama: derleme ortami
FROM golang:1.22 AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
# CGO kapali => tamamen statik ikili, scratch'te calisir
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /out/server ./cmd/server
# 2. asama: calisma zamani
FROM gcr.io/distroless/static-debian12
COPY --from=builder /out/server /server
USER nonroot:nonroot
EXPOSE 8080
ENTRYPOINT ["/server"]
Bu iki aşamalı dosyanın ürettiği imaj, uygulamanın ikili dosyası artı birkaç megabayt kök sertifika ve zaman dilimi verisinden ibarettir. İçinde kabuk bile yoktur; docker exec -it kap sh çalışmaz. Bu bir eksiklik değil, kasıtlı bir tercihtir: kabuk yoksa saldırganın kullanabileceği kabuk da yoktur. Hata ayıklamak istediğinizde geçici olarak alpine tabanlı bir çalışma zamanı aşamasına geçebilirsiniz.
COPY --from yalnızca kendi aşamalarınızı değil, doğrudan bir imajı da kaynak alabilir. Bu, tek bir dosyayı almak için o imajı temel almak zorunda kalmanızı önler:
# Hazir bir imajdan tek dosya al
COPY --from=nginx:1.27-alpine /etc/nginx/mime.types /etc/nginx/mime.types
# Isimlendirilmemis asamalar sirasiyla 0, 1, 2 ... diye referans alinir
COPY --from=0 /out/server /server
İsimlendirilmemiş aşamaları indeksle çağırmak mümkündür ama önermiyorum; araya bir aşama eklediğinizde tüm indeksler kayar ve hata derleme anında değil, çalışma anında ortaya çıkar. Her aşamaya açıklayıcı bir ad verin.
Node.js ve Statik Frontend için Gerçek Örnek#
Frontend derlemeleri çok aşamalı yapının en görünür kazanç sağladığı yerdir: node_modules klasörü yüzlerce megabayt olabilir ama sonuçta ortaya çıkan şey birkaç yüz kilobaytlık statik dosyalardır. Nihai imajda Node.js'in bulunmasına hiç gerek yoktur.
# --- 1. asama: bagimliliklar ve derleme
FROM node:22-alpine AS build
WORKDIR /app
# Once sadece manifest dosyalari: kod degisince bu katman yeniden calismaz
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build # cikti: /app/dist
# --- 2. asama: sadece statik dosyalari sunan nginx
FROM nginx:1.27-alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY deploy/nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
Sunucu tarafı bir Node uygulamasında ise çalışma zamanına Node gerekir, ama geliştirme bağımlılıkları gerekmez. Üç aşamalı desen burada devreye girer: bağımlılıkları kur, derle, sonra yalnızca üretim bağımlılıklarını ve derlenmiş çıktıyı kopyala.
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
FROM node:22-alpine AS build
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build && npm prune --omit=dev
FROM node:22-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
COPY --from=build /app/package.json ./
USER node
CMD ["node", "dist/index.js"]
Boyut farkı teoride kalmaz, ölçülebilir. Tipik bir orta ölçekli projede karşılaştığım büyüklükler kabaca şöyledir:
| Yaklaşım | Nihai imaj | İçinde ne var |
|---|---|---|
Tek aşama, node:22 | 1.1 GB civarı | Node, npm, tüm bağımlılıklar, kaynak kod |
Tek aşama, node:22-alpine | 450 MB civarı | Node, npm, dev bağımlılıkları dahil |
| Çok aşamalı, alpine çalışma zamanı | 150 MB civarı | Node + yalnızca üretim bağımlılıkları |
| Çok aşamalı, nginx (statik site) | 50 MB civarı | nginx + derlenmiş statik dosyalar |
Rakamlar projeye göre değişir ama sıralama değişmez. Kazancın büyük kısmı ilk adımda, yani derleme araçlarını dışarıda bırakmakta gizlidir.
Aynı Dosyadan Farklı İmajlar: --target#
docker build --target asama_adi ile derlemeyi istediğiniz aşamada durdurabilirsiniz. Bu, tek bir Dockerfile'dan hem üretim hem test hem de geliştirme imajı üretmenizi sağlar; üç ayrı dosya tutup senkron kalmaya çalışmaktan çok daha sağlamdır.
FROM python:3.12-slim AS base
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
FROM base AS test
COPY requirements-dev.txt .
RUN pip install --no-cache-dir -r requirements-dev.txt
COPY . .
CMD ["pytest", "-q"]
FROM base AS runtime
COPY app/ ./app/
USER 1000:1000
CMD ["gunicorn", "-b", "0.0.0.0:8000", "app.wsgi:application"]
# CI'da testleri calistiran imaj
docker build --target test -t firmaniz/web:test .
docker run --rm firmaniz/web:test
# Uretime giden imaj - test bagimliliklari icinde yok
docker build --target runtime -t firmaniz/web:1.4.0 .
Compose tarafında da aynı hedefi belirtebilirsiniz. Geliştirme override dosyanızda build: { context: ., target: test } yazarak yerelde test imajını, üretim dosyanızda ise runtime aşamasını kullanabilirsiniz. Bu yapıyı Compose profilleriyle birleştirmek istiyorsanız Compose profilleri ve ortam değişkenleri yazısında iki mekanizmanın nasıl bir arada kullanıldığını anlattım.
Katman Önbelleği ve Derleme Hızı#
Çok aşamalı yapı boyutu düşürür ama derleme süresini kendiliğinden kısaltmaz; onu sizin katman sıralamanız belirler. Docker her satırı bir katman olarak önbelleğe alır ve bir katmanın girdisi değişirse o satırdan sonraki her şey yeniden çalışır. Bu yüzden en az değişen dosyalar en üste, en sık değişenler en alta yazılır.
Somut kural şudur: bağımlılık manifestini (package-lock.json, go.sum, requirements.txt, composer.lock) kaynak kodun öncesinde kopyalayın. Böylece kodda bir satır değiştiğinde bağımlılık kurulumu önbellekten gelir. Yukarıdaki örneklerin hepsinde bu sıralamayı bilerek kullandım.
BuildKit ile iki güçlü ek imkânınız daha var. Birincisi kalıcı önbellek bağlama: paket yöneticisinin indirme önbelleği derlemeler arasında korunur ama imaja girmez. İkincisi derleme gizli bilgisi: özel depo anahtarınız katmanda iz bırakmadan yalnızca o RUN komutu boyunca var olur.
# syntax=docker/dockerfile:1
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
# npm onbellegi imajda degil, BuildKit onbelleginde durur
RUN --mount=type=cache,target=/root/.npm npm ci
FROM golang:1.22 AS gobuild
WORKDIR /src
COPY . .
# Ozel depo tokeni katmana yazilmaz
RUN --mount=type=secret,id=nettoken \
GOPRIVATE=git.firmaniz.com \
sh -c 'git config --global url."https://$(cat /run/secrets/nettoken)@git.firmaniz.com".insteadOf "https://git.firmaniz.com" && go build -o /out/app ./cmd/app'
# Gizli bilgiyi dosyadan besleyerek derle
docker build --secret id=nettoken,src=./.token -t firmaniz/app:1.4.0 .
Derleme sırasında gizli bilgi kullanmanın diğer yolları ve çalışma zamanı gizli bilgi yönetimi arasındaki fark için Docker'da gizli bilgi yönetimi yazısına bakabilirsiniz. ARG ile parola geçirmek bunların arasında değildir; docker history komutu build argümanlarını okunur biçimde gösterir.
Sık Yapılan Hatalar#
Son aşamayı yanlış seçmek. Dockerfile'ın en altındaki FROM nihai imajdır. Test aşamasını en alta yazarsanız docker build varsayılan olarak test imajını üretir ve üretime pytest'li bir imaj gönderirsiniz. Çalışma zamanı aşamasını her zaman en sona koyun.
COPY --from ile fazladan dosya taşımak. COPY --from=build /app /app yazmak çok aşamalı yapının bütün faydasını iptal eder; tüm derleme çıktısını taşımış olursunuz. Yalnızca ihtiyacınız olan yolu kopyalayın: ikili dosya, dist klasörü, üretim node_modules. Ne kopyaladığınızdan emin değilseniz derleme sonrası docker run --rm imaj ls -R /app ile bakın.
.dockerignore yazmamak. COPY . . satırı .git, node_modules, .env ve yerel log dosyalarını da bağlama gönderir. Bunlar derleme bağlamını şişirir, önbelleği gereksiz yere bozar ve .env durumunda doğrudan sızıntı yaratır. En azından şu dosyayı ekleyin:
# .dockerignore
.git
.gitignore
node_modules
dist
.env
.env.*
*.log
Dockerfile
compose*.yaml
Statik olmayan ikiliyi scratch içine koymak. Go'da CGO_ENABLED=0 vermezseniz üretilen ikili dinamik bağlanır ve scratch ya da distroless/static içinde no such file or directory hatasıyla açılmaz. Hata mesajı ikili dosyayı işaret eder ama sorun eksik paylaşımlı kütüphanededir. Aynı şekilde HTTPS isteği yapacak bir uygulama için kök sertifikaları da kopyalamanız gerekir.
Aşama arası bağımlılığı unutmak. Bir aşama yalnızca COPY --from ile referans verildiğinde derlenir. Referans vermediğiniz bir aşama BuildKit tarafından atlanabilir; testlerinizi "aşama olarak var" diye çalıştığını sanmak yanıltıcıdır. Testleri açıkça --target test ile çalıştırın.
Sıkça Sorulan Sorular#
Multi-stage build imajı ne kadar küçültür#
Kazanç dile ve projeye göre değişir, ancak derleyici gerektiren dillerde fark genellikle on kattan fazladır: 900 MB'lık bir Go imajı 15 MB'a, 1 GB'lık bir Node imajı 150 MB'a inebilir. Yorumlanan dillerde kazanç daha ölçülüdür çünkü çalışma zamanının kendisi imajda kalmak zorundadır. Yine de geliştirme bağımlılıklarını ve paket yöneticisi önbelleğini dışarıda bırakmak her durumda kayda değer bir düşüş sağlar.
Ara aşamalar diskte yer kaplar mı#
Evet, ara aşamalar derleme sırasında yerel imaj deponuzda önbellek olarak tutulur ve zamanla birikir. Bu aslında istediğiniz bir şeydir çünkü sonraki derlemeleri hızlandırır, ancak diski dolduran ana kalemlerden biri hâline gelebilir. docker builder prune komutu yalnızca derleme önbelleğini, docker image prune -a ise kullanılmayan tüm imajları temizler. Disk dolduğunda ne yapılacağını Docker diski doldurdu yazısında ayrıntılı anlattım.
distroless mi alpine mi kullanmalıyım#
Alpine küçüktür, bir kabuk ve paket yöneticisi içerir, hata ayıklaması kolaydır; ama musl libc kullandığı için glibc bekleyen bazı ikililerde uyumsuzluk çıkarabilir. Distroless daha küçüktür, kabuk içermez ve saldırı yüzeyi en dar seçenektir; buna karşılık konteynerin içine girip bakmak mümkün değildir. Uygulamanız statik derlenebiliyorsa distroless, işletim sistemi araçlarına ihtiyaç duyuyorsa alpine mantıklıdır.
Derleme aşamasında kullandığım parola imajda kalır mı#
ARG ile geçirdiğiniz değerler imaj geçmişinde kalır ve docker history ile okunabilir; dolayısıyla parola için kesinlikle uygun değildir. RUN içinde yazdığınız dosya da o katmanda kalır, sonradan silmek yetmez. Doğru yöntem BuildKit'in --mount=type=secret mekanizmasıdır; gizli değer yalnızca ilgili komut çalışırken bağlanır ve hiçbir katmana yazılmaz.
Multi-stage build derleme süresini uzatır mı#
Hayır, aksine genellikle kısaltır. Aşamalar birbirinden bağımsız olduğu için BuildKit bunları paralel derleyebilir ve nihai imaj küçüldüğü için registry'ye gönderme ve sunucuda çekme süreleri belirgin şekilde azalır. Uzun süren tek şey ilk derlemedir; sonrasında katman önbelleği devreye girer. Manifest dosyalarını kaynak koddan önce kopyalarsanız fark daha da açılır.
Docker Compose ile multi-stage build kullanabilir miyim#
Evet. Servisin build bloğunda target alanını vererek hangi aşamanın üretileceğini belirtirsiniz: build: { context: ., dockerfile: Dockerfile, target: runtime }. Geliştirme override dosyanızda aynı servise farklı bir hedef vererek yerelde geliştirme imajını, üretimde çalışma zamanı imajını kullanabilirsiniz. docker compose build --no-cache ile önbelleği yok sayarak sıfırdan derleyebilirsiniz.
Eski Docker sürümlerinde çalışır mı#
Çok aşamalı derleme Docker 17.05 sürümünden beri desteklenir, yani bugün kullanımda olan hemen her sürümde çalışır. Ancak --mount=type=cache ve --mount=type=secret gibi ileri özellikler BuildKit gerektirir. Modern Docker sürümlerinde BuildKit varsayılandır; eski bir kurulumda DOCKER_BUILDKIT=1 docker build ... diyerek etkinleştirebilir ya da Dockerfile'ın ilk satırına # syntax=docker/dockerfile:1 yönergesini ekleyebilirsiniz.
Kapanış#
Çok aşamalı derleme, Docker'da öğrenilmesi en kısa ama getirisi en yüksek tekniklerden biridir. Aklınızda kalması gereken dört alışkanlık şunlar: derleme ile çalışma zamanını her zaman ayrı aşamalara koyun, COPY --from ile yalnızca gerçekten gereken dosyaları taşıyın, bağımlılık manifestini kaynak koddan önce kopyalayarak önbelleği koruyun ve gizli bilgileri ARG yerine BuildKit secret mekanizmasıyla verin. Bunlara bir de düzgün bir .dockerignore eklerseniz, imajlarınız hem küçük hem hızlı hem de belirgin biçimde daha güvenli olur.
Bu imajları çalıştıracağınız yer de en az imajın kendisi kadar önemlidir. Kendi derleme ve dağıtım hattınızı kurmak için tam root erişimli VDS veya esnek kaynaklı bulut sunucu paketlerimiz uygun bir zemin sunar; sunucunun bakımı, güvenlik güncellemeleri ve izleme tarafını devretmek isterseniz sunucu yönetimi hizmetimiz bu yükü üstlenir. Derleme sunucunuz için daha yüksek çekirdek sayısına ihtiyacınız varsa dedicated sunucu seçeneğine de bakmanızı öneririm.