天天看點

docker 原理之runc

轉自高相林的博文http://chuansong.me/n/2032520,支援原創!

在過去兩年中随着網際網路和容器技術的發展,幾乎主要的所有的IT供應商和雲服務提供商都開始采用以容器技術為基礎的解決方案,與容器相關的組織也如雨後春筍般增長。于是為了確定容器的可遷移性,容器格式和運作時标準的建立就顯得尤為重要。

是以,Linux基金會于2015年6月成立OCI(Open Container Initiative)組織,旨在圍繞容器格式和運作時制定一個開放的工業化标準。該組織一成立便得到了包括谷歌、微軟、亞馬遜、華為等一系列雲計算廠商的支援。

1 容器格式标準是什麼?

制定容器格式标準的宗旨概括來說就是不受上層結構的綁定,如特定的用戶端、編排棧等,同時也不受特定的供應商或項目的綁定,即不限于某種特定作業系統、硬體、CPU架構、公有雲等。

該标準目前由libcontainer和appc的項目負責人(maintainer)進行維護和制定,其規範文檔就作為一個項目在GitHub上維護。

1 容器标準化宗旨

标準化容器的宗旨具體分為如下五條。

  1. 操作标準化:容器的标準化操作包括使用标準容器建立、啟動、停止容器,使用标準檔案系統工具複制和建立容器快照,使用标準化網絡工具進行下載下傳和上傳。
  2. 内容無關:内容無關指不管針對的具體容器内容是什麼,容器标準操作執行後都能産生同樣的效果。如容器可以用同樣的方式上傳、啟動,不管是PHP應用還是MySQL資料庫服務。
  3. 基礎設施無關:無論是個人的筆記本電腦還是AWS S3,亦或是OpenStack,或者其它基礎設施,都應該對支援容器的各項操作。
  4. 為自動化量身定制:制定容器統一标準,是的操作内容無關化、平台無關化的根本目的之一,就是為了可以使容器操作全平台自動化。
  5. 工業級傳遞:制定容器标準一大目标,就是使軟體分發可以達到工業級傳遞成為現實。

2 容器标準包(bundle)和配置

一個标準的容器包具體應該至少包含三塊部分:

config.json: 基本配置檔案,包括與主控端獨立的和應用相關的特定資訊,如安全權限、環境變量和參數等。具體如下:

  1. 容器格式版本
  2. rootfs路徑及是否隻讀
  3. 各類檔案挂載點及相應容器内挂載目錄(此配置資訊必須與runtime.json 配置中保持一緻)
  4. 初始程序配置資訊,包括是否綁定終端、運作可執行檔案的工作目錄、環境變量配置、可執行檔案及執行參數、uid、gid以及額外需要加入的gid、hostname、低層作業系統及CPU架構資訊。

runtime.json:運作時配置檔案,包含運作時與主機相關的資訊,如記憶體限制、本地裝置通路權限、挂載點等。除了上述配置資訊以外,運作時配置檔案還提供了“鈎子(hooks)”的特性,這樣可以在容器運作前和停止後各執行一些自定義腳本。hooks的配置包含執行腳本路徑、參數、環境變量等。

rootfs/:根檔案系統目錄,包含了容器執行所需的必要環境依賴,如/bin、/var、/lib、/dev、/usr等目錄及相應檔案。rootfs目錄必須與包含配置資訊的config.json檔案同時存在容器目錄最頂層。

3 容器運作時和生命周期

容器标準格式也要求容器把自身運作時的狀态持久化到磁盤中,這樣便于外部的其它工具對此資訊使用和演繹。該運作時狀态以JSON格式編碼存儲。推薦把運作時狀态的JSON檔案存儲在臨時檔案系統中以便系統重新開機後會自動移除。

