Gerçek Mülakattan · Bölüm 23

Production'da hata ayıklama: dağıtık loglama, traceId ve tracing

Mikroservislerde bir isteği loglarda ve trace'te uçtan uca izlemeyi (traceId, W3C traceparent, Micrometer Tracing, MDC, context propagation), merkezi log mimarisini ve production'da bir hatayı ilk dakikadan postmortem'e kadar sistematik araştırmayı öğretir.

~50 dk okumaGerçek mülakatta sorulan 2 soru
Cevaplar:

Bu bölümde

Bölüm 15 gözlemlenebilirliğin üç sinyalini, RED/USE'u ve SLO alarmını genel hatlarıyla anlattı. Bu bölüm iki gerçek mülakat sorusu etrafında derine iniyor: bir isteği beş servis boyunca tek bir kimlikle nasıl izlersin (traceId, traceparent, MDC, context'in kaybolduğu yerler, merkezi log mimarisi) ve production'da bir hata gördüğünde hangi sırayla nereye bakarsın (runbook, komutlar, hata türüne göre ipuçları, bir teşhis hikâyesi).

Mülakatta bu konu soru-cevap olarak gelir ve cevabın yapısı içeriği kadar önemlidir. "Loglara bakarım" diyen aday ile "önce etkiyi ve son değişikliği, sonra RED dashboard'unu, sonra bir hata örneğinin traceId'siyle trace'i açarım" diyen aday arasındaki fark, "bu kişiye gece nöbeti verilir mi?" sorusunun cevabıdır.

Konu anlatımı

Mikroservislerde loglar neden tek başına yetmez

Monolitte bir isteğin bütün logları aynı process'te, çoğu zaman aynı thread'de, aynı dosyada ve zaman sırasıyla yazılır. Bir hatayı bulmak için hata zamanına gidip birkaç satır yukarı bakmak çoğu zaman yeter.

Mikroservislerde aynı kullanıcı isteği şöyle bir yol izleyebilir:

diyagram yükleniyor…
sequenceDiagram
    participant C as Mobil uygulama
    participant G as API Gateway
    participant O as order-service
    participant S as stock-service
    participant P as payment-service
    participant K as Kafka
    participant N as notification-service

    C->>G: POST /checkout
    Note over G: traceId üretilir, traceparent eklenir
    G->>O: POST /orders (traceparent)
    O->>S: POST /reservations (traceparent, parent = order span)
    S-->>O: 200 rezerve edildi
    O->>P: POST /payments (traceparent)
    P-->>O: 502 banka timeout
    O->>K: OrderPaymentFailed (record header: traceparent)
    O-->>G: 409 ödeme alınamadı
    G-->>C: 409
    K->>N: OrderPaymentFailed (aynı traceId)
    Note over N: Push bildirimi gönderilir

Beş servis, servis başına 3-20 pod, pod başına saniyede yüzlerce istek: loglar birbirine karışır. "14:03:12'de payment-service'te 502 var" bilgisi, o 502'nin hangi kullanıcının hangi sepetine ait olduğunu söylemez. Zaman damgasıyla eşleştirmek güvenilmez: pod saatleri kayar, aynı milisaniyede onlarca istek vardır ve Kafka üzerinden giden kısım saniyeler, hatta dakikalar sonra işlenir.

Bu korelasyon problemidir: farklı process, makine ve zamanların ürettiği logları "bunlar aynı iş" diye bir araya getirmek. Çözümün özü: isteğin girdiği ilk noktada bir kimlik üretmek, her ağ çağrısında ve mesajda taşımak, her log satırına yazmak. Bu kimliğin standart adı trace id'dir.

Kimlik tek başına "aynı istek" bilgisini verir ama yapıyı (hangi çağrı hangisinin içinde, paralel mi sıralı mı) ve zaman dağılımını (2 saniyenin 1,8'i nerede geçti) vermez. Bunları span'ler verir. İkisi birlikte distributed tracing'dir.

traceId, spanId ve parent span

Bir trace, tek bir isteğin sistemdeki bütün işinin ağacıdır. Ağacın her düğümü bir span'dir: tek bir iş birimi (gelen HTTP isteğinin işlenmesi, giden bir HTTP çağrısı, bir SQL sorgusu, Kafka'ya bir gönderim, bir mesajın işlenmesi).

Her span'in taşıdıkları:

AlanAnlamıÖrnek
traceIdAğacın kimliği; ağaçtaki tüm span'lerde aynı. 16 byte, 32 hex karakter4bf92f3577b34da6a3ce929d0e0e4736
spanIdBu düğümün kimliği. 8 byte, 16 hex karakter00f067aa0ba902b7
parentSpanIdBu span'i başlatan span. Root span'de boşa1b2c3d4e5f60718
İsim, başlangıç, sürePOST /payments, SELECT payments1.840 ms
KindSERVER, CLIENT, PRODUCER, CONSUMER, INTERNALCLIENT
Attribute/taghttp.response.status_code=502, db.system=postgresql
Status ve event'lerHata durumu, exception kaydıerror, stack trace özeti

Her servis sınırında iki span vardır: order-service kendi tarafında bir CLIENT span açar, payment-service de parent'ı bu CLIENT span olan bir SERVER span açar. Aradaki süre farkı ağda, load balancer'da ve kuyrukta geçen zamandır. "order-service çağrıyı 900 ms görüyor, payment-service isteği 80 ms'de bitiriyor" bulgusu, sorunun payment kodunda değil arada (ağ, sidecar, thread havuzu doygunluğu, DNS) olduğunu söyler.

W3C Trace Context: traceparent ve tracestate

Trace context'in servisler arasında nasıl taşınacağı W3C Trace Context Recommendation'ında (ilk sürüm Şubat 2020, editoryal güncelleme Kasım 2021) tanımlıdır. Bölüm 15'te formatı kısaca gördük; burada her alanın davranışına bakalım:

http
traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
tracestate: congo=t61rcWkgMzE,rojo=00f067aa0ba902b7
ParçaUzunlukKural
version2 hexŞu an 00. ff yasak.
trace-id32 hex (küçük harf)Tamamı sıfır geçersizdir; alıcı header'ı yok sayar. Rastgele üretilmesi önerilir.
parent-id16 hexGönderenin o anki span'inin id'si. Tamamı sıfır geçersizdir.
trace-flags2 hexEn sağdaki bit sampled: 01 "çağıran bu trace'i kaydediyor olabilir", 00 kaydetmiyor. Diğer bitler şimdilik sıfır.

Adı yanıltıcı olabilir: parent-id alıcının parent'ıdır, yani gönderenin span id'sidir. Her atlamada (hop) trace-id aynı kalır, parent-id değişir. Header adları alıcı için büyük-küçük harf duyarsızdır ama küçük harfle gönderilmesi önerilir.

tracestate vendor'a özgü ek bilgidir: virgülle ayrılmış key=value listesi, en fazla 32 üye; her sistem sadece kendi anahtarını günceller. Uygulama kodu genelde dokunmaz; APM araçları kendi bilgilerini burada taşır.

baggage ayrı bir W3C spesifikasyonudur ve iş verisi taşır (baggage: tenantId=marketplace-tr,channel=mobile). İki tehlikesi vardır: her atlamada istek büyür ve üçüncü taraf çağrılara eklenirse içerik dışarı sızar. Baggage'a kişisel veri konmaz.

B3: Zipkin ve Sleuth döneminden kalan format

W3C standardından önce yaygın format Zipkin'in B3 propagation'ıydı ve Spring Cloud Sleuth varsayılan olarak bunu kullanıyordu. İki biçimi vardır:

http
# Çoklu header
X-B3-TraceId: 80f198ee56343ba864fe8b2a57d3eff7
X-B3-SpanId: e457b5a2e4d86bd1
X-B3-ParentSpanId: 05e3ac9a4f6e3b90
X-B3-Sampled: 1

# Tek header: {TraceId}-{SpanId}-{SamplingState}-{ParentSpanId}
b3: 80f198ee56343ba864fe8b2a57d3eff7-e457b5a2e4d86bd1-1-05e3ac9a4f6e3b90

B3'te trace id 64 ya da 128 bit olabilir. Pratik sonuç: Boot 3 servisi W3C üretirken Boot 2 + Sleuth kullanan eski bir servis sadece B3 anlıyorsa trace orada kopar. Spring Boot 3.5'te management.tracing.propagation.produce varsayılanı [W3C], management.tracing.propagation.consume varsayılanı [W3C, B3, B3_MULTI]'dır: yani gelen B3'ü anlar ama giden çağrıda sadece W3C yazar. Geçiş döneminde produce: [W3C, B3] ile ikisini birden yazmak, eski servislerin de zinciri sürdürmesini sağlar. (management.tracing.propagation.type ayarlanırsa ikisini birden ezer.)

Correlation id ile trace id farkı

İkisi sık karıştırılır, çünkü ikisi de "logları bir araya getiren kimlik"tir.

Trace idCorrelation / request id
KapsamTeknik bir istek ağacıUygulamanın tanımladığı bir iş ya da istek
StandartW3C Trace ContextYok; genelde X-Request-Id / X-Correlation-Id
YapıSpan ağacı, parent-child, sürelerDüz bir değer
ÜretenTracing kütüphanesi otomatikGateway ya da uygulama kodu
ÖmürTek istek ve tetiklediği asenkron işlerİstek, ya da günler süren bir iş süreci

Pratikte birlikte kullanılırlar: trace id her log satırında teknik izleme için; iş anahtarı (orderId) log alanı olarak, çünkü bir siparişin oluşturulması, kargoya verilmesi ve iadesi üç ayrı trace'tir; ve müşteriye hata mesajında gösterilen bir referans (çoğu ekip burada doğrudan trace id'yi kullanır, aşağıda koduyla).

Bir incelik: trace örneklenmese bile (sampled=00) trace id üretilir, taşınır ve MDC'ye girer. Yani her log satırında traceId vardır, ama her traceId'nin trace backend'de karşılığı yoktur. Şikâyet edilen isteğin loglarını bulursun ama trace'ini Tempo/Jaeger'da bulamayabilirsin; tail sampling'in önemi buradan gelir.

Spring'de tracing: Sleuth'tan Micrometer Tracing'e

Spring Boot 2.x döneminde tracing Spring Cloud Sleuth ile yapılırdı. Sleuth Spring Boot 3 ile çalışmaz; çekirdeği Micrometer Tracing'e taşındı (1.0, Kasım 2022). Boot 3'teki katmanlar:

  1. Observation API: Spring'in enstrümantasyonu (MVC, WebFlux, HTTP client'lar, Kafka, @Scheduled) bir Observation başlatıp bitirir; aynı observation'dan hem metrik (timer) hem span üretilir.
  2. Bridge: Micrometer Tracing bir facade'dır; altta OpenTelemetry SDK (micrometer-tracing-bridge-otel) ya da Brave (micrometer-tracing-bridge-brave) çalışır.
  3. Exporter: span'leri OTLP ile (Collector, Tempo, Jaeger) ya da Zipkin'e gönderir.

