React SSR 之 Memory Leak


前陣子 Codementor 的 web 在 server side rendering 的時候出現惹 memory leak。 在大家的努力之下總算圓滿解決。 雖然後最後找到的點並不是什麼高深的學問, 但覺得整體發現問題和解決問題的流程應該還是可以記錄一下和大家分享。

出場設定

Codementor 目前大多數的服務都是 host 在 Heroku 上面。架構上是前後端分離的, 也就是說分成了 API server 們,還有前端的 Single Page App (SPA) 們。 但因為我們需要大量的SEO,所以大多數的 web app 同時也會處理 server-side-rendering(SSR)。 也就是說,即使是前端的 SPAs,也會有相對應的 NodeJS server 來處理 SSR。 這次要分享的主題就是發生在 SSR 上面的 memory leak。

發現問題

在某一個夜深人靜的晚上,突然間 Slack 的 system_status channel 上開始警鈴大作, 出現了各種 timeout。 同時 Newrelic 的 monitor 也傳出災情。 隔天早上上班後,大家開始比對各種數據線圖 簡直像在看股票的走勢一樣 最後終於發現是某一個 SPA 做 SSR 的 server 出現了 memory leak。(註1)

在 Heroku 上面的 memory usage graph 大概長這樣:

migrate-legacy-schema--before

快速解

在找到出問題的 service 之後,由於這樣的狀況是會影響到 user 的日常使用的, 於是我們立馬:

  • 先把 Heroku dyno scale up (機器開大先)
  • 並且設定定時重開的 cron job。至於重開的頻率,我們從 memory 的圖來看,希望在 memory 爆掉之前就先重開,所以以我們這次的狀況是設定在四小時一次。

然後就 假裝這個問題解決了,本篇完結 可以開始認真 debug

在 Debug 之前

通常要找 memory leak,常見的作法是直接看 heapdump 之類的東西。 但以我們這次的狀況,其實發現得還算早,所以我們猜想要直接從 deploy 的時間找到問題發生的點,應該不會太困難才對。 所以一開始的策略是設定成:先從簡單的 deployment 時間去找看看,真的不行再用 hard-core 的作法 :p

來 Debug 吧

首先,從上面 memory usage 的圖看起來,memory 的用量是穩定地成長,而不是在某些時候突然大跳躍上去。 這告訴我們,造成 memory leak 的原因,應該是一個 “在很多 route 都會被使用到的點”,而不是某一個特定的 route。

接著,我們想先找到出現 memory leak 的 deploy。 從 Heroku 的記錄看來,memory leak 是在 af1665e1 這個 deploy 出現的。從下圖 git commit 的記錄,這個 deploy 是跟一個叫 feature-request 的 feature 有關。

migrate-legacy-schema--before

這樣我們就可以從 git 的歷史記錄裡面,找到該 deploy 改動的所有 code:

git diff <STARTING_COMMIT> <ENDING_COMMIT>

在有了一個可能的範圍之後,我們開始看在那個 deploy 當中,和 server rendering 有關的部份。 然後發現了兩個可疑的 containers 是在這個 deploy 中才放進 server rendering 的。 又因為這兩個 container 剛好其實和 SEO 無關,所以我們試著在 server side 不 render 它們推一版試看看。 結果登登登~ memory 的問題果然消失惹! 🎉🎉🎉

找到問題的核心

在確定了問題發生的大範圍之後,接著當然就是要找到為什麼這些改動會造成 memory leak。 因為只有真的知道問題發生的原因,之後才不會不小心再踩中阿阿

加快 feedback loop

由於上面被我們從 server rendering 拿掉的兩個 container, 其中還包含了各種 components, library 等等, 所以要找到問題點,勢必要加快 feedback loop 才行。 也就是說,如果每次都要推到 production 來試在這時候是不行地!

要加快整體的 feedback 速度,如果可以在 local reproduce 出這個狀況就太棒惹! 我們試著在 local 把 server 跑起來,然後每打一個 request 就看一次 memory 的狀況,然後把 data 印出來看看:

# usage: $ ruby memory.rb <pid> <rounds>

require 'httparty'

PID = ARGV[1]

def get_memory
  HTTParty.get('http://localhost:3000/the-path-with-issue')
  res = `pmap #{PID} | tail -n 1`
  res.split(' ')[1].gsub('K', '').to_f / 10**3
end

first_memory = nil
last_memory = nil

rounds = ARGV[0].to_i

rounds.to_i.times do |i|
  usage = get_memory
  case i
  when 0
    first_memory = usage
  when rounds - 1
    last_memory = usage
  end

  if i % 10 == 0
    p "time: #{i}, memory: #{usage} mb"
  end
end

p "memory diff after #{rounds} calls: #{last_memory - first_memory} mb"

上面的 code 是用 ruby 寫的,但概念上是用 pmap 這個指令。 理論上應該可以用各種語言都可以實作出來。 如此一來,我們就可以在 local 看到 memory 的狀況惹!

但接下來的問題就是,怎樣的結果才算是有 reproduce 出來,或者說怎樣的結果才算是沒有 memory leak 呢?

Memory Leak 小介紹

在預期要看到怎樣的結果之前,首先我們必須對 memory leak 有一個基本的了解。 根據 wiki 上的定義:

