Mert Özen Her Satırda Daha İleri
DevOps CI/CD Temelleri — #05

Environment'lar ve Promotion: Dev, Test, Staging, Production ve Configuration

Mert Özen 8 Kas 2026 9 dk 7 görüntülenme
Environment'lar ve Promotion: Dev, Test, Staging, Production ve Configuration

Aynı artifact dev, test, staging ve production'a yeniden build edilmeden taşınır; basamaktan basamağa değişen tek şey ortamdan gelen configuration olmalıdır. ASP.NET Core'da appsettings.{Environment}.json ve ortam değişkenleriyle katmanlı configuration, ValidateOnStart ile açılışta kırılan eksik ayar, staging ile production arasında fark envanteri, PR başına preview environment ve non-prod veriyi maskelemek.

Sürüm notu: Bu yazıdaki örnekler .NET 10 (SDK 10.0.402), Docker 29, PostgreSQL 18, act 0.2.97 ve actionlint 1.7.12 ile denenmiştir.

Önce Tahmin Et

Bookstore API'sinin (Application Programming Interface) aynı image'ı staging'de sorunsuz çalışıyor. Aynı image'ı production'da başlatıyorsun ama ödeme servisinin adresini veren ortam değişkenini eklemeyi unutuyorsun. Uygulama açılır mı, açılır da ilk ödeme isteğinde mi patlar, yoksa hiç açılmaz mı? Ve docker ps o container'ı hangi durumda gösterir? İkinci sorunun cevabı beni de şaşırttı.

Environment Zinciri

Bookstore'un artifact'ı production'a varmadan dört durak geçiyor. Her durağın amacı ve kitlesi farklı:

EnvironmentAmaçKim kullanırVeri
DevelopmentGeliştirici makinesi, hızlı denemeGeliştiriciSeed data
TestOtomatik integration ve e2e testleriPipelineHer koşuda sıfırdan
StagingProduction'a en yakın provaQA (Quality Assurance), ürün ekibiMaskelenmiş ya da sentetik
ProductionGerçek kullanıcılarMüşterilerGerçek veri

Promotion, aynı artifact'ın bir duraktan diğerine taşınması demek. Bir önceki yazıdaki ilkeyi hatırla: hiçbir durakta yeniden build yok. Değişen tek şey artifact'ın içine değil, yanına verilen configuration.

05-promotion-tr.png

Durak sayısı kutsal değil. Küçük bir ekip için "PR'da (pull request) test, merge'de staging, onayla production" üç durak yeterli; regülasyonlu bir ortamda ayrı bir UAT (User Acceptance Testing) durağı eklenebilir. Her yeni durak bir bekleme noktası ve bir bakım yükü getiriyor; eklemeden önce o durağın hangi riski yakaladığını söyleyebilmelisin.

Configuration'ı Artifact'tan Ayırmak

The Twelve-Factor App'in üçüncü maddesi bunu tek cümleyle söylüyor: configuration'ı environment'ta tut. Kodla birlikte değişmeyen ama deploy'dan deploy'a değişen her şey (bağlantı adresleri, credential'lar, zaman aşımı değerleri) artifact'ın dışından gelmeli. ASP.NET Core'da bunun altyapısı hazır; mekaniğini ASP.NET Core Web API serisinin "ASP.NET Core'da Program.cs ve Hosting Modeli: WebApplication ve Environment'lar" ve "ASP.NET Core'da Configuration ve Options Pattern: Provider Sırası, User Secrets ve Validation" yazılarında anlattım. Burada pipeline açısından bakalım.

Bookstore ödeme servisine bağlanıyor. Ayarlar bir options class'ında:

using System.ComponentModel.DataAnnotations;

namespace Bookstore.Api.Payments;

public sealed class PaymentOptions
{
    [Required, Url]
    public string BaseUrl { get; init; } = "";

    [Range(1, 60)]
    public int TimeoutSeconds { get; init; } = 10;
}

Program.cs'te bind ediliyor, doğrulanıyor ve açılışta log'a yazılıyor:

builder.Services.AddOptions<PaymentOptions>()
    .BindConfiguration("Payment")
    .ValidateDataAnnotations()
    .ValidateOnStart();

var app = builder.Build();

PaymentOptions payment = app.Services.GetRequiredService<IOptions<PaymentOptions>>().Value;
app.Logger.LogInformation("Environment {Environment}, payment gateway {BaseUrl}, timeout {Timeout}s",
    app.Environment.EnvironmentName, payment.BaseUrl, payment.TimeoutSeconds);

Değerler katmanlı geliyor. appsettings.json bütün environment'ların ortak varsayılanını taşıyor, appsettings.Staging.json sadece staging'e özgü olanı:

// appsettings.json
{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning"
    }
  },
  "AllowedHosts": "*",
  "Payment": {
    "TimeoutSeconds": 10
  }
}

// appsettings.Staging.json
{
  "Payment": {
    "BaseUrl": "https://sandbox.payments.example/api"
  }
}

Production'ın değeri ise image'da hiç yok; deploy sırasında ortam değişkeni olarak geliyor. ASP.NET Core'da iç içe anahtarlar ortam değişkeninde çift alt çizgiyle yazılıyor: Payment__BaseUrl. Aynı image'ı iki container'da başlattım:

$ docker run -d --name cicdwork-api-staging -p 127.0.0.1:18101:8080 \
    -e ASPNETCORE_ENVIRONMENT=Staging bookstore/api:37ec7a4
$ docker logs cicdwork-api-staging
info: Bookstore.Api[0]
      Environment Staging, payment gateway https://sandbox.payments.example/api, timeout 10s
info: Microsoft.Hosting.Lifetime[14]
      Now listening on: http://[::]:8080

$ docker run -d --name cicdwork-api-prod -p 127.0.0.1:18102:8080 \
    -e ASPNETCORE_ENVIRONMENT=Production \
    -e Payment__BaseUrl=https://payments.example/api \
    -e Payment__TimeoutSeconds=5 bookstore/api:37ec7a4
$ docker logs cicdwork-api-prod
info: Bookstore.Api[0]
      Environment Production, payment gateway https://payments.example/api, timeout 5s

Tek image, iki davranış. Bu satırlardaki BaseUrl gibi değerler gizli değil; parola ya da API anahtarı gibi secret'lar ortam değişkenine de düz yazılmaz, bir secret store'dan gelir. Onu yedinci yazıda ve ilerideki Configuration ve Secret Management serisinde ele alacağız.

Açılışta Kır: Eksik Configuration

Giriş sorusuna dönelim. Production container'ını Payment__BaseUrl olmadan başlattığımda:

$ docker logs cicdwork-api-prod
Unhandled exception. Microsoft.Extensions.Options.OptionsValidationException: DataAnnotation validation failed for 'PaymentOptions' members: 'BaseUrl' with the error: 'The BaseUrl field is required.'.
   at Microsoft.Extensions.Options.OptionsFactory`1.Create(String name)
   at Microsoft.Extensions.Options.UnnamedOptionsManager`1.get_Value()

Uygulama ilk ödeme isteğini beklemeden, açılışta düştü. Bu iyi bir şey: ValidateOnStart olmasaydı uygulama açılır, health check'ler yeşil olur ve hata saatler sonra ilk müşterinin ödemesinde ortaya çıkardı. Eksik configuration'ın deploy anında, deploy'u yapan kişinin gözü önünde kırılması gerekir.

Şaşırtıcı kısım docker ps'teydi. Uygulama çöktüğü halde container 20 saniye boyunca "running" görünmeye devam etti:

$ docker run -d --name cicdwork-api-prodfail -e ASPNETCORE_ENVIRONMENT=Production bookstore/api:37ec7a4
2s running exit=0
4s running exit=0
…
20s running exit=0

$ docker run -d --init --name cicdwork-api-prodfail -e ASPNETCORE_ENVIRONMENT=Production bookstore/api:37ec7a4
2s exited exit=134

Container'da dotnet süreci PID (Process ID) 1 olarak çalışıyor. En olası açıklama PID 1'in sinyal davranışı: Linux, bir PID namespace'inin ilk sürecine handler'ı olmayan sinyallerin varsayılan davranışını uygulamıyor, bu yüzden runtime'ın unhandled exception sonrası kendine gönderdiği abort sinyali süreci hemen sonlandırmamış olabilir. Kesin olan şu: --init ile küçük bir init süreci PID 1 oldu ve container 134 çıkış koduyla hemen kapandı. Ders şu: deploy'un başarısını "container çalışıyor mu" sorusuyla değil, health check ile ölç. Bunu bir sonraki yazıda smoke test olarak pipeline'a ekleyeceğiz. Kubernetes gibi orkestratörlerde bu davranış farklı olabilir; ilerideki Kubernetes serisinde bakacağız.

Dev/Prod Parity

Twelve-Factor App'in onuncu maddesi environment'lar arasındaki farkı üç boşlukla tarif ediyor: zaman (kodun yazılmasıyla production'a çıkması arasındaki süre), kişi (kodu yazanla işleteni ayrı olması) ve araç (geliştirmede SQLite, production'da PostgreSQL gibi). Continuous delivery ilk ikisini zaten daraltıyor. Üçüncüsü bilinçli çaba istiyor.

Bookstore'da kural basit: staging production ile aynı image'ı, aynı PostgreSQL ana sürümünü ve aynı reverse proxy configuration'ını kullanıyor. Geliştirici makinesinde de PostgreSQL bir container'da koşuyor; bir önceki yazıdaki integration testleri de aynı image'ı kullanıyor. "Lokalde SQLite yeterli" kararı, PostgreSQL'e özgü bir sorgu hatasının ancak staging'de ortaya çıkması demek.

"Staging'de Çalışıyordu": Fark Envanteri

Staging'de yeşil olan build production'da patladığında ilk iş tahmin değil, envanter. Aynı image'dan geldiklerini doğrula, sonra farkları listele. Configuration farkı için iki container'ın ortam değişkenlerini karşılaştırmak yeterli:

$ docker inspect --format '{{.Image}}' cicdwork-api-staging cicdwork-api-prod
sha256:65e2f2f87061b7f062285c55286280310d168b917036141857d7aeb20f6cf44a
sha256:65e2f2f87061b7f062285c55286280310d168b917036141857d7aeb20f6cf44a
$ diff <(docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}' cicdwork-api-staging | sort) \
       <(docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}' cicdwork-api-prod | sort)
3c3
< ASPNETCORE_ENVIRONMENT=Staging
---
> ASPNETCORE_ENVIRONMENT=Production
8a9,10
> Payment__BaseUrl=https://payments.example/api
> Payment__TimeoutSeconds=5

Image aynı, fark üç satır. Bu kadar net bir liste, "neden farklı davranıyor" sorusunun cevabını genellikle dakikalar içinde verir. Configuration dışında bakılacak yerler:

  • Veri hacmi: staging'de 3.000 sipariş, production'da 30 milyon; index'siz sorgu staging'de 20 ms, production'da 20 saniye.
  • Network: production'da araya giren firewall, proxy, farklı DNS (Domain Name System) kayıtları ve TLS (Transport Layer Security) sertifikaları.
  • Kaynak sınırları: container'a verilen bellek ve CPU (Central Processing Unit) limitleri, bağlantı havuzu boyutları.
  • Bağımlılık sürümleri: veritabanı minor sürümü, ödeme servisinin sandbox ve canlı API'leri arasındaki davranış farkları.
  • Trafik: eşzamanlı istek sayısı, cache'in soğuk ya da sıcak olması.

"Staging production'a benziyor" bir varsayım; bu listeyi düzenli olarak kontrol etmek onu bir gerçeğe çevirir.

PR Başına Preview Environment

Bazı ekipler her PR için geçici bir environment açıyor: ürün yöneticisi değişikliği merge'den önce gerçek bir adreste görebiliyor. Bookstore'da iskeleti şöyle:

name: preview

on:
  pull_request:
    types: [opened, synchronize, reopened, closed]

concurrency:
  group: preview-${{ github.event.number }}
  cancel-in-progress: true

jobs:
  deploy:
    if: github.event.action != 'closed'
    runs-on: ubuntu-latest
    environment:
      name: pr-${{ github.event.number }}
      url: https://pr-${{ github.event.number }}.preview.bookstore.example
    steps:
      - run: echo "deploy ${GITHUB_SHA::7} to pr-${{ github.event.number }} with seed data"

  teardown:
    if: github.event.action == 'closed'
    runs-on: ubuntu-latest
    steps:
      - run: echo "delete pr-${{ github.event.number }} and its database"