JDBC sorguları varsayılan olarak span üretmez; SQL span'leri için datasource-micrometer gibi bir kütüphane ya da OpenTelemetry Java agent gerekir. Aşağıdaki senaryodaki UPDATE span'i böyle bir enstrümantasyon varsayar.

Spring Boot 3.5'te tipik OpenTelemetry + OTLP kurulumu:

xml
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
    <groupId>io.micrometer</groupId>
    <artifactId>micrometer-tracing-bridge-otel</artifactId>
</dependency>
<dependency>
    <groupId>io.opentelemetry</groupId>
    <artifactId>opentelemetry-exporter-otlp</artifactId>
</dependency>
yaml
management:
  tracing:
    sampling:
      probability: 0.1          # varsayılan zaten 0.1 (%10)
  otlp:
    tracing:
      endpoint: http://otel-collector.observability:4318/v1/traces

Spring Boot 4'te yeni spring-boot-starter-opentelemetry starter'ı OpenTelemetry SDK'sını otomatik yapılandırıp metrik ve trace'leri OTLP ile dışa aktarır; OTLP trace ayarları 4.x dokümanında management.opentelemetry.tracing.export.otlp.* altındadır ve management.tracing.enabled, management.tracing.export.enabled olarak yeniden adlandırıldı. Geçişte property adlarını migration guide ile kontrol et: eski ad sessizce yok sayılırsa trace'ler gitmez ve kimse fark etmez.

Alternatif yol OpenTelemetry Java agent'tır (-javaagent): bytecode enstrümantasyonuyla JDBC, executor, HTTP client ve Kafka'yı kod değişikliği olmadan izler, context'i thread havuzları arasında da taşır. Bedeli açılış süresi ve "sihirli" davranıştır; agent ile Micrometer bridge'i aynı anda açmak çift span üretebilir.

Loglara traceId'nin girmesi. Spring Boot 3.2'den itibaren Micrometer Tracing kullanıldığında correlation id log satırına otomatik eklenir; trace id ve span id'yi pattern'e elle eklemek gerekmez. Varsayılan çıktı şöyledir (spring.application.name tanımlıysa uygulama adı da satıra girer):

text
2026-10-22T14:03:12.481+03:00  INFO 1 --- [order-service] [nio-8080-exec-7] [4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7] c.a.order.CheckoutService : Payment failed orderId=81723

Formatı değiştirmek için logging.pattern.correlation kullanılır (bu biçimlendirme CorrelationIdFormatter ile 3.2.0'da geldi). Sleuth dönemindeki formata alışkın ekipler için dokümandaki örnek:

yaml
logging:
  pattern:
    correlation: "[${spring.application.name:},%X{traceId:-},%X{spanId:-}] "
  include-application-name: false   # ad correlation içinde zaten var, iki kez yazılmasın

Mekanizma MDC'dir: tracer bir span'in scope'unu açtığında traceId ve spanId'yi MDC'ye koyar, scope kapanınca eskisini geri yükler; pattern'deki %X{traceId} bunu okur. Aşağıdaki bütün "context kayboldu" sorunlarının anahtarı bu bağlantıdır.

Örnekleme. management.tracing.sampling.probability varsayılanı 0.1'dir. Bu bir head sampling kararıdır: root span açılırken verilir ve sampled flag'iyle aşağıya taşınır; OpenTelemetry tarafında varsayılan sampler parent-based'dir, yani bir trace ya bütünüyle ya hiç kaydedilir.

İstemciye traceId döndürmek. Hata cevabına traceId koymak, "hata aldım" şikâyetini saniyeler içinde loglara bağlar:

java
@RestControllerAdvice
class ApiExceptionHandler {

    private final Tracer tracer;

    ApiExceptionHandler(Tracer tracer) {
        this.tracer = tracer;
    }

    @ExceptionHandler(PaymentDeclinedException.class)
    ProblemDetail handleDeclined(PaymentDeclinedException ex) {
        var problem = ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, "Ödeme alınamadı");
        problem.setProperty("traceId", currentTraceId());
        return problem;
    }

    private String currentTraceId() {
        Span span = tracer.currentSpan();
        return span != null ? span.context().traceId() : "n/a";
    }
}

Burada Tracer ve Span, io.micrometer.tracing paketindendir. Trace id dışarı verilebilir bir değerdir (rastgele, içinde bilgi yok); ama içerideki hata mesajı ve stack trace dışarı verilmez (bkz. Bölüm 18).

MDC: nedir, neden kaybolur

MDC (Mapped Diagnostic Context), SLF4J/Logback'in thread'e bağlı bir Map<String, String>'idir. Logback'te LogbackMDCAdapter içinde ThreadLocal olarak tutulur. Bir thread MDC.put("orderId", "81723") dediğinde, o thread'in bundan sonra yazdığı her log satırı bu alanı taşır; başka thread'ler görmez.

İki sonucu vardır: thread değişirse context kaybolur (executor, @Async, CompletableFuture, reactive scheduler'da yeni thread'in MDC'si boştur ya da başka isteğin artığını taşır) ve temizlenmezse sızar (havuzdan gelen Tomcat thread'i, remove edilmemiş orderId'yi sonraki isteğin loglarına yazar; "loglar yalan söylüyor" türünden bir hata). Doğru kullanım, scope'u try-with-resources ile kapatmaktır:

java
public PaymentResult pay(PaymentCommand command) {
    try (var orderCtx = MDC.putCloseable("orderId", command.orderId().toString());
         var merchantCtx = MDC.putCloseable("merchantId", command.merchantId().toString())) {
        log.info("Payment started amount={} currency={}", command.amount(), command.currency());
        return gateway.charge(command);
    }
}

SLF4J 2'nin fluent API'si, MDC'ye dokunmadan tek bir satıra alan eklemenin yoludur ve structured logging'de JSON alanı olarak görünür:

java
log.atInfo()
   .setMessage("Payment authorized")
   .addKeyValue("orderId", command.orderId())
   .addKeyValue("provider", result.provider())
   .log();

traceId ve spanId'yi MDC'ye tracer koyar ve kaldırır; onları elle MDC.put ile yazmak, tracer'ın scope yönetimiyle çakışır.

Context'in kaybolduğu yerler ve çözümleri

Trace zincirinin kırıldığı yerler neredeyse her zaman aynıdır.

1. Builder kullanılmadan oluşturulan HTTP client. Spring Boot, auto-configure ettiği RestTemplateBuilder, RestClient.Builder ve WebClient.Builder'a observation enstrümantasyonunu ekler; bunlardan üretilen client giden her isteğe traceparent ekler ve bir CLIENT span açar. new RestTemplate() ya da RestClient.create() ile oluşturulan client'ta propagation sessizce çalışmaz: çağrılan servis yeni bir trace başlatır.

java
@Configuration
class PaymentClientConfig {

    // Doğru: auto-configured builder, observation ve propagation dahil
    @Bean
    RestClient paymentRestClient(RestClient.Builder builder) {
        return builder
                .baseUrl("http://payment-service")
                .build();
    }

    // Yanlış: trace header'ı taşımaz, metrik de üretmez
    // @Bean RestTemplate restTemplate() { return new RestTemplate(); }
}

Spring Boot 4'te RestClient/RestTemplate auto-configuration'ı ayrı bir modüle taşındı (spring-boot-starter-restclient); builder bean'i gelmiyorsa bağımlılığı kontrol et. new RestTemplate( kullanımını ArchUnit ile yasaklamak ucuz bir önlemdir.

2. Kafka. Spring Kafka'da observation KafkaTemplate ve listener container için ayrı ayrı açılır (Bölüm 15'te özetlendi):

yaml
spring:
  kafka:
    template:
      observation-enabled: true    # producer traceparent'ı record header'ına yazar
    listener:
      observation-enabled: true    # consumer header'ı okur, işleme span'ini aynı trace'e bağlar

Açılmadığında consumer yeni bir trace başlatır ve notification-service'in logları checkout isteğinden kopar. İki tuzak:

  • Spring Kafka dokümanına göre observation batch listener'ları desteklemez, çünkü bir batch'te observation ile kayıt arasında bire bir eşleme yoktur. Yeni sürümlerdeki recordObservationsInBatch her kayıt için observation üretir ama bunu listener metoduna taşımaz; batch'te her kaydın traceparent header'ını elle okuyup loglamak gerekebilir.
  • Outbox relay'i isteğin context'ini bilmez; traceparent outbox satırına yazılıp relay'de header'a konur (bkz. Bölüm 13 ve 15).

3. @Async ve executor'lar. Spring Framework 6.1 ile gelen ContextPropagatingTaskDecorator, Micrometer context-propagation kütüphanesiyle görevi gönderen thread'deki context'i (observation, MDC'deki trace alanları) yakalar ve görevi çalıştıran thread'de geri yükler. Spring Boot, bir TaskDecorator bean'i varsa onu auto-configured executor'a uygular; decorator'ı ise Boot 3.x ve 4.0'da sen tanımlarsın (Boot 4.1+'da spring.task.execution.propagate-context=true ile Boot kendisi kaydeder):

java
@Configuration
@EnableAsync
class AsyncConfig {

    @Bean
    TaskDecorator contextPropagatingTaskDecorator() {
        return new ContextPropagatingTaskDecorator();
    }
}

Kendi ThreadPoolTaskExecutor'ını oluşturuyorsan decorator'ı ona da ayrıca vermelisin (executor.setTaskDecorator(...)). Spring'in yönetmediği executor'lar (Executors.newFixedThreadPool) için Micrometer'ın ContextExecutorService.wrap(...) sarmalayıcısı kullanılır.

4. CompletableFuture. supplyAsync(...) executor verilmeden çağrılırsa ForkJoinPool.commonPool()'da çalışır; bu havuzun decorator'ı yoktur ve context kaybolur. Kural: her zaman context taşıyan bir executor ver.

java
CompletableFuture<StockView> stock =
        CompletableFuture.supplyAsync(() -> stockClient.get(productId), applicationTaskExecutor);

5. Virtual thread'ler. Virtual thread'ler de ThreadLocal destekler (her birinin kendi kopyası vardır), ama yeni bir virtual thread başlatmak da thread değişimidir: context otomatik geçmez. Spring Boot 3.2+'da spring.threads.virtual.enabled=true ile auto-configured executor virtual thread kullanan bir SimpleAsyncTaskExecutor olur ve TaskDecorator ona da uygulanır; kendi Executors.newVirtualThreadPerTaskExecutor()'ını sarmalamak ise senin işindir. ContextPropagatingTaskDecorator'ın Javadoc'u çok sayıda çok küçük görevde overhead uyarısı yapar (bkz. Bölüm 5).

