繞過 Docker ,大規模殺死容器
作者 | Connor Brewster
策劃 | Tina
Replit 是一種基於瀏覽器的集成開發環境(IDE),用於跨平臺協作編碼,已在 A.Capital Ventures 的 A 輪融資中籌集了 2000 萬美元。Replit 工程師在本文中爲我們介紹了他們如何在 Replit 給用戶提供更流暢的體驗:大規模殺死容器。
造成 REPL 卡死有多種原因,其中有機器故障、競爭條件導致死鎖、容器關機慢等原因。本文主要介紹我們如何修復最後一個原因,即容器關機速度慢。緩慢的容器關機幾乎影響到每個使用該平臺的人,並導致 REPL 無法訪問長達一分鐘。
1Replit 架構
你需要對 Replit 的架構有一些瞭解,然後才能深入研究如何解決容器關機緩慢的問題。
打開 REPL 後,瀏覽器將打開 websocket,將其連接到在可搶佔虛擬機上運行的 Docker 容器。每一臺虛擬機都運行着我們稱爲 conman 的東西,這是容器管理器(container manager)的簡稱。
要確保每一個 REPL 在任何時候都只有一個單一的容器。容器被設計用於促進多人遊戲的功能,因此 REPL 的重要性在於, REPL 中的每個用戶都連接到同一個容器。
當託管這些 Docker 容器的機器關機時,我們必須等待每個容器都被銷燬,然後才能在其他機器上再次啓動它們。這一過程經常發生,因爲我們使用的是可搶佔實例。
以下是嘗試在 mid-shutdown 實例上訪問 REPL 的典型流程。
用戶打開他們的 REPL,該 REPL 打開 IDE,然後嘗試通過 WebSocket 連接到後端評估服務器。
該請求命中負載均衡器,負載均衡器根據 CPU 使用情況選擇一個 conman 實例作爲代理。
docker 容器被關閉,全局存儲中的 REPL 容器項被刪除。
2 容器關機緩慢
在強制終止可搶佔虛擬機之前,將有 30 秒的時間完全關閉虛擬機。通過研究,我們發現,很少能在 30 秒內完成關機。因此,我們必須進一步研究並檢測機器關機例程。
通過添加有關機器關機的日誌和指標,顯然 docker kill 被調用的時間比預期要長得多。正常運行時,docker kill 殺死 REPL 容器通常只需幾毫秒,但是,在關機期間,我們同時殺死 100~200 個容器卻要花費 20 多秒的時間。
Docker 提供了兩種停止容器的方法:docker stop 和 docker kill。Docker stop 會向容器發送一個 SIGTERM 信號,並給容器一個寬限期,讓它優雅地關機。如果容器沒有在寬限期內關機,就會向容器發送 SIGKILL。我們並不在乎寬限期關閉容器,而是希望 docker kill 發送 SIGKILL,這樣它就會立即殺死容器。出於某些原因,docker kill 並不能在幾秒鐘內完成容器的 SIGKILL,這一理論與現實不符,肯定還有別的原因。
要深入探討這個問題,這裏有一個腳本,可以創建 200 個 docker 容器,同時計算出需要多長時間才能殺死它們。
1#!/bin/bash
2COUNT=200
3echo "Starting $COUNT containers..."
4for i in $(seq 1 $COUNT); do
5printf .
6docker run -d --name test-$i nginx > /dev/null 2>&1
7done
8echo -e "\nKilling $COUNT containers..."
9time $(docker kill $(docker container ls -a --filter "{{.ID}}") > /dev/null 2>&1)
10echo -e "\nCleaning up..."
11docker rm $(docker container ls -a --filter "{{.ID}}") > /dev/null 2>&1
12
對於生產中運行的同一類型的虛擬機,即 GCEn1-highmem-4 實例,將會生成如下結果:
1Starting 200 containers...
2................................<trimmed>
3Killing 200 containers...
4real 0m37.732s
5user 0m0.135s
6sys 0m0.081s
7Cleaning up...
8
我們認爲, Docker 運行時發生了一些內部事件,導致關機速度非常緩慢,這就證實了我們的懷疑。現在要挖掘 Docker 本身。
Docker 守護進程有一個啓用調試日誌記錄的選項。通過這些日誌,我們可以瞭解 dockerd 內部發生了什麼,並且每個條目都有一個時間戳,因此可以對這些時間所花費的位置提供一些信息。
在啓用了調試日誌之後,讓我們重新運行腳本,看看 dockerd 的日誌。因爲要處理的容器有 200 個,它會輸出大量的日誌信息,所以我手工選擇了一些有意義的日誌。
12020-12-04T04:30:53.084Z dockerd Calling GET /v1.40/containers/json?all=1&filters=%7B%22name%22%3A%7B%22test%22%3Atrue%7D%7D
22020-12-04T04:30:53.084Z dockerd Calling HEAD /_ping
32020-12-04T04:30:53.468Z dockerd Calling POST /v1.40/containers/33f7bdc9a123/kill?signal=KILL
42020-12-04T04:30:53.468Z dockerd Sending kill signal 9 to container 33f7bdc9a1239a3e1625ddb607a7d39ae00ea9f0fba84fc2cbca239d73c7b85c
52020-12-04T04:30:53.468Z dockerd Calling POST /v1.40/containers/2bfc4bf27ce9/kill?signal=KILL
62020-12-04T04:30:53.468Z dockerd Sending kill signal 9 to container 2bfc4bf27ce93b1cd690d010df329c505d51e0ae3e8d55c888b199ce0585056b
72020-12-04T04:30:53.468Z dockerd Calling POST /v1.40/containers/bef1570e5655/kill?signal=KILL
82020-12-04T04:30:53.468Z dockerd Sending kill signal 9 to container bef1570e5655f902cb262ab4cac4a873a27915639e96fe44a4381df9c11575d0
9...
10
在這裏,我們可以看到殺死每個容器的請求,並且 SIGKILL 幾乎是立即發送到每個容器。
以下是執行 docker kill 後 30 秒左右看到的一些日誌記錄:
這些日誌並不能全面說明 dockerd 所做的一切工作,但是它讓人感覺 dockerd 可能花費了大量時間來釋放網絡地址。
到了這個時候,我決定要開始挖掘 docker 引擎的源代碼,創建自己的 dockerd 版本,並添加一些額外的日誌記錄。
首先找出處理容器終止請求的代碼路徑。我增加了一些額外的日誌信息,這些信息包含不同長度的時間,最後我發現這些時間都用在:
該引擎會將 SIGKILL 發送到容器,然後等待容器停止運行纔對 HTTP 請求作出響應。(來源)。
1<-container.Wait(context.Background(), containerpkg.WaitConditionNotRunning)
2
container.Wait 函數返回一個通道,該通道接收容器的退出代碼和任何錯誤。不幸的是,要獲得退出代碼和錯誤,就必須獲得內部容器結構的鎖。(來源)
1...
2go func() {
3select {
4case <-ctx.Done():
5// Context timeout or cancellation.
6resultC <- StateStatus{
7exitCode: -1,
8err: ctx.Err(),
9}
10return
11case <-waitStop:
12case <-waitRemove:
13}
14s.Lock() // <-- Time is spent waiting here
15result := StateStatus{
16exitCode: s.ExitCode(),
17err: s.Err(),
18}
19s.Unlock()
20resultC <- result
21}()
22return resultC
23...
24
事實證明,在清理網絡資源時持有這個容器鎖,而上面的 s.Lock() 結束了長時間的等待。這種情況發生在 handleContainerExit 裏面。容器鎖在該函數的持續時間內保持不變。這個函數調用容器的 Cleanup 方法,以釋放網絡資源。
那麼爲什麼清理網絡資源需要這麼長時間呢?網絡資源是通過 netlink 來處理的。netlink 是用來在用戶和內核空間進程之間進行通信的,在這種情況下,使用 netlink 與內核空間進程進行通信,配置網絡接口。不幸的是,netlink 是通過串行接口工作的,而釋放每個容器地址的所有操作都受到 netlink 瓶頸的限制。
這裏的情況開始讓人覺得有些絕望。我們似乎沒有什麼辦法可以改變,以逃避等待網絡資源被清理的命運。但我們或許可以完全繞過 Docker 而殺死容器。
對我們來說,我們可以殺死容器,而不必等到網絡資源被清理。關鍵是容器不會產生任何副作用。舉例來說,我們不想讓容器獲得更多的文件系統快照。
我採用的解決方案是通過直接殺死容器的 pid 來繞過 docker。在容器啓動之後, conman 記錄下容器的 pid,然後在需要終止時向容器發送 SIGKILL。因爲容器形成了 pid 命名空間,所以容器 /pid 命名空間中的所有其他進程在容器的 pid 終止時也終止。
來自 pid_namespaces 手冊頁:
當 PID 命名空間的 “init” 進程終止時,內核就會通過 SIGKILL 信號終止該命名空間的所有進程。
考慮到這一點,我們有理由相信,在將 SIGKILL 發送到容器之後,它不再產生任何副作用。
作者介紹:
Connor Brewster,Replit 工程師,Rust 愛好者。
原文鏈接:
https://blog.repl.it/killing-containers-at-scale
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/xwdrGSqeIQHVq7Kd_QbYEw