Node.js İçin Docker: Kimsenin Anlatmadığı Production-Ready Kurulum
Multi-stage build, root dışı kullanıcı, health check, secret yönetimi ve imaj boyutu optimizasyonu. Her Node.js production deployment'ında kullandığım Docker kalıpları.
Çoğu production Node.js Dockerfile'ı kötü. "Biraz suboptimal" kötü değil. Root olarak çalışan, devDependencies gömülü 600MB'lık imajlar, health check yok, docker inspect ile herkesin okuyabileceği şekilde environment variable'lara hardcode edilmiş secret'lar türünde kötü.
Biliyorum çünkü o Dockerfile'ları ben yazdım. Yıllarca. Çalışıyorlardı, bu yüzden hiç sorgulamadım. Sonra bir gün güvenlik denetimi, container'ımızın PID 1 root olarak tüm dosya sistemine yazma erişimiyle çalıştığını işaretledi ve "çalışıyor" ile "production-ready" arasındaki farkın ne kadar büyük olduğunu kavradım.
Bu, artık her Node.js projesi için kullandığım Docker kurulumu. Teorik değil. Bu sitenin ve bakımını yaptığım birkaç başka sitenin arkasındaki servisleri çalıştırıyor. Buradaki her kalıp, ya alternatifinden zarar gördüğüm ya da başkasının zarar gördüğünü izlediğim için var.
Mevcut Dockerfile'ın Muhtemelen Neden Yanlış#
Dockerfile'ının neye benzediğini tahmin edeyim:
FROM node:20
WORKDIR /app
COPY . .
RUN npm install
EXPOSE 3000
CMD ["node", "server.js"]Bu, Dockerfile'ların "hello world"ü. Çalışıyor. Ama production'da canını yakacak en az beş sorunu var.
Root Olarak Çalışmak#
Varsayılan olarak, Docker container'ları root olarak çalışır. Bu, container içindeki Node.js process'inin her şeyi yapabileceği anlamına geliyor — dosya yazma, paket yükleme, sistem ayarlarını değiştirme. Eğer birisi uygulamandaki bir zafiyet üzerinden kod çalıştırabilirse, tam root erişimi elde eder.
# Bunu yap — root olarak çalışma
FROM node:20-slim
RUN groupadd --gid 1001 nodejs \
&& useradd --uid 1001 --gid nodejs --shell /bin/bash --create-home nodejs
USER nodejs
WORKDIR /home/nodejs/appUSER direktifi bundan sonraki tüm komutların (ve nihai CMD'nin) o kullanıcı olarak çalışmasını sağlar. Container güvenliğinin en düşük maliyetli iyileştirmesidir.
Gereksiz Dosyaları Kopyalamak#
COPY . . her şeyi kopyalar. .git dizinini, node_modules'u, .env dosyalarını, test dosyalarını, dökümantasyonu — hiçbirinin production imajında olmaması gereken şeyleri.
# .dockerignore — bunu oluştur, ciddi ol
node_modules
.git
.gitignore
.env*
*.md
tests
coverage
.vscode
.eslint*
.prettier*
docker-compose*.yml
Dockerfile.dockerignore dosyası, Git'in .gitignore'u gibi çalışır. Ayrıştırılmaz, kopyalanmaz, taşınmaz. İmaj boyutunu ve build süresini büyük ölçüde azaltır.
Katman Önbelleğini Bozmak#
Docker her RUN, COPY ve ADD talimatını bir katman olarak önbelleğe alır. Bir katman değiştiğinde, ondan sonraki tüm katmanlar da geçersiz olur. Yani COPY . . ardından RUN npm install yapıldığında, herhangi bir dosya değiştiğinde tüm bağımlılıklar yeniden yüklenir.
# Kötü — herhangi bir kod değişikliği npm install'ı tetikler
COPY . .
RUN npm install
# İyi — önce sadece package dosyalarını kopyala
COPY package.json package-lock.json ./
RUN npm ci
COPY . .package.json'ı ayrı kopyalayarak, Docker bu katmanı önbelleğe alır. npm ci sadece package.json veya package-lock.json değiştiğinde çalışır. Kaynak kod değişiklikleri yalnızca COPY . . katmanını ve sonrasını etkiler. Build süreleri çarpıcı biçimde düşer.
npm install vs npm ci#
Production Dockerfile'larında her zaman npm ci kullan. Arasındaki fark önemli:
npm installlockfile'ı günceller, package.json aralıklarına göre farklı sürümler çözümleyebilir ve bağımlılık sorunlarını sessizce düzeltmeye çalışırnpm citam olarak lockfile'da yazanı yükler,node_modules'u tamamen siler ve baştan kurar, ve package.json ile lockfile uyuşmazsa başarısız olur
npm ci deterministiktir. Aynı lockfile her zaman aynı node_modules'u üretir. npm install deterministik değildir. Production'da bu fark önemlidir.
Multi-Stage Build: Asıl Gizli Silah#
Multi-stage build, Dockerfile'ının yapıyı çalışma zamanından ayırmasını sağlar. Test çalıştırabilir, TypeScript derleyebilir, kod lint'leyebilir — sonra nihai imajda sadece production'da gereken şeyleri gönderirsin.
Temel Kalıp#
# ===== Aşama 1: Bağımlılıklar =====
FROM node:20-slim AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
# ===== Aşama 2: Build =====
FROM node:20-slim AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
# ===== Aşama 3: Production =====
FROM node:20-slim AS runner
WORKDIR /app
RUN groupadd --gid 1001 nodejs \
&& useradd --uid 1001 --gid nodejs --shell /bin/bash --create-home nodejs
# Sadece production'da gereken şeyleri kopyala
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./package.json
USER nodejs
EXPOSE 3000
CMD ["node", "dist/server.js"]Nihai imaj sadece node:20-slim temel imajını, derlenmiş kodu ve node_modules'u içerir. TypeScript derleyici yok, kaynak dosyalar yok, devDependencies yok. İmaj boyutu önemli ölçüde düşer.
devDependencies'i Silmek#
Daha da iyisi — nihai imajda sadece production bağımlılıklarını yükle:
# ===== Aşama 1: Tüm bağımlılıklar (build için) =====
FROM node:20-slim AS deps-all
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
# ===== Aşama 2: Sadece production bağımlılıkları =====
FROM node:20-slim AS deps-prod
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
# ===== Aşama 3: Build =====
FROM node:20-slim AS builder
WORKDIR /app
COPY --from=deps-all /app/node_modules ./node_modules
COPY . .
RUN npm run build
# ===== Aşama 4: Production =====
FROM node:20-slim AS runner
WORKDIR /app
RUN groupadd --gid 1001 nodejs \
&& useradd --uid 1001 --gid nodejs --shell /bin/bash --create-home nodejs
COPY --from=deps-prod /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/package.json ./package.json
USER nodejs
EXPOSE 3000
CMD ["node", "dist/server.js"]Fark: nihai imaj builder'ın tam node_modules'u yerine deps-prod'dan gelen production node_modules'u içerir. Bu, proje yapısına bağlı olarak yüzlerce MB farkettirebilir.
Gerçek Boyut Farkı#
Projelerimden birinin gerçek boyutları:
| Yaklaşım | İmaj Boyutu |
|---|---|
node:20 + npm install | 1.2GB |
node:20-slim + npm ci | 485MB |
| Multi-stage + sadece prod deps | 187MB |
| Multi-stage + Alpine | 98MB |
İlk yaklaşımdan sonuncuya kadar 12x küçülme. Bu daha hızlı pull, daha hızlı deployment, daha az depolama maliyeti ve daha küçük saldırı yüzeyi demek.
Temel İmaj Seçimi: slim vs Alpine vs Distroless#
Temel imaj seçimin, hem boyutu hem de pratikte neyin çalışıp çalışmadığını belirler.
node:20 (Tam Debian)#
~350MB'ın üzerinde temel imaj. git, python3, make, gcc ve birçok aracı içerir. Derleme gerektiren native bağımlılıklar için faydalıdır, ama hepsini production'a taşımak israf.
node:20-slim (Minimal Debian)#
~80MB temel imaj. Tam Debian ama gereksiz araçlar çıkarılmış. Çoğu Node.js uygulaması için tercihim. Tam Debian uyumluluğu — paketler gerektiğinde apt-get çalışır.
node:20-alpine (Alpine Linux)#
~50MB temel imaj. Alpine, glibc yerine musl libc kullanır. Boyut farkı gerçektir ama dikkat gerektiren uyumluluk sorunları vardır:
# Alpine: bazı native modüller ek bağımlılık gerektirir
FROM node:20-alpine
RUN apk add --no-cache python3 make g++
# bcrypt, sharp ve diğer native modüller derleme gerektirirNative bağımlılıkların çoğu Alpine'da çalışır ama ek build araçları gerektirir. sharp, bcrypt, canvas gibi paketlerin hepsini test ettim — çalışıyorlar ama build aşamasında python3, make, ve g++ gerekiyor.
gcr.io/distroless/nodejs20-debian12 (Distroless)#
~30MB temel imaj. Shell yok, paket yöneticisi yok, ls bile yok. Sadece Node.js runtime'ı. En güvenli seçenek çünkü saldırı yüzeyi minimal:
FROM node:20-slim AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY . .
RUN npm run build
FROM gcr.io/distroless/nodejs20-debian12
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./package.json
CMD ["dist/server.js"]Dikkat: distroless'ta shell olmadığı için docker exec -it container sh çalışmaz. Debugging zorlaşır. Production güvenliği için mükemmel, geliştirme rahatlığı için bedeldir.
Benim Tavsiyem#
- Geliştirme:
node:20-slim— debug kolay, her şey çalışır - Production (çoğu proje):
node:20-slimile multi-stage — iyi denge - Production (güvenlik öncelikli): distroless — minimal saldırı yüzeyi
- Production (boyut öncelikli): Alpine multi-stage — en küçük, ama native modülleri test et
Health Check: Container'ının Yaşayıp Yaşamadığını Bil#
Docker health check olmadan container'ın durumunu bilemez. Process çalışıyor olabilir ama uygulama kilitlenmiş, bağlantıları kabul etmiyor veya deadlock'ta olabilir. Docker bunu "healthy" olarak görür çünkü PID hâlâ canlıdır.
Temel Health Check#
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD node -e "fetch('http://localhost:3000/api/health').then(r => { if (!r.ok) throw new Error(); })"Bu 30 saniyede bir /api/health endpoint'ine istek atar. Üç ardışık başarısızlık durumunda container "unhealthy" olarak işaretlenir.
Daha İyi Bir Health Check#
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
CMD node healthcheck.js// healthcheck.js
const http = require("http");
const options = {
hostname: "localhost",
port: process.env.PORT || 3000,
path: "/api/health",
method: "GET",
timeout: 4000,
};
const req = http.request(options, (res) => {
process.exit(res.statusCode === 200 ? 0 : 1);
});
req.on("error", () => process.exit(1));
req.on("timeout", () => {
req.destroy();
process.exit(1);
});
req.end();Ayrı dosya kullanmanın avantajları:
- Timeout yönetimi
- Daha iyi hata işleme
- Port'u environment variable'dan okuma
fetch'in mevcut olmadığı eski Node sürümlerinde çalışma
Uygulama Tarafındaki Health Endpoint#
// src/routes/health.ts
import type { Request, Response } from "express";
export async function healthCheck(req: Request, res: Response) {
const checks = {
uptime: process.uptime(),
timestamp: Date.now(),
status: "ok" as "ok" | "degraded" | "error",
checks: {} as Record<string, { status: string; latency?: number }>,
};
// Veritabanı bağlantısını kontrol et
try {
const start = Date.now();
await db.query("SELECT 1");
checks.checks.database = {
status: "ok",
latency: Date.now() - start,
};
} catch {
checks.checks.database = { status: "error" };
checks.status = "degraded";
}
// Redis bağlantısını kontrol et
try {
const start = Date.now();
await redis.ping();
checks.checks.redis = {
status: "ok",
latency: Date.now() - start,
};
} catch {
checks.checks.redis = { status: "error" };
checks.status = "degraded";
}
const statusCode = checks.status === "ok" ? 200 : 503;
res.status(statusCode).json(checks);
}Health endpoint, downstream bağımlılıkları gerçekten kontrol etmeli. Sadece "200 OK" döndürmek, process'in canlı olduğunu söyler ama uygulamanın sağlıklı olup olmadığını söylemez.
Health Check Parametreleri#
--interval=30s: Ne sıklıkla kontrol edeceğini belirler. 30 saniye çoğu uygulama için iyi. Çok sık yapmak CPU harcar.--timeout=5s: Tek bir kontrolün ne kadar süre bekleyeceği. Health check'in kendisi sorunlu olmamalı.--start-period=30s: Uygulama başlangıcında health check'i kaç saniye bekletir. Node.js uygulamaları 10-30 saniye alabilir. Bu süre boyunca başarısızlıklar sayılmaz.--retries=3: Kaç ardışık başarısızlıktan sonra "unhealthy" sayılır. 3 iyi bir varsayılan — geçici bir ağ kesintisi nedeniyle yanlış alarm vermez.
Secret Yönetimi: Her Şeyi Yanlış Yapıyorsun (Muhtemelen)#
Build zamanında secret yönetimi, çoğu Docker kurulumunun en yanlış gittiği yerdir.
Yaygın Hatalar#
# ❌ YANLIŞ — secret'lar katman geçmişinde kalır
ENV DATABASE_URL=postgres://user:password@host:5432/db
RUN echo "api_key=sk_live_abc123" > /app/.env
# ❌ YANLIŞ — ARG bile katman geçmişinde kalır
ARG GITHUB_TOKEN
RUN git clone https://${GITHUB_TOKEN}@github.com/org/private-repo.gitdocker history komutu bu değerleri gösterir. Katmanlarda kalıcıdır.
Docker Build Secrets (Doğru Yol)#
Docker BuildKit, build zamanı secret'ları güvenli bir şekilde mount etmeye olanak tanır:
# syntax=docker/dockerfile:1
FROM node:20-slim AS builder
WORKDIR /app
COPY package.json package-lock.json ./
# Secret, bir dosya olarak mount edilir — katmana yazılmaz
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \
npm ci
COPY . .
RUN npm run buildBuild komutu:
docker build --secret id=npmrc,src=$HOME/.npmrc -t myapp .Secret dosyası sadece o RUN komutu süresince kullanılabilir. Nihai imaja dahil olmaz. Katman geçmişinde görünmez.
Çalışma Zamanı Secret'ları#
Çalışma zamanı secret'ları (veritabanı URL'leri, API anahtarları) environment variable olarak geçirilmelidir, imaja gömülmemelidir:
# İyi — çalışma zamanı env
docker run -e DATABASE_URL="postgres://..." -e API_KEY="sk_..." myapp
# Daha iyi — env dosyasından
docker run --env-file .env.production myapp# docker-compose.yml
services:
app:
image: myapp
environment:
- DATABASE_URL=${DATABASE_URL}
- REDIS_URL=${REDIS_URL}
env_file:
- .env.productionDocker Swarm/Kubernetes Secrets#
Orchestration araçları kullanıyorsan, bunların kendi secret yönetimleri var:
# docker-compose.yml (Swarm)
services:
app:
image: myapp
secrets:
- db_password
- api_key
secrets:
db_password:
external: true
api_key:
external: trueUygulama secret'ları dosya olarak okur:
import { readFileSync } from "fs";
const dbPassword = readFileSync("/run/secrets/db_password", "utf8").trim();Bu, environment variable'lardan daha güvenlidir çünkü /proc/*/environ üzerinden sızdırılamaz.
Graceful Shutdown: SIGTERM'i Düzgün İşle#
Docker container'ı durdururken bir SIGTERM sinyali gönderir. 10 saniye sonra (varsayılan), SIGKILL ile process'i zorla sonlandırır. Eğer uygulamanın SIGTERM işlemiyorsa, aktif istekler bırakılır, veritabanı bağlantıları temiz kapatılmaz ve veri kaybı olabilir.
PID 1 Problemi#
Dockerfile'ın CMD ["node", "server.js"] olduğunda, Node.js PID 1 olarak çalışır. Sorun: PID 1 varsayılan sinyal işleyicilerine sahip değildir. SIGTERM sinyalini hiçbir handler kurulmamışsa yok sayar.
// server.ts — SIGTERM handler
const server = app.listen(port, () => {
console.log(`Server listening on port ${port}`);
});
function gracefulShutdown(signal: string) {
console.log(`${signal} received. Starting graceful shutdown...`);
// Yeni bağlantıları kabul etmeyi durdur
server.close(() => {
console.log("HTTP server closed");
// Veritabanı bağlantılarını kapat
db.end()
.then(() => {
console.log("Database connections closed");
process.exit(0);
})
.catch((err) => {
console.error("Error during shutdown:", err);
process.exit(1);
});
});
// Zorla kapatma zamanlayıcısı
setTimeout(() => {
console.error("Graceful shutdown timed out. Forcing exit.");
process.exit(1);
}, 8000); // Docker'ın 10 saniyelik timeout'undan önce
}
process.on("SIGTERM", () => gracefulShutdown("SIGTERM"));
process.on("SIGINT", () => gracefulShutdown("SIGINT"));Tini veya dumb-init Kullan#
Alternatif: PID 1 olarak ince bir init process'i kullan:
FROM node:20-slim
# Tini'yi yükle
RUN apt-get update && apt-get install -y --no-install-recommends tini \
&& rm -rf /var/lib/apt/lists/*
ENTRYPOINT ["tini", "--"]
CMD ["node", "dist/server.js"]Tini, sinyalleri düzgün şekilde Node.js process'ine iletir ve zombie process'leri temizler. Node 20+'nın kendi sinyal işleme mekanizması iyileşmiş olsa da, Tini hâlâ en güvenilir yaklaşım.
node:20-slim Zaten Tini İçerir (Neredeyse)#
Aslında docker-init komutu ile kullanabilirsin:
docker run --init myappBu tini'yi otomatik olarak PID 1 olarak ekler. Dockerfile'a bir şey eklemeye gerek yok. Ama docker-compose veya Kubernetes kullanıyorsan bunu ayrıca ayarlamalısın.
Production Docker Compose#
Geliştirme ve production Docker Compose dosyaları farklı olmalı. İşte production compose dosyam:
# docker-compose.production.yml
services:
app:
image: myapp:${VERSION:-latest}
build:
context: .
dockerfile: Dockerfile
target: runner
restart: unless-stopped
init: true
ports:
- "${PORT:-3000}:3000"
environment:
NODE_ENV: production
PORT: "3000"
env_file:
- .env.production
healthcheck:
test: ["CMD", "node", "healthcheck.js"]
interval: 30s
timeout: 5s
retries: 3
start_period: 30s
deploy:
resources:
limits:
memory: 512M
cpus: "1.0"
reservations:
memory: 256M
cpus: "0.25"
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
security_opt:
- no-new-privileges:true
read_only: true
tmpfs:
- /tmpÖnemli ayarlar:
restart: unless-stopped— Crash sonrası otomatik yeniden başlatma, ama elle durdurulunca başlatmainit: true— PID 1 olarak tini kullandeploy.resources— Bellek ve CPU limitleri. Node.js bellek sızıntısı durumunda tüm sunucuyu çökertmesini engellerlogging— Log rotasyonu. Olmadan diskler dolarsecurity_opt: no-new-privileges— Container içindeki process'lerin ayrıcalık yükseltmesini engellerread_only: true— Root dosya sistemi salt okunur. Zararlı kod dosya yazamaztmpfs: /tmp— Geçici dosyalar için yazılabilir alan
İmaj Etiketleme: latest Kullanma#
latest etiketi bir sürüm değildir. "En son ne push edildiyse o" demektir. Geri alma yapılamaz, hangi sürümün çalıştığı bilinemez, iki farklı sunucuda farklı latest olabilir.
# ❌ Kötü
docker build -t myapp:latest .
# ✅ İyi — Git SHA kullan
docker build -t myapp:$(git rev-parse --short HEAD) .
# ✅ İyi — Semantik versiyon
docker build -t myapp:2.1.0 .
# ✅ En iyi — İkisini birden
VERSION=$(git rev-parse --short HEAD)
docker build -t myapp:${VERSION} -t myapp:latest .
docker push myapp:${VERSION}Her zaman spesifik bir etiket ile deploy et. latest'i kolaylık olsun diye tut ama docker-compose.production.yml veya Kubernetes manifest'inde asla latest kullanma.
Tam Production Dockerfile#
İşte her şeyi bir araya getiren tam Dockerfile:
# syntax=docker/dockerfile:1
# ===== Aşama 1: Bağımlılıklar =====
FROM node:20-slim AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
# ===== Aşama 2: Production Bağımlılıkları =====
FROM node:20-slim AS deps-prod
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
# ===== Aşama 3: Build =====
FROM node:20-slim AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
# ===== Aşama 4: Production =====
FROM node:20-slim AS runner
LABEL maintainer="your-email@example.com"
LABEL org.opencontainers.image.source="https://github.com/your-org/your-repo"
WORKDIR /app
# Tini'yi yükle ve temizle
RUN apt-get update \
&& apt-get install -y --no-install-recommends tini \
&& rm -rf /var/lib/apt/lists/*
# Root olmayan kullanıcı oluştur
RUN groupadd --gid 1001 nodejs \
&& useradd --uid 1001 --gid nodejs --shell /bin/bash --create-home nodejs
# Sadece gerekli dosyaları kopyala
COPY --from=deps-prod /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/package.json ./package.json
COPY healthcheck.js ./healthcheck.js
# Root olmayan kullanıcıya geç
USER nodejs
# Ortam değişkenleri
ENV NODE_ENV=production
ENV PORT=3000
EXPOSE 3000
# Health check
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
CMD ["node", "healthcheck.js"]
# Tini init process'i ile başlat
ENTRYPOINT ["tini", "--"]
CMD ["node", "dist/server.js"]Bu Dockerfile:
- Multi-stage ile minimum imaj boyutu sağlar
- Root olmayan kullanıcı ile çalışır
- Sadece production bağımlılıklarını içerir
- Health check dahil
- Tini ile düzgün sinyal yönetimi
- Etiketlerle metadata sağlar
- Build cache'i verimli kullanır
Sık Yapılan Hataların Özeti#
| Hata | Sonuç | Çözüm |
|---|---|---|
| Root olarak çalışmak | Güvenlik açığı | USER nodejs |
npm install | Deterministik olmayan build | npm ci |
COPY . . ilk satır | Yavaş build, gereksiz dosyalar | .dockerignore + aşamalı COPY |
latest etiketi | Geri alma yapılamaz | Git SHA veya semver etiketi |
| Health check yok | Kilitli container tespit edilemez | HEALTHCHECK direktifi |
| Secret ENV'de | Katman geçmişinde görünür | Build secrets veya runtime env |
node:20 (tam) | 1GB+ imaj | node:20-slim veya Alpine |
| SIGTERM yok sayılır | Kaba kapatma, veri kaybı | Graceful shutdown handler |
Bu kalıpların hiçbiri tek başına devrimsel değil. Ama hepsi bir arada, "çalışıyor" ile "production'da güvenle çalışıyor" arasındaki farkı oluşturur. Dockerfile'ını bir kez düzgün kur, sonra her projede kopyala. Yatırım ilk seferinde yapılır, getirisi her deployment'ta geri döner.