Java應用異常狀態監測

dengshenyu發表於2018-05-21

老闆最近分派了一個任務,說線上客戶在部署應用的時候發生了系統級別的OOM,觸發了OOM Killer殺掉了應用,讓我們解決這個問題。

對於這個任務,我從如下幾點開始調研、分析與解決。

1、什麼是系統級別的OOM(Out-Of-Memory)?

當建立程式時,程式都會建立起自己的虛擬地址空間(對於32位系統來說為4g)。這些虛擬地址空間並不等同於實體記憶體,只有程式訪問這些地址空間時,作業系統才會為其分配實體記憶體並建立對映。關於虛擬記憶體和實體記憶體有很多資料,這裡不再贅述,這篇文章寫的通俗易懂,可以看下。

通過虛擬記憶體技術,作業系統可以允許多個程式同時執行,即便它們的虛擬記憶體加起來遠超過系統的實體記憶體(和swap空間)。如果這些程式不斷訪問其虛擬地址,作業系統不得不為它們分配實體記憶體,當到達一個臨界點時,作業系統耗盡了所有的實體記憶體和swap空間,此時OOM就發生了。

2、系統發生了OOM會怎麼樣?

當發生了OOM,作業系統有兩個選擇:1)重啟系統;2)根據策略殺死特定的程式並且釋放其記憶體空間。這兩種策略當然是第二種影響面較小,由於我們線上系統也是採取殺死特定程式的策略,因此這裡只展開第二種。

  • 第二種行為也稱之為OOM Killer。那系統會殺死什麼樣的程式釋放其記憶體呢?這篇文件的“Selecting a Process”部分大概描述了Linux核心的作業系統選取演算法:首先,根據badness_for_task = total_vm_for_task / (sqrt(cpu_time_in_seconds) * sqrt(sqrt(cpu_time_in_minutes)))來算起始值,total_vm_for_task為程式佔用的實際記憶體,cpu_time_in_seconds為執行時間,這個公式會選取佔用記憶體多且執行時間短的程式;
  • 如果程式是root程式或者擁有超級使用者許可權,那麼上述得分會除以4;
  • 如果程式能夠直接訪問硬體(也就是硬體驅動),那麼將得分再除以4。

但文件中描述並不完整,這個是Linux核心OOM_Killer的相關程式碼,然後這篇文章對程式碼進行了分析,除了上述因素之外還包含子程式記憶體、nice值、omkill_adj等因素。

作業系統會對每個程式進行計算得分,並記錄在/proc/[pid]/oom_score檔案中;當發生系統OOM時,作業系統會選取評分最高的程式進行殺死。

3、如何實現系統OOM告警?

OOM告警有兩種方式,如下:

  • 提前OOM告警:在系統即將發生OOM時,發出告警資訊。
  • 事中/事後告警:在系統完成OOM Killer殺死程式後,發出告警資訊。

提前OOM告警是最好的方式,但實際上如果想達到不誤報、不漏報,實現難度極大。我們線上應用為Java應用,考慮這麼個場景:客戶應用不斷申請記憶體,當系統實體記憶體佔用率達到90%的時候,系統及應用下一步行為會是什麼樣?個人認為有三種可能性:1)Java應用停止申請記憶體,並且進行了垃圾回收釋放記憶體,這樣系統將會恢復正常;2)應用繼續申請記憶體導致應用記憶體超過了堆大小,但此時系統仍然有部分實體記憶體,這樣會發生Java應用的OOM;3)應用繼續申請記憶體導致系統耗盡實體記憶體,但此時沒有超過堆記憶體的最大值,這樣會發生作業系統的OOM。對於這個場景來說,我們想準確預判出系統及應用的下一步行為難度極大。

另一方面,我們線上其實已經有基於機器記憶體使用率的報警,這個報警其實已經包含了三種可能性:1)應用本身有問題但不會導致堆溢位或者系統OOM;2)應用可能會導致堆溢位;3)應用可能會導致系統OOM。無論實際情況為哪一種,這個報警都是有意義的。

事中/事後告警也是一種可取的方式,原因在於:1)這種方式能夠實現不誤報、不漏報;2)對於即將發生OOM的應用來說,事中報警與事前報警時間相差其實並不大。另外,到目前為止客訴的情況都是抱怨其應用死了沒有任何通知,排查起來既浪費了客戶時間,也浪費了研發排查問題的時間。

綜合考慮,如果能夠實現Java應用的異常狀態檢測並提供事中/事後報警與現場分析,也是很有意義的!

4、Java應用的異常狀態為哪些?

這裡定義的Java應用異常狀態有:

  • Java應用被使用者殺死(Kill、Kill -9);
  • Java應用發生堆溢位;
  • Java應用被系統OOM(Kill -9)。

5、如何檢測出上述Java應用異常狀態?

