Node.js Uygulamalarında Bellek Sızıntılarında Hata Ayıklama
Yayınlanan: 2022-03-11Bir keresinde içinde V8 çift turbo motorlu bir Audi kullandım ve performansı inanılmazdı. Yolda kimse yokken, Chicago yakınlarındaki IL-80 otoyolunda saat 3'te 140MPH civarında sürüyordum. O zamandan beri “V8” terimi benim için yüksek performansla ilişkilendirildi.
Audi'nin V8'i çok güçlü olmasına rağmen, yine de benzin deponuzun kapasitesi ile sınırlısınız. Aynı şey Google'ın V8'i için de geçerlidir - Node.js'nin arkasındaki JavaScript motoru. Performansı inanılmaz ve Node.js'nin birçok kullanım durumu için iyi çalışmasının birçok nedeni var, ancak her zaman yığın boyutuyla sınırlısınız. Node.js uygulamanızda daha fazla istek işlemeniz gerektiğinde iki seçeneğiniz vardır: dikey ölçekleme veya yatay ölçekleme. Yatay ölçeklendirme, daha fazla eşzamanlı uygulama örneği çalıştırmanız gerektiği anlamına gelir. Doğru yapıldığında, daha fazla istek sunabilirsiniz. Dikey ölçeklendirme, uygulamanızın bellek kullanımını ve performansını iyileştirmeniz veya uygulama örneğiniz için mevcut kaynakları artırmanız gerektiği anlamına gelir.
Geçenlerde, Toptal istemcilerimden birinin bellek sızıntısı sorununu düzeltmesi için bir Node.js uygulaması üzerinde çalışmam istendi. Bir API sunucusu olan uygulamanın, her dakika yüz binlerce isteği işleyebilmesi amaçlandı. Orijinal uygulama neredeyse 600MB RAM kaplıyordu ve bu nedenle sıcak API uç noktalarını alıp yeniden uygulamaya karar verdik. Birçok isteği yerine getirmeniz gerektiğinde genel giderler çok pahalı hale gelir.
Yeni API için yerel MongoDB sürücüsü ile restify'ı ve arka plan işleri için Kue'yi seçtik. Kulağa çok hafif bir yığın gibi geliyor, değil mi? Pek değil. Yoğun yükleme sırasında yeni bir uygulama örneği 270 MB'a kadar RAM tüketebilir. Bu nedenle, 1X Heroku Dyno başına iki uygulama örneğine sahip olma hayalim yok oldu.
Node.js Bellek Sızıntısı Hata Ayıklama Arsenal
Memwatch
“Düğümde sızıntı nasıl bulunur” diye arama yaparsanız, muhtemelen bulacağınız ilk araç memwatch olacaktır . Orijinal paket uzun zaman önce terk edildi ve artık korunmuyor. Ancak, daha yeni sürümlerini GitHub'ın depo için çatal listesinde kolayca bulabilirsiniz. Bu modül kullanışlıdır çünkü yığının 5 ardışık çöp toplama üzerinde büyüdüğünü görürse sızıntı olayları yayabilir.
yığın dökümü
Node.js geliştiricilerinin yığın anlık görüntü almasına ve bunları daha sonra Chrome Geliştirici Araçları ile incelemesine olanak tanıyan harika bir araç.
düğüm denetçisi
Heapdump'a daha kullanışlı bir alternatif, çünkü çalışan bir uygulamaya bağlanmanıza, yığın dökümü almanıza ve hatta hata ayıklamanıza ve anında yeniden derlemenize izin verir.
Bir Spin için “düğüm denetçisi” almak
Ne yazık ki, çalışan işlemlere sinyal gönderilmesine izin vermediğinden, Heroku'da çalışan üretim uygulamalarına bağlanamayacaksınız. Ancak, Heroku tek barındırma platformu değildir.
Düğüm denetçisini çalışırken deneyimlemek için, restify kullanarak basit bir Node.js uygulaması yazacağız ve içine küçük bir bellek sızıntısı kaynağı koyacağız. Buradaki tüm deneyler, V8 v3.28.71.19'a göre derlenen Node.js v0.12.7 ile yapılmıştır.
var restify = require('restify'); var server = restify.createServer(); var tasks = []; server.pre(function(req, res, next) { tasks.push(function() { return req.headers; }); // Synchronously get user from session, maybe jwt token req.user = { id: 1, username: 'Leaky Master', }; return next(); }); server.get('/', function(req, res, next) { res.send('Hi ' + req.user.username); return next(); }); server.listen(3000, function() { console.log('%s listening at %s', server.name, server.url); });
Buradaki uygulama çok basit ve çok bariz bir sızıntısı var. Dizi görevleri , uygulama ömrü boyunca büyüyecek ve yavaşlamasına ve sonunda çökmesine neden olacaktır. Sorun şu ki, yalnızca kapatmayı değil, aynı zamanda tüm istek nesnelerini de sızdırıyoruz.
V8'deki GC, dünyayı durdurma stratejisini kullanır, bu nedenle hafızanızda daha fazla nesne olması, çöp toplamanın daha uzun süreceği anlamına gelir. Aşağıdaki logda, uygulama ömrünün başlangıcında çöpleri toplamanın ortalama 20ms sürdüğünü, ancak birkaç yüz bin istek sonrasında 230ms civarında sürdüğünü açıkça görebilirsiniz. Uygulamamıza erişmeye çalışan kişilerin GC nedeniyle artık 230 ms daha beklemesi gerekecek. Ayrıca GC'nin birkaç saniyede bir çağrıldığını görebilirsiniz, bu da birkaç saniyede bir kullanıcıların uygulamamıza erişimde sorun yaşayacağı anlamına gelir. Ve uygulama çökene kadar gecikme artacaktır.
[28093] 7644 ms: Mark-sweep 10.9 (48.5) -> 10.9 (48.5) MB, 25.0 ms [HeapObjectsMap::UpdateHeapObjectsMap] [GC in old space requested]. [28093] 7717 ms: Mark-sweep 10.9 (48.5) -> 10.9 (48.5) MB, 18.0 ms [HeapObjectsMap::UpdateHeapObjectsMap] [GC in old space requested]. [28093] 7866 ms: Mark-sweep 11.0 (48.5) -> 10.9 (48.5) MB, 23.2 ms [HeapObjectsMap::UpdateHeapObjectsMap] [GC in old space requested]. [28093] 8001 ms: Mark-sweep 11.0 (48.5) -> 10.9 (48.5) MB, 18.4 ms [HeapObjectsMap::UpdateHeapObjectsMap] [GC in old space requested]. ... [28093] 633891 ms: Mark-sweep 235.7 (290.5) -> 235.7 (290.5) MB, 357.3 ms [HeapObjectsMap::UpdateHeapObjectsMap] [GC in old space requested]. [28093] 635672 ms: Mark-sweep 235.7 (290.5) -> 235.7 (290.5) MB, 331.5 ms [HeapObjectsMap::UpdateHeapObjectsMap] [GC in old space requested]. [28093] 637508 ms: Mark-sweep 235.7 (290.5) -> 235.7 (290.5) MB, 357.2 ms [HeapObjectsMap::UpdateHeapObjectsMap] [GC in old space requested].
Bu günlük satırları, –trace_gc bayrağıyla bir Node.js uygulaması başlatıldığında yazdırılır:
node --trace_gc app.js
Node.js uygulamamıza bu flag ile başladığımızı varsayalım. Uygulamayı node-inspector ile bağlamadan önce, çalışan işleme SIGUSR1 sinyalini göndermemiz gerekiyor. Node.js'yi kümede çalıştırırsanız, bağımlı işlemlerden birine bağlandığınızdan emin olun.
kill -SIGUSR1 $pid # Replace $pid with the actual process ID
Bunu yaparak Node.js uygulamasının (daha doğrusu V8) hata ayıklama moduna girmesini sağlıyoruz. Bu modda uygulama, 5858 numaralı bağlantı noktasını V8 Hata Ayıklama Protokolü ile otomatik olarak açar.
Bir sonraki adımımız, çalışan uygulamanın hata ayıklama arayüzüne bağlanacak ve 8080 numaralı bağlantı noktasında başka bir web arayüzü açacak olan düğüm denetçisini çalıştırmaktır.
$ node-inspector Node Inspector v0.12.2 Visit http://127.0.0.1:8080/?ws=127.0.0.1:8080&port=5858 to start debugging.
Uygulama üretimde çalışıyorsa ve yerinde bir güvenlik duvarınız varsa, 8080 numaralı uzak bağlantı noktasını localhost'a tünelleyebiliriz:
ssh -L 8080:localhost:8080 [email protected]
Artık Chrome web tarayıcınızı açabilir ve uzaktan üretim uygulamanıza bağlı Chrome Geliştirme Araçlarına tam erişim elde edebilirsiniz. Ne yazık ki, Chrome Geliştirici Araçları diğer tarayıcılarda çalışmayacaktır.
Bir Sızıntı Bulalım!
V8'deki bellek sızıntıları, onları C/C++ uygulamalarından bildiğimiz için gerçek bellek sızıntıları değildir. JavaScript'te değişkenler boşlukta kaybolmazlar, sadece “unutulurlar”. Amacımız bu unutulan değişkenleri bulmak ve onlara Dobby'nin özgür olduğunu hatırlatmaktır.
Chrome Geliştirici Araçları içinde birden fazla profil oluşturucuya erişimimiz var. Zaman içinde birden çok yığın anlık görüntüsünü çalıştıran ve alan Kayıt Yığın Tahsisleri ile özellikle ilgileniyoruz. Bu bize hangi nesnelerin sızdığını net bir şekilde görmemizi sağlar.
Yığın ayırmaları kaydetmeye başlayın ve Apache Benchmark kullanarak ana sayfamızda 50 eşzamanlı kullanıcıyı simüle edelim.
ab -c 50 -n 1000000 -k http://example.com/
Yeni anlık görüntüler almadan önce, V8 işaret süpürme çöp toplama işlemi gerçekleştirir, bu nedenle anlık görüntüde eski çöp olmadığını kesinlikle biliriz.
Sızıntıyı Anında Onarmak
3 dakikalık bir süre boyunca yığın ayırma anlık görüntülerini topladıktan sonra aşağıdakine benzer bir sonuç elde ederiz:
Bazı devasa diziler, bir çok IncomingMessage, ReadableState, ServerResponse ve Domain nesnelerinin de yığın halinde olduğunu açıkça görebiliriz. Sızıntının kaynağını analiz etmeye çalışalım.
20'li yıllardan 40'lara kadar grafikte yığın farkı seçildiğinde, yalnızca profil oluşturucuyu başlattığınız andan itibaren 20'li yıllardan sonra eklenen nesneleri göreceğiz. Bu şekilde tüm normal verileri hariç tutabilirsiniz.
Sistemde her türden kaç tane nesne olduğunu not ederek, filtreyi 20s'den 1dk'ya genişletiyoruz. Zaten oldukça devasa olan dizilerin büyümeye devam ettiğini görebiliriz. “(dizi)” altında, eşit mesafeli “(nesne özellikleri)” çok sayıda nesne olduğunu görebiliriz. Bu nesneler, bellek sızıntımızın kaynağıdır.

Ayrıca “(kapatma)” nesnelerinin de hızla büyüdüğünü görebiliriz.
Dizelere de bakmak faydalı olabilir. Dizeler listesinin altında bir çok “Hi Leaky Master” ifadesi vardır. Bunlar da bize biraz ipucu verebilir.
Bizim durumumuzda “Hi Leaky Master” dizisinin sadece “GET /” yolu altında birleştirilebileceğini biliyoruz.
Tutucular yolunu açarsanız, bu dizenin bir şekilde req aracılığıyla referans verildiğini görürsünüz, o zaman bağlam oluşturulur ve tüm bunlar dev bir dizi kapanışa eklenir.
Bu noktada, bir tür devasa kapanış dizimiz olduğunu biliyoruz. Aslında gidip tüm kapanışlarımıza gerçek zamanlı olarak kaynaklar sekmesi altında bir isim verelim.
Kodu düzenlemeyi bitirdikten sonra, anında kodu kaydetmek ve yeniden derlemek için CTRL+S tuşlarına basabiliriz!
Şimdi başka bir Yığın Tahsisi Anlık Görüntüsü kaydedelim ve hangi kapanışların hafızayı işgal ettiğini görelim.
SomeKindOfClojure() 'un bizim kötü adamımız olduğu açık. Şimdi, global uzayda bazı dizi adlı görevlere SomeKindOfClojure() kapanışlarının eklendiğini görebiliriz.
Bu dizinin sadece işe yaramaz olduğunu görmek kolay. Onu yorumlayabiliriz. Ama hafızanın zaten işgal ettiği hafızayı nasıl boşaltırız? Çok kolay, sadece görevlere boş bir dizi atarız ve bir sonraki istekte geçersiz kılınır ve bir sonraki GC olayından sonra bellek serbest bırakılır.
Dobby bedava!
V8'de Çöpün Ömrü
V8 yığını birkaç farklı alana bölünmüştür:
- Yeni Alan : Bu alan nispeten küçüktür ve 1MB ile 8MB arasında bir boyuta sahiptir. Nesnelerin çoğu burada tahsis edilmiştir.
- Eski İşaretçi Boşluğu : Başka nesnelere işaretçileri olabilecek nesnelere sahiptir. Nesne New Space'de yeterince uzun süre hayatta kalırsa, Old Pointer Space'e terfi eder.
- Eski Veri Alanı : Yalnızca diziler, kutulu sayılar ve kutusuz ikili diziler gibi ham verileri içerir. Yeni Uzayda GC'de yeterince uzun süre hayatta kalan nesneler de buraya taşınır.
- Büyük Nesne Alanı : Bu alanda başka alanlara sığmayacak kadar büyük nesneler oluşturulur. Her nesnenin bellekte kendi
mmap
'ed bölgesi vardır - Kod alanı : JIT derleyicisi tarafından oluşturulan derleme kodunu içerir.
- Hücre alanı, özellik hücre alanı, harita alanı : Bu alan
Cell
s,PropertyCell
s veMap
s içerir. Bu, çöp toplama işlemini basitleştirmek için kullanılır.
Her alan sayfalardan oluşur. Sayfa, işletim sisteminden mmap ile ayrılan bir bellek bölgesidir. Büyük nesne alanındaki sayfalar dışında her sayfa her zaman 1MB boyutundadır.
V8'in iki yerleşik çöp toplama mekanizması vardır: Scavenge, Mark-Sweep ve Mark-Compact.
Scavenge çok hızlı bir çöp toplama tekniğidir ve New Space'deki nesnelerle çalışır. Scavenge, Cheney Algoritmasının uygulanmasıdır. Fikir çok basit, Yeni Uzay iki eşit yarı-uzaya bölünmüştür: Uzaya ve Uzaydan Uzaya. Scavenge GC, To-Space dolduğunda gerçekleşir. Sadece To ve From alanlarını değiştirir ve tüm canlı nesneleri To-Space'e kopyalar veya iki temizlemeden kurtulurlarsa onları eski alanlardan birine terfi ettirir ve ardından alandan tamamen silinir. Temizleme işlemleri çok hızlıdır, ancak çift boyutlu yığın tutma ve bellekteki nesneleri sürekli kopyalama gibi ek yükleri vardır. Çöpçüleri kullanmanın nedeni, çoğu nesnenin genç yaşta ölmesidir.
Mark-Sweep & Mark-Compact, V8'de kullanılan başka bir çöp toplayıcı türüdür. Diğer adı tam çöp toplayıcıdır. Tüm canlı düğümleri işaretler, ardından tüm ölü düğümleri süpürür ve belleği birleştirir.
GC Performansı ve Hata Ayıklama İpuçları
Web uygulamaları için yüksek performans o kadar büyük bir sorun olmasa da, ne pahasına olursa olsun sızıntılardan kaçınmak isteyeceksiniz. Tam GC'de işaretleme aşaması sırasında, çöp toplama tamamlanana kadar uygulama aslında duraklatılır. Bu, yığında ne kadar çok nesneniz varsa, GC'yi gerçekleştirmenin o kadar uzun süreceği ve kullanıcıların daha uzun süre beklemesi gerekeceği anlamına gelir.
Kapanışlara ve işlevlere her zaman ad verin
Tüm kapanışlarınızın ve işlevlerinizin adları olduğunda yığın izlerini ve yığınları incelemek çok daha kolaydır.
db.query('GIVE THEM ALL', function GiveThemAllAName(error, data) { ... })
Sıcak işlevlerde büyük nesnelerden kaçının
İdeal olarak, tüm verilerin New Space'e sığması için sıcak işlevlerin içindeki büyük nesnelerden kaçınmak istersiniz. Tüm CPU ve belleğe bağlı işlemler arka planda yürütülmelidir. Ayrıca, sıcak işlevler için optimizasyonu bozma tetikleyicilerinden kaçının, optimize edilmiş sıcak işlev, optimize edilmemiş olanlardan daha az bellek kullanır.
Sıcak işlevler optimize edilmelidir
Daha hızlı çalışan ancak aynı zamanda daha az bellek tüketen sıcak işlevler, GC'nin daha az çalışmasına neden olur. V8, optimize edilmemiş işlevleri veya optimize edilmemiş işlevleri tespit etmek için bazı yararlı hata ayıklama araçları sağlar.
Sıcak işlevlerde IC'ler için polimorfizmden kaçının
Satır İçi Önbellekler (IC), nesne özelliği erişimi obj.key
veya bazı basit işlevleri önbelleğe alarak bazı kod parçalarının yürütülmesini hızlandırmak için kullanılır.
function x(a, b) { return a + b; } x(1, 2); // monomorphic x(1, “string”); // polymorphic, level 2 x(3.14, 1); // polymorphic, level 3
x(a,b) ilk kez çalıştırıldığında, V8 monomorfik bir IC oluşturur. x
ikinci kez aradığınızda, V8 eski IC'yi siler ve hem tamsayı hem de dize işlenenlerini destekleyen yeni bir polimorfik IC oluşturur. IC'yi üçüncü kez aradığınızda, V8 aynı prosedürü tekrarlar ve seviye 3'te başka bir polimorfik IC oluşturur.
Ancak, bir sınırlama var. IC seviyesi 5'e ulaştıktan sonra ( –max_inlining_levels bayrağı ile değiştirilebilir) fonksiyon megamorfik hale gelir ve artık optimize edilemez olarak kabul edilmez.
Monomorfik işlevlerin en hızlı şekilde çalışması ve ayrıca daha küçük bellek ayak izine sahip olması sezgisel olarak anlaşılabilir.
Belleğe büyük dosyalar eklemeyin
Bu açık ve iyi biliniyor. İşlenecek büyük dosyalarınız varsa, örneğin büyük bir CSV dosyası varsa, tüm dosyayı belleğe yüklemek yerine satır satır okuyun ve küçük parçalar halinde işleyin. Tek bir csv satırının 1mb'den daha büyük olduğu ve böylece onu New Space'e sığdırmanıza izin verdiği oldukça nadir durumlar vardır.
Ana sunucu iş parçacığını engelleme
Görüntüleri yeniden boyutlandırmak için bir API gibi işlenmesi biraz zaman alan sıcak bir API'niz varsa, onu ayrı bir iş parçacığına taşıyın veya bir arka plan işine dönüştürün. CPU yoğun işlemler, diğer tüm müşterileri beklemeye ve istek göndermeye devam etmeye zorlayan ana iş parçacığını engeller. İşlenmemiş istek verileri bellekte yığılır ve böylece tam GC'nin tamamlanması daha uzun sürer.
Gereksiz veri oluşturmayın
Bir zamanlar restify ile garip bir deneyim yaşadım. Geçersiz bir URL'ye birkaç yüz bin istek gönderirseniz, uygulama belleği birkaç saniye sonra tam bir GC başlayana kadar hızla yüz megabayta kadar büyür, bu da her şeyin normale döndüğü zamandır. Her geçersiz URL için restify'ın uzun yığın izleri içeren yeni bir hata nesnesi oluşturduğu ortaya çıktı. Bu, yeni oluşturulan nesneleri Yeni Alan yerine Büyük Nesne Alanında tahsis edilmeye zorladı.
Bu tür verilere erişim, geliştirme sırasında çok yardımcı olabilir, ancak açıkçası üretimde gerekli değildir. Bu nedenle kural basittir - kesinlikle ihtiyacınız olmadıkça veri üretmeyin.
Araçlarınızı bilin
Son fakat kesinlikle en az olmayan şey, araçlarınızı bilmektir. Çeşitli hata ayıklayıcılar, sızıntı tespit ediciler ve kullanım grafiği oluşturucuları vardır. Tüm bu araçlar, yazılımınızı daha hızlı ve daha verimli hale getirmenize yardımcı olabilir.
Çözüm
V8'in çöp toplama ve kod iyileştiricisinin nasıl çalıştığını anlamak, uygulama performansının anahtarıdır. V8, JavaScript'i yerel derlemeye derler ve bazı durumlarda iyi yazılmış kod, GCC tarafından derlenmiş uygulamalarla karşılaştırılabilir performans elde edebilir.
Ve merak ediyorsanız, Toptal müşterim için yeni API uygulaması, iyileştirmeye açık olmasına rağmen çok iyi çalışıyor!
Joyent kısa süre önce, V8'in en son sürümlerinden birini kullanan yeni bir Node.js sürümünü yayınladı. Node.js v0.12.x için yazılmış bazı uygulamalar yeni v4.x sürümüyle uyumlu olmayabilir. Ancak uygulamalar, Node.js'nin yeni sürümünde muazzam performans ve bellek kullanımı iyileştirmesi yaşayacak.