In computer science, a memory leak is a type of resource leak that occurs when a computer program incorrectly manages memory allocations in such a way that memory which is no longer needed is not released.

概念上就是說,沒有用到的 memory 卻沒有正確的被回收掉的意思。 而在像 JavaScript 這類有 garbage collection(GC) 的語言,其實我們已經不用(也不能)直接管理 memory 了。 但還是會有 memory leak 的狀況。撇開語言實作本身不談的話(就是只討論 code 沒寫好), 大多是因為 有不預期的 shared object

像是:

等等。

回到上面的問題: 怎樣的結果才算是有 reproduce 出來,或者說怎樣的結果才算是沒有 memory leak 呢?

理論上隨著我們一直打 request 到 nodejs 的 server,memory 的用量是會慢慢變多的。 但只要可以被 GC 有效率的回收都不是太大的問題。 所以我們要打 request 打一定的次數讓 GC 可以開始運作,就可以看出 GC 是不是可以有效率的回收 memory 了。

Local 的假設:

在這邊我們其實是去看 dev mode 的 memory 用量,並且假設如果只看 diff 的話,它的行為會是看 production 差不多的。 在上面的設定之下,我們試了:

  • 已知有 memory leak 的 build
  • 已知沒有 memory leak 的 build

結果兩者的結果真的有明顯的差距,証明這樣的假設是有一定的參考價值, 於是可以開始找 debug 囉!

開始找來找去

接下來就是要找到,究竟是哪一行 code 造成 memory leak 的呢? 這個部份其實就是把一些 code 拿掉看看,看 memory 的狀況會不會有所改善。 雖然說乍看之下好像蠻無腦的,但實際上操作起來也是有一點點小撇步在。

首先,如果我們 React 的 container/component 看成一個樹狀結構的話,那我們會從最上面(最靠近 root)的地方開始拿掉。 並且要注意的是,有些 life cycle method 是跟 server rendering 無關的,像是 componentDidMount, componentWillUnmount 等。

在經過了一番苦戰,終於找到了是因為我們在某一個 higher order component(HOC) 裡面, 不預期的讓一個 event emitter 在 server 的 request 間被共用, 而在這個 HOC 的 constructor 裡面,會綁上幾個 event handler。 也就是說每被 request 一次,在 server 上的這個 event emitter 就會多綁上幾個 event handler。 久而久之自然就爆炸惹。

Post-mortem

在把 fix 推上去之後,我們召開了一個快速的會議跟所有 dev team 的成員們分享了整個事情的來龍去脈。 並且把這整個事件的過程,包含每一個動作和它的時間點, 都記錄在一個叫 post-mortem 的文件裡面, 讓之後的人如果遇到類似的問題可以少踩中一些雷。

這些看起來小小的動作和記錄,累積起來就會變成團隊的重要資產, 可以把經驗有效率的傳承下去。

小結

以這次的事件來說,我認為過程還算是順利的。但這主要要歸功於一些基礎建設的幫助。像是:

Monitoring & alert

基本上好的 monitoring 和 alert 就像是一張防護網一樣。當有問題發生的時候,它們可以讓我們更快速的找到問題發生的點。 再者,當狀況發生的時候,有各種 alert 提醒我們,讓我們不至於要等到事後看報紙才知道。 對於我們來說,monitoring 和 alert 是一個要持續維護的東西。 跟人與人之類的關係一樣 意思是說,它不是一個現在做好了以後就會一直好好的東西, 而是要持續地觀察改進。 概念上,我們希望同時把 false-positive 跟 false-negative 都降到最低。 我們不希望有重大的事件發生了但 alert 卻沒有響 (false-negative), 但也不希望一些雞毛蒜皮的事情發生 alert 就在那邊大叫 (false-positive)。

而這一切會隨著團隊的運作方式和系統的演進而改變,所以要靠團隊隨時照顧它讓它保持在最好的狀態。

Deployment frequently

目前在各種地方都顯示,deploy 的頻率和一個軟體團隊的生產力有高度相關。 Deploy 的頻率高同時也意味著,在同一個 deployment 改動的範圍變小了。 以這次的狀況來說,這讓我們在 debug 的時候變得容易許多。 至於要怎麼讓 deploy 變快,又怎麼讓我們在頻繁的 deployment 下又可以維持整個系統的穩定, 那就是另一個故事了。

Git convention

以這次事件來看,另一個幫助到我們的點則是我們使用 git 的規範。 我們會在從 feature branch 要 merge 回 develop 的時候,先 rebase 一次。 如此一來,一個 feature 在 git 的 commit history 的圖上就會是一個小圈圈。 這樣讓我們不管是要 rollback 或者要看 diff 的時候都方便許多。

總結

我認為每個團隊都會有各種不同的習慣去管理/自動化各種流程上的東西。 而在有個種狀況,像是 memory leak, system outage, critical bug 等等時候,這些好的習慣就會挺身而出救我們一條小命。 每個團隊的習慣都不同,所以如果說想要找到 “最好” 的工具或流程,其實意義不是特別大。 但不變的是,如果團隊可以透過各種機會,持續地去改善各個環節,那在之後遇到類似的問題的時候就會更遊刃有餘。 勸世完畢

註1

其實要找到出問題的 service 有時候並不是那麼容易,甚至不一定找得到。 但那就是另一個故事惹~