前言
容器運行時(Container Runtime
)是指管理容器和容器鏡像的軟件。當前業內比較有名的有docker,rkt等。如果不同的運行時只能支持各自的容器,那麼顯然不利於整個容器技術的發展。於是在2015年6月,由Docker以及其他容器領域的領導者共同建立了圍繞容器格式和運行時的開放的工業化標準,即Open Container Initiative
(OCI
),OCI
具體包含兩個標準:運行時標準(runtime-spec)和容器鏡像標準(image-spec)。簡單來說,容器鏡像標準定義了容器鏡像的打包形式(pack format
),而運行時標準定義瞭如何去運行一個容器。
本文包含以下內容:
- runC的概念和使用
- runC運行容器的原理剖析
本文不包含以下內容:
- docker engine使用runC
runC概念
runC是一個遵循OCI標準的用來運行容器的命令行工具(CLI Tool),它也是一個Runtime的實現。儘管你可能對這個概念很陌生,但實際上,你的電腦上的docker底層可能正在使用它。至少在筆者的主機上是這樣。
root@node-1:~# docker info
.....
Runtimes: runc
Default Runtime: runc
.....
安裝runC
runC
不僅可以被docker engine
使用,它也可以單獨使用(它本身就是命令行工具),以下使用步驟完全來自runC's README,如果
依賴項
- Go version 1.6或更高版本
-
libseccomp庫
yum install libseccomp-devel for CentOS apt-get install libseccomp-dev for Ubuntu
下載編譯
# 在GOPATH/src目錄創建'github.com/opencontainers'目錄
> cd github.com/opencontainers
> git clone https://github.com/opencontainers/runc
> cd runc
> make
> sudo make install
或者使用go get
安裝
# 在GOPATH/src目錄創建github.com目錄
> go get github.com/opencontainers/runc
> cd $GOPATH/src/github.com/opencontainers/runc
> make
> sudo make install
以上步驟完成後,runC
將安裝在/usr/local/sbin/runc
目錄
使用runC
創建一個OCI Bundle
OCI Bundle
是指滿足OCI標準的一系列文件,這些文件包含了運行容器所需要的所有數據,它們存放在一個共同的目錄,該目錄包含以下兩項:
- config.json:包含容器運行的配置數據
- container 的 root filesystem
如果主機上安裝了docker,那麼可以使用docker export
命令將已有鏡像導出爲OCI Bundle
的格式
# create the top most bundle directory
> mkdir /mycontainer
> cd /mycontainer
# create the rootfs directory
> mkdir rootfs
# export busybox via Docker into the rootfs directory
> docker export $(docker create busybox) | tar -C rootfs -xvf -
> ls rootfs
bin dev etc home proc root sys tmp usr var
有了root filesystem,還需要config.json,runc spec
可以生成一個基礎模板,之後我們可以在模板基礎上進行修改。
> runc spec
> ls
config.json rootfs
生成的config.json模板比較長,這裏我將它process中的arg 和 terminal進行修改
{
"process": {
"terminal":false, <-- 這裏改爲 true
"user": {
"uid": 0,
"gid": 0
},
"args": [
"sh" <-- 這裏改爲 "sleep","5"
],
"env": [
"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
"TERM=xterm"
],
"cwd": "/",
},
"root": {
"path": "rootfs",
"readonly": true
},
"linux": {
"namespaces": [
{
"type": "pid"
},
{
"type": "network"
},
{
"type": "ipc"
},
{
"type": "uts"
},
{
"type": "mount"
}
],
}
}
config.json 文件的內容都是 OCI Container Runtime 的訂製,其中每一項值都可以在Runtime Spec找到具體含義,OCI Container Runtime 支持多種平臺,因此其 Spec 也分爲通用部分(在config.md中描述)以及平臺相關的部分(如linux平臺上就是config-linux)
-
process
:指定容器啓動後運行的進程運行環境,其中最重要的的子項就是args,它指定要運行的可執行程序, 在上面的修改後的模板中,我們將其改成了"sleep 5" -
root
:指定容器的根文件系統,其中path子項是指向前面導出的中root filesystem的路徑 -
linux
: 這一項是平臺相關的。其中namespaces表示新創建的容器會額外創建或使用的namespace的類型
運行容器
現在我們使用create
命令創建容器
# run as root
> cd /mycontainer
> runc create mycontainerid
使用list
命令查看容器狀態爲created
# view the container is created and in the "created" state
> runc list
ID PID STATUS BUNDLE CREATED OWNER
mycontainerid 12068 created /mycontainer 2018-12-25T19:45:37.346925609Z root
使用start
命令查看容器狀態
# start the process inside the container
> runc start mycontainerid
在5s內 使用list
命令查看容器狀態爲running
# within 5 seconds view that the container is running
runc list
ID PID STATUS BUNDLE CREATED OWNER
mycontainerid 12068 running /mycontainer 2018-12-25T19:45:37.346925609Z root
在5s後 使用list
命令查看容器狀態爲stopped
# after 5 seconds view that the container has exited and is now in the stopped state
runc list
ID PID STATUS BUNDLE CREATED OWNER
mycontainerid 0 stopped /mycontainer 2018-12-25T19:45:37.346925609Z root
使用delete
命令可以刪除容器
# now delete the container
runc delete mycontainerid
runC 的實現原理
runC
可以啓動並管理符合OCI標準的容器。簡單地說,runC
需要利用OCI bundle
創建一個獨立的運行環境,並執行指定的程序。在Linux平臺上,這個環境就是指各種類型的Namespace
以及Capability
等等配置
代碼結構
runC
由Go語言實現,當前(2018.12)最新版本是v1.0.0-rc6,代碼的結構可分爲兩大塊,一是根目錄下的go文件,對應各個runC
命令,二是負責創建/啓動/管理容器的libcontainer
,可以說runC
的本質都在libcontainer
runc create的過程
以上面的例子爲例,以'runc create'這條命令來看runC
是如何完成從無到有創建容器
create
命令的響應入口在 create.go, 我們直接關注其註冊的Action的實現,當輸入runc create mycontainerid
時會執行註冊的Action,並且參數存放在Context中
/* run.go */
Action: func(context *cli.Context) error {
......
spec, err := setupSpec(context)
status, err := startContainer(context, spec, CT_ACT_CREATE, nil)
.....
}
-
setupSpec
:從命令行輸入中找到-b 指定的 OCI bundle 目錄,若沒有此參數,則默認是當前目錄。讀取config.json文件,將其中的內容轉換爲Go的數據結構specs.Spec,該結構定義在文件 github.com/opencontainers/runtime-spec/specs-go/config.go,裏面的內容都是OCI標準描述的 -
startContainer
:嘗試創建啓動容器,注意這裏的第三個參數是 CT_ACT_CREATE, 表示僅創建容器。本文使用linux平臺,因此實際調用的是 utils_linux.go 中的startContainer()。startContainer()根據用戶將用戶輸入的 id 和剛纔的得到的 spec 作爲輸入,調用 createContainer() 方法創建容器,再通過一個runner.run()方法啓動它
/× utils_linux.go ×/
func startContainer(context *cli.Context, spec *specs.Spec, action CtAct, criuOpts *libcontainer.CriuOpts) (int, error) {
id := context.Args().First()
container, err := createContainer(context, id, spec)
r := &runner{
container: container,
action: action,
init: true,
......
}
return r.run(spec.Process)
}
這裏需要先了解下runC
中的幾個重要數據結構的關係
Container 接口
在runC
中,Container用來表示一個容器對象,它是一個抽象接口,它內部包含了BaseContainer接口。從其內部的方法的名字就可以看出,都是管理容器的基本操作
/* libcontainer/container.go */
type BaseContainer interface {
ID() string
Status() (Status, error)
State() (*State, error)
Config() configs.Config
Processes() ([]int, error)
Stats() (*Stats, error)
Set(config configs.Config) error
Start(process *Process) (err error)
Run(process *Process) (err error)
Destroy() error
Signal(s os.Signal, all bool) error
Exec() error
}
/* libcontainer/container_linux.go */
type Container interface {
BaseContainer
Checkpoint(criuOpts *CriuOpts) error
Restore(process *Process, criuOpts *CriuOpts) error
Pause() error
Resume() error
NotifyOOM() (<-chan struct{}, error)
NotifyMemoryPressure(level PressureLevel) (<-chan struct{}, error)
}
有了抽象接口,那麼一定有具體的實現,linuxContainer 就是一個實現,或者說,它是當前版本runC
在linux平臺上的唯一一種實現。下面是其定義,其中的 initPath 非常關鍵
type linuxContainer struct {
id string
config *configs.Config
initPath string
initArgs []string
initProcess parentProcess
.....
}
Factory 接口
在runC
中,所有的容器都是由容器工廠(Factory)創建的, Factory 也是一個抽象接口,定義如下,它只包含了4個方法
type Factory interface {
Create(id string, config *configs.Config) (Container, error)
Load(id string) (Container, error)
StartInitialization() error
Type() string
}
linux平臺上的對 Factory 接口也有一個標準實現---LinuxFactory,其中的 InitPath 也非常關鍵,稍後我們會看到
// LinuxFactory implements the default factory interface for linux based systems.
type LinuxFactory struct {
// InitPath is the path for calling the init responsibilities for spawning
// a container.
InitPath string
......
// InitArgs are arguments for calling the init responsibilities for spawning
// a container.
InitArgs []string
}
所以,對於linux平臺,Factory 創建 Container 實際上就是 LinuxFactory 創建 linuxContainer
回到createContainer(),下面是其實現
func createContainer(context *cli.Context, id string, spec *specs.Spec) (libcontainer.Container, error) {
/* 1. 將配置存放到config */
rootlessCg, err := shouldUseRootlessCgroupManager(context)
config, err := specconv.CreateLibcontainerConfig(&specconv.CreateOpts{
CgroupName: id,
UseSystemdCgroup: context.GlobalBool("systemd-cgroup"),
NoPivotRoot: context.Bool("no-pivot"),
NoNewKeyring: context.Bool("no-new-keyring"),
Spec: spec,
RootlessEUID: os.Geteuid() != 0,
RootlessCgroups: rootlessCg,
})
/* 2. 加載Factory */
factory, err := loadFactory(context)
if err != nil {
return nil, err
}
/* 3. 調用Factory的Create()方法 */
return factory.Create(id, config)
}
可以看到,上面的代碼大體上分爲
- 將配置存放到config
- 加載Factory
- 調用Factory的Create()方法
第1步存放配置沒什麼好說的,無非是將已有的 spec 和其他一些用戶命令行選項配置換成一個數據結構存下來。而第2部加載Factory,在linux上,就是返回一個 LinuxFactory 結構。而這是通過在其內部調用 libcontainer.New()方法實現的
/* utils/utils_linux.go */
func loadFactory(context *cli.Context) (libcontainer.Factory, error) {
.....
return libcontainer.New(abs, cgroupManager, intelRdtManager,
libcontainer.CriuPath(context.GlobalString("criu")),
libcontainer.NewuidmapPath(newuidmap),
libcontainer.NewgidmapPath(newgidmap))
}
libcontainer.New() 方法在linux平臺的實現如下,可以看到,它的確會返回一個LinuxFactory,並且InitPath設置爲"/proc/self/exe",InitArgs設置爲"init"
/* libcontainer/factory_linux.go */
func New(root string, options ...func(*LinuxFactory) error) (Factory, error) {
.....
l := &LinuxFactory{
.....
InitPath: "/proc/self/exe",
InitArgs: []string{os.Args[0], "init"},
}
......
return l, nil
}
得到了具體的 Factory 實現,下一步就是調用其Create()方法,對 linux 平臺而言,就是下面這個方法,可以看到,它會將 LinuxFactory 上記錄的 InitPath 和 InitArgs 賦給 linuxContainer 並作爲結果返回
func (l *LinuxFactory) Create(id string, config *configs.Config) (Container, error) {
....
c := &linuxContainer{
id: id,
config: config,
initPath: l.InitPath,
initArgs: l.InitArgs,
}
.....
return c, nil
}
回到 startContainer() 方法,再得到 linuxContainer 後,將創建一個 runner 結構,並調用其run()方法
/* utils_linux.go */
func startContainer(context *cli.Context, spec *specs.Spec, action CtAct, criuOpts *libcontainer.CriuOpts) (int, error) {
id := context.Args().First()
container, err := createContainer(context, id, spec)
r := &runner{
container: container,
action: action,
init: true,
......
}
return r.run(spec.Process)
}
runner 的 run() 的入參是 spec.Process 結構,我們並不需要關注它的定義,因爲它的內容都來源於 config.json 文件,spec.Process 不過是其中 Process 部分的 Go 語言數據的表示。run() 方法的實現如下:
func (r *runner) run(config *specs.Process) (int, error) {
......
process, err := newProcess(*config, r.init)
......
switch r.action {
case CT_ACT_CREATE:
err = r.container.Start(process) /* runc start */
case CT_ACT_RESTORE:
err = r.container.Restore(process, r.criuOpts) /* runc restore */
case CT_ACT_RUN:
err = r.container.Run(process) /* runc run */
default:
panic("Unknown action")
}
......
return status, err
}
上面的 run() 可分爲兩部分
- 調用 newProcess() 方法, 用 spec.Process 創建 libcontainer.Process,注意第二個參數是 true ,表示新創建的 process 會作爲新創建容器的第一個 process
- 根據 r.action 的值決定如何操作得到的 libcontainer.Process
libcontainer.Process 結構定義在 /libcontainer/process.go, 其中大部分內容都來自 spec.Process
/* parent process */
// Process specifies the configuration and IO for a process inside
// a container.
type Process struct {
Args []string
Env []string
User string
AdditionalGroups []string
Cwd string
Stdin io.Reader
Stdout io.Writer
Stderr io.Writer
ExtraFiles []*os.File
ConsoleWidth uint16
ConsoleHeight uint16
Capabilities *configs.Capabilities
AppArmorProfile string
Label string
NoNewPrivileges *bool
Rlimits []configs.Rlimit
ConsoleSocket *os.File
Init bool
ops processOperations
}
接下來就是要使用 Start() 方法了
func (c *linuxContainer) Start(process *Process) error {
if process.Init {
if err := c.createExecFifo(); err != nil { /* 1.創建fifo */
return err
}
}
if err := c.start(process); err != nil { /* 2. 調用start() */
if process.Init {
c.deleteExecFifo()
}
return err
}
return nil
}
Start() 方法主要完成兩件事
- 創建 fifo: 創建一個名爲
exec.fifo
的管道,這個管道後面會用到 - 調用 start() 方法,如下
func (c *linuxContainer) start(process *Process) error {
parent, err := c.newParentProcess(process) /* 1. 創建parentProcess */
err := parent.start(); /* 2. 啓動這個parentProcess */
......
start() 也完成兩件事:
- 創建一個 ParentProcess
- 調用這個 ParentProcess 的 start() 方法
那麼什麼是 parentProcess ? 正如其名,parentProcess 類似於 linux 中可以派生出子進程的父進程,在runC
中,parentProcess 是一個抽象接口,如下:
type parentProcess interface {
// pid returns the pid for the running process.
pid() int
// start starts the process execution.
start() error
// send a SIGKILL to the process and wait for the exit.
terminate() error
// wait waits on the process returning the process state.
wait() (*os.ProcessState, error)
// startTime returns the process start time.
startTime() (uint64, error)
signal(os.Signal) error
externalDescriptors() []string
setExternalDescriptors(fds []string)
}
它有兩個實現,分別爲 initProcess
和 setnsProcess
,前者用於創建容器內的第一個進程,後者用於在已有容器內創建新的進程。在我們的創建容器例子中,p.Init = true ,所以會創建 initProcess
func (c *linuxContainer) newParentProcess(p *Process) (parentProcess, error) {
parentPipe, childPipe, err := utils.NewSockPair("init") /* 1.創建 Socket Pair */
cmd, err := c.commandTemplate(p, childPipe) /* 2. 創建 *exec.Cmd */
if !p.Init {
return c.newSetnsProcess(p, cmd, parentPipe, childPipe)
}
if err := c.includeExecFifo(cmd); err != nil { /* 3.打開之前創建的fifo */
return nil, newSystemErrorWithCause(err, "including execfifo in cmd.Exec setup")
}
return c.newInitProcess(p, cmd, parentPipe, childPipe) /* 4.創建 initProcess */
}
newParentProcess() 方法動作有 4 步,前 3 步都是在爲第 4 步做準備,即生成 initProcess
- 創建一對 SocketPair 沒什麼好說的,生成的結果會放到 initProcess
- 創建 *exec.Cmd,代碼如下,這裏設置了 cmd 要執行的可執行程序和參數來自 c.initPath,即源自 LInuxFactory 的 "/proc/self/exe",和 "init" ,這表示新執行的程序就是
runC
本身,只是參數變成了init
,之後又將外面創建的 SocketPair 的一端 childPipe放到了cmd.ExtraFiles ,同時將_LIBCONTAINER_INITPIPE=%d
加入cmd.Env,其中%d
爲文件描述符的數字
func (c *linuxContainer) commandTemplate(p *Process, childPipe *os.File) (*exec.Cmd, error) {
cmd := exec.Command(c.initPath, c.initArgs[1:]...)
cmd.Args[0] = c.initArgs[0]
cmd.ExtraFiles = append(cmd.ExtraFiles, p.ExtraFiles...)
cmd.ExtraFiles = append(cmd.ExtraFiles, childPipe)
cmd.Env = append(cmd.Env,
fmt.Sprintf("_LIBCONTAINER_INITPIPE=%d", stdioFdCount+len(cmd.ExtraFiles)-1),
)
......
return cmd, nil
}
- includeExecFifo() 方法打開之前創建的 fifo,也將其 fd 放到 cmd.ExtraFiles 中。
- 最後就是創建 InitProcess 了,這裏首先將
_LIBCONTAINER_INITTYPE="standard"
加入cmd.Env,然後從 configs 讀取需要新的容器創建的 Namespace 的類型,並將其打包到變量 data 中備用,最後再創建 InitProcess 自己,可以看到,這裏將之前的一些資源和變量都聯繫了起來
func (c *linuxContainer) newInitProcess(p *Process, cmd *exec.Cmd, parentPipe, childPipe *os.File) (*initProcess, error) {
cmd.Env = append(cmd.Env, "_LIBCONTAINER_INITTYPE="+string(initStandard))
nsMaps := make(map[configs.NamespaceType]string)
for _, ns := range c.config.Namespaces {
if ns.Path != "" {
nsMaps[ns.Type] = ns.Path
}
}
_, sharePidns := nsMaps[configs.NEWPID]
data, err := c.bootstrapData(c.config.Namespaces.CloneFlags(), nsMaps)
if err != nil {
return nil, err
}
return &initProcess{
cmd: cmd,
childPipe: childPipe,
parentPipe: parentPipe,
manager: c.cgroupManager,
intelRdtManager: c.intelRdtManager,
config: c.newInitConfig(p),
container: c,
process: p,
bootstrapData: data,
sharePidns: sharePidns,
}, nil
}
回到 linuxContainer 的 start() 方法,創建好了 parent ,下一步就是調用它的 start() 方法了
func (c *linuxContainer) start(process *Process) error {
parent, err := c.newParentProcess(process) /* 1. 創建parentProcess (已完成) */
err := parent.start(); /* 2. 啓動這個parentProcess */
......
----- 待續