首先,Java應用發生堆溢位可以通過-XX:+HeapDumpOnOutOfMemoryError引數來生成dump資訊,我們可以通過輪詢方式即可發現是否發生堆溢位(當然基於事件通知方式更好,待調研)。

因此,現在問題在於我們怎麼發現一個Java應用被使用者殺死或者被系統OOM Kill掉?

5.1 ShutdownHook/sun.misc.Signal

老司機可能很快就想到,通過註冊shutdownHook就可以檢測到系統訊號了呀!註冊shutdownHook的確能檢測到SIGTERM訊號(也就是通常不帶引數的Kill命令,如Kill pid),但不能檢測到SIGKILL訊號(Kill -9)。另外,調研發現也可以通過sun.misc.Signal.handle方法來檢測系統訊號,但遺憾的是還是不能檢測到SIGKILL訊號。

5.2 strace

這個工具非常強大,它能夠攔截所有的系統呼叫(包括SIGKILL),並且具有系統已經內建、使用方便、輸出資訊可讀性好等優點。下圖是我的一個實驗(程式24063是一個觸發系統OOM的Java程式):

strace

 

但這個工具的缺點是,被跟蹤的應用的效能影響非常大。應用原來進行系統呼叫(比如open、read、write、close)時會發生一次上下文切換(從使用者態到核心態),使用了strace之後會變成多次上下文呼叫,如下圖所示:

(更多資訊可以參考這篇文章

但無論如何,我們已經找到一種可行的解決方案,雖然效能影響很大,但可以作為debug方案開放給客戶。

5.3 ftrace + 系統日誌

ftrace是Linux系統已經內建的工具(debugfs掛載情況見附錄),它的作用是幫助開發人員瞭解 Linux 核心的執行時行為,以便進行故障除錯或效能分析。重要的是,它對應用本身的效能影響極小,而且我們可以只檢測Kill事件,這樣對客戶應用幾乎零影響(效能分析見第6節)。在我們的場景下,它也支援核心事件(包括程式SIGKILL訊號)監聽。ftrace使用起來非常方便,可以參考這篇文件,或者直接使用這個GITHUB指令碼即可。下面是執行該GITHUB指令碼的一個截圖:

在上圖中,SIGNAL為15的是我執行Kill 29265命令,SIGNAL為9的是我執行Kill -9 29428命令。但這個工具的問題在於,當Java程式觸發系統級別的OOM Killer時,並沒有檢測到相應的訊號(待進一步調研)。

另外,當系統觸發OOM Killer時,會在系統日誌(Centos的為/var/log/messages)中記錄下特定資訊,如下所示:

 5.4 auditd + 系統日誌

(系統日誌用來發現OOM資訊,不再贅述,下文主要介紹auditd)

同事建議可以嘗試下auditd,因此這裡調研auditd,發現它能滿足需求,而且測試效能影響比ftrace更小(效能分析見第6節)。auditd是Linux Auditing System(Linux審計系統)的一部分,它負責接收核心中發生的事件(系統呼叫、檔案訪問),並將這些事件寫入日誌供使用者分析。

下圖是Linux審計系統的框架:

audit框架

其中:

  • 左邊是我們的應用程式;
  • 中間為Linux核心,核心中包含了審計模組,可以記錄三類事件:1)User:記錄使用者產生的事件;2)Task:記錄任務型別(如fork子程式)事件;3)Exit:在系統呼叫結束時記錄該事件。同時,可以結合Exclude規則來過濾事件,最終將這些事件傳送到使用者空間的auditd守護程式;
  • 右邊是在使用者空間的應用程式,其中auditd是核心的守護程式,主要接收核心中產生的事件,並記錄到audit.log中,然後我們可以通過ausearch或者aureport來檢視這些日誌;auditd在啟動時會讀取auditd.conf檔案來配置守護程式的各種行為(如日誌檔案存放位置),並讀取audit.rules中的事件規則來控制核心中的事件監聽及過濾行為;另外,我們也可以通過auditctl來控制核心事件監聽和過濾規則。 

關於更多資訊可以自行搜尋或者看下這篇文章

核心已經內建審計模組,而auditd守護程式也預設在centos(>=6.8)中啟動,下面我們來測試下該工具。首先,我們執行如下命令:

auditctl -a always,exit -F arch=b64 -S kill -k test_kill

這條命令作用是,在kill系統呼叫返回時記錄事件,並且繫結test_kill標記(以便後面進行日誌篩選)。然後,我們可以隨便執行一個指令碼並kill掉,可以在/var/log/audit/audit.log中看到如下輸出:

auditd-test

第一條SYSCALL日誌記錄傳送SIGKILL訊號的程式資訊,第二條OBJ_PID日誌記錄接收SIGKILL訊號的程式資訊。

5.5 Shell + dmesg

如果我們能夠控制Java應用的啟動指令碼,那麼此方式是影響最小的方案。先看下面這個shell指令碼:

#!/bin/bash
java -Xms4g -Xmx4g Main

