Docker容器引擎runC執行框架


http://www.sel.zju.edu.cn/?p=840

 

深入理解Docker容器引擎runC執行框架

1 簡介

根據官方的定義:runC是一個根據OCI標准創建並運行容器的CLI tool。

Docker就是基於runC創建的,簡單地說,runC是Docker中最為核心的部分,容器的創建,運行,銷毀等等操作最終都將通過調用runC完成。下面我們將演示如何使用runC,以最精簡的方式創建並運行一個容器。

1.1 利用runC運行busybox容器

下載並編譯runC

 

 

創建容器的根文件系統

 

 

利用runC的spec命令創建默認的配置文件config.json,其中包含了創建一個容器所需的所有配置信息

 

 

利用runC運行busybox容器

 

 

可以看到,容器成功運行,此時我們打開另一個終端觀察容器的運行狀態

 

 

事實上,runc run是一個復合命令,它包含了容器的創建runc create,啟動runc start以及在退出之后對容器進行的銷毀runc delete,從演示的角度看它是最為直觀的。但是如果想要深入理解runC內部的實現機制,將容器的創建,啟動,銷毀三個步驟分開,顯然會讓整個過程的分析更為簡單和易於接受

下面我們就將結合源碼,對整個容器技術最為核心的部分進行探究—— 容器是如何創建並啟動的

2 源碼分析

首先,我們來對runC的整體代碼結構做一個宏觀的把控:

 

 

可以看到在runC的頂層目錄中,有着一系列形如create.gostart.gorun.go…的go文件,它們和runC的子命令,例如runc create...runc start...runc run是一致的。 另外,在頂層目錄中還有一個名為libcontainer的子目錄。對於Docker項目的發展歷史有所了解的同學應該都知道,libcontainer曾經是Docker中最為核心的包,容器的創建,刪除等一系列工作,最終都是交由它來完成的。

這樣一來,runC的代碼結構就非常清晰了。我們知道,runC是符合OCI標准的容器運行時。不難猜出,它本質上是對libcontainer的一層薄薄的封裝。它會先讀取符合OCI標准的容器配置,再將其轉換成與libcontainer兼容的格式,最后將轉換后的配置交由libcontainer來完成具體的工作。

2.1 容器創建

 

 

create.go的工作主要分為如下兩部分:

  1. 將容器配置從config.json文件加載到內存中,保存在一個類型為*specs.Spec (Spec即為OCI標准的容器配置在內存中的表現形式)的結構體中
  2. 調用startContainer()完成容器的創建工作,值得注意的是runc run , runc create以及runc restore最終都將調用該函數,只是第三個參數不同而已,對於runc create,該參數為CT_ACT_CREATE,表示首次創建容器。接下來程序的執行路徑將因該參數的不同而有所不同。

 

 

startContainer的工作由如下三部分組成:

  1. 從參數中獲取容器的id,例如對於命令runc create abc ,則獲取的id即為abc
  2. 調用createContainer ,根據spec中Container相關的內容,調用libcontainer 創建容器對象,且容器的狀態設置為Stopped。此時的容器僅僅只是一個內存中的數據結構,並沒有與之對應的進程
  3. 創建runner對象並調用相應的run方法,該對象會將spec中的Process轉換成libcontainer兼容的模式,並對容器的IO進行配置

 

 

run方法的工作同樣由三部分組成:

  1. 將OCI標准的進程配置specs.Process轉換為符合libcontainer格式的進程配置libcontainer.Process
  2. 調用setupIO對進程的IO進行配置,因為IO涉及的內容較為復雜,會在另外的文章中詳細敘述
  3. 根據startContainer配置參數的不同,調用不同的方法,分別進行容器的創建,運行或者恢復,本文我們只討論CT_ACT_CREATE這種情況

到此為止,我們已經將OCI格式的配置,不管是Container還是Process都轉換成了libcontainer要求的格式。接着我們將深入libcontainer,真正完成容器實例的創建工作。

 

 

Start方法僅僅只是對start的一個封裝並且會在容器狀態為Stopped時(即新建容器時),創建一個路徑為/run/runc/$ID/exec.fifo的管道文件,它的作用我們會在后文中詳細描述。

值得注意的是start方法的第二個參數對容器的狀態進行了判斷。事實上,命令runc createrunc exec的代碼的執行路徑是類似的,它倆共享了大部分的代碼。因此,這里我們需要對容器的狀態進行判斷,如果容器的狀態為Stopped說明接下來應當進行容器的創建,否則應當在已有容器中exec一個新進程。

 

 

start方法的工作也可以分為如下三部分:

  1. 調用newParentProcess創建parentProcess 對象
  2. 調用parentProcessstart方法,它真正完成容器進程的創建以及初始化工作
  3. 如果isInit參數為true,則說明執行的命令為runc create,更新容器狀態為Created,並且如果定義了hooks(回調函數),則還需要執行PostStart類型的hook函數。否則,如果執行的命令為runc exec,則更新容器狀態為Running

 

 

newParentProcess首先創建了一個名為init的管道,它一方面會在創建容器時給容器的初始化進程傳輸容器的配置信息,另一方面它也會用於runC和容器進程之間的同步。

之后,它會調用commandTemplate創建容器初始化進程的運行模板,如下所示:

 

 

從上面的代碼中我們可以看出,環境變量也是runC進程和容器初始化進程之間進行交互的一種重要方式。上文中的init 管道的信息就是通過環境變量的方式從runC傳遞給容器初始化進程的。

到這里,我們腦海中可能會浮現出另一個問題:c.initPath應該就是容器初始化進程的二進制文件的路徑,那么它是一個獨立於runC的二進制文件么?它又是放在哪的呢?事實上,c.initPath在上文初始化Container對象時會被初始化為/proc/self/exe,而c.initArgs被設置為init ,因此我們創建子進程的過程其實相當於執行了runc init這條命令。

