Linux內核分析: OOM殺掉nginx後導致的系統hang問題

概述

KVM虛擬機在遇到OOM後然後就hang住了,長時間無反應,不得不重啓進行恢復。這篇文檔總結了hang住的原因以及背後的機制,供以後遇到類似問題做參考。
主要是涉及到OOM的機制,進程組,以及nginx的進程模型。

問題現象

系統在內存不足的情況下,就無法通過ssh登陸進去這是可以理解的;無法理解的是,內存不足時,殺掉進程就可以釋放出來一部分可用內存,從而讓系統能夠繼續運行下去,可是這個時候爲什麼會一直hang在這裏呢? 這是內核的bug還是什麼原因?

系統日誌

由於系統長時間hang在那裏,而且事先也沒有打開hungtask,softlockup之類的檢測,所以不得不去重啓恢復,現場也無法保留了。
這也給了我們一個教訓:現有的Linux調試手段,如果不影響性能,我們還是要部署到服務器上。
在系統重啓後,去查看系統日誌/var/log/messages, 發現了在重啓前的一段時間內核一直在打印如下一段信息:

Jan 18 09:50:59 kernel: [ 8202] 0 8202 27050 55 3 0 0 bash
Jan 18 09:50:59 kernel: [ 8234] 0 8234 27600 455 3 0 0 rsync
Jan 18 09:50:59 kernel: [ 8253] 0 8253 27542 474 0 0 0 rsync
Jan 18 09:50:59 kernel: [ 8273] 2156 8273 25227 18 1 0 0 sleep
Jan 18 09:50:59 kernel: [ 8304] 600 8304 25227 19 1 0 0 sleep
Jan 18 09:50:59 kernel: [ 8353] 2188 8353 29284 4840 0 0 0 nginx
Jan 18 09:50:59 kernel: [ 8354] 2188 8354 29284 5714 2 0 0 nginx
Jan 18 09:50:59 kernel: [ 8356] 2188 8356 29284 11348 1 0 0 nginx
Jan 18 09:50:59 kernel: [ 8357] 2188 8357 18681 1240 2 0 0 nginx
Jan 18 09:50:59 kernel: Out of memory: Kill process 8356 (nginx) score 5 or sacrifice child
Jan 18 09:50:59 kernel: Killed process 8356, UID 2188, (nginx) total-vm:117136kB, anon-rss:45388kB, file-rss:4kB
Jan 18 09:50:59 kernel: Out of memory: Kill process 8353 (nginx) score 5 or sacrifice child
Jan 18 09:50:59 kernel: Killed process 8353, UID 2188, (nginx) total-vm:117136kB, anon-rss:41224kB, file-rss:4kB
Jan 18 09:50:59 kernel: Out of memory: Kill process 8354 (nginx) score 6 or sacrifice child
Jan 18 09:50:59 kernel: Killed process 8354, UID 2188, (nginx) total-vm:121304kB, anon-rss:49488kB, file-rss:8kB
Jan 18 09:50:59 kernel: Out of memory: Kill process 8359 (nginx) score 5 or sacrifice child
Jan 18 09:50:59 kernel: Killed process 8359, UID 2188, (nginx) total-vm:117136kB, anon-rss:45932kB, file-rss:8kB
Jan 18 09:50:59 kernel: Out of memory: Kill process 8358 (nginx) score 6 or sacrifice child
Jan 18 09:50:59 kernel: Killed process 8358, UID 2188, (nginx) total-vm:121456kB, anon-rss:49608kB, file-rss:52kB
Jan 18 09:50:59 kernel: Out of memory: Kill process 8357 (nginx) score 6 or sacrifice child
Jan 18 09:50:59 kernel: Killed process 8357, UID 2188, (nginx) total-vm:121304kB, anon-rss:49488kB, file-rss:40kB
Jan 18 09:51:00 kernel: Out of memory: Kill process 8363 (nginx) score 5 or sacrifice child
Jan 18 09:51:00 kernel: Killed process 8363, UID 2188, (nginx) total-vm:117136kB, anon-rss:47364kB, file-rss:40kB
Jan 18 09:51:00 kernel: Out of memory: Kill process 8361 (nginx) score 6 or sacrifice child
Jan 18 09:51:00 kernel: Killed process 8361, UID 2188, (nginx) total-vm:121304kB, anon-rss:49052kB, file-rss:48kB
Jan 18 09:51:00 kernel: Out of memory: Kill process 8360 (nginx) score 5 or sacrifice child
Jan 18 09:51:00 kernel: Killed process 8360, UID 2188, (nginx) total-vm:117136kB, anon-rss:47364kB, file-rss:4kB
Jan 18 09:51:00 kernel: Out of memory: Kill process 8364 (nginx) score 6 or sacrifice child
Jan 18 09:51:00 guomai131030 kernel: Killed process 8364, UID 2188, (nginx) total-vm:121304kB, anon-rss:49484kB, file-rss:12kB
Jan 18 09:51:00 kernel: Out of memory: Kill process 8369 (nginx) score 6 or sacrifice child
Jan 18 09:51:00 kernel: Killed process 8369, UID 2188, (nginx) total-vm:121304kB, anon-rss:49488kB, file-rss:12kB
Jan 18 09:51:00 kernel: Out of memory: Kill process 8368 (nginx) score 5 or sacrifice child
Jan 18 09:51:00 kernel: Killed process 8368, UID 2188, (nginx) total-vm:117136kB, anon-rss:43268kB, file-rss:4kB
Jan 18 09:51:00 kernel: Out of memory: Kill process 8377 (nginx) score 6 or sacrifice child
Jan 18 09:51:00 kernel: Killed process 8377, UID 2188, (nginx) total-vm:121304kB, anon-rss:49484kB, file-rss:8kB
Jan 18 09:51:00 kernel: Out of memory: Kill process 8367 (nginx) score 5 or sacrifice child
Jan 18 09:51:00 kernel: Killed process 8367, UID 2188, (nginx) total-vm:117136kB, anon-rss:47364kB, file-rss:4kB
Jan 18 09:51:00 kernel: Out of memory: Kill process 8379 (nginx) score 6 or sacrifice child
Jan 18 09:51:00 kernel: Killed process 8379, UID 2188, (nginx) total-vm:121308kB, anon-rss:49492kB, file-rss:64kB
Jan 18 09:51:00 kernel: Out of memory: Kill process 8380 (nginx) score 5 or sacrifice child
Jan 18 09:51:00 kernel: Killed process 8380, UID 2188, (nginx) total-vm:117136kB, anon-rss:47364kB, file-rss:4kB
Jan 18 09:51:00 kernel: Out of memory: Kill process 8378 (nginx) score 6 or sacrifice child
Jan 18 09:51:00 kernel: Killed process 8378, UID 2188, (nginx) total-vm:121304kB, anon-rss:49484kB, file-rss:8kB
Jan 18 09:51:00 kernel: Out of memory: Kill process 8382 (nginx) score 6 or sacrifice child
Jan 18 09:51:00 kernel: Killed process 8382, UID 2188, (nginx) total-vm:121304kB, anon-rss:49484kB, file-rss:16kB
Jan 18 09:51:00 kernel: Out of memory: Kill process 8381 (nginx) score 5 or sacrifice child
Jan 18 09:51:00 kernel: Killed process 8381, UID 2188, (nginx) total-vm:117136kB, anon-rss:46808kB, file-rss:8kB
Jan 18 09:51:00 kernel: Out of memory: Kill process 8384 (nginx) score 5 or sacrifice child
...