6. Reactive. Reactor'da operatörler farklı thread'lerde çalışabilir; context Reactor Context'inde taşınır. Spring Boot 3.2+'da spring.reactor.context-propagation=auto ile trace id ve span id pipeline boyunca ThreadLocal'a geri yüklenir ve loglarda görünür.

7. Zamanlanmış işler. @Scheduled metodunun gelen bir isteği yoktur; Spring Boot scheduled görevler için observation auto-configuration'ı içerir, yani her çalıştırma kendi trace'ini alır. Uzun bir batch işinde kayıt başına observation açmak "40.000. kayıtta ne oldu?" sorusunu cevaplanabilir kılar.

Özet tablo:

SınırContext nasıl taşınırTaşınmazsa belirti
Gelen HTTPSpring MVC/WebFlux observation, traceparent okunurHer serviste ayrı trace
Giden HTTPAuto-configured builder'dan üretilen clientÇağrılan serviste yeni trace
Kafkaobservation-enabled, record headerConsumer logları traceId'siz ya da yeni trace
@Async / executorContextPropagatingTaskDecoratorAsync logda traceId boş ya da başka isteğin
CompletableFutureDecorator'lı executor vercommonPool'da context yok
Virtual threadDecorator'lı executorYeni thread'de MDC boş
Reactorspring.reactor.context-propagation=autoOperatörlerde traceId boş
Outbox relaytraceparent kolona yazılırEvent işleme zinciri kopuk

Structured logging: JSON ve alanlar

Bölüm 15 Spring Boot 3.4 ile gelen yerleşik structured logging'i tanıttı. Burada ayrıntılarına ve neden önemli olduğuna bakalım.

Düz metin log bir insan için yazılmıştır: Payment failed for order 81723 after 3 attempts. Bunu bir makinenin anlaması için regex ile parse etmek gerekir; mesaj metni değiştiği gün parser kırılır ve dashboard'lar boşalır. JSON log'da her bilgi bir alandır:

json
{
  "@timestamp": "2026-10-22T11:03:12.481Z",
  "log.level": "WARN",
  "process.thread.name": "http-nio-8080-exec-7",
  "service.name": "payment-service",
  "service.version": "2.14.0",
  "log.logger": "com.acme.payment.ChargeService",
  "message": "Charge failed",
  "traceId": "4bf92f3577b34da6a3ce929d0e0e4736",
  "spanId": "00f067aa0ba902b7",
  "orderId": "81723",
  "provider": "bank-x",
  "attempt": 3,
  "ecs.version": "8.11"
}

Bu satır service.name="payment-service" AND provider="bank-x" gibi sorgulanır ve provider'a göre gruplanıp "hangi banka kaç hata verdi" grafiğine dönüşür.

Spring Boot 3.4+'ta ECS (ecs), GELF (gelf) ve Logstash (logstash) formatları yerleşiktir:

yaml
logging:
  structured:
    format:
      console: ecs                  # dosya için: logging.structured.format.file
    ecs:
      service:
        environment: production     # service.name ve service.version varsayılan olarak
                                    # spring.application.name / spring.application.version'dan gelir
    json:
      stacktrace:
        max-length: 4096            # Boot 3.5+; dev stack trace'ler log hattını tıkamasın

Bu formatlar MDC'deki her anahtar-değeri JSON'a ekler; tracer traceId/spanId'yi MDC'ye koyduğu için onlar da alan olur. logging.structured.json.include/exclude/rename/add ile alanlar düzenlenir; 3.4 öncesinde aynı iş logstash-logback-encoder ile yapılırdı. Yerelde local profilinde düz pattern kullanmak yaygındır.

Ölçek büyüdükçe şema disiplini önem kazanır: bir serviste order_id, diğerinde orderId yazılırsa çapraz servis sorgusu imkânsızlaşır; ECS ya da OpenTelemetry semantic convention'ları gibi ortak bir şema ve ekipler arası "ortak alanlar" listesi gerekir. Elasticsearch'te aynı alanın bir serviste sayı, diğerinde string gelmesi mapping çakışmasına ve o satırların reddedilmesine, yani sessiz log kaybına yol açar.

Ne loglanır, ne loglanmaz

Seviyeler ve anlamları:

SeviyeNe zamanÖrnek
ERRORİşlem başarısız oldu ve birinin bakması gerekiyorÖdeme kaydedilemedi, beklenmeyen exception
WARNBeklenmeyen ama tolere edilen durum; tekrarı sorun işaretidirRetry ile kurtarılan timeout, fallback devreye girdi
INFOİş açısından anlamlı olaylar, düşük hacimliSipariş oluşturuldu, uygulama başladı, config yüklendi
DEBUGGeliştirici teşhisi; production'da kapalıHesaplanan kampanya kuralları, ara değerler
TRACEÇok ayrıntılı akışFramework iç detayları

İş hatası ERROR değildir: "yetersiz bakiye" ya da "stokta yok" bir domain sonucudur, INFO ya da WARN'dır; her reddedilen ödemeyi ERROR loglamak ERROR tabanlı alarmları anlamsızlaştırır. İstek başına birden fazla INFO satırı da yüksek trafikte log hacminin baş nedenidir.

Hassas veri. Log'lar geniş bir ekibin eriştiği, uzun saklanan, yedeklenen ve bazen üçüncü taraf bir SaaS'a giden veridir. Log'a giren her kişisel veri KVKK/GDPR açısından işlenen veridir ve silme talebinde log'lardan silmek pratikte mümkün değildir. Ödeme tarafında ek olarak PCI DSS vardır.

VeriKural
Kart numarası (PAN)Asla tam haliyle. Gösterim gerekiyorsa maskeli (ilk 6 ve son 4 haneden fazlası değil); log'da tercihen sadece token ya da son 4 hane
CVV, PIN, şifreHiçbir koşulda
Access token, refresh token, API key, Authorization headerHiçbir koşulda; gerekiyorsa hash'inin ilk birkaç karakteri
TCKN, telefon, e-posta, adres, IBANMaskeli ya da hiç; mümkünse iç kimlik (customerId)
İstek/cevap gövdesiVarsayılan olarak hayır; açılacaksa alan bazlı maskeleme ile

Maskelemeyi log satırında hatırlamaya bırakmak çalışmaz; biri bir gün log.info("Request: {}", request) yazar. Daha güvenilir yol, hassas veriyi taşıyan tipin toString()'ini güvenli yapmaktır:

java
public record CardNumber(String value) {

    public CardNumber {
        Objects.requireNonNull(value);
        if (!value.matches("\\d{13,19}")) {
            throw new IllegalArgumentException("Geçersiz kart numarası");
        }
    }

    public String last4() {
        return value.substring(value.length() - 4);
    }

    @Override
    public String toString() {
        return "****" + last4();   // log'a, exception mesajına, debugger'a sızan her yer maskeli
    }
}

Record'un otomatik toString()'i bütün alanları yazar; hassas alan taşıyan record'larda bu override zorunludur. İkinci savunma hattı log pipeline'ında (Fluent Bit/Vector/Collector) regex tabanlı maskelemedir. Bankacılıkta log'da tam PAN bulunması bir denetim bulgusudur ve tüm log deposunu PCI kapsamına sokar.

Hacim ve maliyet. Log genelde gözlemlenebilirlik bütçesinin en büyük kalemidir. 200 pod, pod başına saniyede 50 satır, satır başına 500 byte:

text
200 x 50 x 500 B = 5 MB/s  ->  5 MB x 86.400 s ≈ 432 GB/gün

Bu hacim ingest, index ve saklama olarak faturalanır; 30 günlük saklama replikasız yaklaşık 13 TB'tır. Etkisine göre azaltma yolları: gereksiz INFO'yu DEBUG'a çekmek, probe ve health check access log'larını kapatmak, tekrarlayan hata sellerini örneklemek (ilk N satır ve bir sayaç), seviyeye göre farklı saklama süresi ve metrik olması gerekeni metriğe çevirmek.

Merkezi log toplama mimarisi

Kubernetes'te standart yol uygulamanın stdout'a yazmasıdır; uygulama dosyaya yazmaz, log'u kendisi uzak bir sunucuya göndermez. Container runtime stdout'u node'da /var/log/pods/... altına yazar ve rotate eder. Böylece uygulama log backend'inin yavaşlığından etkilenmez (doğrudan Elasticsearch'e yazan bir appender, Elasticsearch yavaşladığında thread'leri bloklayabilir) ve toplama, parse, maskeleme mantığı tek bir yerde yönetilir.

diyagram yükleniyor…
flowchart LR
    subgraph Node["Kubernetes node"]
        A["payment-service pod: stdout JSON"] --> F["/var/log/pods"]
        B["order-service pod: stdout JSON"] --> F
        F --> D["Log agent DaemonSet: Fluent Bit / Vector / OTel Collector"]
    end
    D -->|"parse, k8s metadata, maskeleme"| ES["Elasticsearch / OpenSearch"]
    D -->|"alternatif"| L["Grafana Loki"]
    ES --> KB["Kibana / OpenSearch Dashboards"]
    L --> GR["Grafana"]
    A -->|"OTLP span"| OC["OTel Collector gateway"]
    B -->|"OTLP span"| OC
    OC --> T["Tempo / Jaeger"]
    P["Prometheus"] -->|"scrape /actuator/prometheus"| A
    P --> GR
    T --> GR

Agent katmanı. Her node'daki DaemonSet pod'u container log dosyalarını okur, pod metadata'sını (namespace, label, container) ekler, JSON'u parse eder, maskeler ve backend'e gönderir. Yaygın seçenekler: Fluent Bit (C, hafif, DaemonSet için en yaygın), Fluentd (Ruby, geniş eklenti ekosistemi, daha ağır), Vector (Rust, yüksek performans) ve üç sinyali tek ajanla toplayan OpenTelemetry Collector. Agent'ın tamponu backend yavaşladığında dolar ve log düşürülür; bu yüzden agent'ın kendi metrikleri (düşürülen kayıt, retry) izlenir. "Olay anında loglar eksikti" şikâyetinin sık nedeni budur: en çok log üretilen an, pipeline'ın en çok tıkandığı andır.

Backend seçenekleri:

Elasticsearch / OpenSearch (ELK/EFK)Grafana Loki
IndexLog içeriğini tam metin index'ler (inverted index)Sadece label'ları index'ler; içerik sıkıştırılmış chunk'larda, object storage'da
SorguHer alanda hızlı arama ve aggregationÖnce label ile stream seçilir, sonra içerik taranır (LogQL)
MaliyetIndex nedeniyle depolama ve bellek yüksekUcuz depolama, düşük işletme maliyeti
Zayıf yanıBüyük hacimde pahalı ve işletmesi zorLabel'sız geniş aramalar yavaş; yüksek kardinaliteli label yasak

