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

Development Environment: Compose Watch, Hot Reload ve "Hangi Parça Container'da?" Kararı

Mert Özen 14 Kas 2026 10 dk 13 görüntülenme
Development Environment: Compose Watch, Hot Reload ve "Hangi Parça Container'da?" Kararı

Compose Watch, develop.watch kurallarıyla host'taki değişiklikleri çalışan container'a taşır: sync dosyayı kopyalar ve hot reload'lu araçlara yeter, rebuild image'ı yeniden build edip container'ı yeniler. Bağımlılıklar-Compose'da/uygulama-IDE'de, her şey container'da ve Dev Container modellerini karşılaştırıyor, Bookstore.Api'de 5 saniyelik rebuild'i, Angular dev server'ında sync'i ve non-root kullanıcıda silme izni tuzağı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, .NET SDK 10.0.404, Angular 22.2 (Angular CLI 22.2.7, Node.js 26) ve SQL Server 2025 ile denenmiştir.

Önce Tahmin Et

Bookstore.Api'nin Program.cs'inde bir log mesajını değiştirdin. Bugüne kadarki akışla değişikliğin container'da görünmesi için docker compose up -d --build api yazman gerekiyordu. Compose Watch açıkken dosyayı kaydettikten kaç saniye sonra yeni log satırını görürsün: 5 mi, 30 mu, 60 mı? İkinci soru: Angular'ın dev server'ı container'da çalışıyor ve non-root bir kullanıcıyla. Host'ta bir dosyayı sildiğinde container'daki kopyası da silinir mi?

Hangi Parça Container'da Çalışmalı?

Geliştirme ortamı için üç model var ve hiçbiri her ekip için doğru değil.

(a) Yalnız bağımlılıklar Compose'da, uygulama IDE'de. SQL Server, Redis, Mailpit container'da; Bookstore.Api Visual Studio'da ya da dotnet run ile, bookstore-web ng serve ile host'ta çalışır. Breakpoint, hot reload ve profiler varsayılan olarak çalışır; debug en hızlı burada. Bedeli, host'ta .NET SDK ve Node.js'in doğru sürümlerinin kurulu olması ve uygulamanın container'daki hâliyle (chiseled image, non-root kullanıcı, Linux dosya sistemi) birebir aynı ortamda çalışmaması.

(b) Her şey container'da, Compose Watch ile. Host'ta Docker dışında bir şey gerekmiyor; yeni gelen biri docker compose watch ile bütün sistemi ayağa kaldırıyor. Bedeli, debug'ın daha zahmetli olması ve her değişikliğin bir sync ya da rebuild adımından geçmesi.

(c) Dev Container: IDE'nin kendisi bir container'ın içinde çalışır, SDK'lar image'da durur, bağımlılıklar yine Compose'la gelir. Kurulum tutarlılığı (b) kadar iyi, debug deneyimi (a)'ya yakın; Visual Studio Code'da en iyi çalışıyor. Ayrıntısı IDE'ler serisinin "VS Code ile Full-Stack .NET ve Angular: C# Dev Kit, Angular Language Service, Profiles ve Dev Container'lar" yazısında.

Bookstore'da iki yolu da açık tuttuk: günlük debug için (a), "projeyi ilk kez açtım, her şey çalışsın" ve uçtan uca denemeler için (b). Aynı compose.yaml ikisine de hizmet ediyor.

Model (a): Yalnız Bağımlılıklar

Compose'a service adı verildiğinde yalnız o service'leri ve onların bağımlılıklarını başlatır:

docker compose up -d db migrate

Veritabanı 127.0.0.1:1433'te yayımlı olduğu için host'taki API ona localhost,1433 ile bağlanabiliyor. Connection string'i bu sefer environment variable'la verdik; Visual Studio kullanıyorsan aynı değer user secrets'a da konabilir:

export ConnectionStrings__Default="Server=localhost,1433;Database=BookstoreDb;User Id=bookstore_app;Password=$(cat secrets/db-app-password.txt);TrustServerCertificate=True"
dotnet run --project src/Bookstore.Api --launch-profile http
      Now listening on: http://localhost:5080
      Hosting environment: Development
      Catalog source: SqlBookSource

Angular tarafında host'taki ng serve'ün API'ye aynı origin'den ulaşması için bir proxy config ekledik. Docker serisinde bookstore-web'in public/config.json'ı API'yi http://localhost:5080'te arıyordu, yani tarayıcı başka bir origin'e gidiyor ve CORS (Cross-Origin Resource Sharing; tarayıcının başka bir origin'e istek atmasına sunucunun izin vermesi) gerekiyordu. Artık config /api diyor ve dev server isteği hedefe iletiyor:

// ng serve proxy'si: /api/books -> <target>/books; tarayıcı tek origin'de kalır (geliştirmede CORS yok).
// API_PROXY_TARGET'ı Compose verir (http://bookstore-api:8080); host'taki dotnet run 5080'i dinler
export default {
  '/api': {
    target: process.env.API_PROXY_TARGET ?? 'http://localhost:5080',
    pathRewrite: { '^/api': '' },
  },
};

angular.json'da serve target'ına "options": { "proxyConfig": "proxy.conf.mjs" } eklendi. Config bir .mjs dosyası olduğu için hedefi environment variable'dan okuyabiliyor: host'ta varsayılan localhost:5080, container'da Compose'un verdiği adres. Production'daki nginx image'ı bundan etkilenmiyor; orada config.json container başlarken yazılıyor.

Model (b): Compose Watch

Watch, service'e eklenen bir develop.watch bölümüyle tanımlanıyor. Her kural bir host yolunu izliyor ve değişiklikte bir action çalıştırıyor:

  • sync: değişen dosyaları çalışan container'ın içine kopyalar; container yeniden başlamaz. Kendi hot reload'u olan araçlar (Angular dev server, dotnet watch) için.
  • rebuild: image'ı yeniden build edip container'ı yeniden oluşturur, up --build'in aynısı.
  • sync+restart: kopyalar ve container'ı yeniden başlatır; nginx config'i gibi açılışta okunan dosyalar için.
  • sync+exec: kopyalar ve container'da bir komut çalıştırır (ör. bir reload sinyali); restart ise yalnız yeniden başlatır.

Bookstore.Api için rebuild'i seçtik:

  api:
    ...
    develop:
      watch:
        # API kodunda bir değişiklik: image'ı yeniden build et, container'ı yeniden oluştur
        - path: src/Bookstore.Api
          action: rebuild
          ignore:
            - bin/
            - obj/

ignore'u açıkça yazdık ama aslında gerek yoktu: build context'in .dockerignore'u (**/bin/, **/obj/) watch için de geçerli. Bunu fark etmeden gördük: watch açıkken host'ta dotnet run ile bin/ ve obj/ klasörleri oluştu ve hiçbir rebuild tetiklenmedi. Bu ayar olmasa model (a) ile (b)'yi aynı anda kullanmak sonsuz bir rebuild döngüsü demekti.

Angular için ayrı bir service tanımladık, çünkü production'daki web service'i derlenmiş dosyaları nginx'le sunuyor, dev server değil. bookstore-web'in Dockerfile'ına dev adlı bir stage ekledik; final stage'e giden yolda olmadığı için production build'i onu hiç çalıştırmıyor:

# dev: Compose Watch için ng serve (docker compose --profile dev watch); final image'ın parçası değil
FROM node:26-alpine AS dev
ENV NG_CLI_ANALYTICS=false
WORKDIR /app
RUN chown node:node /app
USER node
COPY --chown=node:node package.json package-lock.json ./
RUN npm ci
COPY --chown=node:node . .
EXPOSE 4200
CMD ["npx", "ng", "serve", "--host", "0.0.0.0", "--port", "4200"]
  # Angular dev server: docker compose --profile dev up --watch web-dev
  web-dev:
    build:
      context: ./web
      target: dev
    profiles: [dev]
    environment:
      API_PROXY_TARGET: http://bookstore-api:8080
    ports:
      - "4200:4200"
    depends_on:
      api:
        condition: service_healthy
    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
        - path: web/public
          target: /app/public
          action: sync
        # Yeni paket: dev image'ı yeniden build et (npm ci image'ın içinde çalışır)
        - path: web/package-lock.json
          action: rebuild

--host 0.0.0.0 şart: dev server varsayılan olarak yalnız container'ın kendi localhost'unu dinler ve yayımlanan port'a gelen istekleri görmez. node_modules image'ın içinde, npm ci ile Linux için kuruluyor; watch yalnız src ve public'i senkronize ediyor.

Watch'ı Çalıştırmak

docker compose --profile dev watch
 Image composeseries-web-dev Built
 Image composeseries-api Built
 Container composeseries-db-1 Running
 Container composeseries-api-1 Recreate
 Container composeseries-api-1 Recreated
 Container composeseries-web-dev-1 Created
 ...
 Container composeseries-api-1 Healthy
 Container composeseries-web-dev-1 Starting
 Container composeseries-web-dev-1 Started
