繞過 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