Kasım 22 2024

Kubernetes Kaynak Yönetimi: Request, Limit ve VPA Ayarları

Hepimiz o sancılı anı en az bir kez yaşamışızdır: Hafta sonu tam kahvenizden ilk yudumu almışken, Slack veya PagerDuty üzerinden bir “Pod crashed” uyarısı düşer. Hemen terminale koşup cluster’a sızarsınız, kubectl describe pod komutunu yapıştırırsınız ve karşınızda o meşhur ibareyi görürsünüz: OOMKilled (Exit Code 137). Ya da daha sinsisi; CPU kullanımı %10 seviyelerindeyken uygulamanın response time’ları bir anda saniyelere fırlar, çünkü arkada amansız bir CPU throttling savaşı dönmektedir.

Kubernetes dünyasında kaynak yönetimi (resources), teoride çok basit görünse de pratikte prod ortamlarının en büyük baş belasıdır. Bu makalede, deneyimli bir devops mühendisinin gözünden, kubernetes üzerinde CPU/Memory dengesini nasıl kuracağımızı, vpa (Vertical Pod Autoscaler) ile kaynak yönetimini nasıl otomatize edeceğimizi ve throttling/OOMKill canavarlarıyla nasıl savaşacağımızı derinlemesine ele alacağız.

Request ve Limit: scheduler’ın Terazi Hassasiyeti

Kubernetes dünyasında kaynakları tanımlarken iki temel kavram kullanırız: requests ve limits. Bunların arasındaki farkı netleştirmek, sistemin kararlılığı için atılacak ilk adımdır.

  • Request: Bir pod’un schedule edilebilmesi için garanti edilen minimum kaynak miktarıdır. Kubernetes scheduler, bir node üzerinde pod’u konumlandırırken sadece bu değere bakar. Eğer node üzerinde boşta yeterli “request” kapasitesi yoksa, pod o node’a yerleşemez (Pending durumunda kalır).
  • Limit: Bir pod’un fiziksel olarak tüketebileceği maksimum kaynaktır. Pod bu sınırı aşmaya çalıştığında kernel seviyesinde kısıtlamalar devreye girer.

QoS (Quality of Service) Sınıfları Neden Önemli?

Request ve limit değerlerini nasıl tanımladığınız, Kubernetes’in podunuza atayacağı QoS sınıfını belirler. Node üzerinde kaynak darboğazı yaşandığında, kubelet ilk olarak hangi pod’u feda edeceğine (eviction) bu sınıfa göre karar verir:

  1. Guaranteed (Garantili): Pod içerisindeki tüm container’ların request ve limit değerleri birbirine eşittir (hem CPU hem Memory için). Kubernetes bu pod’ları en son gözden çıkarır. Veritabanları ve stateful servisler için idealdir.
  2. Burstable (Esneyebilir): Request değeri limit değerinden küçüktür veya sadece request tanımlanmıştır. Web uygulamalarımızın %90’ı bu sınıfa girer. Node sıkıştığında, limitlerine yaklaşan burstable pod’lar ilk hedef tahtasındadır.
  3. BestEffort (Gönlünden Ne Koparsa): Ne request ne de limit tanımlanmıştır. Node üzerinde kaynak bittiği an, kubelet bu pod’ları acımadan öldürür. Prod ortamında asla ama asla kullanılmamalıdır!
# Burstable QoS sınıfına örnek bir pod tanımı
apiVersion: v1
kind: Pod
metadata:
  name: billing-service
  namespace: production
spec:
  containers:
  - name: app
    image: internal/billing:v2.1.0
    resources:
      requests:
        memory: "256Mi"
        cpu: "200m"
      limits:
        memory: "512Mi"
        cpu: "1000m"

CPU Throttling ve OOMKill: Sessiz Katiller

Memory ve CPU, işletim sistemi kernel’ı tarafından farklı şekillerde yönetilir. Bu fark, pod’larımızın hayatta kalma mücadelesini doğrudan etkiler.

OOMKill (Out of Memory)

Memory, sıkıştırılamaz (non-compressible) bir kaynaktır. Bir pod, kendisine tanımlanan memory limit değerini aşarsa, kernel’ın oom-killer mekanizması devreye girer ve pod’u anında öldürür. Pod’unuz durduk yere 137 exit kodu ile çöküyorsa, memory limitiniz uygulamanın anlık yük altında ihtiyaç duyduğu heap size’a yetmiyor demektir.

Bunu debug etmek için şu komutla son çöken pod’ların nedenlerine bakabilirsiniz:

kubectl get pods -n production -o jsonpath='{range .items[*]}{.metadata.name}{"\t"}{.status.containerStatuses[*].lastState.terminated.reason}{"\n"}{end}' | grep OOMKilled

CPU Throttling

CPU ise sıkıştırılabilir (compressible) bir kaynaktır. Pod’unuz CPU limitine ulaştığında işletim sistemi onu öldürmez; bunun yerine pod’a tahsis edilen CPU zaman dilimlerini (CFS shares) kısar. Buna throttling denir.

Uygulamanız çökmez ama bir anda istekleri işlememeye başlar, thread’ler kuyruğa girer ve latency tavan yapar. Birçok junior mühendis bu durumu fark edemez çünkü pod “Running” durumundadır ve CPU kullanımı %100 görünmez (çünkü limit bazlı kırpılmıştır).

Prometheus üzerinde CPU throttling olayını yakalamak için şu PromQL sorgusunu kullanabilirsiniz:

