超神奇!Ruby 容器不用停也能找出記憶體問題!

從去年開始跟朋友營運的 Open Unlight 遊戲專案在前幾個月觀察到疑似記憶體洩漏(Memory Leak)的現象,不過因為這是一個相對龐大的開源專案也因此不是那麼容易的去進行調整跟修正。

剛好最近參加了 TGONext 能跟許多優秀的工程師討論就在 Slack 群組提出來討論,才知道其實像是 Java 或者 .NET 這類語言大多支援對正在運行的服務進行分析(Profiling)的功能。

那麼,我們可以對正在跑的 Ruby 做分析嗎?

Application Performance Monitor

對大多數的 Ruby 開發者來說通常是寫 Rails 而最常見的就是使用 rack-mini-profiler 來幫助開發,不過在正式環境上大多不太會使用,畢竟需要消耗額外的記憶體跟運算資源來處理。

不過使用 Rails 開發的知名論壇系統 Discourse 會讓登入的管理員可以看到。

如果是在開發環境下,我們也有很多像是 Ruby Prof 或者 Memory Proflier 這類工具來做更精細的檢查。

在更多情況下,我們大多會在正式環境上使用 Application Performance Monitor 來進行追蹤,除了業界知名的 Datadog 之外,也還有像是 ScoutAPM 這類服務可以使用,不過實際上使用起來還是有不少限制。

像是有些比較陽春的 APM 服務是無法追蹤記憶體的使用,以及文章開頭提到的遊戲伺服器大多不會被 APM 所支援,需要自己穿插對應的追蹤程式才能夠了解到整個應用的效能狀況,反而會成為另外一個開發上的限制。

像是 Ruby on Rails 這類框架其實在設計時已經有將 Instrumentation 的機制加入,因此我們可以直接向 Rails 詢問資訊,並且轉換成 APM 或者 Metrics 資訊交給其他服務監控跟儲存。

rbspy

雖然名字叫做 rbspy 也是用在 Ruby 的工具,不過他是一個間諜 Ruby 工具。rbspy 是由 Rust 所撰寫的,雖然都是 R 開頭的語言不過設計理念跟撰寫方法都不大相同,但是 Rust 和 Golang 這兩個這幾年比較熱門的語言也有著 Ruby 所沒有的優點,他們都是用來設計作為 C 語言的替代方案,因此我們可以預設有些特性是跟 C 語言類似的只是做了不同的取捨,有興趣的話也可以接觸看看。

要使用 rbspy 的話也很簡單,到 rbspy 的網站找到下載連結,抓到伺服器裡面就可以直接使用,如果想嘗試從原始碼編譯也可以不過並不推薦。

Rust 因為語言的設計理念,一定程度上犧牲了編譯的時間這也表示你會花費很多時間在編譯上。

使用上也非常容易,我們只需要告訴 rbspy 你正在執行的 PID (Process ID) 就可以看到一些訊息,如果在效能上有一些問題的話可以很方便的找出有問題的方法。

不過這個工具實際上並不適合我需要處理的 Memory Leak (記憶體洩漏)的狀況,遊戲伺服器本身在原始可以看出來設計者其實對於演算法跟記憶體的控管還是非常有經驗的,遊戲的順暢度除了去年發現缺少了一個 Index 的問題之外實際上並沒有太多影響遊戲的的卡頓。

另外要注意的是紀錄的時間,雖然文件上說對伺服器影響不大。但是長時間的紀錄必定會造成記憶體的消耗,如果是需要花很久才能追蹤到的效能問題可能就要在另外考慮是否在程式設計上有遺漏的地方,才會出現很少呼叫卻嚴重影響速度的情況。

rbtrace

rbspy 一樣是在文件上說可以「在正式環境使用」的工具之一,同時也具備了可以在不停止 Ruby 執行的狀況下進行分析。不過實際上使用起來還是有一些限制的,首先要使用的話需要安裝 rbtrace 的 Gem 並且在應用程式中進行 require 才能夠生效。

rbtrace 的運作方式則是透過 IPC (Inter-Process Communication) 的方式來溝通,這也是為什麼 rbrace 允許我們對正在執行的 Ruby 進行 eval 來執行任意的 Ruby 程式碼的原因。

不過既然能夠進行 eval 的話,實際上就是有安全性上的疑慮,雖然「能在正式環境使用」但不代表我們必須持續的開啟,以 GitLab 的做法可以看到一個相對恰當的處理。

  1. Gemfile 裡面使用 gem 'rbtrace', require: false 避免被 Bundler 自動載入
  2. 加入 ENABLE_RBTRACE 的 Feature Flag 來控制開啟跟關閉

