Tek Sunucuda Production'da Compose: Image Tag'leri, Restart, Log Rotation, Resource Limit ve Sınırlar
Tek sunucuda Compose'la production'ın kuralı, sunucunun hiç build etmemesi: image CI'da build edilip immutable tag'le registry'ye konur, sunucu pull ve up -d --wait ile onu çeker. Bookstore'da deploy ve rollback'i, up -d'nin bağımlılık zinciri yüzünden yarattığı 11,5 saniyelik kesintiyi, log rotation'ı, resource limit'leri, SIGTERM'ü sqlservr'a iletmeyen entrypoint'i ve Compose 5.6 job'ıyla BACKUP DATABASE'i gerçek çıktılarla görüyoruz.
Sürüm notu: Bu yazıdaki örnekler Docker Compose 5.7.1, Docker Engine 29.9.3, Docker Registry 3, SQL Server 2025 (RTM-CU11) ve .NET 10 ile denenmiştir; sunucu, bir Linux makinedeki Docker Engine'i taklit eden ayrı bir engine'de kuruldu.
Önce Tahmin Et
Bookstore tek bir Linux sunucuda Compose ile çalışıyor. Yeni sürümün tag'ini .env'e yazıp docker compose pull && docker compose up -d diyorsun. Bu sırada sayfayı yenileyen bir kullanıcı ne kadar süre hata görür: hiç, bir saniyeden az, yoksa 10 saniyeden fazla? İkinci soru: sunucuyu güncelleme için yeniden başlatıyorsun. Docker SQL Server container'ını durdururken veritabanı düzgün kapanır mı?
Sunucu Build Etmez: Registry ve Immutable Tag
Şimdiye kadar image'lar build: ile, docker compose'un çalıştığı makinede üretiliyordu. Production sunucusunda bunun üç sorunu var: sunucuda kaynak kod ve SDK'lar gerekiyor, her sunucu "aynı" sürümü kendi build ettiği için aslında farklı image'lar çalıştırabiliyor ve bir sorun çıktığında hangi kodun çalıştığını image'dan anlamak zorlaşıyor. CI/CD Temelleri serisindeki "Build Once, Deploy Many" ilkesi burada da geçerli: image CI'da bir kez build edilir, bir registry'ye immutable bir tag'le konur ve her ortam aynı image'ı çeker.
Base dosyada build:'in yanına bir image: adı ekledik; Compose ikisi birlikte yazıldığında build sonucunu bu adla tag'liyor:
api:
build:
context: .
dockerfile: src/Bookstore.Api/Dockerfile
args:
APP_VERSION: ${TAG:-0.0.0-dev}
# build + image: build edilen image bu adı alır, CI onu push edebilir (docker compose push api web)
image: ${REGISTRY:-localhost:5000}/bookstore/api:${TAG:-dev}
...
web:
build: ./web
image: ${REGISTRY:-localhost:5000}/bookstore/web:${TAG:-dev}
CI'daki release adımı iki komut. Örnekte registry lokal bir Registry 3 container'ı; gerçekte GHCR (GitHub Container Registry; GitHub'ın image registry'si), Docker Hub ya da Azure Container Registry olur. Sunucuya SSH (uzak sunucuya şifreli bağlantı protokolü) ile bağlanan aynı akışı GitHub Actions serisinin "GitHub Actions ile Deploy 2: Kendi Linux Sunucuna SSH, Docker Image ve GHCR" yazısı bir workflow olarak kuruyor.
REGISTRY=localhost:5000 TAG=1.3.0 docker compose build api web
REGISTRY=localhost:5000 TAG=1.3.0 docker compose push api web
Image localhost:5000/bookstore/api:1.3.0 Built
Image localhost:5000/bookstore/web:1.3.0 Built
localhost:5000/bookstore/api:1.3.0 Pushed
localhost:5000/bookstore/web:1.3.0 Pushed
push'a service adlarını yazmak şart: adsız bir push, db'nin mcr.microsoft.com/mssql/server image'ını da göndermeye çalışır. TAG aynı zamanda APP_VERSION build argümanı olarak Dockerfile'a gidiyor ve image'ın org.opencontainers.image.version label'ına yazılıyor; sunucuda hangi sürümün çalıştığını oradan okuyacağız. Production dosyası ise build'i tamamen kaldırıyor:
# Tek sunucuda production: sunucudaki .env'de COMPOSE_FILE=compose.yaml:compose.prod.yaml.
# Sunucu hiç build etmez: image'lar registry'den immutable tag'le gelir (TAG=1.3.0, asla latest).
x-logging: &default-logging
driver: json-file
options:
# Rotation: container başına en fazla 10 MB'lık 3 dosya; yoksa log disk dolana kadar büyür
max-size: "10m"
max-file: "3"
services:
db:
# Büyük bir veritabanının düzgün kapanması (checkpoint) için süre; Docker sonra SIGKILL gönderir, varsayılan 10 sn
stop_grace_period: 60s
environment:
MSSQL_MEMORY_LIMIT_MB: "2048"
deploy:
resources:
limits:
memory: 3g
logging: *default-logging
migrate:
logging: *default-logging
api:
build: !reset null
image: ${REGISTRY:?set REGISTRY}/bookstore/api:${TAG:?set TAG}
deploy:
resources:
limits:
cpus: "1.0"
memory: 512m
logging: *default-logging
web:
build: !reset null
image: ${REGISTRY:?set REGISTRY}/bookstore/web:${TAG:?set TAG}
environment:
APP_ENVIRONMENT: production
# !override: base listeye eklemek yerine onu değiştir (8080 açık kalmamalı)
ports: !override
- "80:8080"
deploy:
resources:
limits:
cpus: "0.5"
memory: 128m
logging: *default-logging
build: !reset null service'i yalnız image:'lı bir service'e çeviriyor: sunucuda kaynak kod olmasa da Compose bir şey build etmeye çalışmıyor, eksik image'ı registry'den çekiyor. ${TAG:?set TAG} 3. yazıdaki zorunlu değişken kalıbı: tag'i unutursan Compose hiçbir şeyi değiştirmeden duruyor. latest'i production'da kullanmamanın sebebi Docker serisinin 4. yazısındaki tag ile digest farkı: latest bugün bir image'ı, yarın başkasını gösterir ve "geri dön" diyebileceğin bir önceki değer yoktur.
Sunucudaki Klasör ve Secret İzinleri
Sunucuya repo'nun tamamı değil, yalnız çalıştırmak için gerekenler gidiyor: üç Compose dosyası (compose.override.yaml bilerek yok), config/, db/ script'leri, secrets/ ve bir .env:
COMPOSE_PROJECT_NAME=composeseries-prod
COMPOSE_FILE=compose.yaml:compose.prod.yaml
REGISTRY=localhost:5000
TAG=1.3.0
Secret dosyalarını 3. yazıda geliştirme kolaylığı için 644 bırakmıştık. Sunucuda her dosyayı yalnız onu okuyacak container kullanıcısının grubuna açtık: SQL Server image'ı uid ve gid 10001 ile, Bookstore.Api uid ve gid 1654 ile çalışıyor.
chmod 700 secrets
chown root:10001 secrets/sa-password.txt secrets/db-app-password.txt
chown root:1654 secrets/api-connection-string.txt
chmod 640 secrets/*.txt
ls -ln secrets
-rw-r----- 1 0 1654 117 <date> api-connection-string.txt
-rw-r----- 1 0 10001 27 <date> db-app-password.txt
-rw-r----- 1 0 10001 28 <date> sa-password.txt
Host'ta 10001 ve 1654 numaralı gruplar yok, ama izin kontrolü numarayla yapılıyor. Klasörün 700 olması container'ları etkilemiyor: bind mount'u Docker root olarak kuruyor, container yalnız kendisine bağlanan dosyayı görüyor. Sunucudaki diğer kullanıcılar ise ne klasöre girebiliyor ne dosyaları okuyabiliyor. Bu düzenle stack'i başlattık; üç service de secret'larını okudu.
pull, up -d --wait ve Rollback
Deploy adımı küçük bir script (labs/compose/06-deploy.sh); CI onu SSH ile sunucuda çalıştırıyor:
#!/usr/bin/env bash
# Sunucuda, compose.yaml, compose.db.yaml, compose.prod.yaml, config/, db/, secrets/ ve .env'in olduğu klasörde çalışır.
# kullanım: ./06-deploy.sh 1.3.1 (rollback: aynı komut, önceki tag'le)
set -euo pipefail
tag="${1:?usage: $0 <tag>}"
sed -i "s/^TAG=.*/TAG=${tag}/" .env
docker compose pull
docker compose up -d --wait
docker compose ps --format "table {{.Service}}\t{{.Image}}\t{{.Status}}"
Önce pull: image'lar indirilirken eski sürüm çalışmaya devam ediyor ve indirme başarısız olursa hiçbir şey değişmiyor. Sonra up -d --wait: değişen service'leri yeniden oluşturup hepsi healthy olana kadar bekliyor; olmazsa script sıfırdan farklı bir exit code'la bitiyor ve pipeline kırmızıya dönüyor. 1.3.1'i deploy ettikten sonra 1.3.0'a geri döndük:
./06-deploy.sh 1.3.0
docker inspect composeseries-prod-api-1 --format '{{index .Config.Labels "org.opencontainers.image.version"}}'
Image localhost:5000/bookstore/api:1.3.0 Pulled
Image localhost:5000/bookstore/web:1.3.0 Pulled
Container composeseries-prod-api-1 Recreate
Container composeseries-prod-web-1 Recreate
...
Container composeseries-prod-api-1 Healthy
Container composeseries-prod-web-1 Healthy
SERVICE IMAGE STATUS
api localhost:5000/bookstore/api:1.3.0 Up 7 seconds (healthy)
db mcr.microsoft.com/mssql/server:2025-latest Up 21 seconds (healthy)
web localhost:5000/bookstore/web:1.3.0 Up 1 second (healthy)
1.3.0
Rollback'in bu kadar sıradan olmasının sebebi immutable tag: 1.3.0 hâlâ registry'de, hâlâ aynı içerikle. Burada bir şart var: veritabanı şeması geriye uyumlu olmalı. 1.3.1 bir kolonu silmiş olsaydı, 1.3.0'ın image'ına dönmek uygulamayı geri getirir ama şemayı getirmez.
Kesinti Ne Kadar?
İlk sorunun deneyi: 1.3.0 çalışırken 1.3.1'e geçtik ve bu sırada yaklaşık 0,1 saniyede bir /api/books'u çağırdık.
62 000
87 200
1 502
150 isteğin 63'ü başarısız oldu; ilk hata 1,2'nci saniyede, son hata 12,7'nci saniyede. 11,5 saniyelik bir kesinti. up -d'nin çıktısı nedenini gösteriyor:
Container composeseries-prod-api-1 Recreate
Container composeseries-prod-api-1 Recreated
Container composeseries-prod-web-1 Recreate
Container composeseries-prod-web-1 Recreated
Container composeseries-prod-db-1 Waiting
Container composeseries-prod-db-1 Healthy
Container composeseries-prod-migrate-1 Starting
...
Container composeseries-prod-migrate-1 Exited
Container composeseries-prod-api-1 Starting
Container composeseries-prod-api-1 Started
Container composeseries-prod-api-1 Waiting
Container composeseries-prod-api-1 Healthy
Container composeseries-prod-web-1 Starting
Container composeseries-prod-web-1 Started
Compose iki container'ı da durdurup yeniden oluşturuyor, sonra bağımlılık zincirini baştan yürüyor: migrate yeniden çalışıyor, API'nin healthy olması bekleniyor, web ancak ondan sonra başlıyor. Bütün bu sürede web container'ı yok ve bağlantılar reddediliyor (000). Karşılaştırmak için yalnız API'yi bağımlılıklarına dokunmadan yeniden oluşturduk (docker compose up -d --no-deps --force-recreate api): 150 istekten yalnız biri 502 aldı, çünkü web ayakta kaldı ve API bir saniyenin altında yeniden dinlemeye başladı.
Tek replica'lı Compose'da sıfır kesinti yok; elinden gelen, kesintiyi kısaltmak. Service'leri tek tek ve doğru sırayla güncellemek (önce migration, sonra API, en son web) ve web'in yeniden oluşturulmasını yalnız web'in image'ı değiştiğinde yapmak süreyi saniyelerden bir isteğe indiriyor; bedeli, deploy script'inin Compose'un bağımlılık zincirini kendisi taşıması. Gerçek sıfır kesinti için iki kopya ve önde trafiği çeviren bir proxy gerekiyor: Deployment Stratejileri serisinin "Blue-Green Deployment: Nginx ile İki Environment, App Service Slot'ları ve Anında Geri Dönüş" yazısı bunu nginx'le kuruyor.
Restart, Log Rotation ve Resource Limit
restart: unless-stopped base dosyada zaten vardı: container çökerse ya da Docker servisi yeniden başlarsa container geri gelir, sen stop ile durdurmadıysan. Log için Docker'ın varsayılan json-file driver'ı rotation yapmıyor; bir container'ın log dosyası disk dolana kadar büyüyebiliyor. Bütün service'lere aynı ayarı vermek için bir x- extension field'ı ve YAML (Compose dosyalarının biçimi) anchor'ı kullandık:
docker inspect composeseries-prod-api-1 --format "{{json .HostConfig.LogConfig}} memory={{.HostConfig.Memory}} nanocpus={{.HostConfig.NanoCpus}}"
docker compose logs api | grep "GC memory"
docker stats --no-stream --format "table {{.Name}}\t{{.MemUsage}}" composeseries-prod-api-1 composeseries-prod-web-1
{"Config":{"max-file":"3","max-size":"10m"},"Type":"json-file"} memory=536870912 nanocpus=1000000000
api-1 | 14:21:14 info: Bookstore.Api[0] Environment: Production, GC memory limit: 384 MB, processors: 1
NAME MEM USAGE / LIMIT
composeseries-prod-api-1 36.09MiB / 512MiB
composeseries-prod-web-1 8.977MiB / 128MiB
deploy.resources.limits bir zamanlar yalnız Swarm'da geçerliydi; bugünkü Compose onu tek engine'de de uyguluyor ve container 512 MB ile 1 CPU aldı. .NET'in GC'si (garbage collector; kullanılmayan belleği geri toplayan bileşen) limiti görüp heap'i 384 MB'a (limitin %75'i) ayarladı, Environment.ProcessorCount 1 oldu; Docker serisinin 8. yazısında ve Kubernetes serisinin "Resource Request ve Limit'ler: QoS, OOMKilled, CPU Throttling ve Container'da .NET GC" yazısında aynı davranışı görmüştük. SQL Server için ayrıca MSSQL_MEMORY_LIMIT_MB verdik: varsayılanı fiziksel belleğin %80'i ve biz SQL Server'ın kullanacağı belleği container limitinin altında açıkça sabitlemek istiyoruz.
Sunucu Yeniden Başlarken ve SQL Server'ın Kapanışı
Docker servisini yeniden başlattık; öncesinde web'i docker compose stop web ile elle durdurmuştuk:
SERVICE STATUS
api Up 41 seconds (healthy)
db Up 41 seconds (healthy)
migrate Exited (0) About a minute ago
web Exited (0) 44 seconds ago
API ve veritabanı geri geldi, elle durdurulan web gelmedi: unless-stopped'ın "unless"i bu. Bakım için durdurduğun bir service'i yeniden başlatmadan sunucuyu reboot edersen sistem yarım açılır; her durumda geri gelmesini istiyorsan always. Bir ayrıntı daha: restart policy'yi Docker uygular, Compose değil. Docker servisi açılırken container'ları depends_on sırasına bakmadan başlatıyor, migrate hiç çalışmıyor ve API veritabanından önce hazır olabiliyor. 2. yazıdaki SqlClient retry'ı burada da işe yarıyor.
İkinci soru ise bizi şaşırttı. Geliştirme ortamında docker compose stop sonrası veritabanının exit code'u 137'ydi. Production dosyasındaki 60 saniyelik stop_grace_period'la denedik:
docker compose exec db ps -eo pid,ppid,comm
s=$(date +%s); docker compose stop db; echo "stop took $(( $(date +%s)-s )) s"
docker inspect composeseries-prod-db-1 --format "exit={{.State.ExitCode}}"
PID PPID COMMAND
1 0 bash
10 1 sqlservr
12 10 sqlservr
Container composeseries-prod-db-1 Stopped
stop took 60 s
exit=137
60 saniye beklendi ve yine SIGKILL (process'i anında sonlandıran, yakalanamayan sinyal). Process listesi sebebi gösteriyor: image'ın entrypoint'i launch_sqlservr.sh adlı bir bash script'i, PID 1 (container'daki ilk process'in numarası) o ve sqlservr'ı arka planda başlatıp bekliyor. docker stop'un gönderdiği SIGTERM (process'ten düzgün kapanmasını isteyen sinyal) PID 1'e, yani bash'e gidiyor; bash onu sqlservr'a iletmiyor. Docker serisinin 3. yazısındaki "uygulama shell'in arkasında başlatılmışsa sinyal hiç ulaşmaz" durumunun SQL Server image'ındaki hâli. Her durdurma ve her sunucu reboot'u, veritabanı için bir elektrik kesintisi gibi bitiyordu. compose.db.yaml'da entrypoint'i doğrudan sqlservr yaptık:
db:
image: mcr.microsoft.com/mssql/server:2025-latest
# Image'ın entrypoint'i SIGTERM'ü sqlservr'a iletmeyen bir bash script'i: docker stop süreyi doldurup
# onu öldürüyordu (exit 137). PID 1 olarak sqlservr bir saniyenin altında düzgün kapanıyor.
entrypoint: ["/opt/mssql/bin/sqlservr"]
Container composeseries-prod-db-1 Stopped
stop took 0 s
exit=0
db-1 | 2026-11-14 14:27:24.35 spid51s Service Broker manager has shut down.
Kapanış bir saniyenin altında, exit code 0 ve log'da düzenli bir shutdown. Bu değişiklikle launch script'inin yaptığı iki işi atlıyoruz: başlangıçtaki izin kontrolü ve /mssql-server-setup-scripts.d desteği; ikisini de kullanmıyoruz. Boş bir volume'le sıfırdan açılışı da denedik, veritabanı ve migrate sorunsuz çalıştı.
Yedekleme: BACKUP DATABASE ve Compose Job'ları
Volume'ün kendisini yedeklemek (Docker serisinin 11. yazısındaki tar yöntemi) çalışan bir SQL Server için tutarlı bir kopya vermez. Doğru yol veritabanının kendi BACKUP DATABASE komutu. Bunu Compose 5.6'da kısmen gelen job'larla yazdık: job, bitene kadar çalışan ve up'ın başlatmadığı bir container. Şimdilik yalnız elle tetiklenen job'lar destekleniyor, zamanlanmış (schedule) job'lar henüz yok.
jobs:
# Elle tetiklenen job (Compose 5.6+): docker compose run backup
backup:
image: mcr.microsoft.com/mssql/server:2025-latest
entrypoint:
- /bin/bash
- -c
- >-
SQLCMDPASSWORD="$$(cat /run/secrets/sa-password)"
exec /opt/mssql-tools18/bin/sqlcmd -C -S db -U sa -b -Q
"BACKUP DATABASE BookstoreDb TO DISK = N'/var/opt/mssql/backup/BookstoreDb-$$(date -u +%Y%m%d-%H%M%S).bak' WITH COMPRESSION, CHECKSUM"
secrets:
- sa-password
depends_on:
db:
condition: service_healthy
triggers:
manual: true
docker compose run --rm backup
mkdir -p backup && docker compose cp db:/var/opt/mssql/backup/. backup/
Processed 416 pages for database 'BookstoreDb', file 'BookstoreDb' on file 1.
Processed 2 pages for database 'BookstoreDb', file 'BookstoreDb_log' on file 1.
BACKUP DATABASE successfully processed 418 pages in 0.076 seconds (42.917 MB/sec).
composeseries-prod-db-1 Copied composeseries-prod-db-1:/var/opt/mssql/backup/. to backup/
Her up'ta Compose job'ların başlatılmadığını hatırlatıyor: jobs are not started by up; trigger them with `docker compose run`: backup. Yedek dosyası önce SQL Server'ın kendi dosya sisteminde, yani veritabanıyla aynı volume'de oluşuyor; volume kaybolursa yedek de gider. docker compose cp onu host'a alıyor, oradan sunucu dışına (object storage, başka bir makine) taşımak şart. Zamanlama için şimdilik host'un cron'u ya da bir systemd timer'ı docker compose run --rm backup'ı çağırabilir. Bir yedeğin değeri geri yüklenebilmesiyle ölçülür: RESTORE VERIFYONLY ile dosyayı doğrulamak ve yedeği düzenli olarak başka bir ortama geri yükleyip denemek, yedek almak kadar önemli.
Üretimde Dikkat
Tek sunucu Compose'un iyi yaptığı bir iş: birkaç service, bir veritabanı, düşük ve öngörülebilir trafik. Kubernetes'e geçmeyi düşündüren işaretler şunlar: kesintiyi bir isteğe bile indirmek yetmiyor ve rolling update gerekiyor; tek sunucunun çökmesi kabul edilemez, yani en az iki node lazım; trafik yatay ölçek istiyor; birden çok ekip aynı platformda kendi service'lerini bağımsız deploy etmek istiyor. Bunlardan hiçbiri yoksa Kubernetes'in işletme yükü kazandıracağından fazlasını götürür; Kubernetes serisinin ilk yazısı "Kubernetes Nedir, Ne Zaman Gerekmez?" bu kararı ayrıntılı tartışıyor. Compose'la kalırken izlenecekler: disk (log'lar, eski image'lar, yedekler), base image'ların güvenlik güncellemeleri (SQL Server'ın 2025-latest tag'i bile production'da bir CU (Cumulative Update; SQL Server'ın toplu güncelleme paketi) sürümüne sabitlenmeli) ve Docker Engine'in kendisinin güncellenmesi.
Sık Düşülen Tuzaklar
- Production'da
latestya dabuild:kullanmak: neyin çalıştığını bilemezsin ve geri dönecek bir sürüm kalmaz. .env'i ve secret dosyalarını sunucuda herkesin okuyabileceği izinlerle bırakmak: dosyaları yalnız ilgili container kullanıcısının grubuna aç.- Bir sunucuda alışkanlıkla
docker compose down -vyazmak: veritabanı volume'ü ve içindeki yedekler birlikte gider. up -d'nin kesintisiz olduğunu sanmak: bağımlılık zinciri yüzünden web de yeniden oluşturuldu ve 11,5 saniye cevap vermedi.- Log driver'ı ayarlamamak:
json-filevarsayılan olarak rotation yapmaz. - Shell script'li bir entrypoint'i olan veritabanı image'ının SIGTERM'ü aldığını varsaymak:
stop_grace_period'u uzatmak yetmez, sinyalin process'e ulaştığınıexit=0ile doğrula. - Yedeği yalnız veritabanının volume'ünde tutmak: aynı arıza ikisini birden götürür.
Küçük Bir Deneme
★☆☆ 06-deploy.sh'yi var olmayan bir tag'le (9.9.9) çalıştır. Hangi adımda duruyor, çalışan sürüm etkileniyor mu? Script .env'i değiştirmişti; bir sonraki elle yazılan docker compose up -d ne yapar? Script'i bu durumu düzeltecek şekilde değiştir.
★★☆ Deploy script'ini kesintiyi kısaltacak şekilde yeniden yaz: önce docker compose run --rm migrate, sonra up -d --no-deps --wait api, en son yalnız image'ı değiştiyse web. /api/books'u çağıran döngüyle yeni kesinti süresini ölç.
★★★ Yedekten geri yükleme job'ı yaz: restore job'ı en son .bak dosyasını RESTORE DATABASE BookstoreDb ... WITH REPLACE ile yüklesin. API çalışırken bu işlem ne olur? Job'ın öncesinde API'yi durdurup sonrasında başlatan bir sırayı Compose komutlarıyla kur ve süreyi ölç.
Bu yazıda Compose 5.3'ün init container'larını, 5.6'nın job'larını ve 5.7'nin secret uyarılarını kullandık; eski bir projede ise hâlâ version: "3.8" ile başlayan bir docker-compose.yml ve docker-compose up göreceksin. Serinin son yazısında Compose'un v1'den v5'e evrimini, Compose Specification'ı ve eski bir dosyayı bugünkü hâline çevirmenin kontrol listesini çıkarıyoruz.