sum(rate(container_cpu_cfs_throttled_seconds_total[5m])) by (pod, container) / sum(rate(container_cpu_usage_seconds_total[5m])) by (pod, container) * 100

Eğer bu oran %5’in üzerindeyse, uygulamanız ciddi şekilde yavaşlatılıyor demektir. Limit değerinizi artırmanın vakti gelmiştir.

VPA (Vertical Pod Autoscaler) ile Otomatik Kaynak Yönetimi

Yazılımcılara “Uygulamanız ne kadar kaynak tüketiyor?” diye sormak, bir çocuğa “Ne kadar dondurma istersin?” diye sormaya benzer. Cevap hep “Çok!” olur. İşte bu noktada vpa devreye giriyor. VPA, pod’ların geçmiş kaynak tüketimlerini izleyerek en ideal request ve limit değerlerini otomatik olarak hesaplar.

VPA üç ana bileşenden oluşur:

  • Recommender: Tarihsel metrikleri (Prometheus veya Metrics Server üzerinden) inceleyerek ideal kaynak önerilerini hesaplar.
  • Updater: Eğer bir pod önerilen değerlerin çok dışındaysa, pod’u silerek yeni değerlerle yeniden ayağa kalkmasını tetikler (eğer mode: Auto ise).
  • Admission Controller: Pod yeniden oluşturulurken mutasyon aşamasında araya girer ve yeni request/limit değerlerini pod spec’ine enjekte eder.

Aşağıda prod ortamında güvenle kullanabileceğiniz bir VPA konfigürasyon örneği yer alıyor:

apiVersion: autoscaling.k8s.io/v1
kind: VerticalPodAutoscaler
metadata:
  name: auth-service-vpa
  namespace: production
spec:
  targetRef:
    apiVersion: "apps/v1"
    kind: Deployment
    name: auth-service
  updatePolicy:
    updateMode: "Auto" # "Off", "Initial" veya "Auto" yapabilirsiniz.
  resourcePolicy:
    containerPolicies:
      - containerName: '*'
        minAllowed:
          cpu: "100m"
          memory: "128Mi"
        maxAllowed:
          cpu: "2000m"
          memory: "4Gi"
        controlledResources: ["cpu", "memory"]

Pro-Tip: Eğer pod’larınızın durduk yere restart olmasını istemiyorsanız, updateMode: "Off" olarak ayarlayın. VPA sadece öneri (recommendation) üretecektir. Siz de bu önerileri inceleyerek (kubectl describe vpa auth-service-vpa) manuel olarak güncelleme yapabilirsiniz.

VPA ve HPA Çelişkisi: İki Kaptan Bir Gemiyi Batırır

Eğer cluster mimarinizde hem hpa (Horizontal Pod Autoscaler) hem de VPA kullanıyorsanız dikkat etmeniz gereken çok kritik bir kural var: İkisini de aynı kaynak metriğine (örn: CPU kullanımına) göre ölçeklenecek şekilde ayarlayamazsınız!

Eğer HPA CPU kullanımına göre pod sayısını artırmaya çalışırken, VPA de aynı CPU metriğine bakıp pod’un dikey boyutunu büyütmeye çalışırsa, sistem kararsız bir döngüye girer. HPA pod ekler, VPA pod’u yeniden başlatıp büyütür, tam bir kaos yaşanır.

Bu Çıkmazdan Nasıl Kurtuluruz?

  1. HPA’yı Custom/External Metriklere Geçirin: HPA’yı CPU/Memory yerine Prometheus üzerinden gelen saniye başına istek sayısı (RPS), kuyruk uzunluğu (RabbitMQ queue size) veya aktif veritabanı bağlantı sayısı gibi işlevsel metriklere göre ölçeklendirin. VPA ise arkada sessizce pod’ların fiziksel limitlerini optimize etsin.
  2. VPA’yı Sadece Öneri Modunda Çalıştırın: VPA’yı updateMode: "Off" modunda bırakıp, Goldilocks gibi open-source araçlar kullanarak sadece doğru kaynak değerlerini bulmak için bir pusula olarak kullanın.

Altın Kurallar ve Best Practices

  • CPU Limitlerini Çok Sıkı Tutmayın (Veya Hiç Koymayın): Modern Kubernetes topluluklarında (özellikle büyük ölçekli altyapılarda) CPU limitlerinin kaldırılması tartışılıyor. Çünkü CPU limitleri CFS throttling nedeniyle gereksiz latency oluşturabiliyor. Bunun yerine yüksek CPU request’i tanımlayıp limit koymamak (veya çok yüksek tutmak) node üzerindeki işlemci gücünü daha efektif kullanabilir. Ancak Memory limitleri kesinlikle zorunludur!
  • Overcommit Oranını İyi Yönetin: Node’larınızdaki toplam Memory limitlerinin, fiziksel node kapasitesini aşırı derecede geçmesine (overcommit) izin vermeyin. Aksi takdirde bir yük anında tüm node kilitlenebilir ve kubelet pod’ları kontrolsüzce öldürmeye başlar.
  • Liveness ve Readiness Probe’lara Dikkat Edin: Probe’ların kullandığı endpoint’lerin yüksek CPU/Memory tüketmediğinden emin olun. Throttling anında probe’lar timeout’a düşerse, Kubernetes sağlam podunuzu “unhealthy” sayıp restart döngüsüne sokabilir.