Loki'de label'lar Prometheus'taki gibidir: service, namespace, level, env gibi düşük kardinaliteli değerler. traceId ya da orderId label yapılmaz (her değer ayrı bir stream olur); bunlar içerikte aranır:

text
{namespace="payments", app="payment-service"} | json | traceId="4bf92f3577b34da6a3ce929d0e0e4736"

Kibana'da KQL ile: traceId : "4bf92f3577b34da6a3ce929d0e0e4736". Servis filtresi olmadan sadece traceId ile aramak, isteğin geçtiği bütün servislerin loglarını zaman sırasıyla verir. "Distributed logging"in pratikteki anlamı tam olarak bu sorgudur.

Trace backend'leri. Jaeger (OTLP'yi doğrudan alır), Grafana Tempo (ağır bir index tutmadan object storage'a yazar; trace id ile ve TraceQL ile aranır, ucuzdur), Zipkin (en eski, basit). Uygulamalar span'leri genelde bir OpenTelemetry Collector'a gönderir; backend değişikliği böylece uygulamaya dokunmaz.

Log, trace ve metriği birbirine bağlamak

Asıl güç sinyaller arasında tek tıkla geçebilmektir:

  • Metrikten trace'e: exemplar. Histogram bucket'ı yanında örnek bir traceId taşır; Grafana'da p99 grafiğindeki noktaya tıklayınca o isteğin trace'i açılır. Micrometer tracing aktifken Prometheus exemplar'ları üretir (Micrometer 1.12+ desteği Prometheus 2.43+ ister); Prometheus'ta exemplar depolama ayrı bir feature flag ile açılır.
  • Trace'ten loga ve logdan trace'e. Grafana'da Tempo veri kaynağına Loki/Elasticsearch bağlanır ("trace to logs"); log veri kaynağında traceId bir "derived field" olarak tanımlanıp tıklanabilir link yapılır.
  • Ortak kaynak etiketleri. service.name, service.version, deployment.environment üç sinyalde de aynı adla bulunmalı; sürüm etiketi olmadan "bu hata yeni sürümde mi?" cevaplanamaz.

Production'da bir hatayı araştırma: runbook

Bu bölümün en önemli kısmı budur. Google SRE kitabının troubleshooting bölümü de aynı sırayı önerir: önce sistemi koşulların izin verdiği kadar iyi çalışır hale getir (kanamayı durdur), sonra kök nedeni ara ve bu sırada kanıtı koru. Beş ilke:

  1. Önce etki, sonra neden. Etkinin büyüklüğü, ne kadar hızlı ve riskli aksiyon alacağını belirler.
  2. Değişiklik en olası şüphelidir. "Ne değişti?" en ucuz ve en verimli sorudur.
  3. Genişten dara. Dashboard → hata örneği → trace → log → bağımlılık → JVM → altyapı. Logların içinde rastgele gezinmek en pahalı başlangıçtır.
  4. Hipotez kur, ölçümle test et. "DB yavaş galiba" bir his; "Hikari pending 0'dan 40'a çıktı" bir kanıt.
  5. Hafiflet, sonra çöz. Rollback ya da flag kapatma kök nedeni bilmeden yapılabilir.

İlk 5 dakika: etkiyi ve aciliyeti belirle, son değişikliğe bak

  • Doğrula: hangi SLO, hangi endpoint, ne zamandan beri? Grafik gerçekten bozuk mu?
  • Kapsam: tüm endpoint'ler mi, biri mi? Tüm pod'lar mı, biri mi? Tüm müşteriler mi, bir segment mi (bir banka, bir mobil sürüm)?
  • Son değişiklikler: deploy geçmişi (kubectl rollout history, CD aracı), config ve feature flag değişiklikleri, bağımlılık sürümü, altyapı (DB failover, node yükseltmesi, sertifika yenileme), trafik (kampanya, flash sale, bot). Deploy'ları Grafana'da annotation olarak görmek bu soruyu saniyelere indirir.
  • İletişim: olay kanalını aç, olayı kimin yönettiğini (incident commander) netleştir. Kanala "bakıyorum" yazmak, beş kişinin aynı şeye paralel bakmasını önler.
  • Karar: sorun bir deploy ile başladıysa ve rollback güvenliyse (geri uyumsuz migration yoksa), kök nedeni beklemeden rollback.

İlk 30 dakika: daralt, kanıt topla, hafiflet

Dashboard ve RED. İstek hızı (trafik düştü mü, arttı mı?), hata oranı ve tipi (4xx mi 5xx mi?), süre dağılımı (p50 sabit p99 yüksekse sorun bir alt kümede; p50 de yükseldiyse ortak bir kaynakta). Endpoint ve pod bazında kırılım.

Bir hata örneğinden trace'e. ERROR loglarından bir örneğin traceId'sini al. Trace'te hangi span hatalı, hata hangi serviste başladı, zaman en çok hangi span'de geçiyor, CLIENT ve SERVER span'leri arasında boşluk var mı? Aynı traceId ile tüm servislerin loglarını açıp exception'ı ve öncesindeki bağlamı oku. Tek bir trace anekdottur: birkaç örnek al ve hata alan ile almayan istekler arasındaki farkı ara (pod, müşteri tipi, payload boyutu).

Bağımlılıklar. Servislerin çoğu kendi kodu yüzünden değil, bağımlılıkları yüzünden yavaşlar: DB (kilit, uzun transaction, yavaş sorgu; bkz. Bölüm 10), connection pool (hikaricp_connections_pending > 0 mı, hikaricp_connections_acquire_seconds), Kafka (partition bazında lag, DLT, rebalance; Bölüm 12), dış servisler (CLIENT span süreleri, resilience4j_circuitbreaker_state; Bölüm 14), cache (hit oranı düştü mü). PostgreSQL'de "şu an kim neyi bekliyor" sorgusu, olay anında en çok işe yarayan sorgudur:

sql
SELECT pid,
       application_name,
       state,
       wait_event_type,
       wait_event,
       now() - xact_start  AS tx_age,
       now() - query_start AS query_age,
       pg_blocking_pids(pid) AS blocked_by,
       left(query, 100)    AS query
FROM pg_stat_activity
WHERE datname = current_database()
  AND state <> 'idle'
ORDER BY xact_start NULLS LAST;

wait_event_type = 'Lock' olan satırlar kilit bekliyordur; blocked_by kimin beklettiğini gösterir. Bekleten oturum idle in transaction ise bir uygulama transaction'ı açık bırakıp başka bir şey (çoğunlukla dış HTTP çağrısı) yapıyordur. Bekleyen kilitler tür ve tablo bazında SELECT pid, locktype, relation::regclass, mode FROM pg_locks WHERE NOT granted; ile görülür (satır kilidi beklemesi çoğunlukla transactionid türündedir ve relation boş gelir). Acil durumda bekleten oturum pg_cancel_backend(pid) (sorguyu iptal) ya da pg_terminate_backend(pid) (bağlantıyı koparır, transaction rollback) ile durdurulur; kimin oturumu olduğunu (application_name) bilmeden yapılmaz.

Uygulama ve JVM. Bağımlılıklar temizse: GC duraklamaları (jvm_gc_pause_seconds) p99 sıçramalarıyla örtüşüyor mu, old gen GC sonrası da yüksek mi (bkz. Bölüm 3)? CPU throttling (bkz. Bölüm 15)? BLOCKED/WAITING thread'ler birikiyor mu? Thread dump'ta aynı noktada bekleyen onlarca thread (pool'dan connection, bir synchronized blok, HTTP client havuzu) teşhisi neredeyse kesinleştirir. Tek dump yanıltabilir; 5-10 saniye arayla 3 dump alıp karşılaştır.

bash
# Container içinde JVM genelde PID 1'dir (exec form ENTRYPOINT ile)
kubectl exec -n payments payment-7c9f-abcde -- jcmd 1 Thread.print > dump-1.txt
kubectl exec -n payments payment-7c9f-abcde -- jcmd 1 GC.heap_info
# JDK 21+: virtual thread'leri de içeren dump
kubectl exec -n payments payment-7c9f-abcde -- jcmd 1 Thread.dump_to_file -format=json /tmp/threads.json

Distroless ya da JRE image'larında jcmd olmayabilir; bu durumda Actuator endpoint'leri devreye girer:

EndpointNe verirDikkat
/actuator/healthBileşen bazında sağlıkDetaylar sadece yetkiliye
/actuator/metrics/{name}Tek metriğin o pod'daki anlık değeri
/actuator/threaddumpThread dump
/actuator/heapdumpHeap dump dosyasıBoot 3.5+'ta erişimi varsayılan olarak kapalı (access: none); JVM dump sırasında durur; dosya token ve kişisel veri içerir
/actuator/loggersLogger seviyesini okuma ve çalışma anında değiştirmeSadece o pod'a uygulanır

Web üzerinden varsayılan olarak sadece health açıktır. Diğerlerini açmak bilinçli bir karardır: ayrı management port'unda, dışarıya kapalı ve yetkilendirmeli. Restart etmeden DEBUG log almak için:

bash
# Önce bir pod'a port-forward (management port'u 8081 varsayımıyla)
kubectl port-forward -n payments pod/payment-7c9f-abcde 8081:8081

curl -X POST http://localhost:8081/actuator/loggers/com.acme.payment.gateway \
     -H 'Content-Type: application/json' \
     -d '{"configuredLevel":"DEBUG"}'

# İş bitince geri al (null: üst logger'dan miras)
curl -X POST http://localhost:8081/actuator/loggers/com.acme.payment.gateway \
     -H 'Content-Type: application/json' \
     -d '{"configuredLevel":null}'

Değişiklik sadece o JVM'e uygulanır ve restart'ta kaybolur. Bu bir özelliktir: tek pod'u DEBUG'a alıp hacmi kontrol altında tutarsın; ama geri almayı unutursan o pod saatlerce log seli üretir.

Altyapı. Sorun tek pod'da ya da tek node'daysa, ya da pod'lar yeniden başlıyorsa:

bash
kubectl get pods -n payments -o wide                 # RESTARTS, hangi node
kubectl describe pod payment-7c9f-abcde -n payments  # Last State, Reason: OOMKilled, probe hataları
kubectl logs payment-7c9f-abcde -n payments --previous   # ölen container'ın son logları
kubectl get events -n payments --sort-by=.lastTimestamp
kubectl top pod -n payments                          # metrics-server gerekir
kubectl describe node <node-adi>                     # MemoryPressure, DiskPressure, NotReady

--previous en çok unutulan bayraktır: restart olmuş container'da kubectl logs yeni container'ın çıktısını verir; çöküşün nedeni öncekinin son satırlarındadır. OOMKilled, exit code 137 ve probe kaynaklı restart ayrımı Bölüm 15'te. Aralıklı UnknownHostException ya da uzun bağlantı kurma süreleri CoreDNS yükü gibi küme sorunlarına işaret edebilir.

Hafiflet. Etkiyi azaltan aksiyonlar, kök neden bilinmeden de alınabilir:

AksiyonNe zamanRisk
RollbackSorun bir deploy ile başladıGeri uyumsuz migration ya da event varsa kırık olabilir (Bölüm 15)
Feature flag kapatmakYeni özellik flag arkasındaEn güvenli ve en hızlı yol
ÖlçeklemekCPU/thread doygunluğu, trafik artışıSorun DB ya da kilitse kötüleştirir: daha çok bağlantı, daha çok bekleme
Rate limit / load sheddingBir bağımlılık çöküyor, kötü niyetli trafikBazı kullanıcılar hata alır, sistem ayakta kalır (Bölüm 14)
Sorunlu pod'u çıkarmakTek pod/node sorunuÖnce kanıt al (dump, log), sonra sil
Kritik olmayan bağımlılığı kapatmakÖneri motoru gibiFallback önceden tasarlanmış olmalı

Sonrası: kök neden, postmortem, aksiyonlar

Etki giderildikten sonra iş bitmez:

  • Kök neden analizi: "5 neden" (5 whys) ile teknik nedenin arkasındaki süreç nedenine inilir. "Migration tabloyu kilitledi" bir nedendir; "migration'lar kilit etkisi açısından review edilmiyor ve staging'de production hacminde veri yok" asıl nedendir.
  • Blameless postmortem: kişileri değil sistemleri hedefler; herkesin elindeki bilgiyle iyi niyetle davrandığı varsayılır. Suçlayan kültürde insanlar hatayı saklar ve sonraki olay daha geç fark edilir.
  • İçerik: zaman çizelgesi (tespit, hafifletme, çözüm), etki (kullanıcı, başarısız işlem, harcanan error budget), kök neden ve katkıda bulunan faktörler, iyi ve kötü gidenler, sahibi ve tarihi olan aksiyonlar.
  • Gözlemlenebilirlik boşluğu: "Bunu daha hızlı nasıl görürdük?" sorusu her postmortem'de sorulur; eksik metrik, alarm ya da runbook somut aksiyon olur.

Checklist tablosu

AşamaSoruNereye bakılır
EtkiKaç kullanıcı, hangi endpoint, ne zamandan beri?SLO dashboard, burn rate
DeğişiklikSon 1-2 saatte ne değişti?Deploy/config/flag geçmişi, Grafana annotation, trafik
KapsamHepsi mi, bir kısmı mı (endpoint, pod, segment, sürüm)?RED panelleri, pod ve service.version kırılımı
Hata tipi4xx mi 5xx mi, hangi exception?Status code kırılımı, exception sınıfına göre gruplama
ÖrnekHata ya da zaman hangi span'de?traceId → trace → aynı traceId'li loglar
DBKilit, uzun transaction, yavaş sorgu?pg_stat_activity, pg_locks, pg_stat_statements
PoolBağlantı bekleyen var mı?Hikari active/pending/acquire/timeout
MesajlaşmaLag artıyor mu, DLT doluyor mu?Partition bazında lag, DLT, rebalance
Dış servisLatency, hata, circuit breaker?CLIENT span'leri, Resilience4j metrikleri
JVMGC, heap, thread birikmesi, throttling?JVM dashboard, thread dump
AltyapıRestart, OOMKilled, node, DNS?kubectl describe, --previous, events
HafifletmeEn hızlı ve en az riskli aksiyon?Rollback, flag, ölçek, rate limit
SonrasıKök neden, gözlem boşluğu, aksiyonlarPostmortem

Hata türüne göre ilk tahmin

BelirtiEn olası nedenİlk bakılacak yer
Ani 5xx artışı, yeni deploy ile aynı andaYeni sürümde bug, config ya da şema uyumsuzluğuException'lar, service.version kırılımı; hızlıca rollback
Yavaşlık, Hikari pending yüksek, DB CPU normalKilit bekleme ya da bağlantıyı tutan uzun transactionpg_stat_activity (Lock, idle in transaction), thread dump
Yavaşlık, DB CPU yüksekYeni sorgu, eksik index, plan değişikliğipg_stat_statements, EXPLAIN (ANALYZE, BUFFERS)
Sadece tek pod'da sorunNode sorunu, gürültülü komşu, o pod'un GC'si ya da throttling'iPod/node kırılımı, kubectl describe
4xx artışıİstemci ya da sözleşme değişikliği (yeni mobil sürüm), auth/tokenEndpoint ve istemci sürümü kırılımı, validation mesajları
Aralıklı timeout'lar3rd party latency, DNS, GC duraklamalarıCLIENT span'leri, GC pause, DNS süreleri, circuit breaker
Restart döngüsüOOMKilled, probe hatası, açılışta çökmeLast State, --previous, events
Lag sürekli artıyorYavaş işleme, zehirli mesaj, rebalance fırtınasıPartition bazında lag, işleme süresi, DLT
Hata belirli saatte tekrarlıyorZamanlanmış iş, cache expire fırtınası, sertifika/token süresi@Scheduled zamanları, o saatteki trafik

Senaryo: ödeme servisinde p99 2 saniyeye çıktı

Somut bir olay, runbook'un nasıl işlediğini gösterir.

14:07. payment-service için latency SLO'sunun ("isteklerin %99'u 800 ms altında") burn rate alarmı çalar. p99 normalde 350 ms civarında; şu an 2,1 sn. p50 değişmemiş: 90 ms. Hata oranı hafif artmış (%0,4), çoğu 503 ve order-service tarafında timeout.

İlk 5 dakika. p50 sabit, p99 yüksek: sorun isteklerin bir alt kümesinde. Yavaşlık POST /payments/{id}/capture ve .../refund'ta; yeni ödeme oluşturma normal; tüm pod'larda aynı. 13:55'te payment-service 2.14.0 deploy'u var, ama kod değişikliği capture/refund yoluna dokunmuyor ve sürüm bir veri migration'ı da içeriyor; rollback'in bunu durdurup durdurmayacağı belirsiz olduğu için 5 dakika daha bakılır.

Trace. Exemplar'dan alınan yavaş bir capture trace'inde SERVER span'i 1.950 ms, altındaki UPDATE payment_transactions span'i (servis JDBC enstrümantasyonu açık çalışıyor) 1.880 ms; diğer span'ler normal. JDBC span'i olmasaydı aynı sonuç "SERVER span'i uzun ama hiçbir alt span uzun değil" boşluğundan ve Hikari metriklerinden çıkarılırdı. Loglarda hata yok, sadece yavaşlık.

Bağımlılık metrikleri. Hikari active yüksek, pending arada 5-10; DB CPU ve I/O normal. "DB yavaş" değil, "DB'de bir şey bekleniyor" tablosu.

pg_stat_activity. Yukarıdaki sorgu çalıştırılır:

text
 pid  | application_name | state  | wait_event_type | wait_event    | query_age | blocked_by | query
------+------------------+--------+-----------------+---------------+-----------+------------+------------------------------------------
 4411 | payment-service  | active | Lock            | transactionid | 00:00:01.7| {3920}     | UPDATE payment_transactions SET status=...
 4418 | payment-service  | active | Lock            | transactionid | 00:00:00.9| {3920}     | UPDATE payment_transactions SET status=...
 3920 | currency-backfill| active |                 |               | 00:00:01.9| {}         | UPDATE payment_transactions SET currency_code='TRY' WHERE id BETWEEN ...

Bekleten oturum (3920), 2.14.0 ile birlikte Kubernetes Job olarak başlatılan currency-backfill: yeni currency_code kolonunu eski satırlar için dolduruyor. 50.000'lik id aralıklarıyla ilerliyor; her batch kendi transaction'ında yaklaşık 2 saniye sürüyor ve bu sürede aralıktaki bütün satırların kilidini tutuyor.

Neden sadece capture/refund? Yeni ödeme INSERT'tür, kilitli eski satırlarla çakışmaz. Capture ve refund mevcut satırı UPDATE eder; satır backfill'in o anki aralığındaysa backfill commit edene kadar (transactionid beklemesi) bekler. Backfill son günlerin "sıcak" satırlarına yaklaştıkça çakışma ve p99 artıyor.

Hafifletme (14:16). Job silinir (kubectl delete job currency-backfill), o anki batch pg_cancel_backend(3920) ile iptal edilir. İki dakikada p99 360 ms'ye döner. Rollback yapılsaydı sorun sürecekti, çünkü yavaşlığı yeni kod değil Job yaratıyordu: değişikliğin hangi parçasının etkili olduğunu bilmek gerekir.

Kalıcı çözüm. Backfill yeniden tasarlanır:

sql
-- Küçük batch, kısa transaction, kilit beklemeye tahammülsüz, sıcak satırlara en son dokunur
SET lock_timeout = '200ms';

WITH batch AS (
    SELECT id
    FROM payment_transactions
    WHERE currency_code IS NULL
      AND created_at < now() - interval '2 days'   -- capture/refund görme ihtimali düşük satırlar
    ORDER BY id
    LIMIT 2000
    FOR UPDATE SKIP LOCKED                          -- canlı trafiğin kilitlediği satırları atla
)
UPDATE payment_transactions p
SET currency_code = 'TRY'
FROM batch
WHERE p.id = batch.id;
-- Her batch ayrı transaction; batch'ler arasında kısa bekleme; son 2 gün en sonda, düşük trafik saatinde

Batch 2.000'e iner, her transaction milisaniyeler sürer; SKIP LOCKED backfill'in canlı trafiğin tuttuğu satırları beklemesini, lock_timeout uzun beklemesini önler (satır kilitleri için bkz. Bölüm 10, backfill adımı için Bölüm 15).

Postmortem aksiyonları: veri migration'ları için review checklist'i (batch boyutu, transaction süresi, canlı yazma yollarıyla çakışma, durdurulabilirlik); backfill Job'larına metrik ve "durdur" flag'i; DB dashboard'una kilit bekleyen oturum sayısı paneli ve uyarısı; her DB bağlantısında anlamlı application_name (bu olayda teşhisi dakikalar kısalttı); staging'de production hacmine yakın veriyle prova.

Hikâyenin mülakattaki değeri şu zincirdir: semptom (p99, p50 değil) → kapsam (sadece UPDATE yapan endpoint'ler) → trace (zaman tek bir DB span'inde) → pool (pending) → DB (kilit, bekleten oturum) → değişiklik (deploy'un içindeki Job) → hafifletme (Job'u durdur, rollback değil) → kalıcı çözüm → önleyici aksiyon.

Loglama anti-pattern'leri

1. Exception'ı yutmak.

java
try {
    ledger.post(entry);
} catch (Exception e) {
    // sessizce devam
}

Hata görünmez, işlem başarılı sanılır, sonuç tutarsız veridir. Ele alamıyorsan yakalama; dönüştürüyorsan cause'u koru: throw new LedgerPostingException("Ledger posting failed", e).

2. Log-and-throw: aynı hatayı iki kez loglamak.

java
} catch (SQLException e) {
    log.error("DB error", e);
    throw new RepositoryException("DB error", e);   // yukarıdaki katman da loglayacak
}

Repository, servis ve controller advice ayrı ayrı loglar: aynı stack trace üç kez, hata sayısı yanlış, bağlam gürültüde. Kural: ya logla ya fırlat. Exception'ı ele alan son nokta (@RestControllerAdvice, listener error handler'ı) bir kez stack trace ile loglar; ara katmanlar bağlamı exception mesajına ya da MDC'ye ekler.

3. String birleştirme ile log.

java
log.debug("Cart contents: " + cart.toDetailedString());   // DEBUG kapalıyken bile string üretilir
log.debug("Cart contents: {}", cart);                      // toString sadece seviye açıksa çağrılır

Placeholder'lı form, seviye kapalıyken mesaj oluşturma maliyetini ödemez. Argümanın kendisi pahalıysa (bir hesaplama) fluent API'de supplier ya da log.isDebugEnabled() kontrolü kullanılır. Ayrıca exception'ı son argüman olarak vermek gerekir: log.error("Charge failed orderId={}", orderId, e) stack trace'i yazar; e.getMessage()'ı mesaja eklemek stack trace'i kaybettirir.

4. Her satırda INFO. "Metoda girildi/çıkıldı" satırları yüksek trafikte hacmi patlatır ve önemli satırları gömer. Akış bilgisi trace'in işidir; span'ler "nereye gidildi, ne kadar sürdü"yü zaten yapılandırılmış verir.

5. Hassas veri loglamak. log.info("Request: {}", request) ile DTO'yu, Authorization header'ını ya da kart bilgisini yazmak. Bir kez yazılan log onlarca sisteme kopyalanır. Çözüm: güvenli toString(), pipeline'da maskeleme, gövdeyi varsayılan olarak loglamamak.