ret=$?
#
# returns > 127 are a SIGNAL
#
if [ $ret -gt 127 ]; then
sig=$((ret - 128))
echo "Got SIGNAL $sig"
if [ $sig -eq $(kill -l SIGKILL) ]; then
   echo "process was killed with SIGKILL"
   dmesg > $HOME/dmesg-kill.log
fi
fi

這個指令碼做了這幾個事情:

  1. 使用Java -Xms4g -Xmx4g Main來啟動一個Java應用;
  2. Java應用退出後通過$?獲取程式退出狀態碼;
  3. 如果退出碼大於128,則為應用收到SIGNAL退出;如果為SIGKILL,則通過dmesg收集kernal ring buffer中的資訊。

如果應用由於被OOM Killer殺死而退出,則dmesg-kill.log中會有如下資訊:

dmesg

 此方案優點在於影響面最小,但程式殺死資訊量相比auditd少,只知道收到何種SIGNAL訊號;而auditd能夠知道SIGNAL訊號來源於哪個程式、使用者、組。 

6、效能測試

6.1 測試環境

測試機器 ecs.n1.medium
CPU 2 vCPU
處理器型號 Intel Xeon E5-2680v3
處理器主頻 2.5 GHz
記憶體 4 GB
系統映象 CentOS 7.4 64位

6.2 測試指令碼

6.2.1 測試一:系統呼叫效能影響

測試方法

從/dev/zero中讀取500個位元組資料並寫入到/dev/null中,迴圈執行1億次(也就是100M):

dd if=/dev/zero of=/dev/null bs=500 count=100M

該指令碼會產生大約2億次系統呼叫(read 1億次,write 1億次)。

測試結果

測試目標 總耗時(s 平均耗時(μs
不加任何事件監聽 41.7 0.2085
auditd 47.1 0.2355
ftrace 77.3 0.3865
strace > 3600 > 18

6.2.2 測試二:JAVA應用效能影響

測試方法:

構造consumer和provider應用,consumer向provider發起HSF呼叫,provider返回預定義資料,迴圈呼叫1百萬次,觀察consumer耗時。

測試結果:

測試目標 總耗時(s 平均耗時(ms
不加任何事件監聽 492 0.492
consumer和provider都開啟auditd 484 0.482
consumer和provider都開啟ftrace 493 0.493
consumer和provider都開啟strace > 3600 > 3.6 

7、總結

綜上,我們可以通過如下手段來解決客戶的應用OOM問題:

  • 1. 使用機器的基於記憶體使用率報警來事前通知客戶;
  • 2. JVM啟動引數可以新增-XX:+HeapDumpOnOutOfMemoryError等引數來協助收集JVM記憶體溢位資訊;
  • 3. 通過系統日誌(/var/log/messages)或者dmesg來收集系統OOM Killer資訊;
  • 4. 使用啟動shell指令碼(見5.5節)或auditd(見5.4節) ftrace 來獲取應用被Kill掉的資訊(可能被客戶自身Kill掉)。
  • 5.【可選】開放strace工具來幫助客戶debug問題。

8、其他工具

8.1 trap

trap命令用於指定在接收到訊號後將要採取的動作,通常在指令碼程式被中斷時完成清理工作。當shell接收到sigspec指定的訊號時,arg引數(命令)將會被讀取,並被執行。下面我試圖攔截當前指令碼的SIGTERM和SIGKILL訊號:

#!/bin/bash

sighdl ()
{
echo "signal caught"
#do something
exit 0
}

trap sighdl SIGKILL SIGTERM

### main script
X=0
while :
do
echo "X=$X"
X=`expr ${X} + 1`
sleep 1
done

測試發現,trap命令能夠檢測到當前程式的SIGTERM訊號,但是無法檢測SIGKILL訊號。這個命令相當於Java應用中的shutdownHook或者Signal。

9、附錄

9.1 ftrace系統debugfs掛載情況

(注:以下統計阿里雲上主要的作業系統)
作業系統 系統版本 debugfs是否已經預設掛載 備註
CentOS
   7.4(64位)  是  
   7.3(64位)  是  
   7.2(64位)  是  
   6.9(64位)    mount -t debugfs nodev /sys/kernel/debug
   6.8(64位)    mount -t debugfs nodev /sys/kernel/debug
   6.8(32位)  否  mount -t debugfs nodev /sys/kernel/debug
 Aliyun Linux      
   17.1(64位)  是  
 Ubuntu      
   16.04(64位)  是  
   16.04(32位)  是  
   14.04(64位)  是  
   14.04(32位)  是  
 Debian      
   9.2(64位)  是  
   8.9(64位)  是  
 SUSE Linux      
   Enterprise Server 12 SP2(64位)  是  
   Enterprise Server 11 SP2(64位)  是  
 OpenSUSE      
   42.3(64位)  是  
 CoreOS      
   1465.8.0(64位)  是  
 FreeBSD      
   11.1(64位)    

  

 


相關文章