Container Debugging: logs, exec, inspect, stats, events, docker debug ve Exit Code 137
Container debugging'i üç soruyla başlar: hiç başlamıyor mu, başlayıp ölüyor mu, çalışıyor ama ulaşılamıyor mu? Exec format error ve CRLF'li script'i, restart döngüsünü, shell'siz chiseled image'a PID ve network namespace'ini paylaşan yardımcı container'la ve docker debug ile bakmayı ve health check process'i yüzünden 30 saniyede bir 137 ile ölen Bookstore.Api'yi stats, inspect ve events ile gerçek çıktılarla çözüyoruz.
Sürüm notu: Bu yazıdaki örnekler Docker Engine 29.9.3, Docker Desktop 4.64 (docker debug), .NET 10 (ASP.NET Core Runtime 10.0.15), busybox:1.37 ve alpine:3 ile denenmiştir.
Önce Tahmin Et
Bookstore.Api'yi --cpus 2 --memory 32m ile başlattın. 12 saniye sonra docker stats şunu gösteriyor: 22.92MiB / 32MiB 71.63%. Bellek limitin altında, health check healthy. Container yaklaşık 35 saniye sonra Exited (137) oluyor ve hiçbir istek almamıştı. Neden? İkinci soru: chiseled image'lı bu container'da docker exec -it dockerseries-api sh ne yapar?
Sistemli Bir Akış: Üç Soru
"Container çalışmıyor" şikâyeti üç farklı sorunun ortak adı. Her birinin ilk bakılacak yeri farklı ve doğru soruyu sormak, rastgele komut denemekten çok daha hızlı:
| Belirti | İlk bakılacak yer | Tipik nedenler |
|---|---|---|
Hiç başlamıyor (Created ya da hemen Exited) | docker run'ın kendi hata mesajı, exit code 125–127 ve 255 | Yanlış entrypoint, eksik dosya, bozuk script, yanlış mimari |
| Başlıyor, sonra ölüyor | docker logs, docker inspect --format '{{json .State}}' | Uygulama hatası, eksik config, OOM (bellek yetersizliği), health check |
| Çalışıyor ama ulaşılamıyor | docker ps'teki PORTS, docker port, network | Port yayımlanmamış, uygulama 127.0.0.1'i dinliyor, yanlış network |
3. yazıda exit code'ları, 12. yazıda üçüncü satırın nedenlerini gördük. Bu yazıda sitedeki serilerin ortak örnek uygulaması Bookstore'un (bir online kitapçı) container'larını bilerek bozup her birinin nasıl göründüğüne bakıyoruz. Bu yazıdaki container'lar (son bölümdeki docker debug hariç) Linux üzerindeki Docker Engine'de çalıştı.
Hiç Başlamıyor: Entrypoint, Script ve Mimari
En basit hata entrypoint'teki bir yazım yanlışı. .NET host'u bu durumda kendini açıklayan bir mesaj veriyor:
docker run --rm --entrypoint dotnet dockerseries-bookstore-api:1.2.0 Bookstore.Ap.dll
echo "exit code: $?"
The command could not be loaded, possibly because:
* You intended to execute a .NET application:
The application 'Bookstore.Ap.dll' does not exist or is not a managed .dll or .exe.
* You intended to execute a .NET SDK command:
No .NET SDKs were found.
...
exit code: 155
Script'le başlayan image'larda mesajlar daha az açık. Bookstore'un bakım işleri için küçük bir script'i entrypoint yaptık, iki bozuk sürümüyle (labs/14-exec-format): birinde ilk satırdaki #!/bin/sh yok, diğeri Windows'ta kaydedilmiş ve satır sonları CRLF (Windows'un satır sonu, \r\n):
docker run --name dockerseries-job dockerseries-job:noshebang; echo "exit code: $?"
docker run --name dockerseries-job dockerseries-job:crlf; echo "exit code: $?"
exec /job.sh: exec format error
exit code: 255
exec /job.sh: no such file or directory
exit code: 255
İkisi de yanıltıcı. exec format error, kernel'in dosyayı nasıl çalıştıracağını bilemediğini söylüyor: shebang satırı olmayan bir script, kernel için tanımadığı bir binary. İkincisi daha da kafa karıştırıcı: /job.sh orada duruyor, ama kernel shebang'deki yorumlayıcıyı /bin/sh\r olarak arıyor, sondaki görünmez \r yüzünden bulamıyor. Windows'ta geliştirip Linux image'ına script koyan herkes bir gün bu hatayı görür. Çözüm, script'leri repository'de LF (Linux'un satır sonu, \n) ile tutmak: .gitattributes'a *.sh text eol=lf satırı Git'in checkout sırasında satır sonlarını değiştirmesini engeller. Bookstore'un repository'sine bu kuralla bir .gitattributes ekledik; bilerek bozuk bıraktığımız job-crlf.sh için -text istisnası var.
exec format error'ın asıl ünlü nedeni yanlış mimari. 9. yazıda ARM (telefonlarda ve yeni dizüstülerde yaygın işlemci mimarisi) bir geliştirici makinesinde RID (Runtime Identifier; .NET'in platform kimliği) vermeden üretilen image'ın linux/arm64 olduğunu görmüştük; bu image x64 bir Linux sunucusunda exec /usr/bin/dotnet: exec format error ile, yani yukarıdakiyle aynı mesaj biçimiyle ölür ve Docker image'ın platformunun host'unkiyle eşleşmediğini söyleyen bir uyarı satırı yazar. Teşhis tek komut; ARM bir makinede build edilmiş image'da sonuç şu:
docker image inspect dockerseries-bookstore-api:1.2.0 --format '{{.Os}}/{{.Architecture}}'
linux/arm64
Bir tuzağa dikkat: Docker Desktop QEMU (başka bir işlemci mimarisinin komutlarını yazılımla çalıştıran emülatör) ile başka mimarilerin image'larını emülasyonla çalıştırabildiği için aynı image geliştiricinin Windows makinesinde yavaş da olsa çalışır ve hata ancak emülasyonun kurulu olmadığı sunucuda çıkar. Kalıcı çözüm 9. yazıdaki gibi: production image'ını CI'da, platformu açıkça vererek üretmek.
Başlayıp Ölüyor: Exit Code ve State
bookstore-web'i zorunlu API_BASE_URL değişkeni olmadan, hata durumunda üç kez yeniden başlatılacak şekilde çalıştırdık:
docker run -d --name dockerseries-web --restart on-failure:3 dockerseries-bookstore-web:1.2.0
docker ps -a --filter name=dockerseries-web --format "{{.Names}} {{.Status}}"
docker inspect dockerseries-web --format 'ExitCode={{.State.ExitCode}} RestartCount={{.RestartCount}} OOMKilled={{.State.OOMKilled}}'
docker logs --tail 1 dockerseries-web
dockerseries-web Exited (2) 7 seconds ago
ExitCode=2 RestartCount=3 OOMKilled=false
/docker-entrypoint.d/40-runtime-config.sh: line 6: API_BASE_URL: API_BASE_URL must be set
Üç alan birlikte hikâyeyi anlatıyor: exit code 2 (script'in kendi hatası, sinyal değil), üç retry ve OOM değil. docker logs container'ın bütün çalıştırmalarının çıktısını birlikte tutar; restart döngüsündeki bir container'da son satırlar son denemenin satırlarıdır. Restart policy hatayı çözmedi, yalnız aynı hatayı dört kez üretti; 3. yazıdaki uyarının pratikteki karşılığı bu.
Shell'siz Container'a Bakmak: Yardımcı Container
İkinci sorunun cevabı:
docker exec dockerseries-api sh
OCI runtime exec failed: exec failed: unable to start container process: exec: "sh": executable file not found in $PATH
Chiseled image'da shell yok ve 13. yazıdan beri container salt okunur, capability'leri düşürülmüş. Bunlar bir şey kaybettirmiyor, çünkü container bir process ve Linux namespace'leri paylaşılabiliyor. İçinde araç olan başka bir container'ı (busybox) API'nin PID (process kimlik numarası) ve network namespace'ine bağladık:
docker run --rm --pid container:dockerseries-api --network container:dockerseries-api busybox:1.37 \
sh -c 'ps; wget -qO- localhost:8080/health; echo; netstat -tln; ls /proc/1/root/app'
PID USER TIME COMMAND
1 1654 0:00 dotnet Bookstore.Api.dll
44 root 0:00 sh -c ps; wget -qO- localhost:8080/health; echo; netstat -tln; ls /proc/1/root/app
50 root 0:00 ps
Healthy
Active Internet connections (only servers)
Proto Recv-Q Send-Q Local Address Foreign Address State
tcp 0 0 127.0.0.11:42491 0.0.0.0:* LISTEN
tcp 0 0 :::8080 :::* LISTEN
ls: /proc/1/root/app: Permission denied
Yardımcı container API'nin process'ini PID 1 olarak görüyor, localhost:8080 API'nin kendisi (aynı network namespace) ve netstat uygulamanın hangi adresi dinlediğini gösteriyor: :::8080, yani bütün arayüzler. 12. yazıdaki "127.0.0.1'e bind olma" sorununu teşhis etmenin en kısa yolu bu satır. (127.0.0.11'deki port Docker'ın gömülü DNS'i.)
Son satırdaki hata önemli: /proc/1/root, process'in gördüğü dosya sistemine açılan bir kapı ve kernel onu ancak hedef process'e ptrace (bir process'in başka bir process'i izleme ve denetleme mekanizması) yetkisi olan birine açıyor. Yardımcı container root ama uid'si API'ninkinden farklı ve SYS_PTRACE capability'si yok. Yalnız bu container'a o capability'yi verince:
docker run --rm --pid container:dockerseries-api --network container:dockerseries-api --cap-add SYS_PTRACE busybox:1.37 \
sh -c 'ls /proc/1/root/app | head -3; ls /proc/1/root/tmp; tr "\0" "\n" < /proc/1/environ | grep ASPNETCORE'
Bookstore.Api
Bookstore.Api.deps.json
Bookstore.Api.dll
clr-debug-pipe-1-4423922-in
clr-debug-pipe-1-4423922-out
dotnet-diagnostic-1-4423922-socket
ASPNETCORE_HTTP_PORTS=8080
Uygulamanın dosyalarını, 13. yazıda sözünü ettiğimiz diagnostik socket'ini ve process'in environment variable'larını görüyoruz. Son komut bir kez daha 13. yazının konusunu hatırlatıyor: environment'a koyduğun secret, process'e erişebilen herkese açık. API container'ı hiç değişmedi; yetkiyi yalnız birkaç saniye yaşayan yardımcı container aldı. Aynı yöntemle dotnet-dump ve dotnet-counters taşıyan bir container'ı API'ye bağlamak da mümkün; araçların diagnostik socket'e ulaşması için /tmp'nin iki container arasında paylaşılması gerekiyor. Bunu adım adım Debugging serisinin "Crash ve Memory Dump Analizi: dotnet-dump, SOS Komutları ve Container'da Dump Almak" yazısı anlatıyor. Kubernetes'teki karşılığı kubectl debug ile ephemeral container; Kubernetes serisinin "Kubernetes'te Debugging: CrashLoopBackOff, ImagePullBackOff, Pending ve kubectl debug" yazısında.
docker debug
Docker Desktop aynı fikri tek komuta çeviriyor. docker debug, container'a dokunmadan yanına araçlarla dolu bir shell açıyor; Docker Desktop 4.49'dan beri bütün kullanıcılara ücretsiz. Bir Docker Desktop özelliği olduğu için Linux sunucudaki Docker Engine'de yok; orada yukarıdaki yardımcı container yöntemi kalıyor. Aynı API image'ını Docker Desktop'ta çalıştırıp denedik:
docker debug dockerseries-api -c "ps aux; ls /app | head -3; id; which curl"
USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND
app 1 0.5 0.9 3175076 77944 ? Ssl 12:46 0:00 dotnet Bookst
root 62 0.0 0.0 5312 1188 pts/0 Ss+ 12:47 0:00 /nix/var/nix/
root 68 0.0 0.0 6460 3228 pts/0 R+ 12:47 0:00 ps aux
Bookstore.Api
Bookstore.Api.deps.json
Bookstore.Api.dll
uid=0(root) gid=0(root) groups=0(root)
/nix/var/nix/profiles/default/bin/curl
Shell root olarak açıldı, uygulamanın dosyalarını doğrudan görüyor ve curl gibi araçlar hazır. Araçlar container'ın dosya sistemine kopyalanmıyor; ayrı bir yerde duruyor ve container kapandığında onlardan iz kalmıyor. -c olmadan çalıştırırsan interaktif bir shell açılır. Çalışan bir container yerine bir image adı da verebilirsin; o zaman image'ın bir kopyası üzerinde çalışırsın.
Vaka: 30 Saniyede Bir 137
İlk sorunun deneyi. Limitler, aynı sunucuya daha çok container sığdırmak isteyen birinin docker stats'a bakarak seçebileceği değerler:
docker run -d --name dockerseries-api --network dockerseries-net --cpus 2 --memory 32m dockerseries-bookstore-api:1.2.0
docker stats --no-stream --format "{{.Name}} {{.MemUsage}} {{.MemPerc}} {{.PIDs}}" dockerseries-api
docker logs dockerseries-api 2>&1 | grep -A1 "GC memory" | tail -1
dockerseries-api 22.92MiB / 32MiB 71.63% 18
Environment: Production, GC memory limit: 24 MB, processors: 2
Yarım dakika sonra:
docker ps -a --filter name=dockerseries-api --format "{{.Names}} {{.Status}}"
docker inspect dockerseries-api --format '{{json .State}}' \
| jq '{Status, ExitCode, OOMKilled, StartedAt, FinishedAt, Health: .Health.Status, LastProbe: .Health.Log[-1]}'
dockerseries-api Exited (137) 17 seconds ago
{
"Status": "exited",
"ExitCode": 137,
"OOMKilled": true,
"StartedAt": "2026-11-14T12:53:48.713885709Z",
"FinishedAt": "2026-11-14T12:54:24.011305045Z",
"Health": "unhealthy",
"LastProbe": {
"Start": "2026-11-14T12:54:23.905362878Z",
"End": "2026-11-14T12:54:24.010611712Z",
"ExitCode": 137,
"Output": ""
}
}
137 ve OOMKilled: true: kernel'in OOM killer'ı (bellek bitince process öldüren mekanizma). Ama bellek kullanımı limitin altındaydı ve istek yoktu. İpucu iki zaman damgasında: son health check 12:54:23.905'te başlamış, container 12:54:24.011'de ölmüş. docker events, container'ın yaşam döngüsünü saniye saniye verir; olayları container başlamadan önce dinlemeye başladık:
docker events --filter container=dockerseries-api --format '{{.Time}} {{.Action}}'
12:53:48 create
12:53:48 start
12:53:53 exec_create: dotnet Bookstore.Api.dll healthcheck
12:53:53 exec_start: dotnet Bookstore.Api.dll healthcheck
12:53:53 exec_die
12:53:53 health_status: healthy
12:54:23 exec_create: dotnet Bookstore.Api.dll healthcheck
12:54:23 exec_start: dotnet Bookstore.Api.dll healthcheck
12:54:24 exec_die
12:54:24 oom
12:54:24 die
(Unix zaman damgalarını saate çevirdim.) Neden burada: 8. yazıda chiseled image için yazdığımız HEALTHCHECK, her 30 saniyede bir container'ın içinde ikinci bir .NET process'i başlatıyor. O process de container'ın cgroup'unda (process'lerin kaynak sınırını paylaştığı grup) çalışıyor ve aynı 32 MB'tan pay istiyor. docker stats'ın gösterdiği 23 MB yalnız API'nin kendisi; ikinci bir runtime'ın açılmasıyla toplam limiti geçti ve kernel cgroup'taki en büyük process'i, API'yi öldürdü. İlk health check, uygulama henüz ısınmamışken geçmişti; ikincisi geçemedi. --cpus'un da etkisi var: aynı denemeyi --cpus 1 ile yaptığımızda container 32 MB'ta ayakta kaldı, 28 MB'ta öldü. Runtime'ın thread'leri ve ASP.NET Core'un varsayılanı olan Server GC'nin (Garbage Collector; kullanılmayan belleği geri toplayan bileşen) ayırdığı bellek, gördüğü işlemci sayısıyla birlikte artıyor; aynı limit 1 ve 2 işlemciyle farklı sonuç veriyor.
Çözüm limitleri ölçerek ve payla seçmek. --memory 128m ile başlatıp aynı network'teki bir container'dan 20 paralel istemciyle /books'a yük bindirdik ve docker stats'ı beşer saniye arayla okuduk:
dockerseries-api 35.45MiB / 128MiB 27.69% 18
dockerseries-api 32.19MiB / 128MiB 25.15% 18
dockerseries-api 31.73MiB / 128MiB 24.79% 18
dockerseries-api 31.73MiB / 128MiB 24.79% 18
dockerseries-api 32.45MiB / 128MiB 25.35% 18
dockerseries-api 36.15MiB / 128MiB 28.24% 18
dockerseries-api Up About a minute (healthy)
OOMKilled=false RestartCount=0
Yük altında en fazla 36 MB; health check'in açtığı ikinci process'le birlikte bile 128 MB'ta rahat bir pay kalıyor. Gerçek bir uygulamada aynı ölçümü veritabanı bağlantıları açıkken, gerçekçi bir yükle ve uzun süre yapmak gerekir. Aynı container'ın Kubernetes'te bu sorunu yaşamayacağına da dikkat: Kubernetes Dockerfile'daki HEALTHCHECK'i çalıştırmaz, probe'ları container'ın dışından HTTP isteğiyle yapar. Bellek limitinin ikinci klasik hatası tersi: limitin üstünde bir GC heap sınırı (DOTNET_GCHeapHardLimit) vermek. Heap limite kadar büyüyebileceğini sanar ve container yine 137 ile ölür; GC ayarları ve OOMKilled teşhisi Kubernetes serisinin "Resource Request ve Limit'ler: QoS, OOMKilled, CPU Throttling ve Container'da .NET GC" yazısında.
Üretimde Dikkat
Disk dolmasını önceden izle. docker system df, image'ların, container'ların, volume'lerin ve build cache'in kapladığı yeri ve ne kadarının geri kazanılabileceğini gösterir:
TYPE TOTAL ACTIVE SIZE RECLAIMABLE
Images 17 0 3.94GB 3.078GB (78%)
Containers 0 0 0B 0B
Local Volumes 3 0 1.931GB 1.931GB (100%)
Build Cache 139 0 3.13GB 2.293GB
Bu yazı serisinin lab engine'inde volume'lerin "%100 reclaimable" görünmesi, o an onları kullanan container olmamasından; içlerinde BookstoreDb var. RECLAIMABLE sütunu "silinebilir" değil "şu an kullanılmıyor" demek. Temizliği docker image prune, docker builder prune gibi komutlarla yaparken 11. yazıdaki volume uyarısını hatırla; build sunucularında build cache için docker builder prune --filter until=168h gibi yaşa dayalı bir filtre daha güvenli.
Log'lar da diski doldurur. Docker'ın varsayılan json-file log driver'ı log dosyalarını döndürmez (rotation yapmaz); günlerce çalışan, çok log yazan bir container sunucunun diskini tek başına doldurabilir. --log-opt max-size=10m --log-opt max-file=3 ya da daemon.json'da aynı ayarlar sınır koyar. Production'da log'ları zaten container'dan çıkarıp merkezi bir yere göndermek gerekir; bu Observability serisinin konusu.
docker events'ı bir sorun olmadan da izle. oom, die ve health_status: unhealthy olaylarını bir izleme sistemine göndermek, bir container'ın gece boyunca yüz kez yeniden başladığını sabah öğrenmekten iyidir.
Sık Düşülen Tuzaklar
Production container'ında exec ile elle düzeltme yapmak. Shell'i olan bir image'da içeri girip bir config dosyasını düzeltmek sorunu o an çözer, ama değişiklik container'ın yazılabilir layer'ında kalır: bir sonraki deploy'da, yeniden başlatmada ya da başka bir node'da kaybolur ve neden "bazen" çalıştığını kimse bilmez. Teşhisi container'da yap, düzeltmeyi image'da, config'te ya da Dockerfile'da yap. 13. yazıdaki --read-only bu alışkanlığı zaten imkânsız kılıyor.
Debug için container'ı --privileged ile yeniden başlatmak. Çalışan container'ı değiştirmek sorunun kendisini yok edebilir (bellek durumu, açık bağlantılar) ve geri alınmayı unutulan bir ayrıcalık bırakır. Yardımcı container ya da docker debug, asıl container'a dokunmadan bakmayı sağlıyor.
Yalnız docker stats'a bakıp limit seçmek. Yukarıdaki vaka tam olarak bu: stats ana process'in anlık kullanımını gösterir; health check, exec ile açılan process'ler, GC'nin yük altındaki büyümesi ve native bellek orada görünmez. Limit, ölçülen en yüksek değerin üstünde pay bırakmalı.
137'yi tek başına "bellek yetmedi" diye okumak. 3. yazıda gördüğümüz gibi docker kill ve docker stop'un süresinin dolması da 137 verir. Ayrımı OOMKilled alanı ve docker events'taki oom olayı yapar.
Küçük Bir Deneme
★☆☆ dockerseries-job:crlf image'ının script'ini bir hex görüntüleyiciyle (od -c labs/14-exec-format/job-crlf.sh | head -2) incele ve \r'yi bul. Dosyayı LF'ye çevirip image'ı yeniden build et.
★★☆ 32 MB'lık vakayı bu kez Dockerfile'daki HEALTHCHECK'i devre dışı bırakarak (docker run --no-healthcheck) tekrarla. Container ayakta kalıyor mu? Sonra /books'a yük bindir; hangi noktada ve hangi exit code ile ölüyor?
★★★ API'yi -v dockerseries-diag:/tmp ile başlat, mcr.microsoft.com/dotnet/sdk:10.0 tabanlı bir yardımcı container'a dotnet-counters'ı kur, aynı volume'ü /tmp'ye ve --pid container:dockerseries-api ile bağlanıp dotnet-counters monitor -p 1 çalıştır. Yük altında GC heap boyutu ne gösteriyor?
Bu yazıyla Bookstore'un container'larını kurmayı, bağlamayı, korumayı ve bozulduklarında okumayı tamamladık. Serinin son yazısında geriye bakıyoruz: legacy builder'ın build çıktısı, port 80 ile çalışan eski .NET image'ları, Debian'dan Ubuntu'ya geçiş, Engine 29'un getirdiği değişiklikler ve 2021'den kalma bir Dockerfile'ı satır satır 2026'ya taşımak.