如此一來我們就只會在 ENABLE_RBTRACE 有設定的狀況下才會啟用,對於 Web 服務來說其實是相對容易的,只需要調整設定後重新啟動。另一方面假設 Web 節點眾多的時候,我們可以只選擇其中一個來進行分析跟追蹤,這樣就能夠讓被攻擊的機率逐步降低。

雖然使用 IPC 在大多數情況下是很難從 Web 打穿到伺服器來進行攻擊,不過我們同時也很難保證正在運行的服務剛好有這樣的漏洞,如果 rbtrace 正好還開啟的話就讓攻擊者更容易利用,因此在使用上需要更加小心謹慎。

至於使用方式除了 rbtrace 預先設計好的幾種機制之外,因為我們可以進行 eval 的處置,就能夠任意的插入我們需要的追蹤並且只改變特定的行為來監控。

rbtrace 這樣的功能正好符合我需要追蹤記憶體狀況的需求,除了基本的 GC 資訊之外,還能夠透過呼叫像是 ObjectSpace 來取得更多有用的資訊。

另外推薦大家可以參考看看前面提到的 APM 服務 Skylight 的一篇技術文章所使用的技巧,透過定時將存在的物件資訊記錄起來後,比較過了一段時間後還持存活的物件來追蹤有哪些物件應該被釋放而沒有被釋放掉。搭配 rbtraceeval 功能和 ObjectSpace#_id2ref 這個方法,我們還能夠在運行中的 Ruby 將原始的物件呼叫出來進行檢查。

使用 Container 的限制

看到這裡的話首先要恭喜你通過第一階段的考驗,已經瞭解了我們大致上該怎麼對一個普通的 Ruby on Rails 或者 Ruby 程式進行除錯,不過這次我們的專案是透過 Docker 來運行的,這是因為 Unlight 這款遊戲在伺服器啟動的配置步驟略為繁瑣,另一方面在我們這個團隊(Open Unlight)的理念下是希望能夠以開源的形式釋出,而容器(Container)技術可以幫助不熟悉的人快速搭建環境。

不過前面提到的 rbspyrbtrace 都是要去執行一段指令,或者加入一些執行檔到容器裡面。這樣的做法其實有點違反容器在使用上的一些建議,首先我們應該將容器視為不可變的(Immutable)有點類似我們進行 Git Commit 之後就需要用新的 Commit 來蓋過原本的修改一樣。

基於不可變這樣的設計方式,我們才能夠做到容易在不同版本之間切換的特性,以及透過 Layer 疊加修改來減少每次製作鏡像(Image)的大小。

另一方面,為了減少容器的大小我們通常會盡可能刪減容器的內不必要的檔案。這樣才能夠在部屬的時候爭取到更短的部署時間來面對增長的流量,如果部署一個容器需要下載 1G 以上的檔案並且花上數分鐘的話,這跟我們直接使用 AWS / GCP 的 Auto-Scale 機制就沒有太大的差別。

由此可見如果想把我們的 Rails 專案透過容器技術來部署的話,實際上就會偏向使用 Puma 等相對輕巧的 Web Server 和將 Assets 改為 CDN 部署,如此一來平均的 Rails 鏡像大小能控制到 100 MB ~ 300 MB 左右,如果使用 Passenger 的話就會增長到 600 MB ~ 800 MB 反而是不太適合的。

微服務與 Sidecar

假設我們希望 Ruby 容器不做出改變,要如何才能讓 rbspy 或者 rbtrace 繼續被使用呢?在跟 TGONext 的導師、組員討論的過程中,導師給了一個在 Microservice(微服務)會使用的一種監控機制 Sidecar (邊車)

如果我們去查詢 Kubernetes 的相關文章中,其實蠻容易找到 Sidecar 的相關資訊,簡單來說 Sidecar 負責各類管理主要應用的工作,包括監控應用的存活、生命週期等等,而監控(Mointing)不就正是我們要做分析(Profiling)的目標之一嗎?

這就要來討論一下 Kubernetes 的 Pod 設計是一個非常優秀的想法,以往我們使用像是 Docker Compose 這類工具時會把一個服務當作一個單位,但是在 Kubernetes 中使用 Pod 當作單位的巧思是否剛好就能夠對應這樣的問題了?