看起來似乎是系統有殺不完的nginx進程(或者線程)。PS:內核是不區分線程與進程的,線程在內核看來也是一個進程。
接着來看下,在開始殺nginx進程時,系統的進程信息,我們只看nginx進程即可:

Jan 18 09:50:59 kernel: [ pid ] uid tgid total_vm rss cpu oom_adj oom_score_adj name
Jan 18 09:50:59 kernel: [29640] 0 29640 12621 178 2 0 0 nginx
Jan 18 09:50:59 kernel: [29641] 0 29641 13282 318 1 0 0 nginx
Jan 18 09:50:59 kernel: [29643] 0 29643 13289 321 0 0 0 nginx
Jan 18 09:50:59 kernel: [29644] 0 29644 13288 323 2 0 0 nginx
Jan 18 09:50:59 kernel: [29645] 0 29645 13283 319 3 0 0 nginx
...
Jan 18 09:50:59 kernel: [30747] 2188 30747 18681 1242 0 0 0 nginx
...
Jan 18 09:50:59 kernel: [ 8353] 2188 8353 29284 4840 0 0 0 nginx
Jan 18 09:50:59 kernel: [ 8354] 2188 8354 29284 5714 2 0 0 nginx
Jan 18 09:50:59 kernel: [ 8356] 2188 8356 29284 11348 1 0 0 nginx
Jan 18 09:50:59 kernel: [ 8357] 2188 8357 18681 1240 2 0 0 nginx