Kubernetes üzerinde kaynak yönetimi, sürekli izlenmesi ve dinamik olarak ayarlanması gereken canlı bir süreçtir. VPA gibi otomasyon araçlarını sisteminize entegre ederek hem cloud maliyetlerinizi düşürebilir hem de o meşhur cumartesi gecesi uykularınızı garanti altına alabilirsiniz.

Category: Genel | LEAVE A COMMENT
Kasım 15 2024

Ansible ile Idempotent Playbook Yazmanın İncelikleri

Sektörde beş yılı deviren her devops mühendisinin ortak kabuslarından biri, “benden önce yazılmış” ve her çalıştığında farklı bir sürpriz sunan ansible playbook’larıdır. Hepimiz o yollardan geçtik: Sadece bir config dosyasındaki parametreyi değiştirmek için çalıştırdığınız playbook, sunucudaki üç servisi yeniden başlatır, SSL sertifikalarını sıfırlar ve deployment pipeline’ını kilitler. İşte bu noktada, modern iac (Infrastructure as Code) felsefesinin kalbi olan idempotent (eşgüçlü) kavramı devreye giriyor. Gerçek bir altyapı otomasyon süreci, aynı playbook’u ister 1 ister 1000 kez çalıştırın, hedef sistemi her zaman tam olarak hedeflediğiniz kararlı durumda (desired state) bırakmalıdır. Bu makalede, işin “YAML yazmaktan” çıkıp gerçek bir yazılım mühendisliği disiplinine dönüştüğü o ince çizgiyi inceleyeceğiz.

1. “Her Şey Yolunda” İllüzyonu: changed_when ve failed_when

Ansible modüllerinin büyük çoğunluğu idempotent çalışacak şekilde tasarlanmıştır. Örneğin ansible.builtin.apt veya ansible.builtin.template modülleri, hedef sistemin durumunu kontrol eder ve bir değişiklik gerekmiyorsa yeşil yanarak yoluna devam eder. Ancak iş shell veya command modüllerine geldiğinde Ansible körleşir. Bu modüller doğası gereği her çalıştığında sisteme bir etki ettiklerini varsayarlar ve her zaman sarı (changed: true) dönerler.

Bu durum sadece göz zevkimizi bozmakla kalmaz; Ansible handler’larının (örneğin servis restart işlemleri) gereksiz yere tetiklenmesine yol açarak production ortamında kesintilere sebep olur. İşte bu kontrolsüzlüğü dizginlemek için elimizdeki en güçlü silahlar: changed_when ve failed_when.

İyi, Kötü ve Çirkin: shell Modülünü Terbiye Etmek

Farz edelim ki bir uygulamanın CLI aracıyla bir konfigürasyon yapacaksınız. Eğer bu konfigürasyon zaten yapılmışsa, komutu tekrar çalıştırmamalı veya çalıştırsak bile Ansible’a “hey, burada yeni bir şey yapmadın, sakin ol” demeliyiz.

Aşağıdaki kötü pratiğe bir göz atalım:

# KÖTÜ PRATİK (Her çalışmada "changed" döner, handler'ları tetikler)
- name: Enable custom application plugin
  ansible.builtin.shell: "myapp-cli plugin enable prometheus"
  register: plugin_output
  notify: Restart MyApp

Şimdi bunu profesyonelce revize edelim. Önce eklentinin durumunu sorgulayalım, ardından sadece eklenti aktif değilse işlem yapalım ve durumu Ansible’a doğru şekilde bildirelim:

# İYİ PRATİK (Idempotent ve güvenli)
- name: Check if prometheus plugin is already enabled
  ansible.builtin.shell: "myapp-cli plugin list | grep -E '^prometheus.*enabled'"
  register: plugin_status
  failed_when: false
  changed_when: false

- name: Enable custom application plugin if not enabled
  ansible.builtin.shell: "myapp-cli plugin enable prometheus"
  when: plugin_status.rc != 0
  register: enable_result
  changed_when: "'plugin successfully enabled' in enable_result.stdout"
  notify: Restart MyApp

Burada ne yaptık? İlk task’ta failed_when: false ve changed_when: false diyerek, sadece bir durum sorguladığımızı ve bu sorgunun sistemi değiştirmediğini, ayrıca grep başarısız olsa bile (yani plugin kurulu değilse) pipeline’ın kırılmaması gerektiğini belirttik. İkinci task’ta ise sadece plugin aktif değilse çalıştık ve çıktıdaki spesifik bir loga bakarak değişikliğin gerçekten gerçekleşip gerçekleşmediğini teyit ettik.

2. Sırları Sızdırmadan Yönetmek: Ansible Vault ve Best Practice’ler

IaC kodlarınızı Git reposunda tutuyorsanız (ki tutmalısınız), API key’ler, veritabanı şifreleri veya SSL private key’ler gibi hassas verileri (secrets) asla düz metin (plain text) olarak commit etmemelisiniz. Ansible bu problemi çözmek için dahili bir şifreleme mekanizması olan Ansible Vault‘u sunar.

Ancak projelerde sıklıkla yapılan hata, tüm group_vars/all.yml dosyasını toptan şifrelemektir. Bu, kod incelemelerinde (Code Review) hangi değişkenlerin değiştiğini görmeyi imkansız hale getirir. Bunun yerine, sadece hassas değerleri şifreleyip referans vermek çok daha temiz bir yaklaşımdır.

Tek Satır Şifreleme (vault_encrypted) Pratiği

