Mert Özen Her Satırda Daha İleri
DevOps Kubernetes — #15

Kubernetes Güvenliği: ServiceAccount, RBAC, Pod Security Standards, securityContext ve NetworkPolicy

Mert Özen 8 Kas 2026 11 dk 4 görüntülenme
Kubernetes Güvenliği: ServiceAccount, RBAC, Pod Security Standards, securityContext ve NetworkPolicy

Her uygulamaya kendi ServiceAccount'unu ver, gerekmiyorsa token'ı mount etme, namespace'i restricted Pod Security Standards ile koru ve default deny NetworkPolicy'den sonra yalnız gereken yolları aç. RBAC'i kubectl auth can-i ile test etmeyi, aspnet:10.0 image'ının root çalışmasını, readOnlyRootFilesystem'ın .NET Data Protection'a etkisini, user namespace'leri ve DNS'i unutan default deny'ı gerçek kind çıktılarıyla görüyoruz.

Sürüm notu: Bu yazıdaki örnekler kind 0.33 ile Kubernetes 1.37 (ağ eklentisi kindnet), kubectl 1.37, PostgreSQL 18 ve .NET 10 ile denenmiştir.

Önce Tahmin Et

Bookstore API'si (Application Programming Interface) bir namespace'te, PostgreSQL başka bir namespace'te çalışıyor. Hiçbir güvenlik ayarı yapmadın. Aynı namespace'teki herhangi bir pod, mesela birinin debug için bırakıp unuttuğu bir busybox, doğrudan PostgreSQL'in 5432 portuna bağlanabilir mi? API pod'unun içinde Kubernetes API'sine erişmeye yarayan bir token var mı? Ve mcr.microsoft.com/dotnet/aspnet:10.0 tabanlı bir image hangi kullanıcıyla çalışır: .NET 8'den beri gelen app kullanıcısıyla mı, root ile mi? Üçünü de gerçek cluster'da denedik; ilk ikisinin cevabı "evet", üçüncüsü çoğu kişinin tahmin ettiği gibi değil.

ServiceAccount ve Token: Pod'un Kubernetes'teki Kimliği

Her pod bir ServiceAccount ile çalışır; belirtmezsen namespace'in default ServiceAccount'u ile. Kubernetes bu kimliğin token'ını varsayılan olarak her pod'a mount eder. Shell'i olan bir image'la baktık:

$ kubectl -n bookstore-sec exec tokencheck -- sh -c 'id; ls /var/run/secrets/kubernetes.io/serviceaccount/'
uid=0(root) gid=0(root) groups=0(root)
ca.crt
namespace
token

Token bir JWT (JSON Web Token); payload'ını çözdük:

{"aud": ["https://kubernetes.default.svc.cluster.local"], "exp": 1825648071, "iat": 1794112071, "sub": "system:serviceaccount:bookstore-sec:default"}
lifetime_s= 31536000

Bu bir projected token: pod'a bağlı, kubelet tarafından düzenli yenilenen, pod silinince geçersizleşen bir token. kind'ın varsayılan API server ayarlarıyla geçerlilik süresi bir yıl çıktı; eski client'larla uyumluluk için genişletilmiş süre. default ServiceAccount'un bugün neredeyse hiçbir yetkisi yok (kubectl auth can-i --list yalnız discovery ve self-review izinlerini gösterdi). Tehlike yarın: biri bir controller'ı denemek için default'a geniş bir rol bağlarsa, namespace'teki her pod o yetkiyi miras alır; uygulamadaki bir SSRF (Server-Side Request Forgery) veya RCE (Remote Code Execution) açığı da doğrudan cluster'a uzanan bir kimlik bulur.

Kural basit: her uygulamaya kendi ServiceAccount'u, Kubernetes API'siyle konuşmayan uygulamalarda token hiç mount edilmesin. Bookstore API'si Kubernetes API'sini hiç çağırmıyor. Worker ise Background Job'lar serisinde anlattığımız leader election için bir Lease nesnesi kullanıyor:

apiVersion: v1
kind: ServiceAccount
metadata:
  name: api
  namespace: bookstore-sec
automountServiceAccountToken: false
---
apiVersion: v1
kind: ServiceAccount
metadata:
  name: worker
  namespace: bookstore-sec
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  name: worker-leader-election
  namespace: bookstore-sec