在幾年前剛接觸的時候,我原本以為 Kubernetes 部署 Rails 會是像這樣:

apiVersion: v1
kind: Pod
metadata:
  name: rails-app
spec:
  containers:
  - name: web
    image: my-rails-app
    command: ["bundle", "exec", "rails", "server"]
    volumeMounts:
    - name: varlog
      mountPath: /app/tmp
  - name: worker
    mountPath: my-rails-app
    command: ["bundle", "exec", "sidekiq"]
    volumeMounts:
    - name: varlog
      mountPath: /app/tmp

如此一來就能夠讓 Rails 跟 Sidekiq 共用並處理上傳檔案,但實際上是有點詭異的。

  1. Workder 的數量跟 Web 綁定
  2. Sidekiq 不一定會剛好取得這個 Web 節點上的檔案

實際上的處理應該是要讓 Web 和 Worker 分成兩種 Pod 來處理,而檔案的部分就會需要借助先上傳到 AWS S3 或者 GCP Bucket 上面,在 Worker 要處理時下載回來的方式。由此可見 Rails 專案想要改為容器來使用並不一定是可以很簡單達成的,使用容器技術不一定會是萬靈藥。

從前面這個例子來看,實際上 Pod 除了主要應用程序之外其他會一起被啟用的容器應該會是各類 Sidecar 來輔助這個主要的應用,而不是另一個服務。

這也很值得我們反過來思考,當 Microservice (或者說 Kubernetes)的架構和性質是這樣的時候,我們實際上真的有減少伺服器的資源使用嗎?在沒有一定的規模下,是否變成開了大量佔用少量資源的應用,反而讓系統資源的利用變得更差了呢?

利用 Sidecar 對容器做 Profiling

當我們決定要以 Sidecar 來對容器做分析的時候,會發現另外一個問題阻礙我們進行。基本上容器會跟其他容器是隔離(Isolated)的狀態,這表示我們無法從一個容器去存取另一個容器,這也是在安全性上的考量。實際上我們之所以能夠快速的啟動應用,也是因為容器技術是採用隔離的方式將命名空間(Namespace)切割開來,讓不同的行程(Process)互相獨立,來做到類似虛擬機(Virtual Machine)的效果,不過以安全性來說因為虛擬機會模擬一個完整的硬體在隔離的安全性上來說會比容器要好的多,這也是為什麼 Google 會要開發一些像是 gVisor 這類工具,以及 Docker 等服務都提供線上掃描的機制來加強容器的安全性,因為一旦被突破影響的範圍會是整台伺服器。

除了容器技術之外還有像是 chroot 這些類似的應用,有興趣的話也可以嘗試看看。

既然我們了解到了容器實際上是透過隔離行程(Process)來做到類似在 Linux 中開啟虛擬機的效果,那麼這也表示他可能也允許我們將不同的容器放在同一個命名空間中來達到互相溝通的目的。

以大家比較常使用的 Docker 來說,就提供了 --pid 選項讓我們可以將我們新啟動的容器附加到另外一個容器的命名空間上,只要能夠達成這一點 rbspy 就可以被正常的使用。

rbspy 的場合

首先,我們先啟動一個普通的 Ruby 容器來作為測試,先建立一個執行無限迴圈的 Ruby 容器讓他保持開啟。

docker run --rm -d --name ruby-app ruby:2.6.5-alpine ruby -e 'loop { sleep 1; }'

rbspy 並不一定會支援最新版本的 Ruby 在撰寫本文的時候最新只能支援到 2.6.5 版而已,如果專案很快就更新到最新版的話可能會無法使用。

接下來我們先不使用 --pid 參數,直接呼叫 ps aux 來看看會看到怎樣的訊息

docker run --rm -it ruby:2.6.5-alpine ps aux

我們會發現當下的 ps 指令的 PID 會是 1 也就是第一個執行的程式,那麼我們再加入 --pid 的選項指定要附加到這個 Docker 容器上來試試看。

docker run --pid container:ruby-app --rm -it ruby:2.6.5-alpine ps aux

如果操作正確的話,我們會看到類似下面這樣的結果:

