概述
在值班過程中,排查了一個erlang otp24的bug.
erlang 編譯模塊在 otp/erts/emulator/beam/jit/x86/beam_asm.cpp:update_gdb_jit_info 中有內存泄露。只分配不釋放。
每次泄露 1K 左右,泄露和編譯模塊描述信息大小相關(模塊名,函數名,數量)。
因為otp24已經超過維護期了,所以提交的 issue 沒有被處理:
https://github.com/erlang/otp/issues/10062
所有的內存泄露,都可以用頻率分析的方式來快速定位。不管什麼語言的應用,只要是linux ELF, windows PE, 都可以用 coredump + 頻率分析定位問題。
排查過程
確認應用有內存泄露
排查步驟
收到上個值班同學的事項,開始排查:
麻煩看下xxx週末出現OOM了,是不是實例規格配置的太低了。
- 通過k8s監控,主要的內存是system。運行了很多天。2025-xx-xx xx:xx 重啓。監控可能有誤。容器監控內存較 erlang vm 監控小。
- 通過k8s監控,發現 RSS 有長期上漲趨勢。
- 通過k8s監控,其它應用沒有長期上漲趨勢。
- 通過k8s監控,其它環境的同一個應用有長期上漲趨勢,確認是應用問題。
- 通過進入容器top,確認是erlang應用佔用的內存。
-
對應的內存泄露用 :erlang.memory() 無法觀測。
階段性結論
- xx 應用有bug。
-
erlang memory看不出來,高度懷疑是c malloc的泄露。
確認泄露點
我將排查過程整理成文檔,交給了業務負責同學。業務負責同學快速定位到了泄露點並修復:
strings core.28 | sort | uniq -c | sort -nr | head -n 200
157933 'Elixir.xxx_module':xxx_method/0-CodeInfoPrologue
157854 elixir_compiler_1
157783 elixir_compiler_1:module_info/1-CodeInfoPrologue
157783 elixir_compiler_1:module_info/1
157783 elixir_compiler_1:module_info/0-CodeInfoPrologue
157783 elixir_compiler_1:module_info/0
157783 elixir_compiler_1::codeHeader
157783 elixir_compiler_1::codeFooter
157783 elixir_compiler_1:'__RELATIVE__'/0-CodeInfoPrologue
157783 elixir_compiler_1:'__RELATIVE__'/0
157783 elixir_compiler_1:'__MODULE__'/1-CodeInfoPrologue
157783 elixir_compiler_1:'__MODULE__'/1
泄露點在於重複的模塊編譯。我們的代碼類似如下,不應該有泄露:
def build_test_mod(mod \\ TestModule) do
Code.compiler_options(ignore_module_conflict: true)
ast = {:__block__, [], [build_block()]}
Module.create(mod, ast, file: "test_module.ex", line: 1)
end
定位otp24編譯時的內存泄露
- 閲讀代碼,看Module.create 做了什麼,刪除模塊做了什麼?模塊名重複有什麼影響,閲讀 elixir, erlang 的文檔和代碼,沒有發現參數誤用情況。
-
構建最小重現用例:
test "global const" do total_time = 17317 emu_flavor = :erlang.system_info(:emu_flavor) Logger.warn("emu_flavor: #{emu_flavor} total_compile_time:#{total_time}") for idx <- 1..total_time do GlobalConst.delete(GlobalMap) GlobalConst.new(GlobalMap, %{qwerty_key: "qwerty_value", qwerty_key2: "qwerty_value2"}) "qwerty_value" = GlobalMap.get(:qwerty_key) # Logger.warn("idx: #{idx}") if rem(idx, 1000) == 0 do Logger.warn("idx: #{idx}") end end Logger.warn("complete:#{total_time}") :timer.sleep(600_000) - 和 emu 模式做對比:發現只有 jit 模式下有問題。因為同樣接口調用下,emu模式無問題,開始懷疑 erlang jit 可能有bug。
- 看 jitallocator 代碼,因為 jit 需要將內存中修改為可執行,我不清楚可執行段是否會動態變化。用 objdump 等工具觀察 coredump,沒有段信息。從 CodeInfoPrologue 等 magic string 出發,調試了一番後,未發現泄露點。
-
開始換用源碼編譯的otp24,從 magic string 附近的代碼找到泄露點,修改代碼確認問題。
驗證:
-註釋掉 update_gdb_jit_info 後內存泄露消失。
-gdb 調試,記錄 symfile 的地址,跑一段時間之後,再打印內存,內容和開始分配的一樣,説明沒有釋放。
一個有用的gdb函數:define xac dont-repeat set $addr = (char *)($arg0) set $endaddr = $addr + $arg1 while $addr < $endaddr printf "%p: ", $addr set $lineendaddr = $addr + 8 if $lineendaddr > $endaddr set $lineendaddr = $endaddr end set $a = $addr while $a < $lineendaddr printf "0x%02x ", *(unsigned char *)$a set $a++ end printf "'" set $a = $addr while $a < $lineendaddr printf "%c", *(char *)$a set $a++ end printf "'\n" set $addr = $addr + 8 end end document xac usage: xac address count end輸出效果:
(gdb) xac 0x7c59c8045d50 1000 0x7c59c8045d50: 0x20 0xf2 0x05 0xd8 0x59 0x7c 0x00 0x00 ' ��Y|' 0x7c59c8045d58: 0x50 0x55 0x05 0xc8 0x59 0x7c 0x00 0x00 'PU�Y|' 0x7c59c8045d60: 0x20 0xc3 0x05 0xc8 0x59 0x7c 0x00 0x00 ' ��Y|' 0x7c59c8045d68: 0x52 0x02 0x00 0x00 0x00 0x00 0x00 0x00 'R' 0x7c59c8045d70: 0x2f 0x30 0x00 0x00 0x00 0x00 0x00 0x00 '/0' 0x7c59c8045d78: 0xe5 0x03 0x00 0x00 0x00 0x00 0x00 0x00 '�' 0x7c59c8045d80: 0x12 0x00 0x00 0x00 0x00 0x00 0x00 0x00 '' 0x7c59c8045d88: 0x80 0x81 0x70 0xf6 0x58 0x7c 0x00 0x00 '��p�X|' 0x7c59c8045d90: 0x60 0x08 0x00 0x00 0x00 0x00 0x00 0x00 '' 0x7c59c8045d98: 0x45 0x6c 0x69 0x78 0x69 0x72 0x2e 0x47 'Elixir.G' 0x7c59c8045da0: 0x6c 0x6f 0x62 0x61 0x6c 0x4d 0x61 0x70 'lobalMap' 0x7c59c8045da8: 0x00 0x80 0x81 0x70 0xf6 0x58 0x7c 0x00 '��p�X|' 0x7c59c8045db0: 0x00 0x90 0x82 0x70 0xf6 0x58 0x7c 0x00 '��p�X|' 0x7c59c8045db8: 0x00 0x45 0x6c 0x69 0x78 0x69 0x72 0x2e 'Elixir.' 0x7c59c8045dc0: 0x47 0x6c 0x6f 0x62 0x61 0x6c 0x4d 0x61 'GlobalMa' 0x7c59c8045dc8: 0x70 0x3a 0x3a 0x63 0x6f 0x64 0x65 0x48 'p::codeH' 0x7c59c8045dd0: 0x65 0x61 0x64 0x65 0x72 0x00 0x90 0x82 'eader��' 0x7c59c8045dd8: 0x70 0xf6 0x58 0x7c 0x00 0x00 0xc0 0x82 'p�X|��' 0x7c59c8045de0: 0x70 0xf6 0x58 0x7c 0x00 0x00 0x27 0x45 'p�X|'E' 0x7c59c8045de8: 0x6c 0x69 0x78 0x69 0x72 0x2e 0x47 0x6c 'lixir.Gl' 0x7c59c8045df0: 0x6f 0x62 0x61 0x6c 0x4d 0x61 0x70 0x27 'obalMap'' 0x7c59c8045df8: 0x3a 0x27 0x5f 0x5f 0x69 0x6e 0x66 0x6f ':'__info' 0x7c59c8045e00: 0x5f 0x5f 0x27 0x2f 0x31 0x2d 0x43 0x6f '__'/1-Co' 0x7c59c8045e08: 0x64 0x65 0x49 0x6e 0x66 0x6f 0x50 0x72 'deInfoPr' 0x7c59c8045e10: 0x6f 0x6c 0x6f 0x67 0x75 0x65 0x00 0xc0 'ologue�' 0x7c59c8045e18: 0x82 0x70 0xf6 0x58 0x7c 0x00 0x00 0xf0 '�p�X|�' 0x7c59c8045e20: 0x83 0x70 0xf6 0x58 0x7c 0x00 0x00 0x27 '�p�X|'' 0x7c59c8045e28: 0x45 0x6c 0x69 0x78 0x69 0x72 0x2e 0x47 'Elixir.G' 0x7c59c8045e30: 0x6c 0x6f 0x62 0x61 0x6c 0x4d 0x61 0x70 'lobalMap' 0x7c59c8045e38: 0x27 0x3a 0x27 0x5f 0x5f 0x69 0x6e 0x66 '':'__inf' 0x7c59c8045e40: 0x6f 0x5f 0x5f 0x27 0x2f 0x31 0x00 0xf0 'o__'/1�' 0x7c59c8045e48: 0x83 0x70 0xf6 0x58 0x7c 0x00 0x00 0x20 '�p�X| ' 0x7c59c8045e50: 0x84 0x70 0xf6 0x58 0x7c 0x00 0x00 0x27 '�p�X|'' 0x7c59c8045e58: 0x45 0x6c 0x69 0x78 0x69 0x72 0x2e 0x47 'Elixir.G' 0x7c59c8045e60: 0x6c 0x6f 0x62 0x61 0x6c 0x4d 0x61 0x70 'lobalMap' 0x7c59c8045e68: 0x27 0x3a 0x63 0x6d 0x70 0x2f 0x31 0x2d '':cmp/1-' 0x7c59c8045e70: 0x43 0x6f 0x64 0x65 0x49 0x6e 0x66 0x6f 'CodeInfo' 0x7c59c8045e78: 0x50 0x72 0x6f 0x6c 0x6f 0x67 0x75 0x65 'Prologue' 0x7c59c8045e80: 0x00 0x20 0x84 0x70 0xf6 0x58 0x7c 0x00 ' �p�X|'
總結
- 所有的內存泄露,不管什麼語言的應用,只要是linux ELF, windows PE, 都可以用 coredump + 頻率分析定位問題,這次的泄露是可讀字符串,對人類友好,頻率分析並不要求字節是可讀字符串。
- 開源組件出問題的可能性還是不小的,雖然一般先從自己代碼開始懷疑,在細心檢查後,可以大膽懷疑底層的問題。
- 從 elixir, erlang 一層層看下去,花了較多時間。在有代碼的情況下,完全不需要那麼多步驟,直接從 CodeInfoPrologue 開始找泄露點,修改代碼來驗證,會比較有效率。還是第一點,開始沒懷疑 erlang 會有bug,但是,如果是erlang層無法監測到的泄露,必然不可能是 elixir,在不瞭解全局的情況下,直接從 magic string 出發找泄露點,也是可行的,不一定非得知道整個過程中幹了什麼,分配了哪些內存,直接從 magic string 的地方 gdb 看調用棧。
- valgrind 的 memory leak 檢測對此次的泄露是有效的,valgrind 可以跟中分配的內存,懸垂的部分,並按調用棧聚合。erlang 啓用 valgrind 需要特別的編譯選項。