基于Linux核心的作業系統,該資訊應該統一地存儲在/run/opencontainer/containers目錄,該目錄結構下以容器ID命名的檔案夾(/run/opencontainer/containers//state.json)中存放容器的狀态資訊并實時更新。有了這樣預設的容器狀态資訊存儲位置以後,外部的應用程式就可以在系統上簡便地找到所有運作着的容器了。

state.json檔案中包含的具體資訊需要有:

  • 版本資訊:存放OCI标準的具體版本号。
  • 容器ID:通常是一個哈希值,也可以是一個易讀的字元串。在state.json檔案中加入容器ID是為了便于之前提到的運作時hooks隻需載入state.json就可以定位到容器,然後檢測state.json,發現檔案不見了就認為容器關停,再執行相應預定義的腳本操作。
  • PID:容器中運作的首個程序在主控端上的程序号。
  • 容器檔案目錄:存放容器rootfs及相應配置的目錄。外部程式隻需讀取state.json就可以定位到主控端上的容器檔案目錄。 标準的容器生命周期應該包含三個基本過程。
  • 容器建立:建立包括檔案系統、namespaces、cgroups、使用者權限在内的各項内容。
  • 容器程序的啟動:運作容器程序,程序的可執行檔案定義在的config.json中,args項。
  • 容器暫停:容器實際上作為程序可以被外部程式關停(kill),然後容器标準規範應該包含對容器暫停信号的捕獲,并做相應資源回收的處理,避免孤兒程序的出現。

4 基于開放容器格式标準的具體實作

從上述幾點中總結來看,開放容器規範的格式要求非常寬松,它并不限定具體的實作技術也不限定相應架構,目前已經有基于OCF的具體實作,相信不久後會有越來越多的項目出現。

容器運作時opencontainers/runc,即本文所講的RunC項目,是後來者的參照标準。

虛拟機運作時hyperhq/runv,基于Hypervisor技術的開放容器規範實作。

測試huawei-openlab/oct基于開放容器規範的測試架構。

2 runC工作原理與實作方式 1 runC從libcontainer的變遷

runC的前身實際上是Docker的libcontainer項目演化而來。runC實際上就是libcontainer配上了一個輕型的用戶端。

從本質上來說,容器是提供一個與主控端系統共享核心但與系統中的其它程序資源相隔離的執行環境。Docker通過調用libcontainer包對namespaces、cgroups、capabilities以及檔案系統的管理和配置設定來“隔離”出一個上述執行環境。同樣的,runC也是對libcontainer包進行調用,去除了Docker包含的諸如鏡像、Volume等進階特性,以最樸素簡潔的方式達到符合OCF标準的容器管理實作。

總體而言,從libcontainer項目轉變為runC項目至今,其功能和特性并沒有太多變化,具體有如下幾點。

  1. 把原先的nsinit移除,放到外面,指令名稱改為runC,同樣使用cli.go實作,一目了然。
  2. 按照開放容器标準把原先所有資訊混在一起的一個配置檔案拆分成config.json和runtime.json兩個。
  3. 增加了按照開放容器标準設定的容器運作前和停止後執行的hook腳本功能。
  4. 相比原先的nsinit時期的指令,增加了runc kill指令,用于發送一個SIG_KILL信号給指定容器ID的init程序。

總體而言,runC希望包含的特征有:

  1. 支援所有的Linux namespaces,包括user namespaces。目前user namespaces尚未包含。
  2. 支援Linux系統上原有的所有安全相關的功能,包括Selinux、 Apparmor、seccomp、cgroups、capability drop、pivot_root、 uid/gid dropping等等。目前已完成上述功能的支援。
  3. 支援容器熱遷移,通過CRIU技術實作。目前功能已經實作,但是使用起來還會産生問題。
  4. 支援Windows 10 平台上的容器運作,由微軟的工程師開發中。目前隻支援Linux平台。
  5. 支援Arm、Power、Sparc硬體架構,将由Arm、Intel、Qualcomm、IBM及整個硬體制造商生态圈提供支援。
  6. 計劃支援尖端的硬體功能,如DPDK、sr-iov、tpm、secure enclave等等。
  7. 生産環境下的高性能适配優化,由Google工程師基于他們在生産環境下的容器部署經驗而貢獻。
  8. 作為一個正式真實而全面具體的标準存在!

2 runC是如何啟動容器的?

從開放容器标準中我們已經定義了關于容器的兩份配置檔案和一個依賴包,runC就是通過這些來啟動一個容器的。首先我們按照官方的步驟來操作一下。

runC運作時需要有rootfs,最簡單的就是你本地已經安裝好了Docker,通過

docker pull busybox

下載下傳一個基本的鏡像,然後通過

docker export $(docker create busybox) > busybox.tar

導出容器鏡像的rootfs檔案壓縮包,命名為busybox.tar。然後解壓縮為rootfs目錄,

mkdir rootfstar -C rootfs -xf busybox.tar

,這時我們就有了OCF标準的rootfs目錄,需要說明的是,我們使用Docker隻是為了擷取rootfs目錄的友善,runc的運作本身不依賴Docker。

接下來你還需要

config.json

runtime.json

,使用

runc spec

可以生成一份标準的

config.json

runtime.json

配置檔案,當然你也可以按照格式自己編寫。

如果你還沒有安裝runC,那就需要按照如下步驟安裝一下,目前runC暫時隻支援Linux平台。

# create a 'github.com/opencontainers' in your GOPATH/srccd github.com/opencontainersgit clone https://github.com/opencontainers/runccd runcmakesudo make install      

最後執行

runc start

你就啟動了一個容器了。

3 runC start運作原理

上面說到過runC就是libcontainer外面裹上了一層很薄的Cli。其中的Cli是為了快速開發Go語言的指令行應用而實作的開發包,它可以為你處理諸如子指令定義,标志位定義和設定幫助資訊等等。并且Cli也是托管在Git上面的一個開源項目,位址為:github.com/codegangsta/cli。

從源碼角度,分析runC start的執行流程,整個分析過程如下圖:

docker 原理之runc

1一切從main()函數開始

整個程式首先執行main.go中的main()函數,在這個函數中,程式通過cli包對runC的各個子指令、參數、版本号以及幫助資訊進行規定。然後程式會通過使用者輸入的子指令來調用對應的處理函數,這裡則調用start.go中的startContainer()函數。

2建立邏輯容器與邏輯程序

所謂的邏輯容器container和邏輯程序process并非時真正運作着的容器和程序,而是libcontainer中所定義的結構體。邏輯容器container中包含了namespace、cgroups、device和mountpoint等各種配置資訊。邏輯程序process中則包含了容器中所要運作的指令以其參數和環境變量等。

對于runC來說,容器的定義隻需要一種就夠了,不同的容器隻是執行個體的内容(屬性和參數)不一樣而已。對于libcontainer來說,由于它需要與底層打交道,不同的平台上就需要建立出完全異構的“邏輯容器對象”(比如Linux容器和Windows容器),這也就解釋了為什麼這裡會使用“工廠模式”:今後libcontainer可以支援更多平台上各種類型容器的實作,而不必改變調用接口。

下面解釋一下邏輯容器Container與邏輯程序process的建立過程。

在startContainer()函數中,程式首先将*.json裝入可以被libcontainer使用的結構體config中。然後使用config作為參數來調用。libcontainer.New()生成用來産生container的工廠factory。再調用factory.Create(config),就會生成一個将config包含其中的邏輯容器container。接下來調用newProcess(config)來将config中關于容器内所要運作指令的相關資訊填充到process結構體中,這個結構體即為邏輯程序process。使用container.Start(process)來啟動邏輯容器。

3啟動邏輯容器Container

runC會調用Start(),Start()函數位于libcontainer/container_linux.go中,主要工作就是調用newParentProcess()來生成parentprocess執行個體(結構體)和用于runC與容器内init程序互相通信的管道。

在parentprocess執行個體中,除了有記錄了将來與容器内程序進行通信的管道與各種基本配置等,還有一個極為重要的字段就是其中的cmd。

cmd字段是定義在os/exec包中的一個結構體。os/exec包主要用于建立一個新的程序,并在這個程序中執行指定的指令。開發者可以在工程中導入os/exec包,然後将cmd結構體進行填充,即将所需運作程式的路徑和程式名,程式所需參數,環境變量,各種作業系統特有的屬性和拓展的檔案描述符等。

在runC中程式将cmd的應用路徑字段Path填充為/proc/self/exe(即為應用程式本身,runC)。參數字段Args填充為init,表示對容器進行初始化。SysProcAttr字段中則填充了各種runC所需啟用的namespace等屬性。

然後調用parentprocess.cmd.Start()啟動實體容器中的init程序。接下來将實體容器中init程序的程序号加入到Cgroup控制組中,對容器内的程序實施資源控制。再把配置參數通過管道傳送給init程序。最後通過管道等待init程序根據上述配置完成所有的初始化工作,或者出錯退出。

4實體容器的配置和建立

容器中的init程序首先會調用StartInitialization()函數,通過管道從父程序接收各種配置參數。然後對容器進行如下配置:

  1. 如果使用者指定,則将init程序加入其指定的namespace。
  2. 設定程序的會話ID。
  3. 初始化網絡裝置。
  4. 對指定目錄下的檔案系統進行挂載,并切換根目錄到新挂載的檔案系統下。設定hostname,加載profile資訊。
  5. 最後使用exec系統調用來執行使用者所指定的在容器中運作的程式。

3 熱遷移的配置與原理簡介 1 熱遷移簡介

所謂熱遷移就是将一個容器進行Checkpoint操作,并獲得一系列檔案,使用這一系列檔案可以在本機或者其他主機上進行容器的Restore工作。目前,在runC中使用了CRIU作為熱遷移的工具,并實作了對容器的Checkpoint和Restore功能。簡要的過程如下圖所示。

docker 原理之runc

2 runC熱遷移原理簡介

在runC中熱遷移的工作主要是調用CRIU(Checkpoint and Restore in Userspace)來完成。CIRU負責當機程序,并将作為一系列檔案存儲在硬碟上。并負責使用這些檔案還原這個被當機的程序。

runC使用SWRK模式來調用criu。這種模式是criu另外兩種模式CLI和RPC的結合體,允許使用者需要的時候像使用指令行工具一樣運作criu,并接受使用者遠端調用的請求。

runC主要通過如下兩個步驟完成熱遷移工作。

  1. 生成container,通過state.json或者配置檔案*.json來生成container結構體。
  2. 使用SWRK模式調用CRIU,runC首先收集并整理要進行Checkpoint或者Restore操作的容器的相關資訊,并填入要發給SWRK模式下的CRIU的結構體中。結構體主要内容如下:
  3. req := &criurpc.CriuReq;{
    Type: &t;,     //C or R
    Opts: &rpcOpts;,   //criu相關參數 
    }      

其中的字段t指定了這個請求是進行Checkpoint操作還是Restore操作,字段rpcOpts中則各種使用者指定的選項和CRIU運作所需的參數。

随後通過syscall.Socketpair()建立runC(criuClient)與CIRU(criuServer)之間的通信管道。然後使用go語言中的os/exec包,以SWRK方式啟動criu。再通過criuClient向criuServer發送request。最後通過criuClient接收執行結果即可。

3 目前版本下runC熱遷移配置與使用

由于目前版本的CRIU并非十分完善,還不能完全支援runC中的一少部分特性,是以在進行熱遷移工作的時候需要對配置檔案進行一些修改。具體修改的内容和原因如下:

  • 因為CRIU不支援seccomp,是以需要将config.json檔案中關于seccomp的相關内容置空。
  • 因為CRIU不支外部終端,是以需要将config.json檔案中terminal的值置為false。
  • 因為CRIU的需求runC所挂載的檔案系統時可讀的,是以将config.json檔案中檔案系統的可讀寫性設定為可讀。

部配置設定置如下圖所示。

docker 原理之runc

正确安裝CRIU及其相關依賴并且對config.json做出以上的修改後就可以使用runC内置的指令對容器進行熱遷移了。

4 Q&A;

Q:對容器熱遷移聽得比較少,也比較好奇,想問問熱遷移的時候容器依賴的檔案系統怎麼解決,是直接copy到目标機嗎,可否使用公共網絡檔案系統解決呢,還有對目标機有什麼特殊要求嗎?

A:依賴檔案系統要保持一緻。最好事先在目标機預置檔案系統。有些容器涉及到不能遷移的裝置之類的,那麼這個容器則不能被遷移。目标機的OS位數要一樣。

Q:解runC,問下和Docker 對比,有那些不同,又有什麼優勢?

A:runC的優勢展現在輕量級和标準化,這是Docker沒有的。而Docker那一套龐大的體系,也是runC沒有的。

Q:dumpfiles是可以持久化的嗎,還是僅僅用于一次性的熱遷移交換?

A:儲存在硬碟,可以進行多次使用。

Q:CRIU 熱遷移網絡配置資訊會儲存麼,還有就是大概熱遷移延時是多少?

A:隻要設定了參數就會儲存,熱遷移的延時這個要看你配套設施和配套軟體。

Q:我對這個熱遷移沒了解過。想問下,這個熱遷移主要是遷移配置rootfs麼,能遷移目前的容器狀态麼,比如正在進行的計算?

A:是的,這個要在對應的機器上有對應的rootfs才能遷移。狀态什麼的都可以遷移。

Q:那是不是意味着我可以利用CRIU工具把容器固化下來,類似于虛拟機的快照,以便在未來的某個時刻進行恢複或者遷移?

A:是的,就是這樣。

繼續閱讀