6. Log'u metrik yerine kullanmak. Sipariş sayısını "Order created" satırlarını sayarak çizmek pahalı, gecikmeli, log kaybında yanlış ve mesaj değişince kırılgandır. Sayılan her şey Counter, süre Timer olur (bkz. Bölüm 15).

7. Bağlamsız hata mesajı. log.error("Error occurred"). İyi bir hata satırı ne yapılmaya çalışıldığını, hangi kimlikle, sonucu ve sonra ne olacağını (retry mi, DLT mi) söyler.

8. MDC'yi temizlememek. remove edilmeyen değer havuzdaki thread'in sonraki isteğine sızar; MDC.putCloseable kullan.

Mülakattaki sorular: adım adım cevap

Distributed logging nasıl olur, loglar neyle takip edilir?

Soru: "Distributed logging nasıl olur, loglar neyle takip edilir?"

Mülakatta beklenen: Anahtar kelimenin traceId (ve korelasyon) olduğunu hemen söylemek, bu kimliğin servisler ve Kafka arasında nasıl taşındığını ve logların merkezi bir yerde bu kimlikle nasıl sorgulandığını anlatmak.

60 saniyelik cevap.

"Mikroservislerde bir istek birden çok servisten ve pod'dan geçtiği için logları zamanla eşleştirmek mümkün değil; her log satırında isteği tanımlayan ortak bir kimlik olmalı. Bunun standardı traceId: isteğin girdiği ilk noktada üretilir, W3C traceparent header'ıyla her HTTP çağrısında, Kafka'da da record header'ında taşınır. Spring Boot 3'te bunu Micrometer Tracing yapıyor; traceId ve spanId'yi MDC'ye koyuyor, log satırına otomatik giriyor. Uygulamalar JSON formatında stdout'a yazar, node'daki Fluent Bit ya da OTel Collector gibi bir ajan logları toplayıp Elasticsearch ya da Loki'ye gönderir. Bir hatayı araştırırken traceId ile arama yapınca isteğin geçtiği tüm servislerin logları sırayla gelir; aynı traceId Tempo ya da Jaeger'da span ağacını, yani zamanın nerede geçtiğini gösterir. Bunun yanında orderId gibi iş anahtarlarını da log alanı olarak tutarım, çünkü bir siparişin yaşamı birden çok trace'e yayılır."

Derin açıklama.

Cevabın dört katmanı vardır ve mülakatçı genelde birini derinleştirir: kimlik (traceId/spanId, traceparent formatı, correlation id farkı), propagation (builder'lı HTTP client, Kafka header'ı, executor'larda context, outbox), log formatı (JSON, ortak alan şeması, maskeleme) ve toplama ve sorgu (stdout → ajan → Elasticsearch ya da Loki, Grafana'da sinyaller arası geçiş). Her biri yukarıda ayrı başlıkta anlatıldı.

"Aklıma traceId gelmedi" durumunun panzehiri, cevabı problemden başlatmaktır: "Loglar farklı makinelerde; aynı isteğe ait olduklarını nasıl bilirim? Ortak bir kimlik lazım." Bu cümleyi kurduğunda traceId kendiliğinden gelir. Ardından örnekleme notu seviyeyi yükseltir: "traceId örneklenmeyen isteklerde de loglara girer; loglarda her zaman bulurum, trace backend'de bulamayabilirim. Bu yüzden hatalı trace'leri tail sampling ile tutarım."

Serviste bir hata aldın; nasıl kontrol edersin, ilk nerelere bakarsın?

Soru: "Bir yerde bir hata aldın serviste, nasıl kontrol edersin, kontrol adımların nasıl olmalı, servisinde ilk nerelere bakarsın?"

Mülakatta beklenen: Rastgele değil sistematik bir sıra: etki ve son değişiklik, metrikler, bir hata örneğinin traceId'siyle trace ve loglar, bağımlılıklar, JVM, altyapı; ve önce etkiyi azaltıp sonra kök nedene inmek.

60 saniyelik cevap.

"Önce etkiyi anlarım: hangi endpoint, ne zamandan beri, hata oranı ne, SLO'yu ne kadar yakıyor. Hemen ardından son değişikliğe bakarım: deploy, config, feature flag, trafik artışı; olayların çoğu bir değişiklikle başlar ve sorun bir deploy ile başladıysa önce rollback yaparım. Sonra servisin RED dashboard'una bakarım: istek hızı, hata tipi 4xx mi 5xx mi, p99 ve p50; hepsi mi etkilenmiş, tek pod mu. Bir hata örneğinin traceId'sini alıp trace'te hangi span'de hata ya da gecikme olduğunu, aynı traceId ile tüm servislerin loglarını görürüm. Span bir bağımlılığı gösteriyorsa ona inerim: DB'de kilit ve yavaş sorgu (pg_stat_activity), Hikari pending, Kafka lag, dış servisin latency'si ve circuit breaker durumu. Bağımlılıklar temizse JVM'e (GC, heap, thread dump) ve altyapıya (restart, OOMKilled, kubectl describe, --previous loglar) bakarım. Etkiyi azalttıktan sonra kök nedeni bulup blameless bir postmortem ve aksiyonlarla kapatırım."

Derin açıklama.

Mülakatçı üç şeyi dinler:

  • Önceliklendirme: "Önce loglara bakarım" diyen aday, belki 30 dakika sürecek bir arama boyunca kullanıcıların hata almasını kabul ediyor demektir. "Deploy ile başladıysa önce rollback" operasyonel olgunluğun en net işaretidir; "rollback güvenli mi?" (geri uyumsuz migration) ve "değişikliğin hangi parçası?" (senaryodaki Job) soruları bir adım ötesidir.
  • Genişten dara: Her adımda bir hipotezi doğrulamak ya da elemek. "p50 sabit p99 yüksek" ya da "sadece tek pod" gibi gözlemler aramayı yarıya böler.
  • Araç bilgisi: hikaricp_connections_pending, pg_blocking_pids, jcmd Thread.print, kubectl logs --previous, /actuator/loggers gibi somut isimler güven verir.

Cevabı zaman dilimleriyle kur ("ilk 5 dakika, ilk 30 dakika, sonrası"), sonra bir hikâye anlat. Bankacılık geçmişi burada avantajdır: tutarsız bir ledger kaydı, geciken bir gün sonu (EOD) batch'i ya da yavaşlayan bir dış sistem (EFT/FAST, kart switch'i) bu yapıya oturur. Hata türüne göre iki-üç eşleştirme ("4xx sözleşme ya da istemci değişikliğidir, aralıklı timeout dış servis, DNS ya da GC'dir") pattern tanıma deneyimini gösterir.

Üç seviyede cevap

Soru: "Distributed logging nasıl yapılır?"

Bütün servislerin loglarını ELK gibi merkezi bir yere gönderirim. Sonra Kibana'da hata mesajını ya da zamanı arayarak ilgili logları bulurum. Her servise de bir request id ekleyebilirim.

Her isteğe bir traceId atanır ve servisler arasında traceparent header'ıyla taşınır. Spring Boot 3'te Micrometer Tracing bunu yapar, traceId ve spanId MDC'ye girer ve log satırına eklenir. Loglar JSON olarak stdout'a yazılır, Fluent Bit ile Elasticsearch'e gider; Kibana'da traceId ile arayınca tüm servislerin logları gelir. Kafka'da da observation açılırsa traceId header'la taşınır. Trace'leri de Zipkin ya da Jaeger'da görürüm.

Üç parçası var: kimlik, taşıma, toplama. Kimlik W3C Trace Context; traceId edge'de üretilir, her atlamada traceparent'la taşınır. Spring'de Micrometer Tracing + OTel bridge kullanırım ve zincirin kırıldığı yerleri bilirim: builder'sız HTTP client'lar, executor ve CompletableFuture'lar (ContextPropagatingTaskDecorator), Kafka'da observation-enabled ve batch listener'lar, outbox relay. Eski Sleuth servisleri varsa geçişte B3 de üretirim.

Loglar ortak alan şemalı ECS JSON; traceId'nin yanında orderId gibi iş anahtarları da var, çünkü bir siparişin yaşamı birden çok trace'tir. Toplama stdout → DaemonSet ajanı → backend; backend seçimi maliyet sorusu: tam metin arama gerekiyorsa Elasticsearch, hacim büyükse Loki (traceId label değil, içerik filtresi). traceId her logda var ama trace'ler örneklenir; hatalı trace'leri tail sampling ile tutarım. Ve üç sinyal arasında tek tıkla geçiş: exemplar ile p99'dan trace'e, trace'ten loglara.