Deploy ve silme adımları burada yer tutucu; gerçek komutlar platforma göre değişiyor (Kubernetes namespace'i, bir PaaS (Platform as a Service) uygulaması). Önemli olan iskelet: PR açıldığında ya da yeni commit geldiğinde deploy, kapandığında silme ve concurrency ile aynı PR'ın iki deploy'unun yarışmaması. İki olayı act ile, PR numarası 42 olan event dosyalarıyla koşturdum:

$ act pull_request -W .github/workflows/preview.yml -e events/opened.json
[preview/deploy  ]   | deploy a6b8464 to pr-42 with seed data
[preview/deploy  ] 🏁  Job succeeded
$ act pull_request -W .github/workflows/preview.yml -e events/closed.json
[preview/teardown]   | delete pr-42 and its database
[preview/teardown] 🏁  Job succeeded

Preview environment'lar UI (User Interface) değişikliklerinde ve ürün incelemesinde çok değerli. Maliyeti ise gerçek: her PR bir uygulama ve bir veritabanı demek, kapanmayan PR'lar unutulmuş kaynaklar demek. Silme job'ına ek olarak, belirli bir süreden eski preview'ları temizleyen zamanlanmış bir job koymak iyi bir sigorta.

Non-Prod'da Veri

Staging'in gerçekçi olması için veriye ihtiyaç var, ama production verisini olduğu gibi kopyalamak kişisel verinin kontrolsüz yayılması demek; KVKK (Kişisel Verilerin Korunması Kanunu) ve GDPR (General Data Protection Regulation) gibi düzenlemeler açısından da risk. İki yol var: tamamen sentetik seed data ya da maskelenmiş kopya. Maskelenmiş kopyada kişiyi tanımlayan alanlar, kopya staging'e açılmadan önce değiştirilir. Bookstore'un staging veritabanında:

UPDATE customers
SET full_name = 'Customer ' || id,
    email = 'customer' || id || '@example.test',
    phone = NULL;

SELECT id, full_name, email, phone, city FROM customers ORDER BY id;
UPDATE 3
 id | full_name  |         email          | phone |    city
----+------------+------------------------+-------+------------
  1 | Customer 1 | customer1@example.test |       | London
  2 | Customer 2 | customer2@example.test |       | Manchester
  3 | Customer 3 | customer3@example.test |       | Arlington
(3 rows)

Şehir alanı kaldı, çünkü kargo hesabı gibi işlerin test edilmesi için gerekli ve tek başına kişiyi tanımlamıyor. Hangi alanın maskeleneceği bir karar; bunu bir kez yazıya dökmek ve maskeleme script'ini pipeline'ın bir adımı yapmak, "bu sefer unuttuk" riskini kaldırır. @example.test gibi ayrılmış bir domain kullanmak da staging'den yanlışlıkla gerçek müşteriye e-posta gitmesini engeller.

Üretimde Dikkat

Configuration değişikliği de bir değişiklik. MinimumCD'nin "configuration artifact ile birlikte deploy edilir" maddesi bunu söylüyor: production'da bir zaman aşımı değerini elle değiştirmek, review'dan geçmemiş ve geçmişi olmayan bir deploy yapmak demek. Environment'a özgü değerleri de repository'de (secret'lar hariç) ya da versiyonlanan bir configuration deposunda tut ve pipeline ile uygula.

Environment adına göre iş mantığı yazma. if (env.IsProduction()) ile ödeme davranışını değiştiren kod, staging'de test ettiğin yolun production'da hiç çalışmaması demek. Environment'lar arasında farklı olması gereken şey davranış değil, değer olmalı.

Configuration drift'i izle. Zamanla elle yapılan küçük değişiklikler environment'ları birbirinden uzaklaştırır. Yukarıdaki diff gibi bir karşılaştırmayı düzenli koşturmak, farkı bir olay sırasında değil sakin bir günde fark etmeni sağlar.

Sık Düşülen Tuzaklar

"Staging production'a benziyor" varsayımı: veri hacmi, network ve kaynak sınırları hiç karşılaştırılmadıysa benzerlik sadece isimde kalır.

Environment başına build: environment.prod.ts gibi dosyalar ya da #if PRODUCTION gibi derleme sabitleri her environment için ayrı artifact üretir. Configuration'ı runtime'a taşı.

Paylaşılan staging veritabanında çakışan testler: iki pipeline aynı anda aynı test verisini değiştirince sonuçlar rastgeleleşir. Otomatik testler kendi izole verisini kurmalı.

Eksik configuration'ı sessizce varsayılana düşürmek: boş bir BaseUrl ile açılan uygulama hata vermez, ama yanlış yere istek atar. Doğrulamayı açılışa koy.

Küçük Bir Deneme

★☆☆ Kendi API'nde kritik bir options class'ına ValidateDataAnnotations ve ValidateOnStart ekle, sonra zorunlu değeri vermeden başlat. Hata açılışta mı, ilk istekte mi geldi?

★★☆ Staging ve production container'larının (ya da pod'larının) ortam değişkenlerini yukarıdaki diff ile karşılaştır. Beklemediğin bir fark çıktı mı?

★★★ Staging veritabanın için bir maskeleme script'i yaz, hangi alanı neden maskelediğini ya da bıraktığını bir tabloda gerekçelendir ve script'i pipeline'da kopyalama adımının hemen arkasına koy.

Bir sonraki yazıda promotion'ın kontrol noktalarına bakacağız: manuel approval ile otomatik gate'ler arasındaki fark, kimin production'a deploy edebileceği ve DORA (DevOps Research and Assessment) araştırmasının ağır onay süreçleri hakkında söyledikleri.