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

Docker Compose'a Giriş: compose.yaml ile Bookstore'u Tek Komutla Ayağa Kaldırmak

Mert Özen 14 Kas 2026 11 dk 12 görüntülenme
Docker Compose'a Giriş: compose.yaml ile Bookstore'u Tek Komutla Ayağa Kaldırmak

Docker Compose, Bookstore'un API, web ve SQL Server container'larını, network'ünü ve volume'ünü tek bir compose.yaml'da tanımlar ve docker compose up -d ile hepsini birlikte kaldırır. Dosyanın anatomisini, build ile image farkını, proje adını, service adıyla DNS'i, logs/exec/config komutlarını, down ile down -v'nin veriye etkisini ve version alanının neden artık obsolete olduğunu 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), .NET 10 ve Angular 22 ile denenmiştir; Docker Desktop 4.99 Compose 5.7.0 ile geliyor ve buradaki komutlar onda da aynı çalışır.

Önce Tahmin Et

Bookstore'un API'si, Angular web'i ve SQL Server'ı tek bir compose.yaml'da tanımlı. Veritabanına kitapları yükledin, docker compose down dedin, ertesi sabah docker compose up -d ile her şeyi yeniden kaldırdın. Kitaplar hâlâ orada mı? Aynı soruyu bir de docker compose down -v için düşün. Son bir soru: service'in adı api, ama web container'ındaki nginx istekleri bookstore-api adına iletiyor. Tarayıcı kitap listesini görebilir mi?

README'deki 12 Adımdan Tek Dosyaya

Docker serisinde sitedeki serilerin ortak örnek uygulaması Bookstore'un (bir online kitapçı) parçalarını tek tek container'a aldık: Bookstore.Api için chiseled bir ASP.NET Core image'ı, bookstore-web için unprivileged nginx, BookstoreDb için SQL Server 2025 ve named volume. 12. yazının sonunda üçünü birlikte çalıştırmak şu demekti: bir network oluştur, bir volume oluştur, veritabanını doğru environment variable'larla başlat, script'leri çalıştır, API'yi connection string'le başlat, web'i API adresiyle ve port'uyla başlat. Her biri uzun bir docker run satırı. Ekibe yeni katılan biri bunları README'den sırayla kopyalıyor ve bir bayrağı unuttuğunda neyin bozulduğunu anlamak yarım gününü alıyor.

Docker Compose bu komutların hepsini bir YAML (girintiye dayalı, configuration dosyalarında yaygın bir veri biçimi) dosyasına, istenen durumu anlatan bir tanıma çeviriyor. Bu seride Bookstore'u bu dosyayla ayağa kaldıracağız: bu yazıda iskelet, sonra başlama sırası ve healthcheck, configuration ve secret'lar, geliştirme döngüsü, birden çok environment ve tek sunuculu production. Dockerfile, image, volume ve network'ün kendisi Docker serisinde anlatıldı; burada onların compose.yaml'daki karşılıklarına bakıyoruz.

compose.yaml'ın Anatomisi

Repo'nun köküne şu dosyayı ekledik:

# Tek komutla Bookstore: SQL Server, Bookstore.Api ve bookstore-web
name: composeseries

services:
  db:
    image: mcr.microsoft.com/mssql/server:2025-latest
    environment:
      ACCEPT_EULA: "Y"
      MSSQL_SA_PASSWORD: ${MSSQL_SA_PASSWORD}
    volumes:
      - db-data:/var/opt/mssql
      - ./db:/scripts:ro
    ports:
      - "127.0.0.1:1433:1433"

  api:
    build:
      context: .
      dockerfile: src/Bookstore.Api/Dockerfile
    environment:
      ConnectionStrings__Default: "Server=db;Database=BookstoreDb;User Id=bookstore_app;Password=${BOOKSTORE_DB_PASSWORD};TrustServerCertificate=True"
    networks:
      default:
        aliases:
          - bookstore-api

  web:
    build: ./web
    environment:
      API_BASE_URL: /api
      APP_ENVIRONMENT: compose
    ports:
      - "8080:8080"

volumes:
  db-data:

Üç top-level element var. services, çalışacak container'ların tanımı; her key bir service adı. volumes, Compose'un yöneteceği named volume'ler. networks burada yazılı değil, çünkü Compose her projeye kendiliğinden bir default network'ü açıyor ve bütün service'leri ona bağlıyor; ayrı network'ler gerektiğinde (ör. veritabanını web'den yalıtmak) bu element eklenir. API'nin altındaki networks.default.aliases ise bir service'e o network'te ek bir DNS adı veriyor; ona birazdan döneceğiz.

Service'lerin image'ı iki yoldan gelir. db'deki image: hazır bir image'ı registry'den çeker. api ve web'deki build: ise image'ı repo'daki Dockerfile'dan üretir: kısa form (build: ./web) yalnız context klasörünü verir ve o klasördeki Dockerfile'ı kullanır, uzun form context ile Dockerfile yolunu ayrı yazar. Bookstore.Api'nin Dockerfile'ı global.json'ı da kopyaladığı için context repo kökü olmalı. image: yazılmayan build edilmiş image'lara Compose <project>-<service> adını veriyor (composeseries-api); ikisi birlikte yazılırsa build sonucu o adla tag'lenir. Bunu 6. yazıda production için kullanacağız.

name: composeseries projenin adı. Compose bütün kaynaklarını bu adla öneklendiriyor ve aynı adı taşıyan kaynakları "kendi" sayıyor. Ad sırasıyla şu yerlerden gelir: -p bayrağı, COMPOSE_PROJECT_NAME environment variable'ı, dosyadaki name:, en son da dosyanın bulunduğu klasörün adı. name: yazmazsan repo'yu bookstore klasörüne klonlayan herkes bookstore, Bookstore-main klasörüne açan biri başka bir proje adı alır; aynı makinede iki kopya yan yana çalışacaksa tam istenen de bu olabilir. Biz adı sabitledik.

${MSSQL_SA_PASSWORD} ve ${BOOKSTORE_DB_PASSWORD} interpolation: Compose bu değerleri dosyayı okurken shell'den ya da proje klasöründeki .env dosyasından alıyor. Repo'ya yalnız boş bir .env.example koyduk, gerçek .env .gitignore'da. İki mekanizmanın (.env ve env_file) farkını ve şifrelerin environment variable'dan daha iyi bir yere taşınmasını 3. yazıda ayrıntılı göreceğiz.

İlk up: Ne Oluşturuldu?

# iki şifreyi doldur
cp .env.example .env
docker compose build
docker compose up -d
 Network composeseries_default Creating
 Volume composeseries_db-data Creating
 Volume composeseries_db-data Created
 Network composeseries_default Created
 Container composeseries-web-1 Creating
 Container composeseries-db-1 Creating
 Container composeseries-api-1 Creating
 Container composeseries-db-1 Created
 Container composeseries-api-1 Created
 Container composeseries-web-1 Created
 Container composeseries-web-1 Starting
 Container composeseries-api-1 Starting
 Container composeseries-db-1 Starting
 Container composeseries-web-1 Started
 Container composeseries-api-1 Started
 Container composeseries-db-1 Started 

docker compose build iki image'ı build etti. Compose 5.0'dan beri kendi içindeki builder'ı yok; build'i Docker serisinin 6. yazısında tanıdığımız Bake'e devrediyor, bu yüzden çıktı docker buildx bake'inkiyle aynı görünüyor. up -d ise bir network, bir volume ve üç container oluşturdu. Adlandırma düzenli: network ve volume <project>_<name>, container <project>-<service>-<index>. Sondaki -1, service ölçeklendiğinde (--scale api=2) artan replica numarası. -d (detached) olmadan Compose bütün service'lerin log'unu terminale akıtır ve Ctrl+C ile hepsini durdurur.

01-compose-project-tr.png

docker compose ps
NAME                  IMAGE                                        COMMAND                  SERVICE   CREATED                  STATUS                                     PORTS
composeseries-api-1   composeseries-api                            "dotnet Bookstore.Ap…"   api       Less than a second ago   Up Less than a second (health: starting)   8080/tcp
composeseries-db-1    mcr.microsoft.com/mssql/server:2025-latest   "/opt/mssql/bin/laun…"   db        Less than a second ago   Up Less than a second                      127.0.0.1:1433->1433/tcp
composeseries-web-1   composeseries-web                            "/docker-entrypoint.…"   web       Less than a second ago   Up Less than a second                      0.0.0.0:8080->8080/tcp, [::]:8080->8080/tcp

(health: starting) Dockerfile'daki HEALTHCHECK'ten geliyor; Compose onu olduğu gibi kullanıyor. Port'lar Docker serisinin 12. yazısındaki kurala uyuyor: dışarıya yalnız giriş noktası web açık; SQL Server'ı SSMS (SQL Server Management Studio; Microsoft'un SQL Server yönetim aracı) veya sqlcmd ile host'tan inceleyebilmek için yalnız 127.0.0.1'e yayımladık; API hiç yayımlanmadı.

Service'ler birbirini adıyla buluyor. Compose'un default network'ü user-defined bir bridge olduğu için Docker'ın gömülü DNS'i service adlarını (ve alias'ları) çözüyor. API'nin connection string'indeki Server=db bu yüzden çalışıyor. Web container'ından baktık:

docker compose exec web nslookup db | grep -A1 Name
docker compose exec web nslookup bookstore-api | grep -A1 Name
Name:	db
Address: 172.19.0.4
Name:	bookstore-api
Address: 172.19.0.3

bookstore-web'in nginx config'i Docker serisinde /api/ isteklerini bookstore-api:8080'e iletecek şekilde yazılmıştı. Service'e bookstore-api adını vermek yerine kısa adı korumak için alias ekledik: aynı container iki adla da çözülüyor. İlk sorunun son kısmının cevabı bu yüzden evet, ama alias olmasaydı nginx her /api/ isteğinde 502 dönerdi.

Günlük Komutlar: logs, exec ve config

Tarayıcıdan localhost:8080'i açtığında sayfa geliyor ama kitap listesi yerine "Catalog is not available" yazıyor. Sebebi log'da:

docker compose logs -f api
api-1  | info: Microsoft.Hosting.Lifetime[14]
api-1  |       Now listening on: http://[::]:8080
api-1  | info: Bookstore.Api[0]
api-1  |       Catalog source: SqlBookSource
api-1  |       Microsoft.Data.SqlClient.SqlException (0x80131904): Login failed for user 'bookstore_app'.

logs bütün service'lerin log'unu service adıyla öneklendirerek birleştirir; arkasına service adı yazınca yalnız onu, -f ile canlı akışı, --tail 50 ile son satırları verir. Hata beklenen bir hata: veritabanı boş, bookstore_app login'i ve BookstoreDb henüz yok. Docker serisinden gelen iki idempotent script'i (şema ve 6 kitap; yalnız okuma yetkili login) db container'ının içinde çalıştırıyoruz:

# .env'deki değişkenleri shell'e al
set -a; . ./.env; set +a
docker compose exec -e SQLCMDPASSWORD="$MSSQL_SA_PASSWORD" -e BOOKSTORE_DB_PASSWORD db \
    /opt/mssql-tools18/bin/sqlcmd -C -S localhost -U sa \
    -i /scripts/01-bookstore.sql -i /scripts/02-app-login.sql
curl -s localhost:8080/api/books | jq -r '.[0:3][] | "\(.title) | \(.priceTry)"'
Changed database context to 'BookstoreDb'.

(6 rows affected)
Changed database context to 'master'.
Changed database context to 'BookstoreDb'.
Çalıkuşu | 240.00
Dune | 420.00
Emma | 180.00

docker compose exec, docker exec'in service adıyla çalışan hâli: container adını (composeseries-db-1) bilmen gerekmiyor. Şifreyi -P ile komut satırına yazmak yerine SQLCMDPASSWORD environment variable'ıyla verdik; -e BOOKSTORE_DB_PASSWORD değer yazılmadığında shell'deki değeri taşıyor ve script onu $(BOOKSTORE_DB_PASSWORD) olarak okuyor. ./db:/scripts:ro bind mount'u script'leri container'a salt okunur veriyor. Bu elle yapılan adımı bir sonraki yazıda bir service'e çevireceğiz.

Üçüncü günlük komut docker compose config: dosyayı, interpolation'ları çözülmüş, göreli yolları mutlaklaştırılmış ve kısa formları uzun forma açılmış hâliyle basar. Birden çok dosya birleştirmeye başladığımızda (5. yazı) en çok güveneceğimiz araç bu olacak:

docker compose config
name: composeseries
services:
  api:
    environment:
      ConnectionStrings__Default: Server=db;Database=BookstoreDb;User Id=bookstore_app;Password=<app-password>;TrustServerCertificate=True
    networks:
      default:
        aliases:
          - bookstore-api
    build:
      context: /home/user/bookstore
      dockerfile: src/Bookstore.Api/Dockerfile
  db:
    ...
    volumes:
      - type: volume
        source: db-data
        target: /var/opt/mssql
        volume: {}
      - type: bind
        source: /home/user/bookstore/db
        target: /scripts
        read_only: true
        bind: {}
...
networks:
  default:
    name: composeseries_default
volumes:
  db-data:
    name: composeseries_db-data

Çıktıda şifreler açık yazılıyor (burada yer tutucuyla değiştirdik). config çıktısını bir issue'ya ya da CI log'una yapıştırmadan önce buna dikkat et.

down, down -v ve Volume'ün Ömrü

compose.yaml'da bir şey değiştirip up -d'yi tekrar çalıştırdığında Compose yalnız tanımı değişen service'i yeniden oluşturur. APP_ENVIRONMENT'ı değiştirdik:

 Container composeseries-db-1 Running
 Container composeseries-api-1 Running
 Container composeseries-web-1 Recreate
 Container composeseries-web-1 Recreated
 Container composeseries-web-1 Starting
 Container composeseries-web-1 Started 

Compose her container'a, tanımının hash'ini bir label olarak yazıyor ve up'ta istenen durumla mevcut durumu karşılaştırıyor; veritabanı ve API'ye dokunulmadı. Şimdi ilk sorunun cevabı:

docker compose down
docker volume ls --filter name=composeseries
 Container composeseries-api-1 Removed
 Container composeseries-web-1 Removed
 Container composeseries-db-1 Removed
 Network composeseries_default Removed
DRIVER    VOLUME NAME
local     composeseries_db-data

down container'ları ve network'ü siler ama named volume'e dokunmaz. Ertesi sabah up -d yeni container'ları aynı volume'e bağlar ve /api/books yine 6 kitap döner. down -v ise projenin volume'lerini de siler:

docker compose down -v
docker compose up -d
curl -s -o /dev/null -w "GET /api/books: %{http_code}\n" localhost:8080/api/books
 Volume composeseries_db-data Removing
 Network composeseries_default Removing
 Volume composeseries_db-data Removed
 Network composeseries_default Removed
GET /api/books: 500

SQL Server boş bir instance olarak başladı ve API yine Login failed for user 'bookstore_app' aldı. Lokal makinede "temiz başlangıç" için down -v tam istediğin şey; production sunucusunda ise bütün veriyi silen bir komut. stop ve start container'ları silmeden durdurup başlatır, restart yeniden başlatır ama compose.yaml'daki değişiklikleri uygulamaz; değişiklik için her zaman up -d.

docker compose, docker-compose ve Dosya Adı

Eski blog yazılarında ve README'lerde docker-compose up göreceksin. O, Python ile yazılmış ilk Compose'du (v1) ve Haziran 2023'te desteği bitti. Bugünkü Compose Go ile yazılmış bir Docker CLI plugin'i ve docker compose (boşlukla) olarak çağrılıyor. Docker'ın resmi image'larında ve Docker Desktop'ta docker-compose adı da aynı binary'ye bağlı bir uyumluluk adı olarak duruyor:

docker-compose version
Docker Compose version v5.7.1

Yani tireli komut eski script'leri bozmasın diye var, ama arkasında v1 değil v5 çalışıyor. Ubuntu'nun kendi paketinden kurulmuş bir sunucuda ise docker-compose hâlâ eski Python sürümü olabilir; Docker serisinin 2. yazısı bu tuzağı anlatıyor.

Dosya adı için öncelik sırası compose.yaml, compose.yml, ardından geriye uyumluluk için docker-compose.yaml ve docker-compose.yml. İkisi birden varsa Compose uyarıp yenisini seçiyor:

WARN[0000] Found multiple config files with supported names: /home/user/bookstore/compose.yaml, /home/user/bookstore/docker-compose.yml
WARN[0000] Using /home/user/bookstore/compose.yaml

Eski dosyaların başındaki version: "3.8" satırı da artık bir şey ifade etmiyor. Compose bugün tek bir şemayı, Compose Specification'ı uyguluyor ve alanı görünce yalnızca uyarıyor:

WARN[0000] /home/user/bookstore/old-compose.yaml: the attribute `version` is obsolete, it will be ignored, please remove it to avoid potential confusion

Eski dosyaları okumayı ve bugünkü biçime çevirmeyi 7. yazıda ayrıntılı göreceğiz.

Üretimde Dikkat

Bu dosya bir geliştirme ortamı tanımı; production'a aynen taşınacak bir dosya değil. Production'da build: yerine registry'den immutable tag'li image:, restart policy, log rotation ve resource limit'leri gerekiyor; bunları 6. yazıda ekleyeceğiz. Bugünden geçerli olanlar: dışarıya yalnız giriş noktasını aç, veritabanı port'unu açacaksan 127.0.0.1'e bağla. Port'un önüne IP yazmazsan Docker onu bütün arayüzlerde yayımlar ve host'taki firewall kurallarını da atlar (Docker serisinin 12. yazısı). name:'i sabitlemek, aynı sunucuda iki ayrı projenin yanlışlıkla aynı container'ları sahiplenmesini de önler: Compose bir kaynağın projeye ait olup olmadığına com.docker.compose.project label'ına bakarak karar veriyor.

Ölçüm için iki hazır komut var: docker compose top service'lerin process'lerini, docker compose stats (Compose 5.7'den beri --aggregate ile proje toplamını da) CPU ve bellek kullanımını gösteriyor.

Sık Düşülen Tuzaklar

  • docker compose restart'ın compose.yaml'daki değişikliği uygulamasını beklemek: restart mevcut container'ı yeniden başlatır, yeni tanım için up -d gerekir.
  • Service adı yerine container adını connection string'e yazmak (Server=composeseries-db-1): proje adı değişince bozulur; DNS adı olarak service adını ya da alias'ı kullan.
  • localhost'u başka bir service'e ulaşmak için kullanmak: container'ın içindeki localhost o container'ın kendisi.
  • -f ile başka klasördeki bir dosyayı çalıştırınca .env'in bulunamaması: Compose .env'i proje klasöründe arar; /tmp/old.yaml ile denediğimizde iki uyarı aldık: The "MSSQL_SA_PASSWORD" variable is not set. Defaulting to a blank string.
  • Alışkanlıkla down -v yazmak: lokalde bile birkaç saatlik test verisini götürür; sunucuda felaket.
  • build:'li bir service'te kod değişikliğinden sonra yalnız up -d demek: image zaten varsa yeniden build edilmez; up -d --build ya da önce docker compose build.

Küçük Bir Deneme

★☆☆ docker compose up -d --scale web=2 çalıştır. Hata mesajını oku ve sebebini açıkla: iki replica aynı host port'unu (8080) isteyemez. ports'u "8080-8081:8080" aralığına çevirip tekrar dene.

★★☆ name: satırını sil ve repo'yu bookstore-copy adlı ikinci bir klasöre kopyalayıp iki kopyayı da başlatmayı dene. Hangi kaynaklar çakışıyor, hangileri çakışmıyor? Host port'unu kopyada değiştirince ikisi aynı anda çalışabiliyor mu?

★★★ docker compose config --format json çıktısını jq ile okuyarak bir service'in bütün environment variable adlarını (değerleri olmadan) listeleyen tek satırlık bir komut yaz. Bunu bir CI adımına koyarsan hangi bilgiyi log'a sızdırmamış olursun?

Bu yazıda veritabanı şemasını elle, exec ile yükledik ve API veritabanı hazır olmadan başladı. Bir sonraki yazıda başlama sırasını depends_on ve healthcheck koşullarıyla tanımlıyor, şema adımını tek seferlik bir migrate service'ine çeviriyor ve uygulama tarafında neden yine de retry gerektiğini görüyoruz.