Junior cevap merkezi toplamayı biliyor ama korelasyonu zamana ve mesaja bırakıyor; "request id" doğru sezgi ama nasıl taşınacağı yok. Mid cevap mekanizmayı doğru anlatıyor. Senior cevap mekanizmanın nerelerde kırıldığını biliyor, örneklemenin "logda var, trace'te yok" sonucunu söylüyor ve backend seçimini maliyete bağlıyor.

Takip sorusu: @Async ile çalışan bir metodun loglarında traceId boş geliyor. Neden olur ve hangi iki farklı yolla çözersin?

Soru: "Serviste hata aldın, ilk nereye bakarsın?"

Önce loglara bakarım, hata mesajını ve stack trace'i bulurum. Kodda o satıra gidip hatayı düzeltirim. Gerekirse local'de tekrar etmeye çalışırım.

Önce ne zaman başladığına ve o sırada deploy olup olmadığına bakarım; deploy varsa rollback düşünürüm. Sonra Grafana'da hata oranına ve latency'ye bakarım, hangi endpoint'in etkilendiğini bulurum. Bir hatalı isteğin traceId'siyle trace'i açıp hangi serviste ve hangi çağrıda hata olduğunu görürüm, sonra o traceId ile logları okurum. DB ya da başka bir servis kaynaklıysa oraya geçerim.

Önce etki ve aciliyet: hangi SLO, kaç kullanıcı, ne zamandan beri. Sonra "ne değişti": deploy, config, flag, trafik, altyapı. Deploy ile başladıysa ve geri uyumsuz bir migration yoksa kök nedeni beklemeden rollback; flag varsa daha da hızlı.

Sonra daraltırım: sadece p99 mu kötü yoksa p50 de mi, tek pod mu hepsi mi, 4xx mi 5xx mi. Bir örneğin traceId'siyle hatanın ya da zamanın hangi span'de olduğunu görürüm. Span bir bağımlılığı gösteriyorsa hipotezi metrikle doğrularım: Hikari pending, pg_stat_activity'de kilit bekleyenler ve blocked_by, consumer lag, circuit breaker. Bağımlılıklar temizse JVM (GC, throttling, birkaç thread dump), restart varsa describe ve --previous. Gerekirse tek pod'u /actuator/loggers ile geçici DEBUG'a alırım. Sonunda blameless postmortem; aksiyonlardan biri her zaman "bunu neden daha önce görmedik?"in cevabıdır.

Junior cevap local debug zihniyetinde: etkiyi ve son değişikliği sormuyor, kullanıcılar hata alırken kod okuyor. Mid cevap doğru başlıyor ve trace kullanıyor ama hipotezi metrikle test etmiyor, alt katmanları somut araçlarla anlatmıyor. Senior cevap önceliği (önce hafiflet), daraltma tekniklerini, her katman için somut bir sinyali ve olay sonrası öğrenmeyi içeriyor; rollback'in ne zaman güvenli olmadığını da biliyor.

Takip sorusu: Rollback yaptın ama hata oranı düşmedi. Bu sana ne söyler ve sonraki adımın ne olur?

Soru: "traceId servisler arasında nasıl taşınır? Kafka'da ne olur?"

traceId bir header'la gönderilir, karşı servis de onu alıp kullanır. Kafka'da da mesajın içine traceId alanı eklerim.

HTTP'de W3C traceparent header'ıyla taşınır: sürüm, traceId, parent span id ve sampled flag. Spring Boot'ta RestClient.Builder ya da WebClient.Builder ile oluşturulan client'lar header'ı otomatik ekler, gelen istekte de Spring MVC okur. Kafka'da payload'a değil record header'ına yazılır; Spring Kafka'da spring.kafka.template.observation-enabled ve spring.kafka.listener.observation-enabled açılınca producer yazar, consumer okur ve span'i aynı trace'e bağlar.

HTTP'de traceparent: traceId sabit kalır, parent-id her atlamada gönderenin span id'siyle değişir, flag örnekleme kararını taşır ki trace ya bütünüyle ya hiç kaydedilsin. Spring'de bu sadece auto-configured builder'lardan üretilen client'larda otomatiktir; new RestTemplate() zinciri sessizce koparır. Boot 3 varsayılanı W3C üretip W3C ve B3 okumaktır; eski Sleuth servisleri varsa B3 de üretirim.

Kafka'da context record header'ındadır; payload iş sözleşmesidir, ona koymam. Observation'ı template ve listener'da ayrı açarım. Mesajlaşmada consumer mesajı saatler sonra işleyebilir ya da bir batch birden çok trace içerebilir; bu yüzden bazı sistemler parent-child yerine span link kullanır. Spring Kafka observation batch listener'ı desteklemez; orada header'ı elle okurum. Outbox'ta traceparent satıra yazılır, relay header'a koyar. Son olarak edge'de dışarıdan gelen traceparent'a körü körüne güvenmem; yeni trace başlatıp dış context'i link olarak tutmak, örnekleme kararının dışarıdan manipüle edilmesini önler.

Junior cevap traceId'yi payload'a koymayı öneriyor; bu, iş sözleşmesini taşıma detayıyla kirletir ve framework enstrümantasyonunu devre dışı bırakır. Mid cevap standardı ve Spring ayarlarını biliyor. Senior cevap header'ın her atlamada nasıl değiştiğini, zincirin kırıldığı yerleri, mesajlaşmada parent-child ile link farkını ve edge'deki güven sınırını ekliyor.

Takip sorusu: Bir consumer aynı mesajı retry topic üzerinden üç kez işledi. Trace'te bunu nasıl görmek istersin ve loglarda nasıl ayırt edersin?

Soru: "Log, metrik ve trace arasındaki fark nedir, ne zaman hangisini kullanırsın?"

Log uygulamanın yazdığı mesajlardır, metrik CPU ve bellek gibi sayılardır, trace de isteğin servisler arasındaki yoludur. Hata olunca log'a, performans için metriğe bakarım.

Metrik zamana göre toplanmış sayılardır; ucuzdur, alarm ve dashboard için kullanılır (RED, USE). Log tek bir olayın detayıdır; hatanın nedenini anlamak için. Trace bir isteğin servisler arası yolculuğudur; darboğazın nerede olduğunu gösterir. Üçünü traceId bağlar: alarm metrikten gelir, yavaş trace'e bakarım, sonra o trace'in loglarını okurum. Metriğe userId gibi yüksek kardinaliteli label koymam, onlar log ve trace'e gider.

Üçü farklı soruları farklı maliyetle cevaplar. Metrik "ne kadar, ne zaman": önceden toplanmış, ucuz, uzun saklanır; alarm ve SLO metrikten kurulur, bedeli kardinalitedir. Log "tam olarak ne oldu": sınırsız bağlam ama hacimle doğrusal maliyet; sayımı log'a yaptırmam. Trace "zaman nerede geçti, kim kimi çağırdı": yapısı vardır ama örneklenir, tam sayım için güvenilmez.

Kullanım bir akıştır: metrik bozulduğunu, trace nerede bozulduğunu, aynı traceId'li loglar neden bozulduğunu söyler. Bir sinyal diğerinin işini yapınca (log'dan sayım, metrikte userId, trace'i audit kaydı sanmak) maliyet ya da doğruluk sorunu çıkar. Dördüncü sinyal continuous profiling'dir: trace span'in yavaş olduğunu, profil CPU'nun o sürede hangi metotta harcandığını söyler.

Junior cevap tanımları biliyor ama "metrik CPU ve bellektir" diyerek istek ve iş metriklerini atlıyor. Mid cevap üç sinyali doğru konumluyor ve kardinaliteyi biliyor. Senior cevap her sinyali cevapladığı soru ve maliyetiyle tanımlıyor, örneklemenin trace'e etkisini, yanlış kullanımın sonuçlarını ve profiling'i ekliyor.

Takip sorusu: Bir ödeme sağlayıcısının hata oranını sağlayıcı bazında izlemek istiyorsun. Bunu metrik mi, log mu, trace mi olarak tasarlarsın ve label'ların ne olur?

Sorulmadı ama sorulabilir

Head ve tail sampling stratejileri

Head sampling kararı trace'in başında verir (Spring Boot'ta varsayılan %10). Ucuzdur ve karar traceparent flag'iyle aşağıya taşınır. Zayıflığı: karar anında isteğin hata alacağı bilinmez; %10 örneklemede hataların %90'ının trace'i kaybolur.

Tail sampling kararı trace tamamlandıktan sonra, bütün span'lere bakarak verir. OpenTelemetry Collector'ın tail sampling processor'ı "hatalı her trace'i tut", "1 sn'den uzunları tut", "kalanından %5" gibi politikalarla çalışır. Bedeli işletmedir: Collector trace'in span'lerini karar süresi boyunca bellekte tutar ve aynı trace'in bütün span'leri aynı Collector örneğine gitmelidir; bu yüzden önde traceId'ye göre yönlendiren bir load-balancing katmanı kurulur. OpenTelemetry dokümanı da tail sampling'in stateful ve işletmesi zor olduğunu belirtir.

Pratikte ikisi birlikte kullanılır: uygulamada geniş örneklenir, Collector'da tail sampling ile backend'e giden hacim düşürülür.

Tek cümle: "Head sampling ucuz ama kör, tail sampling akıllı ama stateful; uygulamada geniş örnekleyip Collector'da hata ve yavaş trace'leri tutan bir tail politikası kurarım."

OpenTelemetry Collector

Collector, telemetriyi alan (receiver), işleyen (processor) ve gönderen (exporter) bağımsız bir süreçtir; her node'da agent (DaemonSet) ya da merkezi gateway (Deployment) olarak çalışır. Uygulamayı backend'den ayırır: Tempo'dan ticari bir araca geçmek uygulamada tek satır değiştirmez. Processor'larla batch'leme, bellek sınırı, PII temizliği ve Kubernetes metadata zenginleştirmesi yapılır.

yaml
receivers:
  otlp:
    protocols:
      http: {}
      grpc: {}
processors:
  memory_limiter:
    check_interval: 1s
    limit_percentage: 80
  attributes/scrub:
    actions:
      - key: http.request.header.authorization
        action: delete
  batch: {}
exporters:
  otlphttp/tempo:
    endpoint: http://tempo.observability:4318
service:
  pipelines:
    traces:
      receivers: [otlp]
      processors: [memory_limiter, attributes/scrub, batch]
      exporters: [otlphttp/tempo]