可以看到,有兩組nginx進程(或線程),分別是uid=0(即root)和uid=2188(即普通用戶)創建的nginx。
然後oom killer殺的進程pid都是83XX,是屬於uid=2188用戶創建的,其中8353/8354/8356/8357是系統已存在的進程,而oom killer後面殺的進程,比如pid爲8359/8363等都是系統先前不存在的進程。
我們接着來分析下這到底是怎麼一回事。

底層機制:線程組,線程與進程

在Linux 2.6的內核開始,引入了一個線程組(thread group)的概念,它是在task_struct結構體中增加了一個tgid(thread group id)字段,

struct task_struct {
    ...
    pid_t tgid;
    ...
}

如果這個task是一個“主線程”(即thread group leader),則它的tgid等於pid,若是子線程則tgid等於進程的pid(即主線程的pid)。在clone系統調用中, 傳遞CLONE_THREAD參數就可以把新進程的tgid設置爲父進程的tgid(否則新進程的tgid會設爲其自身的pid)。
有了tgid,就可以區分某個tast_struct是代表一個進程還是代表一個線程(tgid不等於pid就是線程)了。與此有關的一些系統調用: getpid(2)系統調用返回的就是tast_struct中的tgid,而tast_struct中的pid則由gettid(2)系統調用來返回。
前面的系統日誌顯示,nginx的pid與tgid相等,說明這些nginx都是進程,而不是線程。即他們都是單線程的。

##底層機制:UID(用戶ID)
內核用UID來標示用戶,root用戶的uid是0,非root用戶的uid爲非零。
通過前面的日誌信息,我們可以發現,這10個nginx進程屬於兩個用戶,一個是uid=0,即root;另一個是uid=2188,即普通用戶。
我們還可以發現,oom killer殺掉的只是普通用戶創建的nginx,而不會殺root用戶創建的nginx進程。這是怎麼回事呢?

底層機制:oom killer與root

我們需要來看下oom選擇殺進程的策略:

unsigned int oom_badness(...)
{
    ...
        /*  
     * Root processes get 3% bonus, just like the __vm_enough_memory()
     * implementation used by LSMs.
     */
    if (has_capability_noaudit(p, CAP_SYS_ADMIN))
        points -= 30; 
    ...
}

root用戶創建的進程,相比非root用戶,得分會低一些,所以oom killer會優先殺非root用戶創建的進程。
然後我們接着分析,爲什麼uid=2188的nginx進程明明只有5個,可是oom killer卻啥不完呢?爲什麼會有新的nginx進程產生呢?
這跟nginx的進程模型有關。

應用程序的機制:nginx的進程模型

我們服務器上nginx的進程模型如下:
nginx.png

它工作在master-worker模式,由一個master進程fork出來N(N的數目是在nginx.conf裏面配置的,我們的系統配置的是4)個worker進程,master進程會週期性的檢查worker進程,如果不足N個,它就會重新fork,直至數目達到N爲止。
於是,這就解釋了,爲什麼會不斷的有新的nginx進程產生了,它是由master進程fork出來的。
不過有一點點遺憾的是,由於系統日誌裏面沒有紀錄ppid(parent PID)這一項,所以不知道8353/8354/8356/8357這四個進程的父進程,不過根據理論分析,它應該是PID 30747(因爲還只有這一個nginx進程屬於uid=2188),即30747進程是master進程。
分析到這裏,就可以有一個合理的推測了:在內存不足的時候,由於uid=2188的nginx worker的score較高,所以oom killer就殺掉nginx worker進程以便於釋放出足夠的內存空間,於此同時,nginx master進程發現worker進程不足4了,然後就fork出來新的worker進程。 於是就這樣,oom killer殺worker進程,nginx master進程fork出來新的worker進程,就這樣形成了死循環了
##底層機制:oom killer爲什麼死循環了?
如下這部分代碼印證了我們的合理推測:

restart:
    page = get_page_from_freelist();
    // 如果還是沒辦法申請到內存,那就去殺進程  
    if (page) 
        goto got_pg;    

    // 在發生oom殺進程的情況下,返回值page爲NULL 
    page = __alloc_pages_may_oom();

    // page is NULL now.    
    if (page)
        goto got_pg;    
    
    // 既然殺掉了進程,那就可以重新再去嘗試申請內存了。    
    goto restart;

