Birden Çok Environment: Override Dosyaları, include, extends ve Merge Kuralları
Compose'da ortak tanım compose.yaml'da durur, her ortam yalnız farkını yazar: compose.override.yaml -f verilmediğinde kendiliğinden yüklenir, diğer dosyalar -f ile katmanlanır; map'ler birleşir, port listesi ise beklenmedik şekilde eklenir. Bookstore'u include ile veritabanı dosyasına, extends ile ortak sqlcmd tanımına ayırıyor, !override ve !reset'i, tmpfs'te temiz veritabanıyla --wait ve exit code'lu bir CI adımını 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, SQL Server 2025 (RTM-CU11) ve .NET 10 ile denenmiştir.
Önce Tahmin Et
compose.yaml'da web service'i "8080:8080" ile yayımlanıyor. Production'a benzeyen ortam için ikinci bir dosya yazıp web'e ports: ["80:8080"] veriyorsun ve docker compose -f compose.yaml -f compose.prod.yaml up -d diyorsun. Sunucuda hangi port'lar açık olur: yalnız 80 mi, 80 ve 8080 mi, yoksa Compose hata mı verir? İkinci soru: proje klasöründe bir compose.override.yaml var. -f compose.yaml ile açıkça dosya verdiğinde o da yüklenir mi?
Tek Dosya Neden Yetmiyor?
Dört yazının sonunda compose.yaml'da production'a benzeyen tanımlar, Angular dev server'ı, watch kuralları, geliştirme port'ları ve opsiyonel araçlar yan yana duruyordu. Aynı dosyayı CI'da çalıştırmak, runner'da 1433 ve 4200 port'larını açmak ve dev image'ını build etmek demekti; production'a götürmek ise SQL Server port'unu host'a açık bırakmak. Her ortam için ayrı bir dosya kopyalamak da çözüm değil: üç kopya birkaç hafta içinde birbirinden ayrışır ve hangisinin doğru olduğunu kimse bilmez. Compose'un yolu katmanlar: ortak kısım bir dosyada, her ortam yalnız farkını yazar.
Bookstore'un dosyaları bu yazının sonunda şöyle:
| Dosya | İçerik | Ne zaman yüklenir |
|---|---|---|
compose.yaml | api, web, ortak secret ve config; include ile veritabanı | Her zaman |
compose.db.yaml | db, migrate, sqlcmd | compose.yaml include ettiği için her zaman |
compose.override.yaml | Watch, web-dev, 1433 port'u, Redis, Mailpit | Yalnız -f verilmediğinde, kendiliğinden |
compose.ci.yaml | tmpfs'te veritabanı, yayımlanmayan port'lar, smoke test | -f compose.yaml -f compose.ci.yaml |
compose.prod.yaml | 80 port'u, production environment | -f compose.yaml -f compose.prod.yaml |
compose.override.yaml ve -f ile Katmanlar
Compose, hiç -f verilmediğinde proje klasöründe compose.yaml'ı ve yanında varsa compose.override.yaml'ı okur ve ikincisini birincinin üstüne birleştirir. Bu yüzden geliştirme eklerini bu dosyaya taşıdık: geliştirici yine yalnız docker compose up ya da docker compose watch yazıyor.
# Geliştirme (-f verilmediğinde docker compose up/watch tarafından kendiliğinden yüklenir)
services:
db:
ports:
# Host'taki SSMS / sqlcmd için
- "127.0.0.1:1433:1433"
api:
develop:
watch:
# API kodunda bir değişiklik: image'ı yeniden build et, container'ı yeniden oluştur
- path: src/Bookstore.Api
action: rebuild
# Angular dev server: docker compose --profile dev watch
web-dev:
...
develop:
watch:
# Kaynak dosyalar çalışan container'a kopyalanır; ng serve yeniden derler ve tarayıcıyı yeniler
- path: web/src
target: /app/src
action: sync
initial_sync: true
...
# Opsiyonel service'ler: docker compose --profile cache / --profile tools
redis:
...
mailpit:
...
Bir önceki yazıda söz verdiğimiz initial_sync burada: watch açılırken mevcut web-dev container'ındaki dosyalar host'takilerle eşitleniyor. Override dosyası tam bir tanım olmak zorunda değil; db için yalnız ports, api için yalnız develop yazdık, gerisi compose.yaml'dan geliyor. İkinci sorunun cevabı:
docker compose config db | grep -E "published|host_ip"
docker compose -f compose.yaml config db | grep -c published
host_ip: 127.0.0.1
published: "1433"
0
-f verildiği anda otomatik yükleme kapanıyor: Compose yalnız verdiğin dosyaları, verdiğin sırayla birleştiriyor. CI ve production'da tam da bu istendiği için compose.ci.yaml ve compose.prod.yaml'ı -f ile veriyoruz. Her komutta iki -f yazmak yerine dosya listesi COMPOSE_FILE environment variable'ıyla da verilebilir (Linux'ta : ile ayrılır, Windows'ta ;); sunucudaki .env'e COMPOSE_FILE=compose.yaml:compose.prod.yaml yazmak "bu makinede hep production dosyaları" demek.
Merge Kuralları: Ne Birleşir, Ne Değişir?
İlk sorunun cevabı merge kurallarında. Compose Specification'a göre map'ler (ör. environment, healthcheck, labels) key bazında birleşir, aynı key'de sonraki dosya kazanır. Listeler (sequence) ise varsayılan olarak uç uca eklenir. ports, volumes, secrets ve configs liste olsa da birer "benzersiz key"e göre birleşir: volume'lerde target, port'larda IP, hedef port, yayımlanan port ve protokolün birlikte oluşturduğu dörtlü. "8080:8080" ile "80:8080"'in yayımlanan port'u farklı olduğu için ikisi farklı kayıt sayılıyor:
# compose.prod.yaml'ın ilk hâli
services:
web:
ports:
- "80:8080"
docker compose -f compose.yaml -f prod-naive.yaml config web
ports:
- mode: ingress
target: 8080
published: "8080"
protocol: tcp
- mode: ingress
target: 8080
published: "80"
protocol: tcp
Cevap "80 ve 8080". Compose hata vermiyor, çünkü iki port farklı; production sunucusunda 8080 sessizce açık kalıyor. Bunu ancak config çıktısına ya da sunucudaki ss -tlnp'ye bakan biri fark eder. Çözüm Compose 2.24 sürümleriyle gelen iki YAML (Compose dosyalarının yazıldığı, girintiye dayalı veri biçimi) tag'i: !override bir değeri merge kurallarını atlayarak tamamen değiştirir, !reset base dosyadaki değeri siler.
# Production'a benzer: docker compose -f compose.yaml -f compose.prod.yaml up -d (6. yazı daha ileri gidiyor)
services:
web:
environment:
APP_ENVIRONMENT: production
# !override: base listeye eklemek yerine onu değiştir (8080 açık kalmamalı)
ports: !override
- "80:8080"
docker compose -f compose.yaml -f compose.prod.yaml config web | grep -E "published|target: 8080"
target: 8080
published: "80"
Bir istisna daha: command, entrypoint ve healthcheck.test liste olarak yazılsa da eklenmez, sonraki dosyadaki değer öncekinin yerine geçer. Bir komuta argüman eklemek istediğinde bütün komutu tekrar yazman gerekiyor.
include ile Modüler Compose
Veritabanıyla ilgili her şeyi (db, migrate, sqlcmd, volume'ü ve iki secret'ı) compose.db.yaml'a taşıdık ve ana dosyadan include ettik:
# Bookstore: her ortamın paylaştığı parçalar. Geliştirme ekleri compose.override.yaml'da,
# CI compose.ci.yaml'da, production'a benzer kurulum compose.prod.yaml'da.
name: composeseries
include:
- compose.db.yaml
services:
api:
...
depends_on:
db:
condition: service_healthy
migrate:
condition: service_completed_successfully
include (Compose 2.20'den beri) ile -f birbirine benziyor ama farklı iş yapıyor. -f dosyaları aynı modelin katmanları olarak birleştirir ve göreli yolların hepsi ilk dosyanın klasörüne göre çözülür. include edilen dosya ise kendi başına bir Compose uygulaması gibi yüklenir: göreli yolları (./db, ./secrets) kendi klasörüne göre çözülür, kendi .env'i olabilir, sonra kaynakları ana modele kopyalanır ve api onlara kendi dosyasındaymış gibi bağımlı olabilir. Fark, dosyayı başka bir klasöre taşıdığında ortaya çıkıyor: include edilen dosya kendi yollarıyla çalışmaya devam eder. Bu yüzden include "başka bir ekibin ya da başka bir repo'nun parçasını olduğu gibi al" için, -f "aynı uygulamanın ortama göre farkı" için.
Ad çakışmasında Compose Specification bir uyarı verilmesini ve tanımların birleştirilmemesini söylüyor. Compose 5.7.1'de denedik: ana dosyaya image: mcr.microsoft.com/mssql/server:2022-latest içeren bir db ekleyince hiçbir uyarı çıkmadı ve iki tanım birleştirildi, image 2022 oldu, healthcheck ve secret'lar include edilen dosyadan geldi. Bu davranışa güvenme; include edilen bir service'i değiştirmek istiyorsan bunu açıkça bir override dosyasında yap.
extends ile Service Kalıtımı
migrate ile sqlcmd aynı şeyi yapıyordu: SQL Server image'ındaki istemciyi şifreyi dosyadan okuyarak çalıştırmak. İkisi ayrı ayrı yazıldığında, birinde düzeltilen bir bayrak ötekinde unutuluyordu. migrate'i sqlcmd'den türettik:
# Elle sorgu, up ile hiç başlamaz: docker compose run --rm sqlcmd -Q "SELECT COUNT(*) FROM dbo.Books"
sqlcmd:
image: mcr.microsoft.com/mssql/server:2025-latest
profiles: [cli]
entrypoint:
- /bin/bash
- -c
- >-
SQLCMDPASSWORD="$$(cat /run/secrets/sa-password)"
BOOKSTORE_DB_PASSWORD="$$(cat /run/secrets/db-app-password 2>/dev/null)"
exec /opt/mssql-tools18/bin/sqlcmd -C -S db -U sa -b "$$@"
- sqlcmd
environment:
SQLCMDDBNAME: BookstoreDb
secrets:
- sa-password
depends_on:
db:
condition: service_healthy
# Tek seferlik: BookstoreDb'yi ve uygulama login'ini oluşturur, 0 ile çıkar. sqlcmd ile aynı istemci.
migrate:
extends:
service: sqlcmd
profiles: !reset []
command: ["-i", "/scripts/01-bookstore.sql", "-i", "/scripts/02-app-login.sql"]
environment:
SQLCMDDBNAME: master
secrets:
- db-app-password
volumes:
- ./db:/scripts:ro
extends'in merge kuralları dosya katmanlarınınkine benziyor: environment map olduğu için SQLCMDDBNAME key'i ezildi (migrate henüz var olmayan BookstoreDb'ye değil master'a bağlanmalı), secrets listesi birleşti ve migrate iki secret'ı da aldı, profiles ise !reset ile temizlendi; yoksa migrate de cli profile'ında kalır ve up'ta hiç çalışmazdı. SQLCMDDBNAME, sqlcmd'nin -d yerine okuduğu environment variable; run'a verilen argümanlar entrypoint'teki "$$@"'a gittiği için veritabanını bayrakla vermek her çağrıda tekrarlanması gereken bir ayrıntı olurdu.
docker compose run --rm sqlcmd -W -Q "SELECT DB_NAME() AS Db, COUNT(*) AS Books FROM dbo.Books"
Db Books
-- -----
BookstoreDb 6
(1 rows affected)
extends mı, YAML anchor'ları ve x- ile başlayan extension field'ları mı? Anchor'lar yalnız aynı dosyada çalışır ve YAML'ın kendi özelliği; Compose onları hiç görmez, yalnız açılmış hâlini okur. extends başka bir dosyadaki service'ten de türetebilir (file:) ve Compose'un merge kurallarını uygular. Birkaç satırlık tekrar için anchor, service düzeyinde paylaşılan bir tanım için extends.
CI: Temiz Veritabanı, --wait ve Exit Code
Integration testleri için her koşuda boş bir veritabanı, host'a açılmamış port'lar ve sonucu exit code'la bildiren bir adım istiyoruz:
# CI: docker compose -f compose.yaml -f compose.ci.yaml up -d --wait, sonra run --rm smoke
services:
db:
# Her koşuda temiz veritabanı: veri bellekte durur, down'dan sonra kalmaz
volumes: !override
- type: tmpfs
target: /var/opt/mssql
tmpfs:
size: 2g
# SQL Server uid 10001 ile çalışıyor; yeni bir tmpfs root'a ait
mode: 01777
web:
# CI runner'da hiçbir şey yayımlanmaz; smoke test proje network'ünde çalışır
ports: !reset []
smoke:
image: alpine:3
profiles: [test]
command:
- sh
- -c
- >-
n=$$(wget -qO- http://web:8080/api/books | grep -o '"isbn"' | wc -l);
echo "books: $$n"; test "$$n" -eq 6
tmpfs'teki mode'u ilk denemede yazmamıştık; SQL Server açılmadı: The system directory [/.system] could not be created ... Permission denied. Docker serisinin 11. yazısında docker run ile aynı hatayı tmpfs-mode=1777 ile çözmüştük; Compose'daki karşılığı mode: 01777 (baştaki sıfır YAML'da sekizlik sayı demek). Web'e bir healthcheck de ekledik (wget -q --spider http://127.0.0.1:8080/), çünkü --wait yalnız healthcheck'i olan service'lerin hazır olduğunu anlayabiliyor. Pipeline adımı tek bir script (labs/compose/05-ci.sh):
#!/usr/bin/env bash
# CI adımı: stack'i compose.ci.yaml ile başlat, sağlıklı olmasını bekle, smoke test'i çalıştır; exit code job'ı düşürür
set -euo pipefail
export COMPOSE_PROJECT_NAME=composeseries-ci
export COMPOSE_FILE=compose.yaml:compose.ci.yaml
# Bir adım başarısız olsa da temizle: veritabanı tmpfs'te, hiçbir şey saklanmıyor
trap 'docker compose down -v' EXIT
docker compose up -d --wait
docker compose run --rm smoke
labs/compose/05-ci.sh; echo "exit code: $?"
Container composeseries-ci-migrate-1 Exited
Container composeseries-ci-db-1 Healthy
Container composeseries-ci-api-1 Healthy
Container composeseries-ci-web-1 Healthy
books: 6
Container composeseries-ci-web-1 Removed
Container composeseries-ci-api-1 Removed
Container composeseries-ci-migrate-1 Removed
Container composeseries-ci-db-1 Removed
Network composeseries-ci_default Removed
exit code: 0
Image'lar cache'teyken bütün adım 32 saniye sürdü. --wait, Exited (0) olan migrate'i hata saymadı. Smoke test başarısız olsaydı run container'ın exit code'unu döndürür, set -e script'i durdurur ve trap yine de temizlerdi; sh -c "exit 3" ile denediğimizde run 3 döndü. COMPOSE_PROJECT_NAME'i ayrı verdik: aynı makinede geliştirme stack'i açıkken CI adımını çalıştırmak, ayrı bir proje adı olmadan geliştirme container'larını yeniden oluşturur. Tek komutluk bir alternatif de var: docker compose --profile test up --abort-on-container-exit --exit-code-from smoke. Denediğimizde smoke'un hiçbir şeyi beklemediği için web'den önce başladığını gördük: wget: bad address 'web:8080', books: 0, exit 1 ve Compose bütün stack'i durdurdu. Bu yolu seçersen test service'ine depends_on: web: condition: service_healthy vermen gerekiyor; up --wait ile run'ı ayırmak bu bağımlılığı script'e taşıyor.
Aynı makinede iki SQL Server'ı (geliştirme ve CI) birlikte çalıştırdığımız bir denemede CI'daki veritabanı bellek yetmediği için çöktü ve --wait 70 saniye sonra container composeseries-ci-db-1 is unhealthy ile başarısız oldu. CI runner'ın belleğini ve tmpfs'in boyutunu birlikte hesapla: tmpfs'teki veri de RAM'den yer.
Compose mu, Testcontainers mı? Compose'la kurulan ortam test kodundan bağımsız: aynı stack'e smoke test, Playwright ve elle deneme aynı anda bağlanabilir, ama testler ortamın hazır olduğunu varsayar ve izolasyon ancak proje adıyla sağlanır. Testcontainers'ta container'ı test kodu başlatır, her test sınıfı kendi veritabanını alabilir ve test dotnet test'ten başka bir şey gerektirmez; bedeli, ortamın yalnız testlerin içinde var olması. Integration testleri için Testcontainers'ı, birden çok service'in birlikte çalıştığı uçtan uca senaryolar için Compose'u tercih ediyoruz; ayrıntısı Integration ve E2E Test serisinde.
Üretimde Dikkat
Production'a giden dosya listesini bir yere yaz ve elle yazılmasına bırakma: COMPOSE_FILE'ı sunucudaki .env'e koymak ya da deploy script'inde sabitlemek, birinin bir gün docker compose up -d yazıp production'a compose.override.yaml'ı (Mailpit, açık 1433 port'u) yüklemesini önler. Override dosyasını production sunucusuna hiç kopyalamamak daha da güvenli. Her ortam dosyasının son hâlini docker compose config ile üretip pull request'te gözden geçirmek, merge kurallarının sürprizlerini production'dan önce yakalar; config --hash "*" service başına bir hash verir ve iki ortam arasındaki farkı hızlıca gösterir.
Sık Düşülen Tuzaklar
- Override'da port listesinin değiştirileceğini sanmak: farklı port'lar birleşir ve base'deki port açık kalır; değiştirmek için
!override, silmek için!reset. -fverdikten sonracompose.override.yaml'ın da yüklendiğini sanmak: açıkça verilmedikçe yüklenmez.command'a override'da argüman eklemeye çalışmak: liste olsa da birleşmez, tamamen değişir.- Ortam başına tam bir dosya kopyalamak: kopyalar ayrışır; yalnız farkı yaz ve ortak kısmı tek yerde tut.
- Include edilen dosyadaki göreli yolları ana dosyaya göre yazmak: include edilen dosyanın yolları kendi klasörüne göre çözülür.
- CI ve geliştirme stack'lerini aynı proje adıyla çalıştırmak: biri ötekinin container'larını yeniden oluşturur ya da
down -vile verisini siler. - tmpfs'i non-root bir veritabanı için
modevermeden bağlamak: container açılmaz.
Küçük Bir Deneme
★☆☆ compose.prod.yaml'dan !override'ı kaldır, docker compose -f compose.yaml -f compose.prod.yaml up -d web çalıştır ve docker compose ps'in PORTS sütununa bak. Sonra !override'ı geri koy ve tekrar up -d de; Compose container'ı yeniden oluşturdu mu?
★★☆ compose.ci.yaml'a web için APP_ENVIRONMENT: ci ekle ve smoke test'i config.json'daki environment değerini de kontrol edecek şekilde genişlet. environment map'i nasıl birleşti, API_BASE_URL kayboldu mu?
★★★ compose.db.yaml'ı ayrı bir klasöre (infra/) taşı ve include'u path ve env_file ile long syntax'a çevir. Secret dosyalarının yolları nasıl çözülüyor? Aynı şeyi -f infra/compose.db.yaml ile yapmayı dene ve iki yaklaşımın docker compose config çıktılarını karşılaştır.
Elimizde artık production'a benzeyen bir dosya var, ama hâlâ image'ları sunucuda build ediyor ve restart, log ve kaynak limitleri konusunda hiçbir şey söylemiyor. Bir sonraki yazıda Bookstore'u tek bir sunucuda Compose ile çalıştırıyoruz: registry'den immutable tag'li image'lar, pull ve up -d akışı, log rotation, resource limit'leri, yedekleme ve Compose'un nerede yetmediği.