GitOps'ta Secret Yönetimi: Sealed Secrets, SOPS ve Secret'ı Git'e Hiç Koymamak
Secret'ı Git'e koymanın güvenli yolu, değeri yalnız hedef cluster'ın açabileceği biçimde şifrelemek (Sealed Secrets), ekibin anahtarıyla şifreleyip manifest üretiminde çözmek (SOPS ve KSOPS) ya da Git'e yalnız bir referans koymak (External Secrets Operator). Var olan Secret'ı devralmayı, strict scope'u, anahtar kaybı provasını, KSOPS'u Argo CD'ye eklemeyi ve üç yolun karşılaştırmasını gerçek çıktılarla görüyoruz.
Sürüm notu: Bu yazıdaki örnekler kind 0.33 ile Kubernetes 1.37, Argo CD 3.6.1, Sealed Secrets 0.41.2 (kubeseal 0.41.2), sops 3.13.3, age 1.3.2, ksops 4.5.1 ve Kustomize 5.8 ile denenmiştir.
Önce Tahmin Et
Bookstore'un veritabanı Secret'ı artık Git'te, şifreli bir SealedSecret olarak duruyor. Bir gün cluster'ı kaybediyorsun ve yenisini kuruyorsun: Argo CD, Sealed Secrets controller'ı ve config repo'su aynı, tek fark controller'ın yeni olması. Argo CD uygulamaları sync ettiğinde SealedSecret'lardan Secret'lar oluşur mu? Oluşmazsa Argo CD'de ne görürsün ve nasıl düzelir? Bu durumu lab'da bilerek yarattık; cevabı gerçek çıktısıyla yazının ortasında.
Sorun: "Her Şey Git'te" ve Secret
OpenGitOps'un ilk prensibi istenen durumun tamamının declarative olarak tutulmasını istiyor. Şimdiye kadar sitedeki serilerin ortak örnek uygulaması Bookstore'un (bir online kitapçı) PostgreSQL parolası bu kuralın dışındaydı: api-db Secret'ını bir lab script'iyle her namespace'te elle yarattık ve Git'e koymadık. Yeni bir ortam açmak "önce şu script'i çalıştır" adımı demekti; cluster'ı yeniden kurmak da öyle. Secret'ı olduğu gibi Git'e koymak ise çözüm değil, çünkü Kubernetes Secret'ındaki değer yalnız base64 ile kodlanmış:
kubectl create secret generic api-db --from-literal=password=example-password-change-me --dry-run=client -o yaml
apiVersion: v1
data:
password: ZXhhbXBsZS1wYXNzd29yZC1jaGFuZ2UtbWU=
kind: Secret
metadata:
name: api-db
echo ZXhhbXBsZS1wYXNzd29yZC1jaGFuZ2UtbWU= | base64 -d
example-password-change-me
Kubernetes serisinin ConfigMap ve Secret yazısında söylediğimiz gibi base64 bir şifreleme değil, yalnız bir gösterim biçimi. Repo'yu okuyabilen herkes parolayı okur; Git geçmişi de onu sonsuza kadar saklar. Bu yazıda üç yolu Bookstore'un connection string'i üzerinde deniyoruz: Sealed Secrets (dev ve prod), SOPS (Secrets OPerationS; dosyalardaki değerleri şifreleyen araç) ile age (staging) ve secret'ı Git'e hiç koymamak. Hangi Secret'ın nereden geleceğini seçmek ve döndürmek (rotation) Configuration ve Secret Management serisinin konusu; burada soru yalnız "Git'teki secret".
Sealed Secrets: Cluster'ın Public Key'i ile Şifrelemek
Sealed Secrets iki parça: cluster'da çalışan bir controller ve geliştiricinin makinesindeki kubeseal CLI'ı. Controller açılışta bir anahtar çifti üretiyor ve private key'i kube-system'de bir Secret olarak saklıyor. kubeseal controller'dan yalnız public key'i (sertifikayı) alıp Secret'ı şifreliyor; çıkan SealedSecret nesnesini yalnız o cluster'daki controller açabiliyor. Kurulum resmi manifest'le:
kubectl apply -f https://github.com/bitnami-labs/sealed-secrets/releases/download/v0.41.2/controller.yaml
level=INFO msg="Starting sealed-secrets controller" version=0.41.2
level=INFO msg="Searching for existing private keys"
level=INFO msg="New key written" namespace=kube-system name=sealed-secrets-keyw8mxl
Dev'deki Secret'ı yeni bir parola üretmeden, olduğu gibi şifreledik; PostgreSQL ilk açılışta bu parolayla kurulduğu için değeri değiştirmek veritabanına bağlanamayan bir API demek olurdu. Değerler hiçbir dosyaya düşmeden bir pipe'tan geçiyor:
kubectl -n bookstore-dev get secret api-db -o yaml | kubeseal --format yaml > apps/api/overlays/dev/sealed-api-db.yaml
cd apps/api/overlays/dev && kustomize edit add resource sealed-api-db.yaml
# Cluster'ın public key'iyle şifrelendi: yalnız bu cluster'daki controller açabilir,
# o da yalnız bookstore-dev'deki api-db Secret'ı olarak (strict scope).
apiVersion: bitnami.com/v1alpha1
kind: SealedSecret
metadata:
name: api-db
namespace: bookstore-dev
annotations:
# postgres'ten (wave -1) önce: controller bunu api-db Secret'ına çevirir.
argocd.argoproj.io/sync-wave: "-2"
spec:
encryptedData:
connection-string: AgBjPoUe80QDT1+A4Gf8jh...
password: AgA//J8pi6byvYTyeV5O94...
template:
metadata:
name: api-db
namespace: bookstore-dev
type: Opaque
Değerler kısaltıldı; gerçek dosyada her biri yüzlerce karakterlik bir blob. Bu dosya public bir repo'ya da konabilir: private key olmadan açılmıyor. Sync wave'i -2 verdik, çünkü dördüncü yazıdaki sırada PostgreSQL -1'de ve Secret'a ihtiyaç duyuyor.
Var Olan Secret'ı Devralmak
Commit'i attık, Argo CD SealedSecret'ı uyguladı ve uygulama Degraded oldu:
bitnami.com SealedSecret bookstore-dev api-db Synced Degraded failed update: Resource "api-db" already exists and is not managed by SealedSecret
level=ERROR msg="Error updating, giving up" key=bookstore-dev/api-db error="failed update: Resource \"api-db\" already exists and is not managed by SealedSecret"
Controller, kendisinin yaratmadığı bir Secret'ın üstüne yazmıyor; bu iyi bir varsayılan. Argo CD de SealedSecret için hazır bir health check'e sahip olduğu için sorunu doğrudan uygulamanın health'inde gösteriyor. Devralma için var olan Secret'a sealedsecrets.bitnami.com/managed: "true" annotation'ı ekleniyor. Bir sürpriz daha var: log'daki "giving up" ciddi. Annotation'ı ekledikten sonra controller kendiliğinden tekrar denemedi; SealedSecret değişene ya da controller yeniden başlayana kadar durum aynı kaldı:
kubectl -n bookstore-dev annotate secret api-db sealedsecrets.bitnami.com/managed=true
kubectl -n kube-system rollout restart deploy/sealed-secrets-controller
Synced=True
SealedSecret/api-db
Health Status: Healthy
İkinci satır Secret'ın ownerReferences alanı: Secret artık SealedSecret'a ait, SealedSecret silinirse Secret da gider. Prod'da sırayı ters çevirdik: önce annotation, sonra şifreleme ve commit. Bu sefer ilk sync'te Healthy oldu. kubeseal var olan Secret'ın annotation'larını da kopyaladığı için prod dosyasının template'inde managed: "true" görünüyor; zararsız.
Scope'lar: Şifreli Veri Ad ve Namespace'e Bağlı
Varsayılan scope strict: Secret'ın adı ve namespace'i şifreli verinin parçası. Dev'in dosyasını alıp yalnız namespace'ini değiştirerek başka bir namespace'e uyguladık:
Synced=False no key could decrypt secret (connection-string, password)
REASON MESSAGE
ErrUnsealFailed Failed to unseal: no key could decrypt secret (connection-string, password)
Bu, dev'in parolasının biri dosyayı kopyaladı diye prod'da açılmasını engelliyor. namespace-wide aynı namespace içinde yeniden adlandırmaya, cluster-wide her yerde açılmaya izin veriyor; ikisi de kubeseal --scope ile seçiliyor ve SealedSecret'a bir annotation olarak yazılıyor. Ortam başına ayrı değer kullandığın sürece strict'te kal.
Anahtarın Kendisi: Yenileme, Yedek ve Felaket Provası
Controller her 30 günde bir yeni bir sealing key üretip aktif anahtarlar listesine ekliyor (--key-renew-period ile değişiyor). Eski anahtarlar silinmiyor, yani eski SealedSecret'lar açılmaya devam ediyor; yeni şifrelemeler en yeni anahtarla yapılıyor. kubeseal --re-encrypt var olan dosyaları en yeni anahtarla yeniden şifreliyor. Proje dokümanının altını çizdiği nokta önemli: anahtar yenilemek secret'ın kendisini döndürmek değil. Bir parola sızdıysa onu değiştirmen gerekir; eski anahtarla şifrelenmiş eski bir commit Git geçmişinde duruyor ve o anahtar sızarsa açılabilir.
Önce Tahmin Et'in cevabı. Anahtarı yedekledik, sonra silip controller'ı yeniden başlattık; yeni cluster'da olan da bu: yeni controller, yeni anahtar.
kubectl -n kube-system get secret -l sealedsecrets.bitnami.com/sealed-secrets-key -o yaml > sealed-secrets-key-backup.yaml
kubectl -n kube-system delete secret -l sealedsecrets.bitnami.com/sealed-secrets-key
kubectl -n kube-system rollout restart deploy/sealed-secrets-controller
dev: Synced=False no key could decrypt secret (connection-string, password)
prod: Synced=False no key could decrypt secret (connection-string, password)
secret/api-db
Health Status: Degraded
Git'teki dosyalar artık hiçbir şey ifade etmiyor. Var olan Secret'lar silinmedi (secret/api-db hâlâ orada), bu yüzden çalışan pod'lar ayakta; ama boş bir cluster'da hiçbir Secret oluşmazdı ve PostgreSQL açılmazdı. Yedeği geri yükleyince:
kubectl apply -f sealed-secrets-key-backup.yaml
kubectl -n kube-system rollout restart deploy/sealed-secrets-controller
dev: Synced=True
prod: Synced=True
Health Status: Healthy
NAME TYPE DATA AGE
sealed-secrets-keyw8mxl kubernetes.io/tls 2 31s
sealed-secrets-keywmqw5 kubernetes.io/tls 2 66s
Controller iki anahtarı birlikte kullanıyor: eski dosyaları yedekten gelen anahtarla açıyor, yeni şifrelemeleri en yeni anahtarla yapıyor. Yedek dosyası private key içeriyor; Git'e değil, bir secret manager'a ya da şifreli bir kasaya gider ve her anahtar yenilemesinden sonra güncellenmesi gerekir. Yedeği olmayan bir Sealed Secrets kurulumu, cluster'la birlikte bütün secret'ları kaybetmeye hazır demek.
SOPS ve age: Dosyayı Şifrelemek, Argo CD'de KSOPS ile Çözmek
SOPS farklı bir model: bir YAML (manifest'lerin yazıldığı metin formatı) ya da JSON dosyasının içindeki değerleri şifreliyor ve şifreleme anahtarı cluster'a değil, ekibe ait. age (modern, sade bir dosya şifreleme aracı), Amazon'un KMS'i (Key Management Service; bulutta yönetilen anahtar servisi), Azure Key Vault ya da PGP (Pretty Good Privacy; açık anahtarlı şifreleme standardı) ile çalışıyor. Staging'i age ile denedik. Repo kökündeki .sops.yaml hangi dosyanın hangi anahtarla şifreleneceğini söylüyor:
# Bu repo için SOPS kuralları: staging Secret'ları staging age anahtarıyla şifrelenir.
# Yalnız data/stringData altındaki değerler şifrelenir; kind ve metadata okunur kalır.
creation_rules:
- path_regex: apps/.*/overlays/staging/.*\.enc\.yaml$
encrypted_regex: ^(data|stringData)$
age: age1ukq6ax5v...
age-keygen -o age.agekey
sops encrypt --filename-override apps/api/overlays/staging/api-db.enc.yaml api-db.plain.yaml > apps/api/overlays/staging/api-db.enc.yaml
apiVersion: v1
data:
connection-string: ENC[AES256_GCM,data:3lvZS8...]
password: ENC[AES256_GCM,data:Z8aF3C...]
kind: Secret
metadata:
name: api-db
namespace: bookstore-staging
type: Opaque
sops:
age:
- enc: |
-----BEGIN AGE ENCRYPTED FILE-----
...
-----END AGE ENCRYPTED FILE-----
recipient: age1ukq6ax5v...
encrypted_regex: ^(data|stringData)$
mac: ENC[AES256_GCM,data:ZgbVk/...]
version: 3.13.3
Sealed Secrets'tan farkı dosyanın hâlâ bir Kubernetes Secret'ı olması: yalnız değerler şifreli, yapı okunur ve bir pull request'te hangi key'in değiştiği görülüyor. mac alanı dosyanın bütünlüğünü koruyor. Argo CD bu dosyayı açamaz; manifest'i üreten repo-server'ın SOPS'u çalıştırması gerekir. Bunu Kustomize'a bir generator olarak ekleyen KSOPS (Kustomize için SOPS eklentisi) yapıyor:
# KSOPS: kustomize ksops binary'sini çalıştırır, o da api-db.enc.yaml'ı SOPS ile çözer.
apiVersion: viaduct.ai/v1
kind: ksops
metadata:
name: api-db
annotations:
config.kubernetes.io/function: |
exec:
path: ksops
files:
- ./api-db.enc.yaml
Staging overlay'ine generators: [secret-generator.yaml] ekleyip commit attık. Argo CD'nin ilk tepkisi:
ComparisonError: Failed to load target state: failed to generate manifest for source 1 of 1: rpc error: code = Unknown desc = `kustomize build <path to cached source>/apps/api/overlays/staging` failed exit status 1: Error: loading generator plugins: failed to load generator: external plugins disabled; unable to load external plugin 'ksops'
Üç şey gerekiyor: Kustomize'a plugin ve exec izni, repo-server'a ksops binary'si ve age private key'i. İzin argocd-cm'de, diğer ikisi repo-server Deployment'ına bir patch:
kubectl -n argocd create secret generic sops-age --from-file=keys.txt=age.agekey
kubectl -n argocd patch configmap argocd-cm --type merge -p '{"data":{"kustomize.buildOptions":"--enable-alpha-plugins --enable-exec"}}'
kubectl -n argocd patch deployment argocd-repo-server --patch-file repo-server-ksops.yaml
initContainers:
- name: install-ksops
image: viaductoss/ksops:v4.5.1
# Image'da shell yok: ksops kendini kopyalar (--with-kustomize olmadan Argo CD kendi kustomize'ını korur).
command: ["/usr/local/bin/ksops", "install", "/custom-tools"]
volumeMounts:
- mountPath: /custom-tools
name: custom-tools
containers:
- name: argocd-repo-server
env:
- name: SOPS_AGE_KEY_FILE
value: /sops-age/keys.txt
volumeMounts:
- mountPath: /usr/local/bin/ksops
name: custom-tools
subPath: ksops
- mountPath: /sops-age
name: sops-age
readOnly: true
İlk denememizde init container'a cp ile kopyalama komutu vermiştik; ksops image'ında shell olmadığı için pod açılmadı: exec: "/bin/sh": stat /bin/sh: no such file or directory. KSOPS'un kendi install komutu sorunu çözdü. Repo-server yeniden açıldıktan sonra staging Synced ve Healthy oldu; Argo CD Secret'ı var olanın üstüne uyguladı ve değerler aynı olduğu için hiçbir pod yeniden başlamadı.
Çözülmüş Secret Argo CD'de görünüyor mu? API üzerinden hayır: argocd app manifests hem Git'ten üretilen hem canlı Secret'ın değerlerini maskeliyor:
argocd app manifests bookstore-api-staging --source git
connection-string ++++++++
password ++++++++
Ama maskeleme yalnız gösterimde. Argo CD dokümanı manifest üretiminde secret çözmenin iki sorununu açıkça yazıyor: üretilen manifest'ler Redis cache'inde düz metin duruyor ve repo-server'ın API'sinden alınabiliyor; secret değişikliği de alakasız bir uygulama sync'ine bağlanıyor. Bir de --enable-exec global bir ayar: Argo CD'nin okuduğu herhangi bir repo'daki kustomization, repo-server'da exec function olarak repo'daki bir script'i çalıştırabilir. Kustomize'ın bunu yaptığını yerelde doğruladık. O script de age anahtarının mount edildiği dosyayı okuyabilir. Bu yüzden Argo CD dokümanı secret'ı hedef cluster'da çözen yöntemleri öneriyor; Sealed Secrets bu gruba giriyor, KSOPS girmiyor.
Secret'ı Git'e Hiç Koymamak: External Secrets Operator
Üçüncü yol Git'e şifreli değer de koymamak. External Secrets Operator (ESO) ile Git'te yalnız bir ExternalSecret durur: "Vault'taki şu yoldaki değeri al, api-db adında bir Secret yap". Değer cluster'da, operator tarafından secret manager'dan çekilir; Argo CD hiçbir değer görmez. Mekaniği, Secrets Store CSI (Container Storage Interface; Kubernetes'in depolama eklenti arayüzü) Driver ve Workload Identity ile birlikte, Configuration ve Secret Management serisinin Container ve Kubernetes'te Secret yazısında anlattık; burada kurmadık. Üç yolun Bookstore'un connection string'i için karşılaştırması:
| Soru | Sealed Secrets | SOPS + KSOPS | External Secrets Operator |
|---|---|---|---|
| Git'te ne var? | Cluster anahtarıyla şifreli blob | Değerleri şifreli Secret YAML | Yalnız bir referans |
| Anahtar nerede? | Cluster'da (controller) | Ekipte (age, KMS, Key Vault) | Secret manager'da |
| Argo CD değeri görür mü? | Hayır | Evet (repo-server, Redis) | Hayır |
| Yeni cluster'da ne gerekir? | Anahtar yedeği | age key'i repo-server'a | Secret manager'a erişim |
| Parolayı döndürmek | Yeniden şifrele, commit | sops ile düzenle, commit | Secret manager'da değiştir |
| En uygun yer | Tek cluster, secret manager yok | Değer review'da görülsün isteniyor | Zaten Vault, Key Vault vb. var |
Hiçbiri "doğru" değil. Şirkette zaten bir secret manager varsa ESO çoğu zaman en az parçalı yol; yoksa Sealed Secrets'ın tek gereksinimi bir controller ve anahtar yedeği.
Üretimde Dikkat
Sealed Secrets anahtar yedeğini otomatikleştir: anahtar 30 günde bir yenilendiği için tek seferlik bir yedek bir ay sonra eksik kalır. Felaket provasını bizim yaptığımız gibi gerçekten yap; "yedek var" ile "yedekten açılıyor" farklı şeyler. Yeni bir ortam için kubeseal --fetch-cert ile alınan sertifikayı repo'ya koymak, cluster'a erişimi olmayan geliştiricinin de şifreleme yapabilmesini sağlar (sertifika public). Secret'ı değiştiren her commit bir pod yeniden başlatması gerektirebilir: Kubernetes env değişkeni olarak okunan Secret değişince pod'u kendiliğinden yeniden başlatmaz (Kubernetes serisinin ConfigMap ve Secret yazısı); bir checksum annotation'ı ya da Reloader gibi bir araç gerekiyor. SOPS'ta age private key'i en az iki yerde (repo-server ve bir kasa) tut ve .sops.yaml'a birden fazla alıcı ekle; tek anahtarın kaybı dosyaların kaybı demek.
Sık Düşülen Tuzaklar
Şifrelemeden önce düz Secret'ı bir kez commit etmek. Sonraki commit'te silmek yetmez, Git geçmişinde duruyor; parolayı değiştirmek gerekir.
Var olan bir Secret'ı annotation'sız SealedSecret'a çevirmek ve "giving up" mesajından sonra controller'ın kendiliğinden düzelmesini beklemek.
Sealed Secrets private key'ini yedeklememek. Yeni cluster'da Git'teki her SealedSecret anlamsız olur.
Düz metin dosyayı (api-db.plain.yaml) repo klasöründe unutmak. Şifrelenmemiş dosyayı repo dışında üret ve işin bitince sil; .gitignore'a *.plain.yaml eklemek ikinci bir güvence.
KSOPS için --enable-exec'i açıp config repo'suna kimin commit atabildiğini kısıtlamamak.
Overlay'i Argo CD dışında render eden araçları unutmak. Altıncı yazıdaki ci/promote.sh staging'den prod'a promotion için staging overlay'ini kustomize build ile render ediyordu; KSOPS generator'ı eklendikten sonra script şu hatayla durdu: Error: loading generator plugins: failed to load generator: external plugins disabled; unable to load external plugin 'ksops'. CI'a age anahtarını vermek yerine script'i overlay'deki images listesini doğrudan okuyacak şekilde değiştirdik; promotion için secret'ı çözmeye gerek yok.
Küçük Bir Deneme
★☆☆ lab/scripts/seal-db-secret.sh bookstore-qa ile yeni bir ortamın SealedSecret'ını üret ve --scope namespace-wide ile bir kez daha şifrele. İki dosyanın annotation'larını karşılaştır ve ikincisini api-db-copy adıyla aynı namespace'e uygula.
★★☆ .sops.yaml'a ikinci bir age alıcısı ekle (age-keygen ile yeni bir anahtar), sops updatekeys ile staging dosyasını güncelle ve ikinci anahtarla sops decrypt yapabildiğini doğrula. Diff'te neyin değiştiğine bak.
★★★ Staging için KSOPS yerine Sealed Secrets kullan, sonra argocd-cm'den kustomize.buildOptions'ı ve repo-server patch'ini kaldır. Hangi uygulamaların bundan etkilendiğini Argo CD'de kontrol et ve secret çözmeyi repo-server'dan çıkarmanın attack surface açısından ne kazandırdığını yaz.
Secret'lar artık Git'te ve Argo CD bir commit'ten sonra ne olacağını biliyor. Ama yeni bir sürümün bütün kullanıcılara aynı anda gitmesi hâlâ büyük bir adım. Bir sonraki yazıda Argo Rollouts ile progressive delivery'ye geçiyoruz: Prometheus metriklerine bakarak kendi kendine ilerleyen ya da geri dönen bir canary.