Build Once, Deploy Many: Artifact, Versiyonlama ve Immutable Paket
Artifact'ı bir kez build edip aynısını, tag'i ya da digest'iyle staging'den production'a taşımak, staging'de test edilen şeyin production'a gitmesini garanti eder; aynı commit'ten iki kez build almak bile farklı image üretir. Lock dosyaları ve npm ci ile reproducible build, SHA ile sürüm damgalama, :latest tuzağı ve Angular için runtime config.json.
Sürüm notu: Bu yazıdaki örnekler .NET 10 (SDK 10.0.402), Angular 22.2, Node.js 24, Docker 29, nginx 1.30, act 0.2.97 ve actionlint 1.7.12 ile denenmiştir.
Önce Tahmin Et
Bookstore API'sini (Application Programming Interface) aynı commit'ten iki kez publish ediyorsun; arada bin ve obj klasörlerini siliyorsun. İki Bookstore.Api.dll'in SHA (Secure Hash Algorithm) ailesinden SHA-256 özeti aynı mı çıkar? Sonra aynı publish çıktısından docker build --no-cache ile iki image üretiyorsun. Image ID'leri aynı mı olur? İki cevabı da not et; ikisinin farklı çıkması bu yazının ana fikrini anlatıyor.
Artifact Nedir, Nerede Durur
Artifact, pipeline'ın build aşamasının ürettiği ve deploy edilecek olan şeydir. Bookstore'da dört türü var:
dotnet publishçıktısı: API'nin çalıştırılabilir dosyaları, bir klasör ya da zip.- NuGet paketi: başka projelerin kullandığı bir library'yi dağıtıyorsan (ör. ortak bir
Bookstore.Contracts). - Docker image: publish çıktısı ile runtime'ı tek pakette birleştiren, Bookstore'un asıl deploy birimi.
- Angular
distklasörü: statik dosyalar; bir web sunucusuna kopyalanır ya da kendi image'ına konur.
Artifact'ın bir adresi olmalı. Pipeline içi geçici dosyalar için CI (Continuous Integration) tool'unun kendi artifact deposu yeterli; GitHub Actions'ta bunlar varsayılan olarak 90 gün saklanıyor ve upload-artifact'ın retention-days parametresiyle kısaltılabiliyor. Deploy edilecek paketler ise kalıcı bir yerde durmalı: container image'ları için bir registry (GitHub Container Registry, Azure Container Registry, Harbor), NuGet ve npm paketleri için bir paket deposu (GitHub Packages, Azure Artifacts, Nexus, Artifactory). Rollback için önceki sürüme ulaşabilmek gerekiyor, bu yüzden saklama süresi en az "bir önceki release'e dönebileceğin" kadar olmalı.
Aynı Commit, İki Farklı Paket
Giriş sorusunun gerçek cevabı:
$ dotnet publish src/Bookstore.Api -c Release -o artifacts/api -p:Version=1.4.0
$ shasum -a 256 artifacts/api/Bookstore.Api.dll
31e4cc153cb47bdec85fcec57df6e271da3c988a93a623cf09514f9a9320c59a artifacts/api/Bookstore.Api.dll
$ rm -rf artifacts src/*/bin src/*/obj
$ dotnet publish src/Bookstore.Api -c Release -o artifacts/api -p:Version=1.4.0
$ shasum -a 256 artifacts/api/Bookstore.Api.dll
31e4cc153cb47bdec85fcec57df6e271da3c988a93a623cf09514f9a9320c59a artifacts/api/Bookstore.Api.dll
$ docker build --no-cache -q -f src/Bookstore.Api/Dockerfile -t bookstore/api:rebuild1 .
sha256:8953f0dc3f06320b49852b4931e35a6a74ae7eaa7242ed253eb132f3ce5b401a
$ docker build --no-cache -q -f src/Bookstore.Api/Dockerfile -t bookstore/api:rebuild2 .
sha256:bbcc266e11feafec62d8a1bd171d660e2bbaeffa69a499cfe59d1275ec7f41be
DLL (Dynamic Link Library) birebir aynı: C# derleyicisi .NET SDK'da varsayılan olarak deterministic çalışıyor, aynı girdiden aynı byte'ları üretiyor. Image'lar ise farklı. Kod aynı, base image aynı, dosyalar aynı; ama image katmanları oluşturulma zamanı gibi metadata da taşıyor ve bu her build'de değişiyor. Kullandığım Dockerfile da son derece sade:
FROM mcr.microsoft.com/dotnet/aspnet:10.0
WORKDIR /app
COPY artifacts/api/ .
USER $APP_UID
ENTRYPOINT ["dotnet", "Bookstore.Api.dll"]
Bu örnekte fark zararsız metadata. Gerçek hayatta yeniden build etmek daha fazlasını değiştirebilir: aspnet:10.0 tag'i her patch'te yeni bir image'ı gösterir, NuGet'te Version="10.*" gibi bir aralık yeni bir paketi çeker, runner'daki SDK bir patch ilerlemiş olabilir. Bunun canlı örneğini serinin ilk yazısında gördük: global.json 10.0.402 derken act'teki runner, rollForward: latestFeature yüzünden 10.0.403 kurdu. Staging'de test ettiğin paket ile production'a giden paket farklıysa, staging testinin production hakkında söylediği şey azalır.
Build Once, Deploy Many
Çözüm basit: build'i bir kez yap, çıkan artifact'ı adıyla ya da digest'iyle environment'lar arasında taşı. Bookstore'un release workflow'u:
name: release
on:
push:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
outputs:
image: ${{ steps.image.outputs.ref }}
steps:
- uses: actions/checkout@v7
- uses: actions/setup-dotnet@v6
with:
global-json-file: global.json
- run: dotnet publish src/Bookstore.Api -c Release -o artifacts/api -p:Version=1.4.0
- id: image
run: echo "ref=bookstore/api:${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT"
- run: docker build -f src/Bookstore.Api/Dockerfile -t "${{ steps.image.outputs.ref }}" .
deploy-staging:
needs: build
runs-on: ubuntu-latest
steps:
- run: docker image inspect --format '{{.Id}}' "${{ needs.build.outputs.image }}"
deploy-production:
needs: [build, deploy-staging]
runs-on: ubuntu-latest
steps:
- run: docker image inspect --format '{{.Id}}' "${{ needs.build.outputs.image }}"
build job'ı image referansını bir output olarak dışarı veriyor; deploy job'ları bu referansı kullanıyor ve hiçbir şeyi yeniden build etmiyor. Gerçek bir deploy adımı burada Kubernetes'e ya da bir uygulama sunucusuna image'ı veren komut olurdu; kavramı göstermek için her environment'ın aldığı image'ın ID'sini yazdırıyorum. act ile, job'ların host'taki Docker'ı kullanmasına izin vererek koşturdum:
[release/build] | #8 naming to docker.io/bookstore/api:ae67bbd done
[release/build] 🏁 Job succeeded
[release/deploy-staging] | sha256:14dbbe93691273ae42fb02acb6a8bdeb630d27bd1ce29500ba0f73eb090dd8b8
[release/deploy-staging] 🏁 Job succeeded
[release/deploy-production] | sha256:14dbbe93691273ae42fb02acb6a8bdeb630d27bd1ce29500ba0f73eb090dd8b8
[release/deploy-production] 🏁 Job succeeded
İki environment aynı ID'yi gördü. Production'a giden şey, staging'de denenen şeyin ta kendisi.
Workflow'un ilk halinde deploy-production sadece needs: deploy-staging diyordu. actionlint bunu hemen yakaladı:
.github/workflows/release.yml:32:59: property "build" is not defined in object type {deploy-staging: {outputs: {}; result: string}} [expression]
|
32 | - run: docker image inspect --format '{{.Id}}' "${{ needs.build.outputs.image }}"
| ^~~~~~~~~~~~~~~~~~~~~~~~~
needs context'i sadece doğrudan bağımlılıkların output'larını taşır; build dolaylı bağımlılık olduğu için ifade boş string'e döner. act'te aynı hata sessizce docker image inspect --format '{{.Id}}' "" komutuna ve kırmızı bir job'a dönüştü; gerçek bir deploy adımında boş bir image referansıyla deploy denemesi olurdu. Workflow dosyalarını commit'lemeden önce actionlint'ten geçirmek bu sınıf hataları dakikalar içinde yakalıyor.
Reproducible Build: Sürümleri Kilitlemek
Build'i bir kez yapmak her environment için aynı paketi garanti eder. Ama aynı commit'ten bir ay sonra yeniden build almak istersen (bir hotfix branch'i, bir güvenlik incelemesi) yine aynı girdileri kullanmak istersin. Bunun üç ayağı var.
SDK sürümü için global.json. rollForward değerini latestPatch ya da disable yaparak runner'ın kendi kafasına göre başka bir feature band'e geçmesini engelleyebilirsin. Ne kadar sıkı kilitleyeceğin bir tercih: sıkı kilit tekrarlanabilirlik verir ama güvenlik patch'lerini elle yükseltmeni gerektirir.
NuGet bağımlılıkları için lock dosyası. Directory.Build.props'a <RestorePackagesWithLockFile>true</RestorePackagesWithLockFile> eklediğinde her proje için bir packages.lock.json oluşur ve transitive bağımlılıkların tam sürümleri de içine yazılır. Pipeline'da dotnet restore --locked-mode, lock dosyasıyla uyuşmayan bir restore'u reddeder. Birisi csproj'a paket ekleyip lock dosyasını güncellemeyi unuttuğunda:
$ dotnet restore --locked-mode
src/Bookstore.Api/Bookstore.Api.csproj : error NU1004: The package references have changed for net10.0. Lock file's package references: None, project's package references: Npgsql >= 10.0.3.The packages lock file is inconsistent with the project dependencies so restore can't be run in locked mode. Disable the RestoreLockedMode MSBuild property or pass an explicit --force-evaluate option to run restore to update the lock file. [Bookstore.slnx]
Failed to restore src/Bookstore.Api/Bookstore.Api.csproj (in 27 ms).
npm tarafında karşılığı package-lock.json ve npm install yerine npm ci. npm ci lock dosyasıyla package.json uyuşmazsa hiçbir şey kurmadan durur:
$ npm ci
npm error code EUSAGE
npm error
npm error `npm ci` can only install packages when your package.json and package-lock.json or npm-shrinkwrap.json are in sync. Please update your lock file with `npm install` before continuing.
npm error
npm error Missing: date-fns@4.4.0 from lock file
İki hata da can sıkıcı görünür ama tam olarak istediğin şey: bağımlılık değişikliği bilinçli bir commit olarak, review'dan geçerek gelir, pipeline'ın içinde sessizce olmaz.
Sürüm Damgalamak
Production'daki bir hatayı araştırırken ilk soru "hangi kod koşuyor?" olur. Artifact'ın üzerinde bu sorunun cevabı yazmalı. Bookstore API'si bunun için küçük bir endpoint açıyor:
app.MapGet("/api/info", (IHostEnvironment env) => new
{
Environment = env.EnvironmentName,
Version = Assembly.GetExecutingAssembly()
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion,
});
Pipeline publish ederken sürümü -p:Version=1.4.0 ile veriyor. Aynı image'ı biri staging, biri production olarak iki container'da çalıştırınca:
$ curl -s localhost:18101/api/info
{"environment":"Staging","version":"1.4.0+d3369d479c520bcde062728a03ac3a233768048b"}
$ curl -s localhost:18102/api/info
{"environment":"Production","version":"1.4.0+d3369d479c520bcde062728a03ac3a233768048b"}
+ işaretinden sonraki kısmı ben eklemedim: .NET SDK, repository bir Git deposuysa commit'in tam SHA'sını InformationalVersion'a kendisi ekliyor. Böylece hem insanın okuyacağı sürüm hem de kesin commit tek satırda.
Image tag'i için aynı fikir: bookstore/api:ae67bbd gibi commit SHA'sından türeyen tag'ler, tag push edildiğinde ise bookstore/api:1.4.0. SemVer (Semantic Versioning) kurallarını, changelog ve release notlarını ilerideki Versioning ve Release Management serisinde ele alacağım; tag'den sürüm türetmeyi Git & GitHub serisinin "Git Tag ve Release: Sürümleri İşaretlemek, git describe ve GitHub Releases" yazısında anlattım.
:latest tag'inden kaçın. latest bir sürüm değil, en son kimin push ettiğini gösteren hareketli bir etiket. Production'da image: bookstore/api:latest yazıyorsa, aynı manifest'i iki farklı gün uyguladığında iki farklı kod çalıştırabilirsin ve rollback edeceğin "önceki sürüm"ün adı yoktur.
Angular: Build'e Gömülü Config Yerine Runtime config.json
Bookstore'un web tarafı bir SPA (Single Page Application). Angular'ın klasik yaklaşımı environment.prod.ts gibi dosyalarla API adresini build sırasında koda gömmek. Bu "build once" ilkesiyle çelişir: staging ve production için iki ayrı build gerekir, yani iki ayrı artifact. Angular serisinin "Angular Build ve Deploy: ng build, Environment Dosyaları, Budgets ve SPA Routing" yazısında bu dosyaları anlattım; burada alternatife bakalım. Uygulama açılırken configuration'ı bir JSON (JavaScript Object Notation) dosyasından okusun:
import { bootstrapApplication } from '@angular/platform-browser';
import { appConfig } from './app/app.config';
import { App } from './app/app';
import { RUNTIME_CONFIG, RuntimeConfig } from './app/runtime-config';
fetch('/config.json')
.then((response) => response.json() as Promise<RuntimeConfig>)
.then((config) =>
bootstrapApplication(App, {
providers: [...appConfig.providers, { provide: RUNTIME_CONFIG, useValue: config }],
}),
)
.catch((err) => console.error(err));
Image'da bir config.json var; deploy sırasında her environment kendi dosyasını onun üzerine bağlıyor. Aynı bookstore/web image'ını iki container'da, iki ayrı config ile çalıştırdım:
$ docker run -d -p 127.0.0.1:18111:80 -v "$PWD/deploy/staging/config.json":/usr/share/nginx/html/config.json:ro bookstore/web:d3369d4
$ docker run -d -p 127.0.0.1:18112:80 -v "$PWD/deploy/production/config.json":/usr/share/nginx/html/config.json:ro bookstore/web:d3369d4
$ curl -s localhost:18111/config.json
{
"apiUrl": "https://api.staging.bookstore.example",
"environment": "Staging"
}
$ curl -s localhost:18111/ | grep -o 'main-[A-Z0-9]*\.js'
main-S3Z6CNMD.js
$ curl -s localhost:18112/config.json
{
"apiUrl": "https://api.bookstore.example",
"environment": "Production"
}
$ curl -s localhost:18112/ | grep -o 'main-[A-Z0-9]*\.js'
main-S3Z6CNMD.js
İki environment aynı main-S3Z6CNMD.js'i sunuyor; dosya adındaki hash içerikten türediği için aynı JavaScript olduğu da kesin. Bedeli, uygulama açılırken fazladan bir HTTP (Hypertext Transfer Protocol) isteği ve config.json'ın tarayıcı cache'inde eski kalmaması için ona ayrı bir cache kuralı vermek.
Digest, Checksum ve Provenance
Tag bir isim, digest ise içerik. bookstore/api:1.4.0 tag'i bir registry'de başka bir image'a taşınabilir; sha256:14dbbe… digest'i taşınamaz, çünkü içeriğin kendisinden hesaplanıyor. En sıkı yaklaşım, deploy manifest'inde image'ı digest ile referans vermek. Aynı fikir pipeline içi artifact'lar için de geçerli: upload-artifact yüklediği zip'in SHA-256'sını log'a yazıyor, indiren taraf karşılaştırabiliyor.
Bir adım ötesi provenance: artifact'ın hangi commit'ten, hangi pipeline'da, hangi girdilerle üretildiğini imzalı bir belgeyle kanıtlamak. SLSA (Supply-chain Levels for Software Artifacts) framework'ü bunu seviyelere ayırıyor; yedinci yazıda kısaca, ilerideki DevSecOps serisinde detaylı bakacağız. Bağımlılık tarafındaki riskleri ve SRI'yi (Subresource Integrity) Web Güvenliği (OWASP Top 10) serisinin "OWASP A03: Software Supply Chain Failures — NuGetAudit, npm audit ve SRI" yazısında anlattım; OWASP (Open Worldwide Application Security Project) listesinin bu maddesi, lock dosyalarının neden önemli olduğunu da açıklıyor.
Üretimde Dikkat
Registry'de tag immutability'yi aç. Amazon ECR (Elastic Container Registry), Azure Container Registry ve Harbor gibi registry'ler aynı tag'in üzerine yeniden push edilmesini engelleyebiliyor. Kapalıysa yanlışlıkla aynı sürüm numarasıyla yeni bir image push etmek, production'da "1.4.0" diye koşan şeyin değişmesi demek.
Saklama politikası yaz. Her commit bir image üretirse registry hızla büyür. "Son 30 günün image'ları ve tag'li bütün release'ler kalsın" gibi bir kural hem maliyeti hem de rollback ihtiyacını karşılar.
Base image güncellemelerini bilinçli yap. aspnet:10.0 tag'i güvenlik patch'lerini otomatik getirdiği için faydalı ama aynı zamanda tekrarlanabilirliği bozuyor. Base image'ı digest ile sabitleyip Dependabot veya Renovate gibi bir araçla PR (pull request) olarak güncellemek iki dünyanın ortası.
Sık Düşülen Tuzaklar
Her environment için ayrı build: "staging branch'inden staging build'i, main'den production build'i" düzeni, staging'de test edilen şeyin production'a gitmediği anlamına gelir.
Build'e gömülü configuration: API adresi, feature flag'ler, bağlantı bilgileri artifact'ın içindeyse her environment yeni bir build ister. Configuration'ı artifact'tan ayırmayı beşinci yazıda detaylı ele alacağız.
:latest ile deploy etmek ve tag'leri yeniden kullanmak: ikisi de "neyin koştuğu" sorusunu cevapsız bırakır.
npm install ve lock dosyasız restore: pipeline'da sürümler sessizce kayar ve dünkü yeşil commit bugün kırmızı olur. Neyin değiştiğini bulmak için kod değil, bağımlılık ağacı karşılaştırmak zorunda kalırsın.
Dolaylı needs output'u: yukarıda gördüğün gibi, bir job sadece doğrudan bağımlı olduğu job'ların output'larını görür.
Küçük Bir Deneme
★☆☆ Kendi API'nde /api/info benzeri bir endpoint aç ve InformationalVersion'ın commit SHA'sını içerdiğini doğrula.
★★☆ Projene RestorePackagesWithLockFile ekle, lock dosyalarını commit'le ve pipeline'daki restore'u --locked-mode'a çevir. Sonra csproj'a bir paket ekleyip lock dosyasını güncellemeden push et; pipeline hangi adımda, hangi hatayla kırıldı?
★★★ Aynı commit'ten docker build --no-cache ile iki image üret ve ID'lerini karşılaştır. Ardından SOURCE_DATE_EPOCH ortam değişkeni ve BuildKit'in rewrite-timestamp seçeneğiyle aynı ID'yi elde etmeyi dene; nerede takıldığını not et.
Bir sonraki yazıda artifact'ın kalitesini belirleyen kısma geçiyoruz: testleri pipeline'a hangi sırayla dizeceğimiz, quality gate'ler ve ekibin güvenini sessizce yiyen flaky test'ler.