大致的意思就是,進程在申請內存的時候,如果申請不到足夠的內存,就會觸發oom選擇一個最慘的進程給殺掉它以便於釋放出來一部分內存,然後在殺掉這個進程後,就嘗試繼續申請內存,如果還不夠,就繼續選擇一個進程殺,即在這裏循環,直到申請出來足夠的內存,或者當前把當前這個進程給殺掉,或者實在沒有進程給殺了而panic。

底層機制: 爲什麼需要oom killer?

Linux內存管理模塊有一個overcommit機制,意思是說,進程申請的內存可以大於當前系統free的內存,這可以通過/proc/sys/vm/overcommit_memory這個proc接口來配置。
它的默認值是0,即啓發式策略,儘量減少swap的使用,root可以分配比一般用戶略多的內存;
爲1表示總是會允許overcommit,這一般適用於科學計算程序;
爲2則表示不允許overcommit,系統申請的內存不能超過CommitLimit,在這種情況下,是進程申請內存返回錯誤,而不是去殺死進程。
Linux之所以這麼設計,是出於這麼一個考慮:進程申請的內存不會馬上就被用到,並且,在進程的整個生命週期內,它也不會用到它申請的所有內存。如果沒有overcommit,系統就不能夠充分的利用它的內存,這樣就會導致內存的浪費。overcommit就可以讓系統更加高效的使用它的內存,但是與此同時也帶來了一個風險:oom。memory-hogging程序能夠耗盡整個系統的內存,從而導致整個系統處於halt的狀態,在這種情況下,用戶程序甚至連一個page的內存都無法申請,於是oom killer就出現了,它會識別出來可以爲整個系統作出犧牲的進程,然後殺掉它,釋放出來一些內存。

底層機制: 控制oom killer

oom killer可以殺死哪些進程,而不應該殺死哪些進程,這確實是個難題,所以kernel就導出了一些接口給用戶,讓用戶來控制,於是就把這個難題拋給了用戶。
這個接口就是/proc//oom_adj, 它的範圍是-17~+15,值越高,就越容易被殺掉,如果把該值設置爲-17,oom就永遠也不會考慮殺它。

底層機制: oom殺進程的策略是怎麼樣的?

oom killer選擇殺哪個進程,是基於它的badness score,該值體現在/proc//oom_score裏面。它的原則是,儘可能少殺進程來儘可能釋放出足夠多的內存,同時不去殺那些耗費內存很多的無辜進程。badness score的計算會用到進程的內存大小,CPU時間(user time + system time), 運行時間,以及oom_adj值。進程消耗的內存越多,得分就越高;進程運行的時間越長,得分就越低。
這也解釋了,爲什麼新fork出來的進程容易被殺死,因爲它的運行時間短,得分高
oom killer選擇victim進程的策略大致如下:

  1. 它必須擁有大量的頁框
  2. 殺掉這個進程只會損失少量的工作
  3. 它的靜態優先級必須低(可以通過nice來給不重要的進程設置低的優先級)
  4. 它不能夠擁有root權限
  5. 它不能直接訪問硬件
  6. 它不能夠是0號進程(swapper),1號進程(init),以及內核線程

分析清楚了oom的機制後,我們就可以採取合理的手段來解決該問題了。

我們這個問題的解決方法

由於罪魁禍首在於master進程不停的fork出來子進程,所以我們可以採取的手段是先殺掉master,再殺掉worker。master被殺掉後,流量將不會再被導入進來,這也強於流量被導入進來了但是卻沒有辦法處理。這樣業務將不會受到影響。

社區對於OOM的一些討論

oom這一塊總來的來看還是有很多缺陷,不然也不會導致這種問題發生。
社區裏面對oom的處理也提出了一些很好的建議,比如增加一個oom cgroup,這樣痛過cgroup來控制oom。具體參考 Taming the OOM killer

總結與思考

這個問題給我們的一個教訓是,對於可用的Linux定位手段,在不影響系統性能和穩定性的前提下,我們還是都需要部署到服務器上去。

PS:以上討論,基於的OS版本是CentOS-6(Kernel 2.6.32),文檔中的內核代碼均摘自CentOS-6的kernel-2.6.32。

Contact me: [email protected]

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章