- 10 yıllık bir Rails production uygulamasında dağıtım sırasında
web Dyno belleği aniden yükseldi; sürekli 400~500 req/s yük ve zirvede saniyede binlerce isteği işleyen bir servis olduğu için hızlı bir hafifletme gerekiyordu
- Heroku'da bellek sınırına yaklaşan Dyno'lar yeniden başlatıldı ve son 3 gündeki kod/metrik değişiklikleri geri alındı, ancak bellek sızıntısı devam etti
- Sidekiq ve Delayed::Job normal görünürken, yalnızca bazı Puma worker'larının büyümesi deseni belirli bir trafik türü ile ilişki şüphesini doğurdu
rbtrace, ObjectSpace, heapy, sheap, reap ile heap izlendiğinde, Puma istek işleme thread'inin ActiveSupport::Notifications::Event içindeki @children dizisi üzerinden 32.067 nesneyi ve 1.9GiB belleği tuttuğu görüldü
- Manipüle edilmiş query parametreleri, Bugsnag'in URL temizleme sürecinde
URI::InvalidURIError oluşturuyordu; kısa vadeli çözüm Bugsnag yükseltmesi, uzun vadeli çözüm ise Rails yükseltmesiydi
Çalışan Rails uygulamasında sızıntı başladı
- Hedef, gerçek gelir üreten production bir hizmet olan 10 yıllık bir Rails uygulamasıydı
- Normalde sürekli yük 400~500 req/s seviyesindeydi ve zirvede saniyede binlerce isteğe çıkıyordu
- Olağan dağıtım akışı sırasında bellek sıçraması başladı ve pager uyarısı geldi
- Uygulama Heroku üzerinde çalıştığı için durum, Dyno başına bellek değerleri üzerinden izlendi
Arızayı hafifletme Dyno yeniden başlatmayla başladı
- Görünen şey basit bir bellek şişmesi (bloat) değil, sızıntı gibiydi; geçici çözüm süreçleri yeniden başlatmaktı
- Normalde gün içindeki çok sayıdaki dağıtım
web instance'larını yeniden başlatıyordu, ancak bellek sınırına yaklaşan Dyno'lar elle yeniden başlatıldı
Şüpheli değişiklikler geri alınsa da sızıntı sürdü
- İlk büyük sıçramadan hemen öncesine kadar gidilerek 3 günlük kod değişikliği denetlendi
- Olası ilgili görünen üç değişiklik vardı
development modunda Rails kod yeniden yükleme nedeniyle bellek sızıntısına yol açan bir değişiklik
- Belirli istek filtreleme sırasında Redis çağrılarının amaçlanandan fazla yapılmasına neden olan bir değişiklik
- Daha fazla veritabanı çağrısı ve
ActiveRecord instance yüklenmesine yol açan N+1 türü bir değişiklik
- İlk iki değişiklik düzeltildi, üçüncüsü geri alındı ve bunlar tek tek dağıtıldı, ancak sızıntı devam etti
- Ruby dil metrikleri ve Puma pool kullanım metriklerini toplamak için yapılan araç değişiklikleri de geri alındı, ancak bellek artışı durmadı
Sızıntı deseni belirli bir trafiğe işaret ediyordu
- Sızıntı yalnızca
web Dyno'larda meydana geliyordu; Sidekiq ve Delayed::Job Dyno'ları normal görünüyordu
- Her
web Dyno her zaman sızdırmıyordu
- Saatler boyunca uzun süre çalışan bir web süreci gibi görece düz bellek kullanımı gösterebiliyordu
- Sonra bir noktada bir, bazı ya da tüm Dyno'lar sızdırmaya başlıyordu
- Puma cluster modunda çalışıyordu ve Dyno başına 8 vCPU için 12 worker process kullanıyordu
- Aynı Dyno içinde bile 12 worker'dan yalnızca bazılarının neredeyse tüm belleği kullandığı durumlar vardı
- OpenTelemetry Traces yoğun şekilde sample edildiğinden belirli bir istek türünü belirli bir Dyno ile ilişkilendirmek zordu; sample edilmeyen loglarla korelasyon analizi yapmak da araçlar açısından kolay değildi
Heap dump toplama prosedürü
- Çalışan Ruby sürecine bağlanmak için
rbtrace kullanıldı
rbtrace'in sürece yüklenmiş olması gerektiğinden Gemfile içine eklendi ve yüklenip yüklenmeyeceği environment variable ile kontrol edildi
gem "rbtrace", require: String(ENV.fetch("FEATURE_ENABLE_MEMORY_DUMPS", false)) == "true"
- Heroku'da
heroku ps:exec ile sızıntı yaşayan Dyno'ya SSH tüneli açıldı ve ps ile Ruby süreçleri RSS'e göre sıralandı
ps -eo pid,ppid,comm,rss,vsz --sort -rss | grep ruby
web Dyno'da aynı PPID'ye sahip süreçler Puma worker'larıydı ve en çok belleği kullanan worker'ın PID değeri hedef alındı
- Bellek allocation izlemesi
ObjectSpace.trace_object_allocations_start ile açıldı; bunun performans, bellek ve CPU üzerinde etkisi olabilirdi
DUMP_PID=<pid>
rbtrace --pid="${DUMP_PID}" --eval="Thread.new{require 'objspace';ObjectSpace.trace_object_allocations_start}.join"
- Heap dump,
ObjectSpace.dump_all ile /tmp altında oluşturuldu ve birkaç saat çalışmış sızıntılı süreçlerde JSON dosyası 5~6GiB boyutuna kadar çıkıyordu
rbtrace --pid="${DUMP_PID}" --eval="Thread.new{require 'objspace'; GC.start(); io=File.open('/tmp/heap-${DUMP_PID}.json', 'w'); ObjectSpace.dump_all(output: io); io.close}.join" --timeout=600
gzip "/tmp/heap-${DUMP_PID}.json"
- Heroku'da dump dosyası
heroku ps:copy ile lokale alındı; heapy ile retained memory'yi görmek için en az üç kadar dump toplandı
- İş bittikten sonra allocation izleme kapatıldı ve dump'lar silindi ya da Dyno yeniden başlatıldı
Heap analizinde 1.9GiB tutan Thread ortaya çıktı
heapy'nin retained memory raporu ve sheap diff'i tek başına başlangıç noktasını bulmak için yeterli olmadı
- Ruby heap dump'ının referans grafiğini analiz edip görselleştiren
reap ile flame graph oluşturuldu
- Flame graph, Ruby GC perspektifindeki root'tan aşağıdaki nesnelere giden referansları gösteriyordu; daha fazla bellek tutan nesneler daha geniş hücrelerle gösteriliyordu
- Üçüncü heap dump'ta bir
Thread, 1.9GiB belleği tutuyordu
- Gerçekte aşağıdaki
Array, 32.067 nesneye referans vererek 1.9GiB'ı elde tutuyordu
sheap ile referans yolu izlendi
- Güncel
main branch'indeki sheap kullanılarak ikinci ve üçüncü dump karşılaştırıldı
- Dump boyutu 6GiB'a yaklaştığı için parse edilmesi zaman aldı
find_path sonucuna göre sorunlu Thread, telemetri ya da metrik araçlarının arka plan thread'i değil, istek işleyen bir Puma thread'iydi
ActiveSupport::SubscriberQueueRegistry, Rails 6.1'de event adı başına ActiveSupport::Subscriber listesini saklayan thread başına bir Hash olarak çalışıyordu
- Bu registry bir
Hash'e referans veriyordu ve içindeki Array'lerden biri ActiveSupport::Notifications::Event'i tutuyordu
- Bu
Event de @children dizisi üzerinden 32.067'den fazla child Event nesnesine referans veriyordu
- İlk child
Event'in adı redirect_to.action_controller idi ve içinde ActionDispatch::Request nesnesi bulunuyordu
Anormal istek yeniden üretim ipucu oldu
- Heap içindeki
ActionDispatch::Request, gerçek route ve geçerli public resource ID içeriyordu, ancak query parametreleri manipüle edilmişti
- İstek yolunda
password=[FILTERED] yer alıyordu; bu da hassas veri temizleme sürecinin devreye girdiğini gösteriyordu
- Aynı yol ve parametrelerle production uygulamasına gizli tarayıcı penceresinden istek gönderildiğinde 500 server error oluştu
- Loglarda
URI::InvalidURIError görüldü ve isteğin ulaştığı Dyno da doğrulandı
- O Dyno o anda normal bellek kullanımı gösteriyordu, ancak dağıtım kısa süre durdurulup izlendiğinde sızıntı eğilimi ortaya çıktı
- Lokalde,
activesupport Gem içine binding.pry ve puts debug kodu eklenerek aynı durum ve backtrace yeniden üretildi
Asıl neden Rails ve Bugsnag değişikliklerinin birleşimiydi
- Hata backtrace'i Ruby standart kütüphanesindeki
uri Gem'ini işaret ediyordu; bu da Bugsnag'in Bugsnag.cleaner.clean_url içinde kullanılıyordu
- Bu kod, Rails breadcrumb URL'lerini temizlerken
ActiveSupport::Notifications.subscribe bloğu içinde çalışıyordu
- Sorun, iki etmenin birleşmesiydi
- Rails 6.1'deki
ActiveSupport::Subscriber, event'leri Event#children ve paylaşılan bir Array ile takip ediyordu
- Bugsnag değişikliği, Rails breadcrumb URL temizliği için
URI kullanıyordu ve hatalı URI'larda exception üretebiliyordu
URI, invalid URI üzerinde hata fırlattığında Bugsnag'in subscribe bloğu ActiveSupport::Notifications::Event işlenirken exception üretiyordu
- Bu exception nedeniyle parent
Event, Subscriber#event_stack içinden pop edilemiyor ve parent Event bellekte kalarak sızıntıya neden oluyordu
- Parent
Event, #children dizisi üzerinden child Event'lere referans vermeye devam ettiği için daha fazla belleği elde tutuyordu
- John Hawthorn'un Rails 7.1 düzeltmesi,
Event#children kavramını ve event takibi için kullanılan paylaşılan Array'i kaldırarak iki sızıntı nedenini birden ortadan kaldırdı
Çözüm Bugsnag yükseltmesi ve Rails yükseltmesiydi
- Rails'in güncel sürümlerinde John Hawthorn'un düzeltmesi sayesinde bu sorun artık oluşmuyor
- O sırada uygulama Rails 6.1 kullandığı için Rails düzeltmesinden hemen yararlanmak mümkün değildi
- Bugsnag zaten
Bugsnag.cleaner.clean_url'in invalid URI durumunda exception fırlatmamasını sağlayan bir düzeltme yayınlamıştı
- Kısa vadeli çözüm, bu düzeltmeyi içeren Bugsnag Gem sürümüne yükseltmekti
- Uzun vadeli çözüm, Rails sürümünü yükseltmekti
- İlk bellek sıçramasıyla zaman olarak çakışan değişiklik, Bugsnag
v6.26.0'dan v6.26.1'e yükseltmeydi; amaç başka bir bağımlılıktaki deprecation warning'i düzeltmekti
1 yorum
Hacker News yorumları
Elle bellek yönetiminin neden bu kadar korkutucu olduğunu anlamıyorum. RAII ve net sahiplik kuralları varsa bellek yönetimi kolay bir mühendislik işidir.
Aksine, referans sayımı ya da paylaşımlı işaretçileri dayatan framework’ler daha zor geliyor. Çünkü sahiplik belirsizleşiyor.
Kendin oluşturduysan kendin serbest bırakırsın; devrettiysen artık ilgilenmezsin. İşletim sistemi kaynakları olan handle ve socket’leri de otomatik kaynak yöneticisi olmadan elle yönetiyoruz; tasarımı özellikle otomatik bellek yönetimiyle karmaşıklaştırmak için bir neden göremiyorum.
Yıllar boyunca geliştirme yaparken çoğu geliştiricinin bellek yönetimini de aynı anda akılda tutacak kadar çalışma belleği payına sahip olmadığını düşünmeye başladım. Yöntemi mekanik olarak bilseniz bile kafanızda çok fazla şeyi aynı anda çevirmeye çalışırsanız bir şeyler gözden kaçar.
Buna karşılık, elle bellek yönetimini neredeyse hiç zorlanmadan her seferinde doğru yapan az sayıda kişi de var. Onlar için bu gerçekten kolay olduğu için, başkalarına neden zor geldiği pek anlaşılır gelmez. Böyle biri için otomatik bellek yönetiminin faydaları belirsiz, dezavantajları ise büyük görünebilir.
Kabaca söylemek gerekirse, bu hatalar başka hatalarla yer değiştirmiş değil; sadece ortadan kaybolmuş durumda. Programcıdan daha fazla iş de istemiyor; aksine elle bellek yönetimine göre yapılacak işi azaltıyor.
Elbette çöp toplama her zaman kazanır demek değil; gerçek dezavantajları da var. Ama çoğu programda modern çöp toplayıcılar yeterince iyi olduğundan bu dezavantajlar pek sorun olmuyor.
Mantık hatalarında da benzer bir sorun var ve Java gibi dillerde de nadiren bellek sızıntısı mümkün, ama bellek güvenli diller bir iyileştirme. TypeScript’in JavaScript’ten daha iyi olmasına benziyor. Bellek hatalarını %1’den %0,01’e düşürebilen otomasyon varken, sızıntıları ve tanımsız davranışı önlemeyi neden hâlâ elle ilgilenilmesi gereken bir konu olarak bırakalım, anlamıyorum.
Java gibi kolay ama ek yükü olan çöp toplamalı bir dil kullanabilirsiniz ya da Rust gibi öğrenme eğrisi olan ama ek yük getirmeyen, sahipliği zorunlu kılan bir dil kullanabilirsiniz. Mantık hataları da baş ağrıtır, ama bellek hataları özellikle kötü şöhretlidir; çünkü net hata mesajları vermeyebilir ya da oluştuğunda program durmayabilir.
Bir yan not olarak, biçimsel doğrulama da bir hata sınıfını fiilen ortadan kaldırmanın bir yoludur. Şimdilik doğruluğun en kritik olduğu sistemlerde görülüyor; çünkü bellek yönetiminden farklı olarak dezavantajları çok büyük. Kod aşırı derecede uzun, zahmetli olur ve belirli bir yapıyı dayatır. Ancak biçimsel doğrulama daha iyi hale geldikçe bunun da daha ana akım olacağını düşünüyorum.
“Ben gerçek bir programcı değilim. Çalışırmış gibi görünen şeyleri birbirine ekleyip geçiyorum. Gerçek programcılar ‘Çalışıyor ama belleği oradan buradan sızdırıyor. Bunu düzeltmemiz gerekmez mi?’ derdi. Ben ise her 10 istekte bir Apache’yi yeniden başlatırım.” — Rasmus Lerdorf, PHP Non-Designer
https://en.wikiquote.org/wiki/Rasmus_Lerdorf
Eskiden çalıştığım yer, bellek sızıntısı yüzünden 5 milyon dolar kaybetmenin en aptalca yolu ödülünü hak ederdi.
90’ların Solaris yazıcı sürücüsünde bir bellek sızıntısı vardı[1]. O dönemde büyük bir bankada yüklenici olarak çalışıyordum; o zamanlar sözleşme teyitlerinde faksın hukuki statüsü mahkemelerde yeterince sınanmamış olduğundan bankalar işlemleri faksla kayda geçiriyordu. Faks gönderen sistem, işlem teyidini yazdırmak için belgeyi belirli bir yazıcıya da gönderiyordu; biri de bu teyidi alıp karşı tarafa telefonda okuyarak görüşme kaydına[2] geçiriyor ve hukuken doğruluyordu.
Bir gün bellek sızıntısı yüzünden yazıcı sürücüsü çöktü ve teyitlerden biri yazdırılmadı; sorumlu kişi de telefonda okuyamadı. Piyasa sert hareket etti ve karşı taraf o işlemi DK yaptı[3]. Banka yöneticileri ne kadar ortalığı ayağa kaldırsa da işe yaramadı; 5 milyon dolarlık zararı deftere yazdıktan sonra o bankayla bir daha işlem yapmama politikası oluşturuldu[4]. Faks yazdırma işi Windows NT’ye taşındı.
[1] Harika kitap “Expert C Programming”e göre bu sorun, dönemin Sun Microsystems CEO’su Scott McNealy’nin CEO olmasına rağmen düşük performanslı bir iş istasyonu alması ve sorunu sıkça yaşaması; yeterince şikâyet etmesinin ardından geliştiricilerin sonunda düzeltmesiyle çözülmüş: https://progforperf.github.io/Expert_C_Programming.pdf
[2] Bankaların menkul kıymetler birimindeki görüşmeler, hukuk ve uyum gerekçeleriyle neredeyse her zaman kaydedilir.
[3] DK, “Don’t know”un kısaltmasıdır. Karşı taraf bir işlemi “bilmiyorum” derse, sözleşmenin kurulmuş olduğu gerçeğine itiraz ediyor demektir.
[4] Karşı taraf başka yerde işlem yapıp komisyonu başka bir bankaya ödeyebileceği için muhtemelen daha çok zararı biz görmüşüzdür.
Citi de bir krediyi fazla erken geri ödediği gerekçesiyle dava yaşamıştı. Finans sektöründe, işine geliyorsa herkesin yazılı sözleşme üzerinden sert çıkacağını düşünüyorum.
C’de Valgrind sayesinde sızıntıları bulmak çok kolaydır
Düzeltmek daha zordur ama tasarım doğruysa genelde kolay olur. Çoğunlukla, çağıran taraf için bellek ayıran bir fonksiyon değilse, aynı fonksiyon içinde ayırır ve serbest bırakırsınız. Çağıran taraf için ayıran bir fonksiyonsa, o çağrının kendisi çağıran taraftaki bir ayırma olarak kabul edilir
Kod tabanının statik analizinde, hata işleme yollarının sorunun en yaygın nedeni olduğu görüldü
Blok kapsamı, fonksiyon kapsamı, dosya kapsamı, global kapsam olduğu gibi; sorun alanı ya da çözümün soyutlaması olan modelde de çeşitli kapsam düzeyleri vardır. Ancak bunun öğretildiğini hiç görmedim
Bir kapsamın
$SCOPE::foo()içinde bir kaynağı edinip$SCOPE::cleanup()içinde serbest bırakmaması, gözle bulması oldukça kolay bir durumdur. Kod yazmaya dalmadan önce sorun alanını ve önerilen çözümü modelleyebilmek faydalıdırYahoo hakkında duyduğum bir hikâye aklıma geliyor. Reklam sunucusunda bir bellek sızıntısı vardı ve yaklaşık 10000 istekten sonra bellek yetersizliği yaşanıyordu
Çözüm, 8000 istekten sonra sunucuyu yeniden başlatmaktı. Bu yöntem 1-2 yıl işe yaradı; sonrasında 8000 istekten sonra bile bellek yetersizliği oluşmaya başladı
Bir sonraki çözüm, 6000 istekten sonra sunucuyu yeniden başlatmaktı
Bu yöntemin işe yaraması için yeniden başlatmanın inanılmaz hızlı olması gerekir
Rails geliştiricisiyken, bu tür sorunlara daha fazla donanım eklemek üretkenlik adına makul bir ödün olarak görülüyordu. Bu tür sorunları önemsiyorsanız daha sıkı araçlar kullanırsınız gibi bir hava vardı
Kişisel olarak mükemmeliyetçi eğilimim yüzünden bu yaklaşımı kabul etmekte zorlanıyorum ama pratikte çalıştığını inkâr etmek de zor
Çöp toplama olan ve olmayan dilleri ikisini de kullandım. Genelde elle yönetim yazması daha zor, otomatik yönetim ise sorun çözmesi daha zordur
İkisini de yapabilen bir dil kullanmak isterim. Keşif amaçlı kod yazarken otomatik bellek yönetimi rahattır; bazı kod türlerinde ise elle bellek yönetimi avantajlıdır
Yasaklama ile zorunluluk arasında bir orta yol bulunamaması sinir bozucu
@[manualfree]özniteliğiyle fonksiyon ya da modül bazında kolayca kapatılabilir;v -gc noneile proje genelinde de kapatılabilirhttps://vlang.io
“Sızıntıları profillemeye yönelik çeşitli araçlar, heap dump’ları anlama ve yaygın sızıntı nedenleri hakkında çok sayıda yazı yazıldı”
Of, sızıntılar ve heap dump’lar ha. Birilerinin daha sağlıklı bir diyete ihtiyacı var gibi görünüyor