Uçtan Uca: .NET + Angular Uygulamasını Managed Kubernetes'e Almak (AKS, EKS, GKE) ve Production Checklist'i
Managed Kubernetes'te control plane sağlayıcıda kalır; image çekmek ve cloud'a erişmek için node kimliği ve workload identity kullanırsan pipeline'da ve cluster'da tek bir cloud secret'ı kalmaz. AKS, EKS ve GKE farklarını, OIDC ile AKS'e deploy eden ve actionlint'ten geçen workflow'u, pluto'nun göremediği deprecated API istemcilerini ve kind'da çalışan kod halindeki production checklist'ini görüyoruz.
Sürüm notu: Bu yazıdaki örnekler kind 0.33 ile Kubernetes 1.37, kubectl 1.37, pluto 5.24, actionlint 1.7.12, Azure.Identity 1.21 ve .NET 10 ile denenmiştir. Bulut cluster'larında çalıştırılmadı; bulut tarafındaki bilgiler sağlayıcıların Kasım 2026 dokümanlarından.
Önce Tahmin Et
Cluster'ını Kubernetes 1.37'den bir sonraki minor sürüme yükseltmeden önce deprecated API (Application Programming Interface) kontrolü yapıyorsun. pluto detect-api-resources çalıştırıyorsun ve cevap net: "There were no resources found with known deprecated apiVersions." Yükseltme güvenli mi? Aynı dakikada cluster'ın kendi metriklerine bakınca şu satırı görüyorsun:
apiserver_requested_deprecated_apis{group="",removed_release="",resource="endpoints",subresource="",version="v1"} 1
İkisi nasıl aynı anda doğru olabilir? Cevap, bu yazının ana fikrinin küçük bir örneği: production'a hazır olmak, cluster'daki nesnelere bakmakla bitmez; o nesnelerle konuşan her şeyi de görmek gerekir.
Managed Kubernetes: Neyi Devrediyorsun, Neyi Tutuyorsun
AKS (Azure Kubernetes Service), EKS (Amazon Elastic Kubernetes Service) ve GKE (Google Kubernetes Engine) control plane'i senin yerine işletir: API server, etcd, scheduler, controller-manager, bunların yedekliliği, sertifika rotasyonu ve yamaları. Node'lar, node'ların upgrade zamanlaması, cluster'a kurduğun eklentiler ve tabii uygulaman senin sorumluluğunda kalır; GKE Autopilot, EKS Auto Mode ve AKS Automatic gibi modlar node yönetimini de büyük ölçüde üstlenir ama karşılığında pod spec'ine daha sıkı kurallar koyar. Self-managed bir cluster (kubeadm, k3s, Rancher) ancak on-prem zorunluluğu ya da çok özel bir ağ ihtiyacı varsa mantıklı; serinin ilk yazısında konuştuğumuz operasyon maliyeti control plane'i kendin işlettiğinde katlanır. Bookstore için seçim managed cluster, ayrıca çoğu ekip için ilk seçenek tek node pool, sağlayıcının varsayılan CNI'ı (Container Network Interface) ve managed PostgreSQL. Cluster'ı kod olarak kurmak ilerideki Infrastructure as Code: Terraform serisinin konusu; burada uygulamanın cluster'a nasıl gittiğine odaklanıyoruz.
Image'ı Secret'sız Çekmek
Lokal cluster'da image'ları kind load ile node'lara yükledik. Bulutta image bir registry'den çekilir ve en iyi yol, node'un kimliğine registry'den okuma yetkisi vermektir; Kubernetes Secret'ında registry şifresi tutmazsın:
- AKS:
az aks update --attach-acr, kubelet'in managed identity'sine ACR (Azure Container Registry) üzerindeAcrPullrolünü verir. - EKS: node IAM (Identity and Access Management) rolüne ECR (Elastic Container Registry) için
AmazonEC2ContainerRegistryPullOnlypolicy'si eklenir. - GKE: aynı projedeki Artifact Registry'den varsayılan ayarlarla çekilir; farklı proje için node service account'una
roles/artifactregistry.readerverilir.
Yalnız başka bir organizasyonun registry'si ya da Docker Hub'daki private bir repo gibi durumlarda on beşinci yazıdaki imagePullSecrets'a dönersin.
Workload Identity: Uygulamanın Cloud'a Secret'sız Erişimi
On ikinci yazıda Data Protection key'lerini blob storage'a koymayı önerdik. API'nin blob storage'a ve Key Vault'a erişmesi için bir kimliğe ihtiyacı var ve bu kimliğin bir connection string ya da client secret olması gerekmiyor. Workload identity, pod'un ServiceAccount token'ını bulut sağlayıcısının kimlik sistemine federe eder: Kubernetes'in imzaladığı kısa ömürlü token, cloud'un kısa ömürlü token'ıyla takas edilir. AKS'te ServiceAccount'a hangi Entra ID uygulamasını temsil ettiği, pod'a da webhook'un devreye girmesi gerektiği yazılır:
apiVersion: v1
kind: ServiceAccount
metadata:
name: api
namespace: bookstore-prod
annotations:
azure.workload.identity/client-id: "00000000-0000-0000-0000-000000000000"
---
# Deployment'ın pod template'inde
metadata:
labels:
app.kubernetes.io/name: api
azure.workload.identity/use: "true"
spec:
serviceAccountName: api
Dikkat: on beşinci yazıda bu ServiceAccount'ta automountServiceAccountToken: false demiştik. Workload identity, webhook'un mount ettiği ayrı bir projected token kullanır; yine de bu ayarla birlikte gerçek bir AKS cluster'ında denemeden production'a taşıma. Cluster tarafında --enable-oidc-issuer --enable-workload-identity açılır ve Entra ID'deki managed identity'ye system:serviceaccount:bookstore-prod:api subject'iyle bir federated credential eklenir. Uygulama tarafında kod değişikliği küçük:
using Azure.Core;
using Azure.Identity;
using Microsoft.AspNetCore.DataProtection;
var builder = WebApplication.CreateBuilder(args);
// pod'da webhook AZURE_CLIENT_ID, AZURE_TENANT_ID ve AZURE_FEDERATED_TOKEN_FILE değişkenlerini enjekte eder
TokenCredential credential = new WorkloadIdentityCredential();
builder.Services.AddDataProtection()
.SetApplicationName("bookstore")
.PersistKeysToAzureBlobStorage(new Uri(builder.Configuration["DataProtection:BlobUri"]!), credential)
.ProtectKeysWithAzureKeyVault(new Uri(builder.Configuration["DataProtection:KeyUri"]!), credential);
var app = builder.Build();
app.MapGet("/", () => "Bookstore.Api");
app.Run();
Bu kodu derleyip webhook'un enjekte edeceği değişkenler olmadan lokal makinede çalıştırdık; pod label'ını unuttuğunda göreceğin hata tam olarak bu:
fail: Microsoft.AspNetCore.DataProtection.KeyManagement.KeyRingProvider[48]
Azure.Identity.CredentialUnavailableException: WorkloadIdentityCredential authentication unavailable. The workload options are not fully configured. See the troubleshooting guide for more information. https://aka.ms/azsdk/net/identity/workloadi...
Lokal geliştirmede DefaultAzureCredential (sırayla ortam değişkenlerini, workload identity'yi, Azure CLI (Command Line Interface) oturumunu dener) daha rahattır; production'da hangi kimliğin kullanıldığını açıkça belli eden WorkloadIdentityCredential'ı tercih ediyoruz. EKS'te karşılığı EKS Pod Identity: ServiceAccount'a annotation yazılmaz, aws eks create-pod-identity-association ile namespace/ServiceAccount çifti bir IAM rolüne bağlanır, AWS (Amazon Web Services) SDK (Software Development Kit) for .NET (en az 3.7.734.0) kimliği container credential provider üzerinden kendisi bulur. AWS dokümanı Pod Identity'yi IRSA'ya (IAM Roles for Service Accounts) tercih ediyor. GKE'de Workload Identity Federation, Autopilot'ta her zaman açık; önerilen yol IAM rolünü doğrudan Kubernetes ServiceAccount'unu temsil eden bir principal:// kimliğine vermek. Üçünde de sonuç aynı: repoda, pipeline'da ve cluster'da tek bir cloud secret'ı yok.
CI: Image Push, Migration ve Kustomize ile Deploy
GitHub Actions'tan AKS'e giden workflow, önceki yazıların parçalarını sırayla birleştiriyor: OIDC (OpenID Connect) ile Azure'a giriş, image'ı build edip ACR'ye push etmek, on üçüncü yazıdaki migration Job'unu çalıştırmak, on dördüncü yazıdaki gibi overlay'deki image'ı digest ile güncelleyip uygulamak ve sonunda aşağıdaki checklist'i koşmak:
name: deploy-aks
on:
push:
branches: [main]
permissions:
id-token: write
contents: read
env:
REGISTRY: bookstoreacr.azurecr.io
CLUSTER: bookstore-prod
RESOURCE_GROUP: rg-bookstore-prod
jobs:
deploy:
runs-on: ubuntu-24.04
environment: production
steps:
- uses: actions/checkout@v7
- uses: azure/login@v3
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
- name: Build and push the API image
id: api
run: |
az acr login --name "${REGISTRY%%.*}"
image="$REGISTRY/bookstore-api:${GITHUB_SHA::12}"
docker build -t "$image" src/Bookstore.Api
docker push "$image"
digest=$(docker inspect --format '{{index .RepoDigests 0}}' "$image")
echo "digest=$digest" >> "$GITHUB_OUTPUT"
- uses: azure/use-kubelogin@v1
with:
kubelogin-version: v0.2.22
- uses: azure/aks-set-context@v5
with:
resource-group: ${{ env.RESOURCE_GROUP }}
cluster-name: ${{ env.CLUSTER }}
use-kubelogin: "true"
- name: Run the migration job
run: ./deploy/run-migration.sh "deploy/jobs/migrate.yaml" "migrate-${GITHUB_SHA::12}"
- name: Deploy with Kustomize
working-directory: deploy/overlays/prod
run: |
kustomize edit set image "bookstore-api=${{ steps.api.outputs.digest }}"
kubectl apply -k .
kubectl -n bookstore-prod rollout status deploy/api --timeout=300s
- name: Production checklist
run: ./deploy/prod-check.sh bookstore-prod
Bu workflow GitHub'da ve act ile koşulmadı: OIDC girişi, ACR ve AKS gerçek bir Azure aboneliği istiyor. actionlint 1.7.12 ile doğruladık (çıkış kodu 0; shellcheck kurulu olmadığı için run blokları ayrıca taranmadı), action'ların input adlarını da kendi action.yml dosyalarından kontrol ettik. Workflow'daki run-migration.sh ve prod-check.sh ise kind'da gerçekten çalıştı. Entra ID entegrasyonlu AKS'te yerel hesaplar kapalıyken kubectl'in token alabilmesi için kubelogin gerekir, workflow'daki use-kubelogin bunun için. Runner'da kustomize binary'si yoksa ayrı bir adımla kurulmalı. OIDC'nin Azure tarafındaki kurulumu GitHub Actions serisinin OIDC ile Azure'a deploy yazısında, environment onayları ise o serinin Environment'lar yazısında anlatıldı.
Upgrade Takvimi ve Deprecated API Kontrolü
Kubernetes yılda üç minor sürüm çıkarır ve her minor yaklaşık 14 ay yama alır; 1.35'in topluluk desteği 28 Şubat 2027'de, 1.37'ninki 28 Ekim 2027'de biter. Sağlayıcılar bunun üstüne kendi takvimlerini koyar: AKS üç GA (General Availability) minor sürümü destekler, Premium katmanda LTS (Long-Term Support) ile yaklaşık iki yıla uzatır; EKS 14 aylık standart desteğin ardından 12 ay daha extended support verir ama cluster saati başına ücret 0,10 dolardan 0,60 dolara çıkar; GKE release channel'larla (Rapid, Regular, Stable, Extended) çalışır. Ders basit: yılda en az bir, tercihen iki upgrade planla; extended support bir sigortadır, strateji değil.
Upgrade'i kıran şey çoğu zaman kaldırılan bir API'dir (serinin on sekizinci yazısı). Önce Tahmin Et'teki çelişkinin cevabı burada. pluto cluster'da saklanan nesnelere ve manifest dosyalarına bakar; bizim cluster'da eski apiVersion'la saklanmış nesne yoktu. Ama biri kubectl get endpoints çalıştırdı:
$ kubectl -n bookstore-prod get endpoints api
Warning: v1 Endpoints is deprecated in v1.33+; use discovery.k8s.io/v1 EndpointSlice
NAME ENDPOINTS AGE
api 10.244.1.37:8080,10.244.2.68:8080 39s
API server bu isteği apiserver_requested_deprecated_apis metriğine yazdı. Production'da bu isteği atan bir insan değil, eski bir monitoring agent'ı, bir operator ya da eski bir client library'si kullanan senin kendi aracın olabilir. Metriğin removed_release label'ı API'nin hangi sürümde kaldırılacağını söyler; burada boş, çünkü v1 Endpoints için bir kaldırma sürümü henüz açıklanmadı. Sağlayıcılar bu bilgiyi kullanır: AKS, son 12 saatte deprecated API kullanımı görürse minor upgrade'i bloklar; EKS, upgrade insights'ta son 30 günün kullanımını gösterir; GKE, deprecated kullanım gördüğünde otomatik minor upgrade'leri durdurur. Upgrade öncesi kontrol listen ikisini birden içermeli: manifest'ler ve cluster nesneleri için pluto, istemciler için API server metriği ya da sağlayıcının insight'ları.
Production Checklist'i Kod Olarak
Seri boyunca konuştuğumuz kuralları bir doküman yerine pipeline'da koşan bir script'e çevirdik. Her Deployment için probe'ları, resource'ları, image tag'ini, güvenlik ayarlarını, replica sayısını ya da HPA (Horizontal Pod Autoscaler) durumunu ve PDB'yi (PodDisruptionBudget) kontrol ediyor:
#!/usr/bin/env bash
# kullanım: ./prod-check.sh <namespace>
set -euo pipefail
ns="$1"
fail=0
deployments=$(kubectl -n "$ns" get deploy -o json)
hpas=$(kubectl -n "$ns" get hpa -o json)
pdbs=$(kubectl -n "$ns" get pdb -o json)
for name in $(echo "$deployments" | jq -r '.items[].metadata.name'); do
d=$(echo "$deployments" | jq --arg n "$name" '.items[] | select(.metadata.name == $n)')
labels=$(echo "$d" | jq -c '.spec.selector.matchLabels')
problems=()
for c in $(echo "$d" | jq -r '.spec.template.spec.containers[].name'); do
ct=$(echo "$d" | jq --arg c "$c" '.spec.template.spec.containers[] | select(.name == $c)')
[[ $(echo "$ct" | jq '.readinessProbe != null') == true ]] || problems+=("$c: no readinessProbe")
[[ $(echo "$ct" | jq '.livenessProbe != null') == true ]] || problems+=("$c: no livenessProbe")
[[ $(echo "$ct" | jq '.resources.requests.cpu != null and .resources.requests.memory != null') == true ]] \
|| problems+=("$c: missing cpu/memory requests")
[[ $(echo "$ct" | jq '.resources.limits.memory != null') == true ]] || problems+=("$c: no memory limit")
[[ $(echo "$ct" | jq -r '.image') =~ :latest$|^[^:]+$ ]] && problems+=("$c: mutable image tag")
[[ $(echo "$ct" | jq '.securityContext.readOnlyRootFilesystem == true') == true ]] \
|| problems+=("$c: writable root filesystem")
done
[[ $(echo "$d" | jq '.spec.template.spec.securityContext.runAsNonRoot == true') == true ]] \
|| problems+=("pod: runAsNonRoot not set")
[[ $(echo "$d" | jq '.spec.template.spec.automountServiceAccountToken == false') == true ]] \
|| problems+=("pod: service account token mounted")
has_hpa=$(echo "$hpas" | jq --arg n "$name" '[.items[] | select(.spec.scaleTargetRef.name == $n)] | length')
replicas=$(echo "$d" | jq '.spec.replicas')
[[ $has_hpa -gt 0 || $replicas -ge 2 ]] || problems+=("single replica without HPA")
has_pdb=$(echo "$pdbs" | jq --argjson l "$labels" '[.items[] | select(.spec.selector.matchLabels == $l)] | length')
[[ $has_pdb -gt 0 ]] || problems+=("no PodDisruptionBudget")
if [[ ${#problems[@]} -eq 0 ]]; then
echo "OK $ns/$name"
else
fail=1
for p in "${problems[@]}"; do echo "FAIL $ns/$name $p"; done
fi
done
exit $fail
kind'daki bookstore-prod namespace'ine on beşinci yazıdaki sertleştirilmiş API'yi ve Angular web'ini (nginx-unprivileged, salt okunur dosya sistemi, /tmp için emptyDir, config.json ConfigMap'ten) koyduk. İlk koşu ve aynı script'in eski bookstore namespace'indeki çıktısı:
$ ./prod-check.sh bookstore-prod
FAIL bookstore-prod/api api: no livenessProbe
FAIL bookstore-prod/api no PodDisruptionBudget
FAIL bookstore-prod/web web: no livenessProbe
FAIL bookstore-prod/web no PodDisruptionBudget
$ ./prod-check.sh bookstore
FAIL bookstore/api api: no livenessProbe
FAIL bookstore/api api: writable root filesystem
FAIL bookstore/api pod: runAsNonRoot not set
FAIL bookstore/api pod: service account token mounted
FAIL bookstore/api no PodDisruptionBudget
Güvenliğe odaklanırken liveness probe'u ve PDB'yi unutmuşuz; checklist'in değeri tam olarak bu. Liveness probe'ları, iki PDB'yi ve API için HPA'yı ekledikten sonra:
$ ./prod-check.sh bookstore-prod
OK bookstore-prod/api
OK bookstore-prod/web
Script'in kontrol edemediği maddeler de listede durmalı: graceful shutdown ve preStop (onuncu yazı), resource değerlerinin ölçümle seçilmesi (dokuzuncu), NetworkPolicy ve Pod Security Standards (on beşinci), veritabanı yedeğinin geri yüklemesinin denenmesi (on ikinci), log, metrik ve trace toplama ile alert'ler (ilerideki Observability serisi), Gateway ve TLS (Transport Layer Security) (altıncı yazı ve ilerideki SSL (Secure Sockets Layer)/TLS serisi). Bulutta Gateway API implementation'ı da sağlayıcıya göre değişir: GKE'nin kendi Gateway controller'ı, AKS'te Application Gateway for Containers ya da app routing eklentisinin Gateway API modu, EKS'te AWS Load Balancer Controller.
Üretimde Dikkat
İlk production deploy'undan önce bir "gün 2" provası yap: bir node'u drain et, bir pod'u sil, bir deploy'u geri al, veritabanını yedekten başka bir instance'a geri yükle. Bunların her biri bu serinin bir yazısında kind'da denediğin şeyler; bulutta ilk kez bir olay anında denemek istemezsin. Node pool upgrade'lerinde surge ayarını ve PDB'leri birlikte düşün: maxUnavailable: 1 PDB'si ve tek replica'lı bir Deployment, upgrade'i ya kilitler ya da kesintiye zorlar. Maliyeti erken izle: request'leri şişirilmiş pod'lar, boşta duran node'lar ve unutulan load balancer'lar faturanın en hızlı büyüyen kalemleridir. Ve upgrade'i otomatikleştir ama gözetimsiz bırakma: sağlayıcının maintenance window'u, senin release takviminle çakışmamalı.
Sık Düşülen Tuzaklar
Registry şifresini imagePullSecrets ile her namespace'e kopyalamak, oysa node kimliği yeterli. Cloud erişimi için client secret'ı Kubernetes Secret'ında tutmak. Workload identity'de pod label'ını ya da federated credential'ın subject'indeki namespace/ServiceAccount adını yanlış yazıp "workload options are not fully configured" ile saatler kaybetmek. Deprecated API kontrolünü yalnız manifest'lerle yapıp cluster'a eski API'yle konuşan istemcileri kaçırmak. Extended support'u kalıcı bir çözüm sanıp altı kat control plane ücreti ödemek. Checklist'i bir wiki sayfasında tutup pipeline'a koymamak. Ve lokal cluster'da her şey çalışıyor diye bulutta ağ, kimlik ve load balancer farklarını test etmeden ilk deploy'u production'a yapmak.
Küçük Bir Deneme
★☆☆ prod-check.sh'a iki kural ekle: image'ın digest ile (@sha256:) belirtilmesi ve terminationGracePeriodSeconds'ın uygulamanın Shutdown:TimeoutSeconds değerinden büyük olması. kind'daki namespace'lerde çalıştır.
★★☆ kind cluster'ında kubectl get endpoints ve kubectl get --raw /apis/discovery.k8s.io/v1/endpointslices çağrılarını yap, ardından apiserver_requested_deprecated_apis metriğini oku. Hangi çağrı metriğe yansıdı? Kendi .NET uygulamanda KubernetesClient paketiyle Endpoints okuyan bir kod varsa aynı uyarıyı nerede görürsün?
★★★ Ücretsiz deneme hesabıyla küçük bir AKS (ya da EKS/GKE) cluster'ı aç, workload identity ile API'yi bir blob container'ına Data Protection key'i yazacak şekilde kur, bu yazıdaki workflow'u kendi repo'nda çalıştır. kind'da çalışıp bulutta çalışmayan her şeyi bir listeye yaz; o liste senin gerçek production checklist'indir. İş bitince cluster'ı silmeyi unutma.
Bu yazıyla uygulamayı production'a taşıyan yol tamamlandı. Serinin son bloğunda sürümlere dönüyoruz: bir sonraki yazıda internette hâlâ dolaşan 2018–2021 dönemi manifest'lerini, dockershim'i ve PodSecurityPolicy'yi okuyup bugünkü karşılıklarına çeviriyoruz.