rules:
  - apiGroups: ["coordination.k8s.io"]
    resources: ["leases"]
    verbs: ["get", "create", "update"]
  - apiGroups: ["coordination.k8s.io"]
    resources: ["leases"]
    resourceNames: ["order-outbox-leader"]
    verbs: ["delete"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: worker-leader-election
  namespace: bookstore-sec
subjects:
  - kind: ServiceAccount
    name: worker
    namespace: bookstore-sec
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: Role
  name: worker-leader-election

RBAC: En Az Yetkiyi Test Ederek Vermek

RBAC (Role-Based Access Control) dört nesneden oluşur: Role (namespace içinde izinler), ClusterRole (cluster genelinde ya da her namespace'te tekrar kullanılabilir izinler), RoleBinding ve ClusterRoleBinding (izinleri bir kimliğe bağlar). Yukarıdaki Role yalnız Lease'leri okuyup güncelleyebiliyor, silmeyi de yalnız tek bir isimli Lease için veriyor. Yazdığını tahmin etme, test et: kubectl auth can-i bir kimliğin yerine geçip soruyu API server'a sorar.

can-i update leases --as=system:serviceaccount:bookstore-sec:worker -> yes
can-i delete leases/order-outbox-leader --as=system:serviceaccount:bookstore-sec:worker -> yes
can-i delete leases/other --as=system:serviceaccount:bookstore-sec:worker -> no
can-i list secrets --as=system:serviceaccount:bookstore-sec:worker -> no
$ kubectl auth can-i get leases -n bookstore-sec --as=system:serviceaccount:bookstore-sec:api
no
$ kubectl auth can-i get leases -n bookstore --as=system:serviceaccount:bookstore-sec:worker
no

Son satır Role'ün namespace sınırını gösteriyor: aynı worker başka namespace'teki Lease'e erişemiyor. Bu kontroller pipeline'da da koşturulabilir; bir RBAC değişikliğinin "secret okuyabilir mi" sorusuna verdiği cevabın değişmesi review'da yakalanacak bir şey. Kaçınılacak üç şey: verbs: ["*"] veya resources: ["*"], bir uygulama ServiceAccount'una ClusterRoleBinding vermek ve secrets üzerinde list/watch izni (list, namespace'teki bütün secret'ların içeriğini döndürür).

Pod Security Standards: Namespace Label'ıyla Zorunlu Kurallar

PSS (Pod Security Standards) üç seviye tanımlar: privileged (kısıtsız), baseline (bilinen yetki yükseltme yollarını kapatır) ve restricted (güncel sertleştirme önerileri). Bunları cluster'a gömülü PSA (Pod Security Admission) controller'ı uygular; namespace'e label koymak yeter. Kubernetes 1.25'te kaldırılan PodSecurityPolicy'nin yerini bu aldı (eski manifest'leri okumak serinin on sekizinci yazısında):

kubectl label ns bookstore-sec \
  pod-security.kubernetes.io/enforce=restricted \
  pod-security.kubernetes.io/enforce-version=v1.37 \
  pod-security.kubernetes.io/warn=restricted
Warning: existing pods in namespace "bookstore-sec" violate the new PodSecurity enforce level "restricted:v1.37"
Warning: tokencheck: allowPrivilegeEscalation != false, unrestricted capabilities, runAsNonRoot != true, seccompProfile
namespace/bookstore-sec labeled

Label mevcut pod'ları silmez, yalnız uyarır. Önceki yazılardaki sade API Deployment'ını bu namespace'e uyguladığımızda Deployment yaratıldı, ama pod'u yaratılamadı:

Warning: would violate PodSecurity "restricted:latest": allowPrivilegeEscalation != false (container "api" must set securityContext.allowPrivilegeEscalation=false), unrestricted capabilities (container "api" must set securityContext.capabilities.drop=["ALL"]), runAsNonRoot != true (pod or container "api" must set securityContext.runAsNonRoot=true), seccompProfile (pod or container "api" must set securityContext.seccompProfile.type to "RuntimeDefault" or "Localhost")
deployment.apps/api created
NAME   READY   UP-TO-DATE   AVAILABLE   AGE
api    0/2     0            0           5s
Warning   FailedCreate   replicaset/api-5c4bc79dd4   ... Error creating: pods "api-5c4bc79dd4-69vnx" is forbidden: violates PodSecurity "restricted:v1.37": ...

Bu davranışı bil: kubectl apply başarılı görünür, hata yalnız ReplicaSet'in event'lerinde görünür; Deployment 0/2'de bekler. enforce-version'ı sabitlemek, cluster upgrade'inde restricted'ın tanımı sıkılaştığında mevcut workload'ların bir gecede reddedilmesini önler. Yeni bir seviyeye geçmeden önce warn ve audit modlarıyla haftalarca gözlem yapmak en güvenli yol.

securityContext: .NET Uygulamasını Sertleştirmek

Restricted seviyeyi karşılayan, üstüne de root dosya sistemini salt okunur yapan Deployment:

    spec:
      serviceAccountName: api
      automountServiceAccountToken: false
      securityContext:
        runAsNonRoot: true
        seccompProfile:
          type: RuntimeDefault
      containers:
        - name: api
          image: bookstore-api:1.2.0
          securityContext:
            allowPrivilegeEscalation: false
            readOnlyRootFilesystem: true
            capabilities:
              drop: ["ALL"]
          env:
            - name: ConnectionStrings__Default
              valueFrom:
                secretKeyRef:
                  name: api-db
                  key: connection-string
          volumeMounts:
            - name: tmp
              mountPath: /tmp
      volumes:
        - name: tmp
          emptyDir:
            sizeLimit: 64Mi

Her satırın bir sebebi var. runAsNonRoot, image root olarak çalışmak isterse container'ı başlatmaz. allowPrivilegeEscalation: false, setuid binary'leriyle yetki kazanmayı engeller. capabilities.drop: ["ALL"], Linux capability'lerini sıfırlar; 8080 gibi 1024 üstü bir portta dinleyen Kestrel'in hiçbirine ihtiyacı yok. seccompProfile: RuntimeDefault, container runtime'ın varsayılan syscall filtresini açar. readOnlyRootFilesystem, saldırganın container içine dosya bırakmasını zorlaştırır.

Önce Tahmin Et'teki üçüncü soru burada önem kazanıyor. Chiseled image'ın kullanıcısı 1654 (app). Ubuntu 24.04 tabanlı varsayılan aspnet:10.0 image'ında ise APP_UID=1654 ortam değişkeni tanımlı ama USER satırı yok; Dockerfile'da USER $APP_UID yazmazsan container root olarak çalışır (yukarıdaki uid=0(root) çıktısı tam olarak bu image'dan). Aynı Deployment'ı bu image ile denediğimizde:

api-full-7fc798f798-z6srz   0/1     CreateContainerConfigError   0          12s
Warning   Failed   pod/api-full-7fc798f798-z6srz   Error: container has runAsNonRoot and image will run as root (pod: "api-full-7fc798f798-z6srz_bookstore-sec(b90f6f93-ac19-4d09-8a68-dba149846624)", container: api-full)

Çözüm Dockerfile'a USER $APP_UID eklemek ya da securityContext'e runAsUser: 1654 yazmak; ilki image'ın her yerde güvenli çalışmasını sağlar.

Salt okunur dosya sistemiyle .NET uygulaması açıldı, ama log'da dikkat edilmesi gereken bir değişiklik var:

04:28:44.078 warn: Microsoft.AspNetCore.DataProtection.Repositories.EphemeralXmlRepository[50] Using an in-memory repository. Keys will not be persisted to storage.
04:28:44.078 warn: Microsoft.AspNetCore.DataProtection.KeyManagement.XmlKeyManager[59] Neither user profile nor HKLM registry available. Using an ephemeral key repository. Protected data will be unavailable when application exits.

Data Protection /home/app/.aspnet'e yazamadığı için key'leri bellekte tutuyor; on ikinci yazıda gördüğümüz key ring sorunu artık container restart'ında bile yaşanır. Salt okunur dosya sistemi, paylaşılan bir key store'u zorunlu kılar. /tmp için emptyDir ise başka bir şeyi korur: .NET runtime'ı diagnostics IPC (Inter-Process Communication) socket'ini /tmp'ye açar; orası yazılamazsa dotnet-counters ve dotnet-dump process'i bulamaz. Bunu bir sonraki yazıda debug container'ıyla kullanacağız.

Bir adım daha: user namespace'ler. Kubernetes 1.36'da GA (General Availability) olan hostUsers: false, container'daki UID'leri (User ID) node'da ayrı bir aralığa eşler; container içinde root olan bir process node'da yetkisiz bir kullanıcıdır. kind'da denedik:

$ kubectl -n bookstore-sec exec userns -- sh -c 'id; cat /proc/self/uid_map'
uid=1000 gid=0(root) groups=0(root)
         0 1017053184      65536
$ kubectl -n bookstore-sec exec client -- sh -c 'id; cat /proc/self/uid_map'
uid=1000 gid=0(root) groups=0(root)
         0          0 4294967295

hostUsers: false olan pod'da container'ın 0–65535 aralığı node'da 1017053184'ten başlıyor; normal pod'da eşleme birebir. Node'un kernel'i ve container runtime'ı bunu desteklemeli; managed cluster'da açmadan önce sağlayıcının dokümanına bak.

NetworkPolicy: Varsayılan Olarak Herkes Herkese Erişir

Önce Tahmin Et'teki ilk soruyu policy yokken denedik. Aynı namespace'teki sıradan bir client pod'u PostgreSQL'e doğrudan bağlanabildi:

$ kubectl -n bookstore-sec exec client -- nc -z -w 3 postgres.bookstore-data 5432; echo "nc exit=$?"
nc exit=0

NetworkPolicy, pod'lar arası trafiği label'larla tarif eder. Bir pod'u seçen ilk policy o pod için "varsayılan reddet" moduna geçirir, policy'ler yalnız izin ekler. İki namespace'e de bir default deny koyduk:

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: default-deny
  namespace: bookstore-sec
spec:
  podSelector: {}
  policyTypes: ["Ingress", "Egress"]
$ kubectl -n bookstore-sec exec client -- wget -q -T 5 -O- http://api/api/books
wget: bad address 'api'

Hata "connection refused" değil, "bad address": egress'i kapattığımız için DNS (Domain Name System) sorgusu bile gidemedi. Default deny'dan sonra ilk eklenecek kural her zaman DNS'tir. Sonra yalnız gerçekten gereken yollar:

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: allow-dns
  namespace: bookstore-sec
spec:
  podSelector: {}
  policyTypes: ["Egress"]
  egress:
    - to:
        - namespaceSelector:
            matchLabels:
              kubernetes.io/metadata.name: kube-system
          podSelector:
            matchLabels:
              k8s-app: kube-dns
      ports:
        - protocol: UDP
          port: 53
        - protocol: TCP
          port: 53
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: api
  namespace: bookstore-sec
spec:
  podSelector:
    matchLabels:
      app.kubernetes.io/name: api
  policyTypes: ["Ingress", "Egress"]
  ingress:
    - from:
        - podSelector:
            matchLabels:
              app.kubernetes.io/name: client
      ports:
        - port: 8080
  egress:
    - to:
        - namespaceSelector:
            matchLabels:
              kubernetes.io/metadata.name: bookstore-data
          podSelector:
            matchLabels:
              app.kubernetes.io/name: postgres
      ports:
        - port: 5432
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: postgres-from-api
  namespace: bookstore-data
spec:
  podSelector:
    matchLabels:
      app.kubernetes.io/name: postgres
  policyTypes: ["Ingress"]
  ingress:
    - from:
        - namespaceSelector:
            matchLabels:
              kubernetes.io/metadata.name: bookstore-sec
          podSelector:
            matchLabels:
              app.kubernetes.io/name: api
      ports:
        - port: 5432

Client'ın API'ye çıkışı için de küçük bir egress policy (client-to-api) ekledik. Sonuç:

$ kubectl -n bookstore-sec exec client -- wget -q -T 5 -O- http://api/api/books
[{"id":1,"title":"Clean Architecture","author":"Robert C. Martin","price":42.50}, ...]
$ kubectl -n bookstore-sec exec client -- nc -z -w 3 postgres.bookstore-data 5432; echo "nc exit=$?"
nc exit=1
$ kubectl -n bookstore-sec exec client -- wget -q -T 5 -O- http://api.bookstore.svc.cluster.local/
wget: download timed out

API veritabanına ulaşıyor (kitaplar PostgreSQL'den geldi), client API'ye ulaşıyor, ama client artık ne veritabanına ne de başka namespace'teki bir servise gidebiliyor. namespaceSelector ile podSelector'ın aynı to öğesinde olması "ve" demek; ayrı liste öğeleri olsaydı "veya" olurdu ve bookstore-data'daki her pod'a izin verirdi. Bu tek girintilik fark en sık yapılan NetworkPolicy hatası. Bir uyarı daha: NetworkPolicy'yi CNI (Container Network Interface) eklentisi uygular. kind'ın kindnet'i uyguluyor; uygulamayan bir CNI'da bu nesneler hata vermeden yaratılır ve hiçbir şey engellenmez. Managed cluster'da policy desteğinin açık olduğunu (AKS (Azure Kubernetes Service) tarafında ağ politikası seçeneği; EKS (Amazon Elastic Kubernetes Service) tarafında VPC (Virtual Private Cloud) CNI'ın policy modu veya Calico/Cilium) kontrol et ve her zaman yukarıdaki gibi negatif bir testle doğrula.

Image Pull Secret ve Image'ın Kaynağı

Private registry'den image çekmek için imagePullSecrets pod'a ya da ServiceAccount'a eklenir; ServiceAccount'a eklemek her Deployment'a tek tek yazmaktan daha az hata payı bırakır. Managed cluster'larda daha iyisi secret'sız erişimdir: AKS'te kubelet identity'ye registry'den pull yetkisi vermek, EKS'te node rolüne ECR (Elastic Container Registry) okuma izni. Neyin çalıştığından emin olmak ise ayrı bir konu: tag yerine digest ile deploy etmek, image'ı imzalamak ve admission'da imzayı doğrulamak, SBOM (Software Bill of Materials) üretmek. Bunlar ilerideki DevSecOps serisinin konusu; burada önemli olan, manifest'teki bookstore-api:1.2.0'ın her zaman aynı byte'lar olmadığını bilmek.

Üretimde Dikkat

Güvenlik ayarları kademeli açılır: önce warn/audit, sonra enforce; önce tek namespace, sonra hepsi. NetworkPolicy'de default deny'ı production'a ilk kez uygularken unutulan bağımlılıklar çıkar: harici bir ödeme servisi, SMTP (Simple Mail Transfer Protocol) sunucusu, metrik toplayıcı, Gateway'den gelen trafik. Policy'leri önce staging'de, trafik log'larına bakarak kur. readOnlyRootFilesystem'ı açtıktan sonra uygulamanın yazmaya çalıştığı yerleri log'lardan bul; .NET'te tipik adaylar /tmp, Data Protection key'leri ve bazı kütüphanelerin cache klasörleri. RBAC'i düzenli denetle: kubectl auth can-i --list --as=... çıktısını her ServiceAccount için saklayıp değişiklikleri izlemek ucuz bir başlangıç. Ve hatırla: buradaki her ayar savunma katmanıdır, uygulamanın kendi güvenliğinin yerine geçmez; OWASP (Open Worldwide Application Security Project) listesindeki açıklar için Web Güvenliği (OWASP Top 10) serisi geçerliliğini koruyor.

Sık Düşülen Tuzaklar

Her uygulamayı default ServiceAccount'la ve mount edilmiş token'la çalıştırmak. aspnet:10.0 image'ının kendiliğinden non-root çalıştığını sanmak. Restricted seviyeyi açıp kubectl apply'ın başarılı görünmesine güvenmek; pod'ların yaratılamadığı yalnız ReplicaSet event'lerinde görünür. readOnlyRootFilesystem'ı açıp /tmp'yi ve Data Protection key'lerini unutmak. Default deny'dan sonra DNS'e izin vermemek. NetworkPolicy'de namespaceSelector ve podSelector'ı ayrı liste öğeleri yazıp istemeden bütün namespace'e kapı açmak. Policy'yi desteklemeyen bir CNI'da policy yazıp korunduğunu sanmak. Ve secrets üzerinde list yetkisini "sadece listeliyor" diye zararsız görmek.

Küçük Bir Deneme

★☆☆ bookstore-sec namespace'inde warn=restricted label'ını bırak, enforce'u kaldır ve on birinci yazıdaki RabbitMQ Deployment'ını uygula. Hangi uyarıları alıyorsun, hangilerini RabbitMQ image'ını değiştirmeden düzeltebilirsin?

★★☆ API'nin NetworkPolicy'sindeki egress kuralını namespaceSelector ve podSelector ayrı liste öğeleri olacak şekilde yeniden yaz. bookstore-data'ya ikinci bir pod koyup API'nin (ya da aynı label'lı bir test pod'unun) ona erişebildiğini göster.

★★★ Worker için leader election'ı gerçekten çalıştır: .NET'te KubernetesClient paketiyle bir Lease alıp yenileyen küçük bir BackgroundService yaz, iki replica çalıştır ve kubectl get lease order-outbox-leader -o yaml ile holderIdentity'nin pod silindiğinde el değiştirdiğini izle. Role'den update verb'ünü çıkarınca log'da ne görüyorsun?

Güvenlik ayarlarını sıkılaştırdıkça bir şeyler kırılacak: pod açılmayacak, bağlantı zaman aşımına uğrayacak, process OOMKilled olacak. Bir sonraki yazıda bu kırılmaları sistemli şekilde teşhis etmeyi ele alıyoruz: CrashLoopBackOff, ImagePullBackOff, Pending ve shell'siz chiseled image'larda kubectl debug.