Tüm dosyayı şifrelemek yerine, sadece gizli tutmak istediğiniz değeri terminalde şifreleyin:

ansible-vault encrypt_string 'super_secret_db_password' --name 'vault_db_password'

Bu komut size YAML formatında şifrelenmiş bir blok verecektir. Bu bloğu değişken dosyanıza güvenle yapıştırabilirsiniz:

# group_vars/production.yml
db_username: "app_user" # Düz metin olarak kalmasında sakınca yok
db_password: "{{ vault_db_password }}" # Şifrelenmiş değere referans

# Şifrelenmiş blok:
vault_db_password: !vault |
          $ANSIBLE_VAULT;1.1;AES256
          3565393065646134373463326139613661646231363539303362333633643730306265353866
          62376663363236373961633535366430333766396662366264370a3736363339386333343632
          3431663030313534663831633633636636316262333066366432366164343164376435383561
          32303533663731306132373335623233390a3666363435303964313032393335396161613936
          653063616664653838323630

Playbook’u çalıştırırken Vault şifresini güvenli bir şekilde okutmak için --vault-password-file parametresini veya çevresel değişkenleri (environment variables) kullanabilirsiniz. CI/CD pipeline’larında bu şifreyi runner’a bir secret olarak tanımlamak en güvenli yoldur.

3. Altyapıyı Test Etmek: Molecule ile TDD Yaklaşımı

“Yazdığım playbook production’da çalışır mı?” sorusunun cevabı hiçbir zaman “deneyip görelim” olmamalıdır. Yazılım dünyasındaki Unit/Integration test kavramının IaC dünyasındaki karşılığı Molecule‘dur. Molecule; Ansible rollerinizi izole ortamlarda (genellikle Docker veya Podman üzerinde, bazen de Vagrant/AWS’te) otomatik olarak ayağa kaldırır, playbook’unuzu çalıştırır (idempotency testi dahil) ve ardından ortamı temizler.

Molecule Kurulumu ve Örnek Senaryo

Molecule’ü projenize dahil etmek için Docker driver’ı ile birlikte yükleyin:

pip install molecule molecule-plugins[docker] ansible-lint

Bir Ansible rolü içinde Molecule senaryosu başlatmak için:

molecule init scenario --driver-name docker

Bu komut, rolünüzün altında molecule/default adında bir klasör oluşturur. Buradaki en kritik dosya testlerin nasıl koşulacağını belirleyen molecule.yml dosyasıdır:

# molecule/default/molecule.yml
dependency:
  name: galaxy
driver:
  name: docker
platforms:
  - name: test-ubuntu-target
    image: geerlingguy/docker-ubuntu2204-ansible:latest
    pre_build_image: true
    volumes:
      - /sys/fs/cgroup:/sys/fs/cgroup:rw
    cgroupns_mode: host
    privileged: true
provisioner:
  name: ansible
  playbooks:
    converge: ${MOLECULE_PLAYBOOK:-converge.yml}
verifier:
  name: ansible

molecule test komutunu çalıştırdığınızda şu adımlar sırasıyla işletilir:

  1. Dependency: Gerekli harici roller indirilir.
  2. Lint: Ansible-lint ile kod standartları taranır.
  3. Destroy/Create: Eski test container’ları silinir ve yenileri ayağa kaldırılır.
  4. Converge: Playbook’unuz ilk kez çalıştırılır (kurulumlar yapılır).
  5. Idempotence: Playbook ikinci kez çalıştırılır. Eğer herhangi bir task “changed” dönerse test başarısız sayılır! İşte gerçek idempotent testi budur.
  6. Verify: Belirlediğiniz test script’leri (örneğin servis gerçekten çalışıyor mu kontrolü) koşturulur.

4. Performans Optimizasyonu: Dakikaları Saniyelere İndirmek

Ansible, mimarisi gereği her bir task için hedef makineye SSH bağlantısı açar, modülü transfer eder, çalıştırır ve sonucu geri alır. Yüzlerce sunucudan oluşan bir envanterde bu durum ciddi bir performans darboğazı (bottleneck) yaratır. Neyse ki birkaç ince ayarla bu süreyi dramatik şekilde azaltabiliriz.

SSH Pipelining Aktifleştirme

Ansible’ın varsayılan davranışında, modül dosyaları önce hedef makineye kopyalanır ve ardından SSH üzerinden çalıştırılır. SSH Pipelining aktifleştirildiğinde ise bu işlemler kopyalama adımı atlanarak doğrudan SSH oturumu üzerinden (piped) gerçekleştirilir.

# ansible.cfg
[ssh_connection]
pipelining = True
ssh_args = -o ControlMaster=auto -o ControlPersist=60s

Not: Pipelining kullanabilmek için hedef sunuculardaki /etc/sudoers dosyasında requiretty seçeneğinin aktif olmaması gerekir (modern dağıtımlarda varsayılan olarak pasiftir).

Fact Gathering Mekanizmasını Optimize Etmek

Ansible her playbook başlangıcında hedef sisteme dair tüm sistem bilgilerini (IP, CPU, RAM, disk durumları vb.) toplar (Gathering Facts). Eğer playbook’unuzda bu değişkenleri (örn: ansible_distribution) kullanmıyorsanız, bu adımı tamamen kapatın:

- hosts: webservers
  gather_facts: false
  tasks:
    # ...

Eğer bazı task’lar için bu bilgilere ihtiyacınız varsa, akıllı cache (fact caching) mekanizmasını devreye sokarak her çalıştırmada bu süreyi tekrar ödemekten kurtulabilirsiniz:

# ansible.cfg
[defaults]
gathering = smart
fact_caching = jsonfile
fact_caching_connection = /tmp/ansible_fact_cache
fact_caching_timeout = 86400 # 24 saat cache'le

Özet ve Kapanış

Yazması kolay, yönetmesi zor bir araçtır Ansible. Onu sıradan bir shell script tetikleyicisinden profesyonel bir iac aracına dönüştüren şey, sizin yazdığınız playbook’lardaki detay seviyesidir. changed_when ile kontrolü elinizde tutmak, secrets yönetiminde hassas davranmak, Molecule ile test güvencesi sağlamak ve performans parametrelerini optimize etmek sizi ekipten bir adım öne çıkaracaktır. Altyapınızın her zaman tahmin edilebilir, kararlı ve hızlı kalması dileğiyle!

Category: Genel | LEAVE A COMMENT
Kasım 8 2024

Docker Multi-Stage Build ile İmaj Boyutunu %80 Küçültme

Modern devops süreçlerinde, CI/CD pipeline’larının hızını doğrudan etkileyen en kritik unsurlardan biri optimize edilmiş container imajlarıdır. Üretim ortamına (production) her deployment çıktığımızda, şişkin bir docker imajının registry’ye push edilmesi ve ardından Kubernetes node’ları tarafından pull edilmesi ciddi bir zaman ve network maliyeti yaratır. İşte tam bu noktada, multi-stage build yaklaşımı imaj boyutlarını radikal bir şekilde düşürerek deployment süreçlerimizi hızlandırır, cold-start sürelerini minimize eder ve güvenlik açıklarını (vulnerabilities) büyük oranda azaltır. Bu yazıda, sadece teoriden bahsetmeyeceğiz; hantal bir imajı alıp adım adım ameliyat masasına yatıracağız.

Acı Gerçek: 800 MB’lık Hello World İmajı Nasıl Yapılır?

Pek çok yazılım ekibinde, “çalışıyor işte kurcalama” mantığıyla yazılmış Dockerfile’lar görürüz. Özellikle Go, Rust gibi statik olarak derlenen dillerde veya Node.js gibi devasa node_modules klasörüne sahip ortamlarda bu durum tam bir faciaya dönüşebilir. Gelin, basit bir Go uygulaması üzerinden durumu simüle edelim.

Aşağıda, sadece HTTP 200 dönen basit bir Go web sunucusunun Dockerfile’ı yer alıyor:

FROM golang:1.21

WORKDIR /app

COPY . .

RUN go build -o main .

EXPOSE 8080

CMD ["./main"]

Bu Dockerfile ile build aldığınızda, karşınıza çıkacak imaj boyutu yaklaşık 850 MB civarında olacaktır. Peki neden? Çünkü Go derleyicisine, paket yöneticisine, Debian tabanlı işletim sisteminin tüm araçlarına (curl, git, apt vb.) sadece uygulamayı çalıştırmak için ihtiyacımız yok. Bunlar sadece derleme (build-time) aşamasında lazım olan araçlar. Production runtime’ında bu araçların durması hem gereksiz bir disk yükü hem de potansiyel birer güvenlik açığıdır.

Multi-Stage Build Nedir ve Neden Hayat Kurtarır?

Multi-stage build, tek bir Dockerfile içerisinde birden fazla FROM ifadesi kullanarak imaj oluşturma sürecini aşamalara (stage) bölmemizi sağlar. Derleme araçlarını ilk aşamada bırakır, sadece ortaya çıkan nihai çıktıyı (artifact) bir sonraki temiz ve hafif aşamaya kopyalarız.

Bu yöntemle, ilk aşamada (builder) tüm ağır SDK’leri kullanabilir, işimiz bittiğinde ise sadece binary dosyasını alıp minimalist bir base image üzerine koyabiliriz. Böylece, yüzlerce megabaytlık derleme araçları nihai imajın katmanlarında (layer) yer almaz.

Dockerfile’ı Ameliyat Ediyoruz

Şimdi yukarıdaki kötü senaryoyu multi-stage kullanarak optimize edelim. Dockerfile’ımızı iki aşamaya bölüyoruz: “builder” ve “runner”.

# 1. Aşama: Derleme (Build Stage)
FROM golang:1.21-alpine AS builder

WORKDIR /app

# Bağımlılıkları kopyala ve indir
COPY go.mod go.sum ./
RUN go mod download

# Kaynak kodları kopyala ve derle
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o /app/main .

# 2. Aşama: Çalıştırma (Runtime Stage)
FROM alpine:3.18

WORKDIR /app

# Sadece derlenmiş binary'yi ilk aşamadan kopyalıyoruz
COPY --from=builder /app/main .

EXPOSE 8080

# Non-root user tanımlayarak güvenliği artırıyoruz
RUN adduser -D appuser
USER appuser

CMD ["./main"]

Sonuç inanılmaz: İmaj boyutu 850 MB’tan 20 MB seviyelerine düştü! Yaklaşık %97 oranında bir küçülme elde ettik. Peki burada tam olarak ne yaptık? Neden bu kadar büyük bir fark oluştu?

  • AS builder: İlk satırda bu aşamaya bir isim verdik. Böylece sonraki aşamalarda bu isme referans verebiliyoruz.
  • Alpine Base Image: Derleme aşamasında Debian yerine hafif bir Alpine imajı seçtik.
  • CGO_ENABLED=0: Go uygulamasını derlerken C kütüphanelerine statik bağımlılık olmamasını sağladık. Bu sayede binary dosyamız tamamen taşınabilir hale geldi.
  • COPY –from=builder: İşte sihirli an! İkinci aşamada, ilk aşamada ürettiğimiz /app/main dosyasını çekip aldık. Geri kalan tüm Go SDK ve intermediate katmanlar çöpe gitti.

Layer Caching: Docker’ı Akıllıca Kandırmak

Multi-stage build kullanmak harika, ancak CI/CD pipeline’larımızın saniyeler içinde tamamlanmasını istiyorsak layer caching mekanizmasını doğru kullanmalıyız. Docker, Dockerfile’daki her bir satırı (instruction) yukarıdan aşağıya doğru sırayla çalıştırır ve her satır için bir cache katmanı oluşturur.

Eğer bir satırda değişiklik tespit edilirse (örneğin kopyalanan kodlar değiştiyse), Docker o satırdan sonraki tüm adımların cache’ini geçersiz kılar (cache invalidation).

Sık yapılan bir hata şudur:

COPY . .
RUN go mod download

Bu senaryoda, projedeki herhangi bir .go dosyasında tek bir satır değiştirdiğinizde, Docker tüm bağımlılıkları (go dependencies) internetten tekrar indirmeye başlar. Çünkü COPY . . satırı cache’i bozmuştur. Bunu önlemek için bağımlılık tanımlarını (go.mod, package.json vb.) kaynak koddan önce kopyalamalıyız:

COPY go.mod go.sum ./
RUN go mod download  # Bu satır, bağımlılıklar değişmediği sürece cache'den gelir!
COPY . .

Bu basit yer değişimi, CI/CD pipeline’larınızdaki build sürelerini dakikalardan saniyelere indirecektir. “Neden her seferinde npm install bekliyoruz?” sorusunun yanıtı tam olarak buradadır.

Distroless Base Image: İçeride Kimse Var mı?

İmaj boyutunu Alpine kullanarak 20 MB’a düşürdük, ancak daha da ileriye gidebiliriz. Alpine imajları her ne kadar küçük olsa da içinde hala bir shell (sh, ash) ve bir paket yöneticisi (apk) barındırır. Eğer bir saldırgan container içerisine sızmayı başarırsa, bu araçları kullanarak container içinde exploit çalıştırabilir veya internal network’ünüzü tarayabilir.

Google tarafından geliştirilen distroless imajlar, sadece uygulamanızın çalışması için gereken minimum çalışma zamanı (runtime) bağımlılıklarını içerir. İçinde shell yoktur, paket yöneticisi yoktur, temel linux komutları (ls, cd, ping) bile yoktur.

Şimdi runtime stage kısmını distroless static imajı ile güncelleyelim:

# 2. Aşama: Distroless Runtime
FROM gcr.io/distroless/static-debian12:latest

WORKDIR /

# Binary'yi kopyala
COPY --from=builder /app/main /main

# Distroless imajlarında varsayılan olarak nonroot kullanıcısı hazırdır
USER nonroot:nonroot

EXPOSE 8080

ENTRYPOINT ["/main"]

Bu değişiklikten sonra imaj boyutumuz yaklaşık 10-12 MB bandına çekilir. En önemlisi, container’ın saldırı yüzeyini (attack surface) neredeyse sıfıra indirmiş olduk. Container içinde /bin/sh olmadığı için uzaktan kod çalıştırma (RCE) açıklarının sömürülmesi imkansıza yakın hale gelir.

Güvenlik Tarama (Security Scanning) Testi

Yaptığımız bu optimizasyonların güvenlik tarafındaki yansımasını görmek için popüler açık kaynaklı tarama aracı Trivy kullanabiliriz. İlk yazdığımız 850 MB’lık imajı tarattığımızda muhtemelen düzinelerce “High” ve “Critical” seviyeli işletim sistemi açığı ile karşılaşacaktık.

Distroless imajımızı taratmak için terminalde şu komutu çalıştırabiliriz:

trivy image my-distroless-app:latest

Çıktıda göreceğiniz üzere, işletim sistemi seviyesindeki açık sayısı (Vulnerabilities) sıfıra yakın çıkacaktır. Bu, DevSecOps süreçlerinizi otomatize ederken security gate’lerden (güvenlik bariyerleri) takılmadan geçmenizi sağlar.

Özet ve Best Practice’ler

Özetlemek gerekirse, production seviyesinde container imajları hazırlarken şu altın kuralları asla unutmamalıyız:

  • Her zaman multi-stage build kullanın. Derleme ortamı ile çalışma ortamını birbirinden kesin çizgilerle ayırın.
  • Değişme sıklığı en az olan adımları (bağımlılık yükleme) Dockerfile’ın en üstüne, sık değişenleri (application code) en altına yazarak layer caching avantajını maksimize edin.
  • Çalışma ortamında Alpine veya daha iyisi distroless imajları tercih edin.
  • Container’ları asla root kullanıcısı ile çalıştırmayın; Dockerfile içinde mutlaka yetkisiz bir kullanıcı (non-root) tanımlayın.

Bu pratikleri uygulayarak hem bulut maliyetlerinizi (network egress ve storage) düşürebilir hem de çok daha güvenli ve hızlı deployment süreçlerine sahip olabilirsiniz. Unutmayın, en iyi imaj, içinde ihtiyacınız olmayan hiçbir şey barındırmayan imajdır!

Category: Genel | LEAVE A COMMENT
Kasım 1 2024

SLO/SLI/SLA: DevOps Ekiplerine Güvenilirlik Mühendisliği Rehberi

Gecenin 3’ünde, sırf bir API gateway endpoint’i anlık olarak 500ms yavaş yanıt verdi diye PagerDuty alarmıyla uyanıp monitöre boş gözlerle baktıysanız, tebrikler: Yanlış kurgulanmış bir alarm stratejisinin kurbanı oldunuz. Modern mikroservis mimarilerinde sistemlerin kararlılığını ve reliability (güvenilirlik) seviyesini ölçmek, her saniye CPU veya memory değerlerini izleyip eşik değer aşılınca ortalığı ayağa kaldırmakla olmaz. Modern sre (Site Reliability Engineering) dünyasında, sistem sağlığını ve mühendislik eforumuzu yönetmek için kullandığımız üç kutsal kısaltmamız var: sli, slo ve sla. Bu yazıda teorik tanımların ötesine geçecek, production ortamında işinize yarayacak gerçekçi error budget hesaplamalarını ve Prometheus üzerinde burn rate tabanlı dinamik alarmları nasıl kuracağımızı öğreneceğiz.

Kavram Karmaşasını Çözelim: SLI vs. SLO vs. SLA

Bu üç kavram genellikle birbirine karıştırılır veya aynı şeymiş gibi kullanılır. İşleri basitleştirmek için aralarındaki ilişkiyi netleştirelim:

  • SLI (Service Level Indicator): “Sistemimiz şu an ne kadar iyi çalışıyor?” sorusunun cevabıdır. Doğrudan ölçülebilir, anlık teknik metriklerdir. Örneğin; “Son 5 dakikadaki başarılı HTTP isteklerinin (2xx/3xx/4xx), toplam isteklere oranı.”
  • SLO (Service Level Objective): “Sistemin ne kadar iyi çalışmasını hedefliyoruz?” sorusunun yanıtıdır. SLI metriğine bağlı bir hedef ve zaman aralığı tanımlar. Örneğin; “Gelen tüm isteklerin %99.9’u, 30 günlük kayan pencerede (rolling window) 200ms’in altında yanıt vermelidir.”
  • SLA (Service Level Agreement): “Eğer sistemi hedeflediğimiz kadar ayakta tutamazsak ne kadar ceza ödeyeceğiz?” sorusunun ticari ve hukuki cevabıdır. Mühendislerden ziyade hukuk, ürün yönetimi (Product Management) ve müşterileri ilgilendirir. Genellikle SLO değerinden daha düşük bir taahhüt içerir (örneğin SLO %99.9 ise, SLA %99.5 olabilir).

Kısacası: SLI ölçer, SLO hedefler, SLA ise patlarsa fatura keser.

Hata Bütçesi (Error Budget): Neden Kusursuzluk Aramıyoruz?

SRE felsefesinin en büyük kabullerinden biri şudur: %100 güvenilirlik (reliability) yanlış bir hedeftir. Kullanıcılarınızın internet bağlantısı bile %100 kesintisiz değilken, altyapınız için %100 uptime hedeflemek sadece bütçenizi tüketir ve ekibin hızını (velocity) sıfırlar.

Bunun yerine Error Budget kavramını kullanırız. Formül oldukça basittir:

Error Budget = 100% - SLO%

Eğer bir servis için 30 günlük SLO hedefimizi %99.9 olarak belirlediysek, bu bizim 30 günde %0.1 oranında hata yapma veya kesinti yaşama hakkımız olduğu anlamına gelir. 30 günün saniye karşılığı 2.592.000 saniyedir. Bu durumda hata bütçemiz tam olarak 2.592 saniye, yani yaklaşık 43.2 dakikadır.

Bu bütçe sadece sistemin çökmesiyle tükenmez; yeni deployment’lar sırasındaki yavaşlıklar, geçici veritabanı bağlantı kayıpları da bu bütçeden yer. Eğer ay sonuna doğru hata bütçeniz duruyorsa, production ortamına korkusuzca riskli güncellemeler (feature deployment) gönderebilirsiniz. Bütçe tükendiyse, yeni deployment’ları durdurur ve sadece reliability/bug-fix işlerine odaklanırsınız.

Prometheus ile SLI Tanımlama (Pratik Uygulama)

İşleri biraz daha pratikleştirelim. Elimizde Prometheus tarafından scrape edilen bir HTTP servisinin metrikleri olduğunu varsayalım. En popüler SLI türlerinden biri olan Availability (Kullanılabilirlik) metriğini hesaplayalım.

Burada kritik bir SRE detayı var: Genellikle 5xx durum kodlarını sunucu hatası (unavailability) kabul ederken, 4xx durum kodlarını (örneğin 404 veya 401) kullanıcı hatası olarak kabul eder ve sistemin sağlığını bozmadığı için SLI hesabına dahil etmeyiz (tabii 4xx’ler aniden tavan yapmadıysa).

Yazacağımız PromQL sorgusu şu şekildedir:

sum(rate(http_requests_total{job="my-service", status!~"5.."}[5m]))
/
sum(rate(http_requests_total{job="my-service"}[5m]))

Bu sorgu, son 5 dakika içinde 5xx dışındaki isteklerin, toplam isteklere oranını verir. Çıktı 0 ile 1 arasında olacaktır (örneğin 0.9995, yani %99.95 başarı).

Burn Rate Alerts: CPU Alarmlarını Çöpe Atın

Geleneksel alert mekanizmaları genellikle “Anlık CPU %90’ı geçerse Slack’e mesaj at” veya “Hata oranı %1’i geçerse nöbetçiyi ara” şeklindedir. Bu yaklaşımlar iki büyük sorun yaratır:

  1. Gürültü (Alert Fatigue): Anlık bir trafik spike’ı nedeniyle hata oranı 1 dakikalığına %2’ye çıkabilir ve kendi kendine düzelir. Boşu boşuna uykunuz bölünür.
  2. Yavaş Ölümler (Slow Burns): Hata oranınız saatlerce %0.2’de kalabilir. Bu oran geleneksel alarmları tetiklemez ancak 30 günlük %99.9’luk SLO bütçenizi sinsice sıfırlar.

SRE pratikleri bu sorunu Burn Rate (Tüketim Hızı) alarmları ile çözer. Burn rate, hata bütçenizi ne kadar hızlı tükettiğinizi gösteren katsayıdır.

  • Burn Rate = 1: Bütçeniz tam olarak planlanan sürede (örneğin 30 günde) tükenecek demektir.
  • Burn Rate = 14.4: Bütçenizin %2’sini sadece 1 saat içinde tüketeceğiniz anlamına gelir. Acil müdahale (Pager) gerekir!
  • Burn Rate = 2.9: Bütçenizin %5’ini 6 saatte tüketeceksiniz demektir. Bu durumda bilet (Jira ticket) açılması yeterlidir.

Prometheus Kural Dosyası (Prometheus Alert Rules)

Aşağıdaki konfigürasyon bloğu, Google SRE Workbook standartlarına uygun olarak yazılmış, çoklu zaman pencereli (multi-window, multi-burn-rate) bir alert kuralı örneğidir. Hem anlık gürültüleri engeller hem de yavaş bütçe tüketimlerini kaçırmaz.

groups:
  - name: service-slo-alerts
    rules:
      # Critical Alert: 1 saatlik burn rate > 14.4 VE son 5 dakikada da bu eğilim devam ediyorsa
      - alert: ServiceAvailabilityCriticalBurnRate
        expr: |
          (
            sum(rate(http_requests_total{status=~"5.."}[1h]))
            /
            sum(rate(http_requests_total}[1h]))
          ) > (1 - 0.999) * 14.4
          and
          (
            sum(rate(http_requests_total{status=~"5.."}[5m]))
            /
            sum(rate(http_requests_total}[5m]))
          ) > (1 - 0.999) * 14.4
        for: 2m
        labels:
          severity: critical
          tier: platform
        annotations:
          summary: "Hızlı Hata Bütçesi Tüketimi (Burn Rate 14.4x)"
          description: "Hata bütçesi son 1 saatte çok hızlı tükeniyor! Current error rate: {{ $value | humanizePercentage }}"

      # Warning Alert: 6 saatlik burn rate > 6 (Bütçenin %5'i 6 saatte bitiyor)
      - alert: ServiceAvailabilityWarningBurnRate
        expr: |
          (
            sum(rate(http_requests_total{status=~"5.."}[6h]))
            /
            sum(rate(http_requests_total}[6h]))
          ) > (1 - 0.999) * 6
          and
          (
            sum(rate(http_requests_total{status=~"5.."}[30m]))
            /
            sum(rate(http_requests_total}[30m]))
          ) > (1 - 0.999) * 6
        for: 15m
        labels:
          severity: warning
        annotations:
          summary: "Yavaş Hata Bütçesi Tüketimi (Burn Rate 6x)"
          description: "Son 6 saattir hata bütçesi eriyor. Nöbetçi ekibin mesai saatlerinde incelemesi önerilir."

Yukarıdaki kurallarda dikkat ederseniz (1 - 0.999) * 14.4 gibi dinamik bir matematiksel formül kullandık. Buradaki 0.999 bizim SLO hedefimiz olan %99.9’dur. Eğer yarın bir gün bu hedefi %99.5’e çekmek isterseniz sadece bu katsayıları değiştirmeniz yeterli olacaktır.

Bu Yapıyı Kurduktan Sonra Sizi Ne Bekliyor?

Bu sisteme geçiş yapmak sadece teknik bir araç değişimi değil, ciddi bir kültürel dönüşümdür. Burn rate alarmlarına geçtiğinizde:

  • Gecenin yarısında uyanma oranınız (false-positive paging) dramatik şekilde düşecek.
  • Ekipler “Bu hafta çok downtime oldu, deployment yapmayalım” kararını hissiyatla değil, doğrudan Grafana paneline bakarak somut verilerle (Error Budget Dashboard) verecek.
  • Yazılım geliştiriciler ve sistem yöneticileri aynı dile konuşmaya başlayacak: “Hata bütçemiz ne kadar kaldı?”

Kendi sistemlerinizde bu dönüşümü başlatmak için ilk adım olarak en kritik 3 servisinizi belirleyin, basit birer kullanılabilirlik SLI’ı yazın ve error budget grafiğinizi oluşturun. Unutmayın, mükemmel bir sistem kesintisiz çalışan sistem değil, ne zaman ve ne kadar hata yapabileceğini bilen ve bunu kontrol altında tutan sistemdir.

Category: Genel | LEAVE A COMMENT