Mert Özen Her Satırda Daha İleri
DevOps Docker Compose — #05

Birden Çok Environment: Override Dosyaları, include, extends ve Merge Kuralları

Mert Özen 14 Kas 2026 11 dk 10 görüntülenme
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İçerikNe zaman yüklenir
compose.yamlapi, web, ortak secret ve config; include ile veritabanıHer zaman
compose.db.yamldb, migrate, sqlcmdcompose.yaml include ettiği için her zaman
compose.override.yamlWatch, web-dev, 1433 port'u, Redis, MailpitYalnız -f verilmediğinde, kendiliğinden
compose.ci.yamltmpfs'te veritabanı, yayımlanmayan port'lar, smoke test-f compose.yaml -f compose.ci.yaml
compose.prod.yaml80 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.
  • -f verdikten sonra compose.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 -v ile verisini siler.
  • tmpfs'i non-root bir veritabanı için mode vermeden 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.