Kubernetes’te .NET Pod’u Neden Çöküyor? Exit 139 / SIGSEGV Avı

CrashLoopBackOff klasik bir Kubernetes derdidir, ama bazen pod düzgün başlar, birkaç dakika çalışır, sonra aniden ölür. Loglar tertemizdir, hiçbir exception yoktur. Elinizde tek ipucu vardır: Exit Code 139.

Bu yazıda, on-prem bir kümede iki .NET 8/10 mikroservisinin neden çöktüğünü adım adım bulduğumuz gerçek bir vakayı özetliyorum. Sonunda suçlu beklediğim yerde değildi.

Exit Code 139 ne anlatır?

139 = 128 + 11. Buradaki 11, SIGSEGV sinyalidir — yani bir segmentation fault, native (yönetilmeyen) bir kod katmanında bellek ihlali. Bu, .NET’in yakaladığı normal bir exception değildir; runtime’ın altında, bir C kütüphanesinde patlar. Bu yüzden uygulama loglarında stack trace göremezsiniz.

İlk işiniz, çökmenin başlangıçta mı yoksa çalışırken mi olduğunu ayırmak:

kubectl -n <ns> describe pod <pod>
# Last State: Terminated / Reason: Error / Exit Code: 139
# Started ... Finished ... → arada 5-6 dakika varsa, çökme runtime'da

Bizim vakada pod ~5 dakika sağlıklı çalışıp /health 200 döndürüyor, sonra ölüyordu. Yani sorun başlangıçta değildi.

Tuzak: probe hataları sebep değil, sonuçtur

Event’lerde bunları görmek kafa karıştırır:

Warning  Unhealthy  Readiness probe failed: HTTP probe failed with statuscode: 503
Warning  Unhealthy  Startup probe failed: dial tcp ...:8080: connect: connection refused
Warning  BackOff    Back-off restarting failed container

Bunların hiçbiri kök neden değil. Container çökünce 8080 portu kapanır, doğal olarak probe’lar bağlanamaz. Probe konfigürasyonunu kurcalamak zaman kaybıdır — asıl soru segfault’ın hangi native kütüphanede olduğudur.

Crash dump’ı almak: asıl zorluk burada

Native segfault’ı görmenin tek yolu, .NET runtime’a çökme anında bir crash report ürettirmektir. Deployment’a şu env değişkenlerini ekleyin:

env:
  - name: DOTNET_DbgEnableMiniDump
    value: "1"
  - name: DOTNET_DbgMiniDumpType
    value: "2"
  - name: DOTNET_EnableCrashReport      # insan-okur JSON üretir
    value: "1"
  - name: DOTNET_DbgMiniDumpName
    value: "/dumps/core.%p"             # yazılabilir yola yönlendir

İki kritik tuzak:

1. read-only filesystem. Eğer pod’unuzda readOnlyRootFilesystem: true varsa (güvenlik için yaygındır), createdump dosyayı /tmp‘e yazamaz ve şunu görürsünüz:

[createdump] Crashing thread ... signal 11 (000b)
[createdump] Could not create ... '/tmp/coredump.1': Read-only file system (30)

Bu bir izin hatası değil, bir mount hatasıdır. Dosyanın sahibi olmanız (chown) işe yaramaz; root fs salt-okunur mount edilmiştir. Çözüm, dump için ayrı bir yazılabilir volume mount etmektir:

volumeMounts:
      - name: dumps
        mountPath: /dumps
    securityContext:
      fsGroup: 2000                 # non-root user yazabilsin
volumes:
  - name: dumps
    emptyDir:
      sizeLimit: 2Gi

emptyDir root fs’ten bağımsız ayrı bir mount olduğu için read-only kısıtının dışında kalır. Üstelik container restart’larında kalıcıdır — CrashLoop’ta dump kaybolmaz.

2. Dump’a baktığınız pod yanlış instance olabilir. Dump çöken eski instance’ta oluşur; pod restart olunca ephemeral filesystem silinir. emptyDir kullanmak bu sorunu da çözer.

Suçluyu bulmak: crash report JSON’ı okumak

Pod bir kez daha çökünce crashreport.json oluşur. İçinde crashed: true olan thread’i bulun — kök neden onun stack’indedir. Bizim vakada (alttan üste doğru):