Watch enabled

docker compose watch önce up'ın yaptığını yapıyor (build, oluştur, başlat), sonra izlemeye geçiyor ve terminalde yalnız watch olaylarını gösteriyor; uygulama log'ları için ayrı bir terminalde docker compose logs -f. docker compose up --watch aynı işi log'larla birlikte tek terminalde yapıyor. API'nin Recreate edilmesi dikkat çekici: develop bölümünün eklenmesi bile service tanımını değiştirdiği için Compose container'ı yeniden oluşturdu.

app.html'de başlığı değiştirdik:

Syncing service "web-dev" after 2 changes were detected
web-dev-1  | Application bundle generation complete. [0.101 seconds] - 2026-11-14T13:55:59.762Z
web-dev-1  |
web-dev-1  | Component update sent to client(s).

Dosya container'a kopyalandı, dev server değişikliği yaklaşık 0,1 saniyede derledi ve tarayıcıya component güncellemesini gönderdi; sayfa yenilenmeden başlık değişti. "2 changes", editörün dosyayı geçici bir dosyaya yazıp yeniden adlandırmasından geliyor. Sonra ilk sorunun deneyi, API'deki log mesajı:

Rebuilding service(s) ["api"] after changes were detected...
 Image composeseries-api Building
 Image composeseries-api Built
service(s) ["api"] successfully built
 Container composeseries-api-1 Recreate
 Container composeseries-api-1 Recreated
 Container composeseries-api-1 Starting
 Container composeseries-api-1 Started 

Dosyayı kaydettikten 5 saniye sonra yeni log satırı geldi. Bunun sebebi Docker serisinde kurduğumuz Dockerfile düzeni: csproj önce kopyalanıp restore ediliyor, kod sonra; kod değişikliğinde restore layer'ı cache'ten geliyor ve yalnız dotnet publish çalışıyor (build log'unda 2,0 sn). Restore'u kodla aynı layer'a koyan bir Dockerfile'da aynı değişiklik her seferinde paketleri yeniden indirirdi.

Daha hızlısı da mümkün: SDK image'ında dotnet watch run çalıştırıp kodu sync ile vermek, hot reload'u container'a taşır. Bedeli 1,3 GB'lık SDK image'ının geliştirme ortamına girmesi ve uygulamanın production'daki chiseled image'dan farklı bir ortamda çalışması; 5 saniyelik rebuild bizim için yeterli olduğu için bu yolu seçmedik.

Watch'ın Sınırları ve Dosya İzinleri

Watch'ı durdurmak (Ctrl+C) container'ları durdurmuyor: docker compose watch sonlandığında dört service de çalışmaya devam ediyordu. İzleme bitiyor, sistem ayakta kalıyor.

Senkronizasyonun yönü tek: host'tan container'a. Container'ın içinde oluşan dosyalar (ör. .angular/cache) host'a gelmiyor; bu iyi bir şey, çünkü bind mount'ta yaşanan "container root olarak dosya yazdı, host'ta silemiyorum" sorunu burada yok. Compose'un dokümanı watch'ı yalnız build:'li service'ler için tarif ediyor; image'la tanımlı bir Redis service'ine sync+restart kuralı verdiğimizde Compose 5.7.1 dosyayı yine de kopyalayıp container'ı yeniden başlattı. rebuild ise doğası gereği bir build: tanımı ister.

İkinci sorunun cevabı dosya izinlerinde. Compose dosyaları container'a yazarken container'ın kullanıcısını sahip yapıyor, ama silme ve klasör işlemleri için container'ın içinde komut çalıştırıyor (image'da stat, mkdir ve rmdir bulunması bu yüzden şart). Küçük bir deneyde dosyaları root'a ait bir klasöre COPY edip USER nobody ile çalıştırdık; yeni dosya eklemek çalıştı ama host'ta bir dosyayı silince:

rm: can't remove '/app/src/index.txt': Permission denied
time="2026-11-14T14:00:28Z" level=warning msg="Error handling changed files: deleting paths in f043855b5cc4c4e6747c06a0ad15133cd925fafe1672658d568a2f2fc3aa518f: exit code 1"

Silinen dosya container'da kaldı ve dev server onu derlemeye devam ederdi. bookstore-web'in dev stage'inde COPY --chown=node:node ve RUN chown node:node /app bu yüzden var: container'ın kullanıcısı hedef klasörün sahibi olmalı.

