メモリリークの可能性
hirokoma opened this issue · comments
いつも、お世話になっております。
normalize-japanese-addresses
で住所正規化を行う非常に簡易的なWebAPIを実行したところ、後述のメモリエラーが発生しました。 エラーと遭遇した状況を以下に記載します。
使用マシン:Amazon Linux(メモリサイズ: 15.6GB)
使用Webフレームワーク:Express
var bodyParser = require('body-parser')
var express = require('express')
var app = express()
const { normalize } = require('@geolonia/normalize-japanese-addresses')
app.use(bodyParser.urlencoded({ extended: true }))
app.use(bodyParser.json())
app.post('/req-post', async (req, res) => {
res.header('Content-Type', 'application/json; charset=utf-8')
try {
res.send(await normalize(req.body.text)); // 北海道札幌市西区24-2-2-3-3 のような文字列を受け取る
} catch (e) {
res.send({ "level": -1 });
}
})
app.listen(process.env.PORT || 80)
以上のソースを express_server.js
というファイルで保存し、コマンド PORT=8084 node express_server.js
でサーバを起動させます。
次に、 http://localhost:8084/req-post
に対して {"text": "北海道札幌市西区24-2-2-3-3"}
をPOSTで送信します。すると、以下のように結果がJSONで得られます。ここまでは問題はございません。
$ curl -s -X POST -H "Content-Type: application/json" http://localhost:8084/req-post -d '{"text":"北海道札幌市西区24-2-2-3-3"}' | jq .
{
"pref": "北海道",
"city": "札幌市西区",
"town": "二十四軒二条二丁目",
"addr": "3-3",
"level": 3
}
しかし、このAPIを数千〜数万回ほど実行したところ、以下のエラーが現出しexpressサーバがダウンしました。サーバーを再起動させてもやはり数千〜数万回ほど実行したところで同じ現象が起きます。また、何度か再起動してサーバーが落ちる条件を探ってみましたが、特定の住所が原因で落ちているわけではないようでした。
<--- Last few GCs --->
[1740604:0x4e10600] 197790 ms: Mark-sweep (reduce) 186.7 (233.7) -> 186.6 (201.2) MB, 46.2 / 0.0 ms (average mu = 0.830, current mu = 0.227) last resort GC in old space requested
[1740604:0x4e10600] 197843 ms: Mark-sweep (reduce) 186.6 (201.2) -> 186.5 (200.7) MB, 52.8 / 0.0 ms (average mu = 0.686, current mu = 0.001) last resort GC in old space requested
<--- JS stacktrace --->
FATAL ERROR: CALL_AND_RETRY_LAST Allocation failed - JavaScript heap out of memory
1: 0xa68ab0 node::Abort() [node]
2: 0x99bc57 node::FatalError(char const*, char const*) [node]
3: 0xc4214e v8::Utils::ReportOOMFailure(v8::internal::Isolate*, char const*, bool) [node]
4: 0xc424c7 v8::internal::V8::FatalProcessOutOfMemory(v8::internal::Isolate*, char const*, bool) [node]
5: 0xe0c085 [node]
6: 0xe1e351 v8::internal::Heap::AllocateRawWithRetryOrFailSlowPath(int, v8::internal::AllocationType, v8::internal::AllocationOrigin, v8::internal::AllocationAlignment) [node]
7: 0xdef728 v8::internal::Factory::CodeBuilder::BuildInternal(bool) [node]
8: 0xdf000e v8::internal::Factory::CodeBuilder::Build() [node]
9: 0x13efecc v8::internal::RegExpMacroAssemblerX64::GetCode(v8::internal::Handle<v8::internal::String>) [node]
10: 0x10ee172 v8::internal::RegExpCompiler::Assemble(v8::internal::Isolate*, v8::internal::RegExpMacroAssembler*, v8::internal::RegExpNode*, int, v8::internal::Handle<v8::internal::String>) [node]
11: 0x110b5fe v8::internal::RegExpImpl::Compile(v8::internal::Isolate*, v8::internal::Zone*, v8::internal::RegExpCompileData*, v8::base::Flags<v8::internal::JSRegExp::Flag, int>, v8::internal::Handle<v8::internal::String>, v8::internal::Handle<v8::internal::String>, bool, unsigned int) [node]
12: 0x110bcfd v8::internal::RegExpImpl::CompileIrregexp(v8::internal::Isolate*, v8::internal::Handle<v8::internal::JSRegExp>, v8::internal::Handle<v8::internal::String>, bool) [node]
13: 0x110c95c v8::internal::RegExp::IrregexpPrepare(v8::internal::Isolate*, v8::internal::Handle<v8::internal::JSRegExp>, v8::internal::Handle<v8::internal::String>) [node]
14: 0x110ca1d v8::internal::RegExpImpl::IrregexpExec(v8::internal::Isolate*, v8::internal::Handle<v8::internal::JSRegExp>, v8::internal::Handle<v8::internal::String>, int, v8::internal::Handle<v8::internal::RegExpMatchInfo>) [node]
15: 0x1161931 v8::internal::Runtime_RegExpExec(int, unsigned long*, v8::internal::Isolate*) [node]
16: 0x14cfa19 [node]
Aborted (core dumped)
FATAL ERROR: CALL_AND_RETRY_LAST Allocation failed - JavaScript heap out of memory
とあり、ヒープ領域のメモリ確保ができなかった旨のエラーと見受けられます。
なお、このエラーの解決方法として知られている、起動時に --max-old-space-size
オプションを付与するという方法も試しましたが、上述と同エラーが現出し問題解決には至りませんでした。
参考:https://blog.gaji.jp/2019/12/17/1860/
APIを数千〜数万回ほど実行する過程において日本全国あらゆる住所を引数として与えていたため、normalize-japanese-addresses
の内部で日本全国の「都道府県 - 市区町村 - 丁目」の住所情報jsonがメモリにキャッシュされていき、キャッシュがある一定サイズを超えたときにヒープ領域のメモリ確保に失敗したのかもしれません。もしくは、 normalize-japanese-addresses
自体のバグで、意図していなかったメモリリークが起きているのかもしれません。
この問題の解決方針についてご教示いただければ幸いです。
詳細なレポートありがとうございました!
念の為、nodeのバージョンも教えていただけますか?以前、nodejs 12.x でテストが JavaScript heap out of memory
というエラーで失敗し、 nodejs 14.x にアップグレードすることによって解決されました(おそらく、 axios または axios-cache-adapter のライブラリの nodejs 12.x との相性が悪い疑いですが、、)
「都道府県 - 市区町村 - 丁目」の住所情報jsonがメモリにキャッシュされていき
そうですね。キャッシュは LIFO 型、リクエスト数はこちらでカストマイズできます。 (現在、ハードコードされているリミットになりますが、カストマイズできるようにはしたほうがいいと思うので #100 作りました)
早急なご返信ありがとうございます。 nodeバージョンは以下になります。
$ node --version
v15.4.0
ありがとうございます!私の方で再現できましたらまたお知らせします。
もし気になるところがあればぜひPRを送ってもらえると助かります 👍
ちなみに消費しうるメモリの理論値は 1GBオーダーでしょうか、10GBオーダーでしょうか、それともそれ以上でしょうか。
もしサーバースペックを上げ搭載メモリサイズを増やせば解決するのであれば、そのような手段も検討しようと思います。
もし急いでいるようであれば、PM2を利用するとメモリの利用状況の監視ができ、メモリの最大値を設定して自動的に再起動させることもできますので、問題が回避されるかもですね。
https://pm2.keymetrics.io/docs/usage/memory-limit/
メモリの必要量についてはわかりかねますので、よろしくお願いいたします。
追記: PM2は一例です。他にももっといいものがあるかもですね。
@keichan34 @miya0001
アドバイスありがとうございます。また、キャッシュの調整版もありがとうございました。
まだ検証は出来ていないのですが、結果が出たら共有させていただきます。
また、別の角度からの解決アイデアなのですが、 AWS Lambda上で正規化を行うというのは如何でしょうか。
つまり「キャッシュしない」ということになるのですが、ソースコードを拝見したところjapanese-addresses のAPIエンドポイントへリクエストを投げる処理があることから、仮にキャッシュしないような使い方をすると御社のサーバへ高負荷を与える可能性があると懸念しております。
現在、弊社の normalize-japanese-addresses を使うサービスは全て AWS Lambda を利用しています。「キャッシュしない」というわけではない(一定期間内にリクエストがあれば同じプロセスが再利用されます)のですが、プロセスが生きている期間が短いのでリークの影響が抑えられています。
APIエンドポイントの負荷ですが、今のところ GitHub Pages に静的 JSON ファイルが置いてあり、今のところ負荷は気にしていません。
いつもお世話になっております。
先日は、本問題に対応いただきましてありがとうございました。
normalize-japanese-addressesのバージョンを2.0.0にアップデートし、再び動作テストをしたところ、
以前と同一と思われるエラーが発生いたしました。
取り急ぎ、環境情報や実行時ログを以下に記します。
いつもご相談に乗っていただきながら大変恐縮ではありますが、
こちらにつきましても進展がございましたら、ご助言いただけますと幸いです。
<補足>
経験則ではございますが、1000〜1500件の正規化中に、本メモリ問題が発生するように思われます。
AWS Lambda上におきましても、1000〜1500件の住所を連続的
に正規化する場合、同様の事象が発生いたしました。
■検証環境について
・CPU
→ 8コア
・メモリ
→ 16GB
・nodeバージョン
→ v14.16.1
・geoloniaのバージョン
→ "@geolonia/normalize-japanese-addresses": "^2.0.0"
■サーバ停止時のログメッセージ
$ node express_server.js
// 中略
{ text: '東京都千代田区二番町14' }
住所正規化結果
{ pref: '東京都', city: '千代田区', town: '二番町', addr: '14', level: 3 }
{ text: '大阪府大阪市西区千代崎三丁目北2番30号' }
住所正規化結果
{ pref: '大阪府', city: '大阪市西区', town: '千代崎三丁目', addr: '北2-30', level: 3 }
{ text: '長崎県佐世保市白南風町9番2号' }
住所正規化結果
<--- Last few GCs --->
[328:0x5792f00] 82644 ms: Mark-sweep (reduce) 247.1 (292.9) -> 247.0 (287.2) MB, 66.7 / 0.0 ms (average mu = 0.659, current mu = 0.091) last resort GC in old space requested
[328:0x5792f00] 82704 ms: Mark-sweep (reduce) 247.0 (259.2) -> 246.9 (258.9) MB, 60.3 / 0.0 ms (average mu = 0.477, current mu = 0.000) last resort GC in old space requested
<--- JS stacktrace --->
FATAL ERROR: CALL_AND_RETRY_LAST Allocation failed - JavaScript heap out of memory
1: 0xa04200 node::Abort() [node]
2: 0x94e4e9 node::FatalError(char const*, char const*) [node]
3: 0xb7978e v8::Utils::ReportOOMFailure(v8::internal::Isolate*, char const*, bool) [node]
4: 0xb79b07 v8::internal::V8::FatalProcessOutOfMemory(v8::internal::Isolate*, char const*, bool) [node]
5: 0xd34395 [node]
6: 0xd46c01 v8::internal::Heap::AllocateRawWithRetryOrFailSlowPath(int, v8::internal::AllocationType, v8::internal::AllocationOrigin, v8::internal::AllocationAlignment) [node]
7: 0xd19069 v8::internal::Factory::CodeBuilder::BuildInternal(bool) [node]
8: 0xd1943e v8::internal::Factory::CodeBuilder::Build() [node]
9: 0x131d346 v8::internal::RegExpMacroAssemblerX64::GetCode(v8::internal::Handle<v8::internal::String>) [node]
10: 0x100fdfa v8::internal::RegExpCompiler::Assemble(v8::internal::Isolate*, v8::internal::RegExpMacroAssembler*, v8::internal::RegExpNode*, int, v8::internal::Handle<v8::internal::String>) [node]
11: 0x102ca1e v8::internal::RegExpImpl::Compile(v8::internal::Isolate*, v8::internal::Zone*, v8::internal::RegExpCompileData*, v8::base::Flags<v8::internal::JSRegExp::Flag, int>, v8::internal::Handle<v8::internal::String>, v8::internal::Handle<v8::internal::String>, bool, unsigned int) [node]
12: 0x102d11b v8::internal::RegExpImpl::CompileIrregexp(v8::internal::Isolate*, v8::internal::Handle<v8::internal::JSRegExp>, v8::internal::Handle<v8::internal::String>, bool) [node]
13: 0x102dd1c v8::internal::RegExp::IrregexpPrepare(v8::internal::Isolate*, v8::internal::Handle<v8::internal::JSRegExp>, v8::internal::Handle<v8::internal::String>) [node]
14: 0x102dddd v8::internal::RegExpImpl::IrregexpExec(v8::internal::Isolate*, v8::internal::Handle<v8::internal::JSRegExp>, v8::internal::Handle<v8::internal::String>, int, v8::internal::Handle<v8::internal::RegExpMatchInfo>) [node]
15: 0x1081691 v8::internal::Runtime_RegExpExec(int, unsigned long*, v8::internal::Isolate*) [node]
16: 0x1401219 [node]
中止
@ekurerice なるほど。。詳細にありがとうございました。
今の所私達の環境には発生していないのですが、なにか心当たりがありそうなところが思いつけば私達の方から修正します。もし @ekurerice 様の方で修正方法がわかればいつでもプルリクエストを受け付けています 👍
お疲れ様です。非常に素晴らしいライブラリを提供してくださりありがとうございます。
私も、皆様が遭遇したように200Mbyteあたりでのout of memoryエラーに悩まされていました。
ネットによくある解決策のnodeのheap memoryの設定も何も役に立ちませんでした。
以下の記事を見つけ、もしかするとと思い、cacheRegexes.ts 149行目をコメントアウトしたところ問題なく大量データの正規化が出来ています。
https://github.com/geolonia/normalize-japanese-addresses/blob/master/src/lib/cacheRegexes.ts#L149
恐らくキャッシュを配列の形に置いているので、通常のメモリではなく、そのあたりの限界値でエラーが起きているものと思われます。現在のheap memoryの挙動を見ていると、都道府県や市区町村のデータはキャッシュされるので、メモリは順調に増えますが、150M程度になると、50Mに戻るという形で推移しています。
Maximum number of entries in Node.js Map?
https://stackoverflow.com/questions/54452896/maximum-number-of-entries-in-node-js-map/54466812#54466812
取り急ぎ、ご報告いたします。
Maximum number of entries in Node.js map! なるほど。
この可能性が非常に高い。修正を試してみますね。
ありがとうございました!
もし、可能でしたら、ローカルのjsonデータを呼びに行くパターンと、githubのjsonデータを呼びに行くパターンでモードを切り替えられるとものすごくうれしいです。
他力本願で申し訳ありません。
そうですね。。データをプリロードすることを以前検討しましたがメンテナンスが課題で先延ばしになったんですね。PRは受け付けますが今のところ優先度は低いです。。申し訳ありません。
承知いたしました!素晴らしいツールを本当にありがとうございます。
先程 v2.2.0 をリリースしました。 regexキャッシュが膨らめないようにLRUキャッシュを導入し、以前OOMで失敗していた nodejs12.x のテストを最有効にし、通るようになりました。
もし可能であればこのバージョンを試していただき、以前発生していたエラーがこのバージョンでも発生するか確認いただけると助かります。 🙇