Oğuzhan KARAGÜZEL

OĞUZHAN KARAGÜZEL

Yazılım geliştirici

Sunucum Neden Kilitlendi? Docker Derleme Önbelleği Diski Doldurdu

Yazar: Oğuzhan KARAGÜZEL ·

Bir gün baktım, hem site hem CMS kaplumbağa gibi. Sayfalar zor açılıyor, CMS'te kaydet tuşuna basıyorum, bir şey olmuyor. Sunucuya SSH ile bağlanmam bile dakikalar sürdü. Kodda bir değişiklik yapmamıştım, bellek de boştu. Meğer her deploy'da Docker'ın arkada bıraktığı derleme artıkları birikip diski doldurmuş. Bu yazıda ne gördüğümü, sorunu nasıl bulduğumu, nasıl çözdüğümü ve bir daha olmasın diye neler yaptığımı anlatacağım. Siz de sunucuda docker compose up --build ile deploy ediyorsanız bu yazı tam size göre.

Kurulum: her push'ta sunucuda derleme

Site ve CMS aynı sunucuda, Docker Compose ile çalışıyor: iki .NET uygulaması, PostgreSQL ve Redis. Sunucu 4 çekirdek, 6 GB RAM ve 90 GB diskli, Ubuntu 24.04 kurulu bir sanal sunucu. Deploy'u GitHub Actions SSH üzerinden yapıyor. Sunucuda çalışan komutlar kabaca şunlardı:

Bash
git fetch origin master
git reset --hard origin/master
docker compose up -d --build
docker image prune -f

Yani imajlar sunucunun üstünde derleniyor, sonra etiketi kalmayan eski imajlar siliniyordu. Ben de kullanılmayan imajlar temizlendiğine göre disk dolmaz diye düşünüyordum. Doldu.

Belirtiler: bellek boş, işlemci tavanda

Sunucuya girebildiğimde ilk iş yüke ve belleğe baktım:

Bash
$ uptime && free -m
 14:44:51 up 3 days, 12:19,  1 user,  load average: 76.70, 60.86, 35.55
               total        used        free      shared  buff/cache   available
Mem:            5925        1175        1094         179        4133        4750

4 çekirdekli bir makinede yük ortalaması 76. Ama bellek neredeyse boş, 4,7 GB kullanılabilir durumda. Demek ki "uygulama belleği yedi bitirdi" durumu değil. top'a bakınca tablo biraz daha netleşti:

Bash
%Cpu(s): 39.3 us, 58.4 sy,  0.0 ni,  0.0 id,  0.0 wa,  0.0 hi,  2.2 si
    PID USER   %CPU  COMMAND
2422120 root   54.3  kworker+
2393805 1654   23.6  dotnet
   2095 dnsmasq 18.1 redis-s+