Collector'ın kendisi de izlenir: kuyruk doluluğu, düşürülen span, exporter hataları.

Tek cümle: "Uygulamalar OTLP ile Collector'a yazar; batch, maskeleme, örnekleme ve backend seçimi Collector'da merkezi olarak yönetilir."

SLO tabanlı alerting

Burn rate ve çok pencereli alarm Bölüm 15'te. Hata ayıklama açısından eklenecek nokta: alarm araştırmanın başlangıç noktasıdır ve iyi bir alarm onu hızlandırır. Alarm mesajı hangi SLO'yu, mevcut burn rate'i, dashboard ve runbook linkini ve mümkünse en çok etkilenen endpoint'i taşımalı. Semptom alarmı kullanıcı etkisini, neden metrikleri (Hikari pending, consumer lag) teşhisi verir; nedenler page değil dashboard'dır.

Tek cümle: "Uyandıran alarm SLO burn rate'ten gelir ve runbook linki taşır; neden metrikleri araştırmanın ikinci adımında dashboard'da durur."

Chaos engineering

Chaos engineering, sistemin arızaya tepkisini kontrollü deneylerle ölçmektir: pod öldürmek, bağımlılığa 2 sn gecikme eklemek, DNS'i bozmak. Sıra: kararlı durumu bir metrikle tanımla (checkout başarı oranı), hipotez kur ("bir payment pod'u ölürse oran %99,5'in altına düşmez"), etki alanını küçük tut, çalıştır, ölç. Araçlar: Chaos Mesh, LitmusChaos, AWS FIS, uygulama seviyesinde Toxiproxy.

Deney aynı zamanda alarm ve runbook testidir: "pod öldü ama alarm çalmadı" ya da "gecikme eklendi ama trace'te görünmüyor" bulguları, gerçek olay yaşanmadan kapatılan boşluklardır.

Tek cümle: "Chaos deneyleri dayanıklılığı olduğu kadar alarm ve runbook'ları da test eder; kararlı durum metriği ve küçük etki alanıyla başlarım."

Log saklama süresi ve maliyet

Saklama süresini üç girdi belirler: operasyonel ihtiyaç (olayların çoğu günler içinde araştırılır), regülasyon (bankacılıkta bazı kayıtlar yıllarca saklanır) ve gizlilik (KVKK/GDPR verinin gerektiğinden uzun tutulmamasını ister). Çelişkinin çözümü katmanlı saklamadır: kısa süreli sıcak katman (index'li), ucuz soğuk katman (object storage) ve ayrı tutulan audit kayıtları. Elasticsearch'te ILM, Loki'de stream bazında retention bunu yönetir. Servis başına log hacmini metrik olarak ekiplere göstermek (showback) maliyeti sahibine verir.

Tek cümle: "Saklamayı seviye ve amaca göre katmanlarım: uygulama logu kısa ve sıcak, audit kaydı uzun ve ayrı; maliyeti önce kaynakta, gereksiz log üretimini keserek düşürürüm."

Audit log ile application log farkı

Application logAudit log
AmaçTeşhisKim, neyi, ne zaman yaptı: hesap verebilirlik
OkuyucuGeliştirici, SREDenetçi, güvenlik, müşteri itirazı
Kayıp toleransıKabul edilebilirKabul edilemez
SaklamaGün/haftaYıllar (regülasyona göre)
DeğiştirilebilirlikÖnemsizAppend-only, değiştirilemez (WORM, hash zinciri)
Yazma yoluAsenkron stdout → pipelineİşlemle aynı transaction'da DB'ye ya da outbox ile

Audit kaydını uygulama loguna yazmak sık bir hatadır: pipeline kayıp tolere eder, seviye ayarıyla kapatılabilir, saklama kısadır. "Müşterinin limitini kim değiştirdi?" sorusunun cevabı ayrı, değiştirilemez bir audit kaydında aranır.

Tek cümle: "Application log teşhis içindir ve kayıp tolere eder; audit log hesap verebilirlik içindir, işlemle birlikte güvenilir şekilde yazılır, değiştirilemez ve uzun saklanır."

Continuous profiling

Trace bir span'in 800 ms sürdüğünü söyler ama CPU'nun o sürede hangi metotta harcandığını söylemez. Continuous profiling, production'da örneklemeli ve düşük overhead'le sürekli CPU, allocation ve lock profili toplar; sonuç zaman içinde sorgulanan flame graph'lardır. Java'da temel araçlar JDK Flight Recorder (JFR) ve async-profiler'dır; Grafana Pyroscope gibi araçlar bunları sürekli toplar, bazıları profil örneklerini span'lere bağlar.

Örnek: yeni sürümden sonra CPU %30 arttı ama trace'lerde belirgin yavaş span yok. İki sürümün profil farkı (diff flame graph), her istekte yeniden derlenen bir regex'i gösterir. Tek seferlik teşhis için: jcmd 1 JFR.start duration=60s filename=/tmp/rec.jfr.

Tek cümle: "Metrik ne kadar, trace nerede, profil neden yavaş olduğunu söyler; production'da JFR ya da async-profiler tabanlı sürekli profil toplarım."

Sık yapılan hatalar

(Loglama kod hataları yukarıdaki anti-pattern'ler bölümünde; burada tasarım ve olay müdahalesi hataları.)

  • Logları zaman damgasıyla eşleştirmeye çalışmak → saat kayması, aynı milisaniyede onlarca istek, asenkron gecikme yüzünden yanlış eşleşme → her satırda traceId ve iş anahtarı.
  • HTTP client'ı new RestTemplate() ya da RestClient.create() ile oluşturmak → traceparent taşınmaz, trace ikiye bölünür → auto-configured builder'lar, ArchUnit ile yasak.
  • Kafka'da traceId'yi payload'a koymak ya da observation'ı açmamak → sözleşme kirlenir ya da consumer yeni trace başlatır → observation-enabled, context record header'ında.
  • Executor'larda context'i taşımamak → async loglarda traceId boş ya da başka isteğin → ContextPropagatingTaskDecorator, executor'sız supplyAsync yok.
  • "traceId logda varsa trace'i de backend'dedir" sanmak → %10 head sampling'de çoğu trace kaydedilmez → hatalı ve yavaş trace'ler için tail sampling.
  • Loki'de traceId ya da userId'yi label yapmak → her değer ayrı stream, index patlar → label düşük kardinaliteli, traceId içerik filtresi.
  • Olayda önce kod okumaya başlamak → kullanıcılar hata alırken dakikalar geçer → önce etki ve son değişiklik, mümkünse önce hafiflet.
  • Her yavaşlıkta pod sayısını artırmak → sorun DB kilidi ya da havuzsa daha çok bağlantı, daha çok bekleme → önce Hikari pending ve DB beklemeleri.
  • "Deploy ile çakışıyor, rollback yeter" demek → sorun deploy'un yanında gelen bir Job, migration ya da config olabilir → değişikliğin hangi parçasının etkili olduğunu doğrula.
  • Restart olmuş pod'da kubectl logs'a bakıp "log yok" demek → çıktı yeni container'ındır → --previous ve describe pod'daki Last State.
  • Tek bir thread dump'tan sonuç çıkarmak → anlık görüntü yanıltır → 5-10 saniye arayla birkaç dump.
  • /actuator/loggers ile açılan DEBUG'u kapatmayı unutmak → o pod saatlerce log seli üretir → iş bitince configuredLevel: null.

Hızlı tekrar

S: traceparent header'ının dört alanı nedir ve hangisi her atlamada değişir?

C: version-traceid-parentid-flags, ör. 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01. traceId sabit kalır; parent-id gönderenin o anki span id'siyle her atlamada değişir; flag'in son biti sampled'dır.

S: Spring Boot 3'te tracing'i ne yapar ve traceId loga nasıl girer?

C: Micrometer Tracing (Observation API + OpenTelemetry ya da Brave bridge); Sleuth Boot 3 ile çalışmaz. Tracer traceId/spanId'yi MDC'ye koyar, Boot 3.2+ bunları log satırına otomatik ekler; format logging.pattern.correlation ile değişir.

S: Varsayılan örnekleme oranı nedir ve sonucu ne olur?

C: management.tracing.sampling.probability=0.1 (%10). traceId her logda vardır ama trace'lerin çoğu backend'e gitmez; hata trace'lerini tutmak için tail sampling gerekir.

S: new RestTemplate() ile oluşturulan client neden trace'i koparır?

C: Observation enstrümantasyonu Spring Boot'un auto-configured builder'larına eklenir. Builder kullanılmayan client traceparent eklemez; çağrılan servis yeni bir trace başlatır.

S: MDC nedir ve neden @Async'te kaybolur?

C: Thread'e bağlı (ThreadLocal) bir anahtar-değer map'idir. İş başka thread'e geçince yeni thread'in MDC'si boştur; ContextPropagatingTaskDecorator (Spring 6.1+) context'i yakalayıp hedef thread'de geri yükler.

S: Kafka'da trace context nasıl taşınır ve neye dikkat edilir?

C: Record header'ında traceparent ile; Spring'de spring.kafka.template.observation-enabled ve spring.kafka.listener.observation-enabled. Observation batch listener'ları desteklemez; outbox relay'de traceparent satıra yazılıp header'a konmalıdır.

S: Loki ile Elasticsearch'ün temel farkı nedir?

C: Elasticsearch log içeriğini tam metin index'ler (hızlı arama, yüksek maliyet). Loki sadece label'ları index'ler, içeriği sıkıştırılmış chunk'larda object storage'da tutar (ucuz); traceId label değil içerik filtresidir.

S: Production'da bir hatada ilk 5 dakikada ne yaparsın?

C: Etkiyi ve kapsamı belirlerim (SLO, endpoint, ne zamandan beri, hepsi mi bir kısmı mı), son değişikliğe bakarım (deploy, config, flag, trafik) ve deploy ile başladıysa ve güvenliyse rollback ederim.

S: p50 sabit, p99 yüksek; Hikari pending artmış, DB CPU normal. İlk şüphe ve bakılacak yer?

C: Kilit bekleme ya da bağlantıyı uzun tutan transaction. pg_stat_activity'de wait_event_type='Lock', pg_blocking_pids ve idle in transaction oturumlar; uygulamada thread dump.

S: Restart olan bir pod'un çöküş nedenini görmek için hangi iki komut?

C: kubectl describe pod (Last State, Reason: OOMKilled, probe hataları, events) ve kubectl logs --previous (ölen container'ın son logları).

S: Çalışma anında restart etmeden log seviyesini nasıl değiştirirsin?

C: POST /actuator/loggers/{logger} ile {"configuredLevel":"DEBUG"}. Sadece o pod'a uygulanır ve restart'ta kaybolur; iş bitince null ile geri alınır.

Kaynaklar