ld-musl-x86_64.so.1                                      ← Alpine / musl libc
libcoreclr.so
System.Threading...
Npgsql.Internal.NpgsqlConnector.ConnectAsync             [Npgsql.dll]
Npgsql.Internal.NpgsqlConnector.SetupEncryption
Npgsql.Internal.NpgsqlConnector.TryNegotiateGssEncryption
System.Net.Security.NegotiateAuthentication.GetOutgoingBlob
System.Net.NegotiateAuthenticationPal.InitializeSecurityContext  [System.Net.Security.dll]
(unknown native)                                         ← SIGSEGV burada

Tablo netti: Npgsql bir PostgreSQL bağlantısı açarken GSS/Kerberos şifreleme müzakeresi deniyor, bu .NET’in NegotiateAuthentication native katmanına iniyor ve Alpine/musl ortamında segfault üretiyordu. En dipteki ld-musl-x86_64.so.1, Alpine base image kullandığımızın kanıtıydı.

Çökmenin neden 5 dakika sonra geldiği de buradan anlaşıldı: uygulama açılışta havuzdaki bağlantıları kullanıyor, ama yeni bir bağlantı açılması gerektiğinde (cache refresh job, scheduled job, pool yenileme) Open çağrılıyor → GSS negotiate → segfault.

Kök neden: Alpine (musl) + native auth

mcr.microsoft.com/dotnet/aspnet:X.0-alpine image’ı musl libc kullanır. musl, glibc’nin tam ikamesi değildir; .NET’in bazı native auth yolları (GSSAPI, Kerberos, NTLM) musl’da kırılgandır ya da çöker. PostgreSQL’imiz basit kullanıcı/parola ile bağlanıyordu — yani GSS’e hiç ihtiyacımız yoktu — ama Npgsql yine de handshake’te onu deniyordu.

Çözüm: iki katman

1. Acil — GSS denemesini kapat (runtime). Connection string’e tek parametre:

Host=...;Username=postgres;Database=...;Gss Encryption Mode=Disable;

Bu, çöken kod yoluna (TryNegotiateGssEncryption) hiç girmeden bağlantıyı açar. Connection string secret/Helm values’tan geliyorsa tek yerden güncelleyip tüm servisleri korursunuz. Alternatif olarak ortam değişkeniyle de denenebilir: PGGSSENCMODE=disable.

2. Kalıcı — glibc tabanlı base image (build). Bir sonraki image’da Alpine’den çıkın:

# yerine: mcr.microsoft.com/dotnet/aspnet:10.0-alpine
FROM mcr.microsoft.com/dotnet/aspnet:10.0-bookworm-slim AS base
ENV TZ=Europe/Istanbul        # "Turkey" legacy isim; kanonik olanı kullanın

glibc tabanlı image, bu sınıf native-auth çökmelerini topyekûn kapatır. musl + native auth kombinasyonu ileride LDAP, SMTP auth veya token doğrulama gibi başka yerlerde de patlayabilir.

Not: Alpine’de native paket eklemeye çalışmak (gss-ntlmssp gibi) genelde çıkmaz sokaktır — o paket Alpine repolarında bile yoktur. Doğru hamle, ihtiyaç duymadığınız GSS yolunu kapatmaktır, native zinciri yamamak değil.

Çıkarımlar

  • Exit 139 = SIGSEGV, native katmanda çökme. Uygulama loglarında stack trace aramayın.
  • Probe hataları semptomdur, sebep değil. Container çökünce port kapanır.
  • Crash dump şarttır. DOTNET_EnableCrashReport=1 + yazılabilir emptyDir mount + fsGroup üçlüsü olmadan teşhis edemezsiniz.
  • crashreport.json çoğu zaman binary dump’tan hızlı cevap verircrashed: true thread’in stack’ine bakın, yeter.
  • Base image seçimi bir mimari karardır. Alpine küçüktür ama native auth’ta risklidir; kurumsal servisler için glibc (bookworm-slim / jammy-chiseled) daha az sürpriz üretir.
  • Aynı template’ten doğan tüm servisleriniz aynı bombayı taşır. Düzeltmeyi platform katmanında (paylaşılan base image + ortak connection string template) yapın, servis servis dolaşmayın.

Crash dump altyapısını kümenize bir kez kurun; bir dahaki “5 dakikada bir ölen pod” vakasında teşhis 6 turdan 6 dakikaya iner.

Bunlar da hoşunuza gidebilir...