[elct9620] Desktop % docker run --pid container:ruby-app --rm -it ruby:2.6.5-alpine ps aux
USER       PID %CPU %MEM    VSZ   RSS TTY      STAT START   TIME COMMAND
root         1  0.0  0.3  72388 12308 ?        Ss   02:16   0:00 ruby -e loop {
root         6  0.0  0.0   1612   536 pts/0    Rs+  02:22   0:00 ps aux

現在我們已經可以順利跟我們的 Ruby 應用程式共用命名空間,也因此可以看到我們正在跑的 Ruby 了,接下來改用 rbspy 來觀察看看這個 Ruby 行程。

我們可以先寫一個簡單的 Dockerfile 來製作專門用來跑 rbspy 的鏡像,之後只要發佈到正式環境的 Registry 上就能夠在需要的時候使用。

FROM alpine

RUN apk add --update --no-cache curl
RUN curl -L https://github.com/rbspy/rbspy/releases/download/v0.3.8/rbspy-v0.3.8-x86_64-unknown-linux-musl.tar.gz | tar zxv -C /usr/local/bin

使用指令製作鏡像

docker build -t rbspy .

然後用同樣的方法來觀察我們的 Ruby 容器

docker run --pid container:ruby-app --cap-add=SYS_PTRACE --rm -it rbspy rbspy record --pid 1

這樣就可以順利看到 ruby-app 裡面我們正在呼叫的 loop 方法,不過因為 loop 是透過 C 設計的,我們只能看到類似 <c function> - unknown 這樣的資訊,大家可以用自己的專案來測試看看。

這邊有特別加入了 SYS_PTRACE 的權限,這是因為像是 rbspy 這類工具通常會需要一些系統權限,在一般的伺服器上我們可以利用 sudo 指令來透過管理員權限執行,但是在容器中為了安全會關掉大部分可以存取系統的權限因此我們需要手動開放給這個用來分析的容器使用。

rbtrace 的場合

要使用 rbtrace 就沒有 rbspy 那麼容易,首先我們需要在我們的專案中加入 rbtrace 這個 Gem 來測試,為了方便實驗我們用 Rack 撰寫一個非常簡單的 Web 服務來做測試。

# Gemfile

# frozen_string_literal: true

source "https://rubygems.org"

git_source(:github) {|repo_name| "https://github.com/#{repo_name}" }

# gem "rails"

gem 'rbtrace', '0.4.12'
gem 'rack'
gem 'puma'

新增完 Gemfile 之後,使用 bundle install 來安裝環境,要注意的是 Bundler 的版本最好是 1.17.3 因為 ruby-2.6.5 內建的 Bundler 還沒有更新到 2.x 版本。

然後加入一個 config.ru 檔案來撰寫簡單的伺服器內容:

# frozen_string_literal: true

require 'rbtrace'

app = lambda { |_| [200, { 'Content-Type' => 'text/plain'}, ["Hello World"] ] }

run app

這邊並沒有使用 Bundler.require 所以不會自動載入 Gemfile 內所有的 Gem 因此要手動的把 rbtrace 引用

接著撰寫一個 Dockerfile 讓我們可以用 Docker 來啟動服務。

FROM ruby:2.6.5-alpine

# Ruby 安裝 C Extension 的必要套件
RUN apk add --update --no-cache build-base

# 設定專案目錄
ENV APP_ROOT /src/app
RUN mkdir -p $APP_ROOT
WORKDIR $APP_ROOT

# 安裝 Gem
ADD Gemfile Gemfile.lock $APP_ROOT/
RUN bundle install

# 加入專案
ADD . $APP_ROOT/

# 容器設定
EXPOSE 9292
VOLUME ["/tmp"]

做完之後就可以用 docker build -t ruby-app . 指令來製作測試用的鏡像專案。

我們先啟動我們的 Web 服務並且測是是否正常。

docker run -d --rm --ipc=shareable -p 9292:9292 --name ruby-app ruby-app rackup -o 0.0.0.0

這邊指令看起來稍微複雜,不過主要是讓 Rack 接收任意 IP 位置的連線以及將 Port 暴露出來,唯一需要注意的是 --ipc=shareable 這個設定項,在官方文件的說法是不一定需要加入這個設定,在不同版本下可能會有不一樣的設定存在。

接下來應該就可以用 curl 指令觸發伺服器回傳 Hello World 的訊息

curl localhost:9292
# => Hello World

然後我們再製作 rbtrace 的 Docker 鏡像讓我們可以透過另一個容器來觀察這個網頁伺服器。

FROM ruby:2.6.5-alpine

RUN apk add --update --no-cache build-base
RUN gem install rbtrace -v 0.4.12

使用 docker build -t rbtrace . 指令來製作鏡像,這邊如果有仔細看的話會發現不論是應用還是 rbtrace 都有指定版本。這是因為 rbtrace 在某些指令會需要引用某些 rbtrace 的檔案,如果版本不同或者 Gem 的安裝路徑不一樣的話這個就會發生找不到檔案的情況。

在容器裡面或者一般的伺服器中就不會發生這樣的狀況,會造成這個現象的原因是因為呼叫時會參考我們 rbtrace 容器的路徑資訊而不是我們要分析的容器內的資訊,因此在製作時需要特別注意將兩邊的設定統一。

有了 rbtrace 鏡像後,我們用類似 rbspy 的方法來啟動 rbtrace 服務,因為內建很多觀察的選項,我們這次採用 io 資訊來觀察 HTTP 請求時發生的 I/O 事件變化。

docker run -it --pid container:ruby-app --ipc container:ruby-app --rm --sysctl kernel.msgmnb=1048576 --volumes-from ruby-app rbtrace rbtrace --pid 1 -c io

啟動的指令也相對 rbspy 複雜更多,裡面有幾個重點需要注意:

  1. --pid 設定值還是必須的,這是因為 rbtrace 也需要使用 PID 來尋找目標對象
  2. --ipc 選項需要正確指定,不然會被容器拒絕使用 IPC 來溝通
  3. --sysctl 選項是因為 rbtrace 建議提供足夠的記憶體,以免追蹤時遺失紀錄

除了上述三點之外特別值得討論的是 volumes-from 這個選項,前面我們在製作容器時刻意將 /tmp 劃分為 Volume 是為了方便讓其他容器存取,實際上使用的時候應該依照真實狀況調整設定(像是直接掛載在目錄之類的)

需要 /tmp 目錄的共用雖然增加了風險,但這也是 rbtrace 的限制之一,在 rbtrace 的設計中 IPC 會使用 Unix Socket 來實現,而我們無法修改產生 Unix Socket 的路徑,因此必須讓 rbtrace 生成的 .sock 檔案位置能被雙方抓取到,才能夠正常的建立通訊來溝通。

最後,我們在使用一次 curl 指令去開啟 Web 伺服器,就能看到 rbtrace-c io 偵測到了一些處理。

*** attached to process 1
TCPServer#accept_nonblock
  TCPServer#__accept_nonblock <0.000032>
TCPServer#accept_nonblock <0.000040>

IO#to_io <0.000002>

IO.select
  IO.select <0.000009>
  IO#inspect <0.000003>
  IO#inspect <0.000004>
  IO#inspect <0.000005>
  IO#inspect <0.000004>
  IO#inspect <0.000004>
  IO#inspect <0.000003>
  IO#syswrite <0.000014>
  IO#syswrite <0.000004>
  IO#flush <0.000003>
  IO#write <0.000026>
  IO.select <0.002220>
  IO#close <0.000097>
IO.select <3.609509>

另一方面,如果善用 rbtrace 的話,我們甚至可以修改出客製化的監控機制來將資訊回報到其他監控的服務上來做為參考依據,不過跟前面提到的資安問題要一起權衡考量會是比較好的。

總結

雖然目前因為遊戲專案的更新時程(因為 TCP 連線是有狀態的,無法任意重新部署)還沒有正式地將上面實作的分析工具導入,不過我們可以觀察到現階段來說使用 Ruby 來做線上的分析其實並不容易。雖然 Rails 這類框架在設計時已經提供了不錯的監控機制。

但是整合 APM 服務也不一定是每個專案都會採用的方法(畢竟在盈利之前這類都可能會是額外的成本,即使再出問題時除錯會加快很多)而要做到可靠且安全的除錯,在文章中所敘述的做法也頂多只能達到「還可以接受」的程度,這某方面來說也讓我們在線上除錯這件事情上變得更有挑戰。

不過 Java 或者 .NET 之所以能提供這類服務,也表示他們在這之中有所取捨(可能是建制出來的套件的別大之類的)我們在需求選擇上來說還是需要仔細評估的,以 Ruby 來說在條件適合的前提下也許轉換到 JRuby 也可能會是一個不錯的做法。

在台灣我們幸運的地方大概是不容易遇到很大規模的應用,所以這類效能分析的問題不太會需要經常煩惱。但同時也是我們不幸的地方,在真正遇到需要解決問題的時候,往往要先想辦法學會工具在學會閱讀這些工具的報告,最後才能解決問題也因而錯過了修復的關鍵時機。

有興趣了解更多有趣的技巧可以到我的部落格弦而時習之了解更多資訊。