如果執行的命令為runc create,還需要將前文提到的exec.fifo這個管道同樣以環境變量的形式傳遞到容器初始化進程中。最后,調用newInitProcess將所有配置都填充至結構體initProcess中。

 

 

initProcess結構的start方法真正完成了容器進程的創建,並通過init管道協助其完成初始化工作。該方法首先調用p.cmd.Start()創建一個獨立的進程,執行命令runc init。接着通過init管道將容器配置p.bootstrapData寫入管道中。然后再調用parseSync()函數,通過init管道與容器初始化進程進行同步,待其初始化完成之后,執行PreStart Hook等一些回調操作。最后,關閉init管道,容器創建完成。

runC端在創建容器時所做的工作我們已經基本了解了,下面我們來看看runc init,也就是容器初始化進程具體完成了哪些工作。

 

 

作為容器的初始化進程,必須先通過init管道獲取配置才能進行下一步的工作。顯然,我們首先要做的就是從環境變量中獲取與runC進程進行交互的管道的信息,包括init管道。對於runc create還有管道exec.fifo,即上方代碼中的fifofd。緊接着,調用函數newContainerInit,創建用於初始化的接口對象initer,該函數的代碼如下:

 

 

該函數的作用非常明顯,從init管道中讀取容器配置,解析至initConfig中。對於runc create,創建linuxStandardInit結構,將各種配置信息寫入其中。最后,調用該結構的Init方法真正對容器進行初始化。

 

 

Init方法真正完成了對容器的初始化工作,它會對容器的網絡,路由,hostname等一系列屬性進行配置。這些工作一般都是直接通過系統調用設置完成的,因此我們就不再細述了。接下來我們將重點描述容器初始化進程和其父進程,也就是runC進程的同步過程。

我們都知道,每個容器都有自己的根文件系統,到目前為止我們依然還是宿主機文件系統的視角,那么文件系統根目錄的切換是在哪里進行的呢?答案是顯然的,prepareRootfs

 

 

prepareRootfs先對容器的Mounts和Dev等信息進行配置,之后再調用syncParentHooks,通過init管道向runC進程發送procHooks信號。runC進程接收到procHooks信號之后,執行容器的PreStart Hook回調函數,再通過init管道給容器初始化進程發送信號procResume,通知其繼續執行。可見容器的PreStart Hook是在根目錄尚未切換之前執行完成的。最終,調用chroot函數,切換根目錄。至此,容器的文件系統切換完畢。

在文件系統准備完成之后,Init方法還會對Console, hostname等屬性進行配置。當一切就緒之后,調用syncParentReady通過init管道通知runC進程,獲取響應之后,關閉init管道,同步結束,准備開始執行用戶指定的容器進程。

不過在找到了用戶指定的容器程序在容器文件系統的執行路徑之后,初始化進程又打開了我們之前多次提到的exec.fifo這個管道,並且往里面寫入了一個字節,之后才執行Exec系統調用,切換到用戶程序。既然exec.fifo是一個管道,那么我們在這一端寫入之后,就必須有消費者在另外一端進行讀取,否則寫進程就會一直處於阻塞狀態。

事實上,此處對exec.fifo管道的寫阻塞正是runc createrunc start執行流的分界點。容器的創建工作,在容器初始化進程往exec.fifo管道進行寫操作的那一刻,就全部結束了。

2.2 容器啟動

相對於容器的創建,容器的啟動就非常簡單了

 

 

當我們執行runc start命令時,我們首先會獲取相應容器的狀態。顯然,只有狀態為Created的容器才是合法的,此時需要調用容器的Exec方法。

 

 

Exec方法僅僅只是對exec的簡單封裝。而exec方法的工作很簡單,找到exec.fifo管道的路徑,打開它,並調用readFromExecFifo從管道中將容器初始化進程從另一端寫入的字節讀出。一旦管道中的數據被讀出,容器內的初始化進程將不再被阻塞,緊接着將完成Exec系統調用,容器初始化進程將被切換為用戶指定的程序。到此為止,一個容器真正啟動成功。

可是這一路分析下來,似乎並沒有對容器的namespace進行配置的操作?事實上,子進程runc init的執行流在進入Go語言的運行時之前,會被包/runc/libcontainer/nsenter劫持,先去執行一段C代碼。這段C代碼同樣會從init管道中讀取容器的配置,主要是namespace的路徑,clone flag等等,並根據這些配置,調用setns系統調用,將容器進程加入到合適的namespace中。之后再進入Go的運行時,完成上文所述的各種初始化操作。

3 總結

簡而言之,runC創建容器的過程如下圖所示:
runc
1. runc create命令加載文件config.json中容器的配置並轉化為與libcontainer兼容的模式
2. libcontainer根據配置創建Container以及ParentProcess對象
3. Parentproces創建runc init子進程,中間會被/runc/libcontainer/nsenter劫持,使runc init子進程位於容器配置指定的各個namespace內
4. ParentProcessinit管道將容器配置信息傳輸給runc init進程,runc init再據此進行容器的初始化操作。初始化完成之后,再向另一個管道exec.fifo進行寫操作,進入阻塞狀態
5. 執行runc start命令,從管道exec.fifo中讀取上一個步驟寫入的字節。runc init進程不再阻塞,執行Exec系統調用,切換至用戶指定的容器進程,容器真正創建並啟動完成

注:

  1. 文中源碼對應的runC版本為v1.0.0-rc5,commit:4fc53a81fb7c994640722ac585fa9ca548971871
  2. 文中引用的代碼因文章效果做了部分刪減,詳細的源碼注釋參見我的Github

參考文獻


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM