天天看點

Linux Container 研究報告

1. 綜述

lxc是Linux Container的使用者态工具包。其代碼由三部分組成:

  1. shell腳本,部分lxc指令是用shell腳本寫就的。
  2. c語言代碼,最終編譯成可執行檔案。這部分代碼也用來提供最終的lxc指令。但是這些代碼以處理指令行參數,讀取配置檔案等為主。
  3. c語言代碼,最終編譯為動态連結庫liblxc.so。該動态庫提供了lxc項目的大部分功能,如配置檔案分析、日志記錄、容器的建立、通信等。lxc指令的各項功能基本都是通過調用liblxc.so中的函數來完成的。

在命名習慣上, 生成lxc指令的c源檔案都有

lxc_

的字首。而生成liblxc.so的c源檔案則沒有該字首。

2. lxc-start

lxc-start的執行過程大緻就是兩步:

  1. 解析指令行和配置檔案
  2. 建立新的程序運作container

2.1 解析指令行和配置檔案

lxc-start的main函數在lxc_start.c中。從main的流程中能大緻窺探出lxc-start的執行過程。首先調用

lxc_arguments_parse

來分析指令行,再調用

lxc_config_read

來解析配置檔案,等擷取好足夠的資訊後,調用

lxc_start

開始了container。

lxc_arguments_parse

函數的實作位于arguments.c中。通過該函數的執行,指令行參數中的資訊被存放到了類型為

lxc_arguments

的變量my_args中。指令行參數中有幾個我們可以注意一下:

  • "-n" 指定了container的名字
  • "-c" 指定了某個檔案作為container的console
  • "-s" 在指令行中指定key=value的config選項
  • "-o" 指定輸出的log檔案
  • "-l" log的列印級别。用"-l DEBUG"指令行參數,log列印的資訊最詳細

lxc_arguments

中有一個字段

lxcpath

指定了lxc container的存放路徑。可以在指令參數-P選項中設定。如果不設定,會啟用預設參數。該預設參數是在編譯lxc代碼的時候指定的,一般情況下為"/usr/local/var/lib/lxc"或"/var/lib/lxc"。在本文中,我們一律用LXCPATH來表示該路徑。

另外,我們約定用CT-NAME表示-n參數指定的container的名稱。

lxc_config_read

它的實作在confile.c中。逐行讀取配置檔案。并調用confile.c下的

parse_line

來每行做解析。因為配置檔案每行都是key=value的形式的,是以

parse_line

的主要内容是找到"=",分析出(key, value)對并做處理。

配置檔案的路徑由-f參數指定。不指定則從LXCPATH/CT-NAME/config中讀取。

confile.c的真正核心内容在與95行定義的一個結構體數組

static struct lxc_config_t config[] = {
    { "lxc.arch",                 config_personality          },
    { "lxc.pts",                  config_pts                  },
    { "lxc.tty",                  config_tty                  },
    { "lxc.devttydir",            config_ttydir               },
    .....
};
           

其中

lxc_config_t

結構體的定義如下:

typedef int (*config_cb)(const char *key, const char *value, struct lxc_conf *lxc_conf);
struct lxc_config_t {
    char* name;
    config_cb cb;
};
           

lxc_config_t

結構體的name字段給定了key的值,回調函數cb給出了對應key的處理方法。整個config結構體數組就是鍵值與對應動作的一個查找表。

現在我們來考慮一個場景。我們在配置檔案中寫入了

lxc.tty = 4

parse_line

會解析出(lxc.tty, 4)的序對。到config中查詢得出其對應的處理函數是

config_tty

,于是就開始調用

config_tty

函數指針

config_cb

一共有三個參數,

key

指的是如

lxc.tty

之類的鍵,而

value

指的是如

4

之類的值。

lxc_conf

用來存放config檔案的分析結果。

lxc_config_read讀取好的配置檔案放在類型為lxc_conf的結構體指針conf。lxc_conf的定義在conf.h中。

2.2 調用lxc_start

讓我們再次回到main函數。前面我們通過分析指令行參數和配置檔案,收集了container的一系列資訊,接下來就該啟動container了。

main函數255行的

lxc_start

打響了了啟動container的第一槍。

lxc_start

的實作在start.c中,函數原型如下:

int lxc_start(const char* name, char *const argv[], struct lxc_conf *conf, const char *lxcpath)
           

四個參數的含義如下:

  • name CT-NAME
  • argv container要執行的第一個指令。可以通過指令行參數指定, 如“lxc-start -n android4.2 /init”,這裡的argv就會是{"/init", NULL}。如果沒有指定,預設是{"/sbin/init", NULL}
  • conf 前面解析好的container配置檔案中指定的配置資訊。
  • lxcpath LXCPATH

lxc_start

首先調用

lxc_check_inherited

來關閉所有打開的檔案句柄。0(stdin), 1(stdout), 2(stder)和日志檔案除外。緊接着就調

__lxc_start

__lxc_start

其原型如下:

int __lxc_start(const char* name, struct lxc_conf *conf, struct lxc_operators *op, void *data, const char *lxcpath)
           

lxc_operators

結構體定義如下:

struct lxc_operations {
    int (*start)(struct lxc_handler *, void *);
    int (*post_start)(struct lxc_handler *, void *);
};
           

各個參數含義如下:

  • name CT-NAME
  • conf container配置資訊
  • op 用lxc_operator結構體來存放了兩個函數指針start和post_start。這兩個指針分别指向start.c的start函數和post_start函數。
  • data start_args類型的結構體,唯一的成員變量argv指向了lxc_start的實參argv,也就是container要執行的init。
  • lxcpath LXCPATH

__lxc_start

代碼不複雜。我們将比較重要的幾個函數調用抽出來看。

lxc_init

__lxc_start

調用的第一個函數,用來初始化lxc_handler結構體。傳入的三個參數依次為:

  • name CT-NAME
  • conf container的配置資訊
  • lxcpath LXCPATH

函數先新配置設定一個lxc_handler的結構體handler,設定其conf、lxcpath和name字段。然後調用了

lxc_command_init

新建立了一個socket并listen之,建立socket的句柄放置在handler->maincmd_fd中。該socket的作用應為接受外部指令。

lxc_command_init

的實作在commands.c中。其分析可以詳見子產品commands.c部分。

接着是

lxc_set_state

。它将STARTING的狀态消息寫入到另一個socket中。

lxc_set_state

的實作調用了monitor.c的

lxc_moitor_send_state

。對其分析可以參見monitor.c子產品。

接着是部分環境變量的設定:

LXC_NAME, LXC_CONFIG_FILE, LXC_ROOTFS_MOUNT, LXC_ROOTFS_PATH, LXC_CONSOLE, LXC_CONSOLE_LOGPATH

接下來的四件事情:

  1. 調用

    run_lxc_hooks

    運作pre-start的腳本。
  2. 調用

    lxc_create_tty

    建立tty
  3. 調用

    lxc_create_console

    建立console
  4. 調用

    setup_signal_fd

    處理程序的信号響應

我們來重點分析第二步和第三步

終端裝置的建立

1. tty

lxc_create_tty

通過調用openpty的指令來為container配置設定tty裝置。conf->tty參數指定了要配置設定的tty的個數。conf->tty_info結構體用來存放配置設定好的tty的相關資訊。

如果conf->tty的值是4,那麼lxc_create_tty執行完之後的結果是:

conf->tty_info->nb_tty: 4
conf->tty_info->pty_info: 大小為4的類型為lxc_pty_info的數組的頭指針
           

lxc_pty_info的定義如下:

struct lxc_pty_info {
    char name[MAXPATHLEN];
    int master;
    int slave;
    int busy;
};
           

conf->tty_info->pty_info

的每一項都記錄了一個新建立pty的資訊,master表示pty master的句柄,slave表示slave的句柄,name表示pty slave的檔案路徑,即"/dev/pts/N"。

2. console

lxc_create_console

同樣調用openpty用來建立console裝置。建立好的console裝置資訊存放在類型為

lxc_cosnole

的結構體變量

conf->console

中。

lxc_console的結構體定義如下:

struct lxc_console {
    int slave;
    int master;
    int peer;
    char *path;
    char *log_path;
    int log_fd;
    char name[MAXPATHLEN];
    struct termios *tios;
};
           

各個參數的含義如下:

  • slave 新建立pty的slave
  • master 新建立pty的master
  • path console檔案路徑,可以通過配置檔案"lxc.console.path"或者指令行參數-c指定。預設為"/dev/tty"
  • log_path console的日志路徑
  • peer 打開path, 傳回句柄放入peer中。
  • log_fd 打開log_path, 傳回句柄放入log_fd中。
  • name slave的路徑。
  • tios 存放tty舊的控制參數。

lxc_spawn

讓我們繼續回到

__lxc_start

的主流程。前面通過調用

lxc_init

初始化了一個lxc_handler的結構體handler,然後在主流程裡,又将傳入的ops參數和data參數指派給了handler的ops字段和data字段。接着就以handler為參數,調用了

lxc_spawn

lxc_spawn

是啟動新的容器的核心。程序通過Linux系統調用clone建立了擁有自己的PID、IPC、檔案系統等獨立的命名空間的新程序。然後在新的程序中執行

/sbin/init

。接下來我們來看具體過程。

  1. 首先調用

    lxc_sync_init

    來為将來父子程序同步做初始化。
  2. 準備clone調用需要的flag。各個flags如下:
    • CLONE_NEWUTS

       子程序指定了新的utsname,即新的“計算機名”
    • CLONE_NEWPID

       子程序擁有了新的PID空間,clone出的子程序會變成1号程序
    • CLONE_NEWIPC

       子程序位于新的IPC命名空間中。這樣SYSTEM V的IPC對象和POSIX的消息隊列看上去會獨立于原系統。
    • CLONE_NEWNS

       子程序會有新的挂載空間。
    • CLONE_NEWNET

       如果配置檔案中有關于網絡的配置,則會增加該flag。它使得子程序有了新的網絡裝置的命名空間
  3. 調用pin_rootfs。如果container的根檔案系統是一個目錄(而非獨立的塊裝置),則在container的根檔案系統之外以可寫權限打開一個檔案。這樣可以防止container在執行過程中将整個檔案系統變成隻讀(原因很簡單,因為已經有其他程序以讀寫模式打開一個檔案了,是以裝置是“可寫忙”的。是以其他程序不能将檔案系統重新挂載成隻讀)。
  4. 調用lxc_clone,在新的命名空間中建立新的程序。
  5. 父子程序協同工作,完成container的相關配置。

我們先來看一下lxc_clone是如何建立新的程序的。

lxc_clone

該函數的原型如下:

pid_t lxc_clone(int (*fn)(void *), void *arg, int flags)
           

三個參數的含義如下:

fn: 子程序要執行的函數入口
arg:fn的輸入參數
flags: clone的flags
           

lxc_clone

為clone api做了一個簡單的封裝,最後結果就是子程序會執行fn(arg)。在

lxc_spawn

處,

lxc_clone

是這樣調用的:

handler->pid = lxc_clone(do_start, handler, handler->clone_flags)
           

是以lxc_clone執行完後,handler的pid字段會保留子程序的pid(注意不是“1”,是子程序調用getpid()會變成1)。父程序繼續,子程序執行do_start。

父子程序同步

同步機制

程序間同步的函數實作在sync.c中,其實作機制的分析可以見sync.c子產品。這裡隻列舉用于同步的函數:

  • lxc_sync_barrier_parent/child(struct lxc_handler* handler, int sequence)

     發送sequence給parent/child,同時等待parent/child發送sequence+1的消息過來。
  • lxc_sync_wait_parent/child(struct lxc_handler* handler, int sequence)

     等待parent/child發送sequence的消息過來。

同步完成配置的過程

  1. 父程序lxc_clone結束後,開始等待子程序發送

    LXC_SYNC_CONFIGURE

    的消息過來。此時,執行do_start的子程序完成了四件事情:
    1. 将信号處理表置為正常。
    2. 通過prctl api,将子程序設定為“如果父程序退出,則子程序收到SIGKILL的消息”。
    3. 關閉不需要的file handler
    4. 發送

      LXC_SYNC_CONFIGURE

      給父程序,通知父程序可以開始配置。同時等待父程序發送配置完成的消息

      LXC_SYNC_POST_CONFIGURE

  2. 受到子程序發送的

    LXC_SYNC_CONFIGURE

    的消息後,父程序繼續執行。父程序執行的動作如下:
    1. 調用lxc_cgroup_path_create建立新的cgroup
    2. 調用lxc_cgroup_enter将子程序加入到新的cgroup中。
    3. 如果有新的網絡命名空間,則調用lxc_assign_network為之配置設定裝置
    4. 如果有新的使用者空間,如果配置了使用者ids(包括uid,gid)映射,則做使用者ids映射。該映射将container的id映射到了真正系統中一個不存在的id上,使得container可以在一個虛拟的id空間中做諸如“切換到root”之類的事情。詳細讨論可參見子產品相關配置。
    5. 發送

      LXC_SYNC_POST_CONFIGURE

      給子程序,并等待子程序發送

      LXC_SYNC_CGROUP

      的消息
  3. 子程序收到

    LXC_SYNC_POST_CONFIGURE

    的消息被喚醒。完成如下動作:
    1. 如果id已被映射,則切換到root
    2. 開始container的設定,調用lxc_setup。主要有utsname、ip、根檔案系統、裝置挂載、console和tty等終端裝置的各個方面的配置。
    3. 發送

      LXC_SYNC_CGROUP

      給父程序。并等待父程序發送

      LXC_SYNC_CGROUP_POST

      消息。
  4. 父程序被喚醒。根據配置檔案中對CGROUP的相關配置,調用setup_cgroup進行cgroup的設定。然後發送

    LXC_SYNC_CGROUP_POST

    給子程序。等待子程序發送

    LXC_SYNC_CGROUP_POST+1

  5. 子程序被喚醒。調用handler->ops->start函數。實際上是完成了對start.c中start函數的調用。該函數功能簡單,基本就是通過exec執行了container的init程式,預設情況下為/sbin/init.
  6. 子程序并沒有給父程序傳回

    LXC_SYNC_CGROUP_POST+1

    的消息,而是關掉了父子程序間的通信信道。這導緻父程序被喚醒。被喚醒後,父程序完成了以下動作:
    1. 調用detect_shared_rootfs, 檢測是否共享根檔案系統,是的話解除安裝。
    2. 修改子程序tty檔案的使用者ids
    3. 執行handler->ops->post_start, 列印"XXX is started with pid XXX"字樣。

相關配置

在這一部分中,我們針對前面講的父子程序的同步配置過程來對部分重要的函數做分析。

lxc_cgroup_path_create和lxc_cgroup_enter

函數定義在cgroup.c中。原型如下:

char* lxc_cgroup_path_create(const char* lxcgroup, const char* name)
int lxc_cgroup_enter(const char* cgpath, pid_t pid)
           

lxc_cgroup_path_create函數的作用是在cgroup各個已挂載使用的子系統的挂載點上為新建立的container建立一個檔案夾。lxc_cgroup_enter的作用是把新建立的container加入到group cgpath中。

下面我們來舉例說明: 比如挂載的子系統有blkio和cpuset,他們的挂載點分别是/cgroup/blkio和/cgroup/cpuset。

lxc_cgroup_path_create函數運作結束後,則會多出兩個目錄/cgroup/blkio/lxcgroup/name和/cgroup/cpuset/lxcgroup/name。如果傳入參數lxcgroup為空,則會使用“lxc”。函數的傳回值是新建立目錄的相對路徑。即“lxcgroup/name”。

lxc_cgroup_enter函數結束後,程序号"pid"會被追加到檔案/cgroup/blkio/lxcgroup/name/tasks和/cgrop/cpuset/lxcgroup/name/tasks中。在lxc_spawn中,pid的實參用的是handler->pid,即clone出的子程序的id。

通過檢視/proc/mounts檢視cgroup的挂載點。通過檢視/proc/cgroups檢視正挂載使用的子系統。

id映射

檢視confile.c下的config_idmap函數,可知在container配置檔案中可以通過lxc.id_map來設定id映射。格式如下:

lxc.id_map = u/g id_inside_ns id_outside_ns range
           

其中,u/g指定了是uid還是gid。後三個選項表示container中的[id_inside_ns, id_inside_ns+range)會被映射到真實系統中的 [id_outside_ns, id_outside_ns+range)。

在config_idmap執行後,配置檔案中的配置條目被作為連結清單存放到conf->id_map字段下。在lxc_spawn中,通過調用lxc_map_ids函數來完成配置。

lxc_map_ids的實作在conf.c中,原型如下:

int lxc_map_ids(struct lxc_list *idmap, pid_t pid)
           

第一個參數idmap為配置資訊的連結清單,pid為新clone出的子程序的pid。lxc_map_ids的基本過程比較簡單,就是将以u開頭的配置項寫入到檔案/proc/pid/uid_map中,将以g開頭的配置項寫入到檔案/proc/pid/gid_map中。

id映射是clone在flag CLONE_NEWUSER時指定的一個namespace特性。有關id映射可以參見此處。

lxc_setup

子程序do_start中調用的lxc_setup是一個非常重要的函數。container裡面的很多配置都是在lxc_setup中完成的。這裡重點分析setup_console和setup_tty兩個函數,來檢視比較困擾的終端字元裝置是如何虛拟的。

setup_console的實作在conf.c中,函數原型如下:

int setup_console(const struct lxc_rootfs *rootfs, const struct lxc_console *console, 
        char *ttydir)
           

rootf變量描述了container根檔案系統的路徑和挂載點。console變量描述了container的console裝置的相關資訊。在do_start的調用中,傳來的實參是lxc_conf->console,這個字段是我們在lxc_init時初始化的。回顧當時的初始化過程:

  • console->slave和console->master分别存儲了新配置設定的pty的slave和master的句柄。
  • console->peer存儲了打開原系統"/dev/tty"的句柄。
  • console->name指向了新配置設定pty的檔案路徑

setup_console根據ttydir的值,分支調用了setup_dev_console或者setup_ttydir_console。我們隻看setup_dev_console來了解原理。

在setup_dev_console中,主要動作就是将console->name指向的pty通過BIND的方式挂載到rootfs/dev/console檔案中。可以看出,我們對container /dev/console的通路,實質上是對新配置設定pty的通路。

setup_tty的過程與此類似,在rootfs/dev/目錄下建立tty1, tty2等正常檔案,然後用BIND的方式将新建立的pty挂載到其上。

setup_cgroup

顯然,container無法通路原來的cgroup根檔案系統,是以這個任務隻能由父程序在lxc_spawn中調用。該函數實作比較簡單,根據config檔案中的配置條目,将對應的value值寫入到對應的cgroup檔案中。

剩下的事情

主程序陷入等待。子程序開始運作。

繼續閱讀