initial_sync ve Watch'ın Başlangıç Durumu

Watch yalnız açıkken olan değişiklikleri görür. Watch kapalıyken web/src'de değişiklik yaptıysan ve container yeniden oluşturulmadıysa, container eski dosyalarla çalışmaya devam eder. Kurala initial_sync: true eklemek, watch başlarken mevcut container'daki dosyaları host'takilerle karşılaştırıp farkı kopyalamasını sağlıyor (Compose 2.39.4'ten beri; sync türü action'lar için). Bookstore'da web-dev container'ı her watch başlangıcında zaten image'dan yeniden oluşmadığı için bu ayarı eklemeye değer; bir sonraki yazıda dev ortamını ayrı bir dosyaya taşırken ekliyoruz.

Üretimde Dikkat

develop bölümü production'da hiçbir şey yapmıyor; watch komutu çalıştırılmadıkça Compose onu yok sayıyor. Yine de production'a gidecek dosyada dev server tanımı ve 4200 port'u durmamalı; dev'e özgü her şeyi bir sonraki yazıda compose.override.yaml'a taşıyacağız. dev stage'i Dockerfile'da durabilir: production build'i final'a giderken ona uğramıyor ve CI süresine eklenmiyor. Windows'ta repo'yu Windows dosya sisteminde (/mnt/c/...) değil, WSL'in (Windows Subsystem for Linux; Windows'un içinde çalışan Linux ortamı) kendi dosya sisteminde (~/ altında) tutmak hem build context'in gönderilmesini hem watch'ın dosya olaylarını hızlandırıyor; C:\ altındaki dosyalarda her işlem Windows ile Linux arasındaki dosya paylaşımından geçiyor.

Sık Düşülen Tuzaklar

  • Host'taki node_modules'u container'a senkronize etmek: Windows'ta kurulmuş native modüller Linux container'da çalışmaz; node_modules'u .dockerignore'a koy ve image'ın içinde npm ci ile kur.
  • Dev server'ı --host 0.0.0.0 olmadan başlatmak: port yayımlı görünür ama tarayıcı bağlantı kuramaz.
  • Non-root kullanıcı için hedef klasörün sahipliğini ayarlamamak: dosya eklemek çalışır, silmek Permission denied ile başarısız olur ve container host'tan farklılaşır.
  • Dockerfile'da restore'u kodla aynı layer'a koymak: her rebuild paketleri yeniden indirir ve watch döngüsü dakikalara çıkar.
  • Watch'ı durdurunca container'ların da durduğunu sanmak: docker compose watch bitince service'ler çalışmaya devam eder; kapatmak için docker compose --profile dev down.
  • bin/ ve obj/'u izlemek: host'ta build alındığında sürekli rebuild tetiklenir; .dockerignore ya da ignore ile dışarıda bırak.

Küçük Bir Deneme

★☆☆ web/nginx/default.conf için production web service'ine sync+restart kuralı ekle (target: /etc/nginx/conf.d/default.conf). nginx config'ine bir header ekleyip curl -I localhost:8080 ile kontrol et. Container yeniden başladığında config'in kalıcı olmadığını nasıl gösterirsin?

★★☆ Bookstore.Api için rebuild yerine SDK image'ında dotnet watch run çalıştıran bir api-dev service'i yaz ve src/Bookstore.Api'yi sync ile ver. Aynı log değişikliği bu sefer kaç saniye sürüyor? Hangi değişikliklerde (ör. yeni bir NuGet paketi) yine de rebuild gerekiyor?

★★★ Model (a) ile (b)'yi karıştır: API host'ta dotnet run ile, Angular container'da web-dev ile çalışsın. API_PROXY_TARGET'a hangi adresi vermelisin? Docker Desktop'ta host.docker.internal, Linux'ta extra_hosts: ["host.docker.internal:host-gateway"] ile dene ve API'nin hangi arayüzü dinlemesi gerektiğini Docker serisinin 12. yazısıyla karşılaştır.

Artık aynı compose.yaml'da production'a benzeyen tanımlar, dev server, watch kuralları ve geliştirme port'ları yan yana duruyor. Bir sonraki yazıda bunları compose.override.yaml, -f ile katmanlanan dosyalar, include ve extends ile dev, CI ve production'a benzer ortamlara ayırıyor ve merge kurallarının nerede şaşırttığını görüyoruz.