2423805 70     11.0  postgres  (zombie)
2423442 root    3.9  runc:[2+
2423815 1654    3.9  curl

Burada dikkatimi üç şey çekti:

  • sy yüzde 58: İşlemcinin yarısından fazlası uygulamalara değil, çekirdeğe (kernel) gidiyor. Normal bir web sunucusunda bu değer birkaç yüzdeyi geçmez.
  • En çok işlemci yiyen süreç kworker: Bu, çekirdeğin kendi iş parçacığı. Yani uygulamalar değil, işletim sisteminin kendisi bir şeyle uğraşıyor.
  • Üst üste binmiş runc ve curl süreçleri: Docker'ın sağlık kontrolleri (healthcheck) her 10 saniyede bir konteynerin içinde curl çalıştırıyor. Sunucu yavaşlayınca bu kontroller zamanında bitmiyor, yenileri üstüne geliyor ve yük daha da artıyor. Sağlık kontrollerinin cevabı 8 ile 28 saniye arasında geliyordu.

Yük ortalamasının bu kadar yüksek çıkmasının sebebi de bu. Linux'ta yük ortalaması sadece işlemci bekleyen süreçleri saymaz, çekirdekte bir işin bitmesini bekleyenleri de sayar. Çekirdek tıkanınca bekleyenler birikir ve sayı onlara çıkar.

Sunucuyu yeniden başlatmayı da denedim. reboot kapanırken takılı kaldı. Bu da sorunun uygulamada değil, işletim sistemi tarafında olduğunu iyice belli etti.

Teşhis: df ve docker system df

Çekirdeği bu kadar zorlayan şeylerin başında dolmak üzere olan bir disk gelir. Disk doldukça dosya sistemi boş yer bulmak için çok daha fazla uğraşır; veritabanı gibi diske yazan süreçler de sırada beklemeye başlar. Kontrol etmek tek komut:

Bash
df -h /

Disk neredeyse tamamen doluydu. Siteye yüklediğim görseller olamazdı, topu topu otuz tane var. Neyin yer kapladığını görmek için Docker'ın kendi raporuna baktım:

Bash
docker system df

Bu komut imajların, konteynerlerin, volume'ların ve derleme önbelleğinin (Build Cache) ne kadar yer tuttuğunu ayrı ayrı gösteriyor. Yükün büyük kısmı derleme önbelleğindeydi.

Asıl neden: legacy builder ve silinmeyen ara katmanlar

Docker bir imajı derlerken Dockerfile'daki her adım için bir katman oluşturur ve sonraki derleme hızlı olsun diye bu ara katmanları saklar. .NET projelerinde bu katmanlar hiç de küçük değil: SDK imajı, dotnet restore ile inen paketler, derleme çıktıları... Her deploy iki uygulama için yeni bir katman yığını bırakıyordu.

Deploy betiğindeki docker image prune -f ise sadece etiketsiz (dangling) imajları siliyor, derleme önbelleğine hiç dokunmuyor. Üstüne bir de sunucuda Docker'ın yeni derleyicisi BuildKit'in eklentisi (buildx) kurulu değildi. Temizlik komutunu çalıştırınca Docker bunu kendisi söyledi:

Bash
$ docker builder prune -af
DEPRECATED: The legacy builder is deprecated and will be removed in a future release.
            Install the buildx component to build images with BuildKit

Kısacası imajlar eski "legacy builder" ile derleniyor ve her deploy'un ara katmanları birikip duruyordu. O gün art arda birkaç deploy yapınca 90 GB'lık disk doldu.

Çözüm: builder prune ve biraz sabır

Temizlik komutu basit:

Bash
docker builder prune -af

-a kullanılmayan bütün derleme önbelleğini siler, -f de onay sormaz. Komut yaklaşık 55 GB yer açtı ve disk kullanımı yüzde 18'e düştü. Yalnız hızlı değildi: zaten zorlanan bir diskte bu kadar veriyi silmek yaklaşık dört saat sürdü. Bu sırada site yavaş da olsa açılmaya devam etti.

Bir de şunu söylemem lazım: aynı günlerde sunucu sağlayıcısının tarafında ayrı bir donanım arızası da çıktı ve sunucuya bir süre hiç erişemedim. O arızayı sağlayıcı giderdi; disk sorunuyla bir ilgisi yoktu. Böyle bir olayı incelerken iki ayrı sorunu birbirine karıştırırsanız yanlış sonuca varırsınız, o yüzden ayrı tuttum.

Önlemler: deploy'da otomatik temizlik ve log sınırı

Bir daha yaşamamak için iki değişiklik yaptım. Birincisi, deploy betiğinin sonuna derleme önbelleği temizliği ekledim:

Bash
docker compose up -d --build
docker image prune -f
# Bir günden eski derleme önbelleğini sil: bir sonraki deploy
# son katmanlardan yine yararlanır, ama artıklar birikmez.
docker builder prune -af --filter "until=24h"

--filter "until=24h" son 24 saatin önbelleğini bırakıyor. Böylece art arda yapılan deploy'lar önbellekten yine hızlanıyor ama eski katmanlar her seferinde siliniyor.

İkincisi, konteyner loglarına sınır koydum. Docker varsayılan olarak her konteynerin çıktısını sınırsız büyüyen tek bir JSON dosyasında tutuyor. Uzun süre çalışan bir sitede bu dosya da diski doldurabilir. Docker Compose'ta bütün servislere ortak bir log ayarı vermek kolay:

YAML
x-logging: &default-logging
  driver: json-file
  options:
    max-size: "10m"
    max-file: "3"

services:
  web:
    logging: *default-logging
  cms:
    logging: *default-logging

Bu ayarla her servisin logu en fazla 3 × 10 MB yer kaplıyor.

Bunların yanında şunları da tavsiye ederim:

  • buildx'i kurun. BuildKit hem daha hızlı derliyor hem de kendi önbelleğini temizleyen bir çöp toplama (garbage collection) mekanizması var. Legacy builder zaten kaldırılmak üzere.
  • Disk için alarm kurun. Disk yüzde 80'i geçince haber veren basit bir izleme olsaydı bu sorunu saatler önce görürdüm.
  • Mümkünse imajı sunucuda derlemeyin. İmajı CI'da derleyip bir registry'ye göndermek, sunucuda sadece docker compose pull çalıştırmak hem diski hem işlemciyi rahatlatır.

Benzer bir durumda kontrol listesi

Sunucunuz durduk yere yavaşladıysa, bellek boşsa ve işlemci çekirdekte vakit harcıyorsa sırayla şunlara bakın:

  1. uptime ve free -m: Yük yüksek ama bellek boş mu?
  2. top: sy yüksek mi, en üstte kworker gibi çekirdek süreçleri mi var?
  3. df -h: Disk dolmak üzere mi? df -i ile inode'lara da bakın; çok sayıda küçük dosya, yer boşken bile inode'ları bitirebilir.
  4. docker system df: Yeri imajlar mı, volume'lar mı, derleme önbelleği mi kaplıyor?
  5. du -sh /var/lib/docker/containers/*: Konteyner logları şişmiş mi?

Sonuç

Bu olayda kodun hiçbir suçu yoktu, sorun deploy düzenindeydi. Sunucuda imaj derliyorsanız derleme önbelleğini de sizin yönetmeniz gerekiyor; docker image prune bunun için yetmiyor. Deploy sonunda bir docker builder prune, sınırlanmış konteyner logları ve basit bir disk alarmı bu tür bir kesintiyi baştan engeller. Site hızıyla ilgili diğer konular için Web Sitesi Hızı ve Core Web Vitals yazıma bakabilirsiniz. Deploy'dan sonra ilk isteklerin neden yavaş geldiğini de ReadyToRun ile Soğuk Başlangıç yazısında anlattım.