Linux 容器技术笔记
《自己动手写docker》 的读书笔记。有些代码块并不完整,还是需要结合原书一起看。
基础技术
基础技术主要包含Namespace 隔离技术、Cgroups 资源管理以及AUFS 文件系统等。
而容器技术的核心理念就是通过Linux 提供的一系列工具或者API,以类似搭积木的形式去创造一个相对独立、干净的虚拟环境。
对于虚拟环境中的进程而言,对于外界是无感的,甚至以为自己有root 权限;但是宿主环境却是能够看到或者管理虚拟环境中的进程。
Namespace 隔离
Namespace 是Linux Kernel 的一个功能,用于隔离一系列的系统资源,当前Linux 一共实现了6 种不同类型的Namespace:
Namespace 类型 | 系统调用参数 | 说明 |
---|---|---|
Mount Namespace | CLONE_NEWNS | linux 2.4.19 引入,主要用来隔离文件挂载点,因为未考虑后续更多的命名空间,所以系统调用参数名与其他参数名不一致 |
UTS Namespace | CLONE_NEWUTS | linux 2.6.19 引入,主要用于隔离主机名和域名 |
IPC Namespace | CLONE_NEWIPC | linux 2.6.19 引入,主要用于隔离SystemV IPC 和POSIX message queue |
PID Namespace | CLONE_NEWPID | linux 2.6.24 引入,主要用于隔离PID 关系 |
Network Namespace | CLONE_NENET | linux 2.6.29 引入,主要用于隔离网络环境 |
User Namespace | CLONE_NEWUSER | linux 3.8 引入,主要用于隔离用户和用户组设置 |
与Namespace 相关的系统API 主要有以下3 个:
创建Namespace
- 在Bash 中可以通过
unshare
命令创建指定的Namespace:
#!/bin/bash
# 使用 unshare 创建新 namespace
unshare --fork --pid --mount --uts --ipc --net --user bash <<EOF
echo "当前进程 PID: $$"
echo "主机名: $(hostname)"
hostname "new-namespace"
echo "新主机名: $(hostname)"
EOF
- 或者可以通过C 语言调用系统API 创建:
#define _GNU_SOURCE
#include <sched.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
// 注意需要预先分配栈空间,这也导致只能通过底层的编程语言实现这一功能
// 而python/node 则只能通过ffi 调用共享库的方式实现
#define STACK_SIZE (1024 * 1024)
static char child_stack[STACK_SIZE];
int child_func(void *arg) {
printf("Child PID: %ld\n", (long)getpid());
execlp("/bin/bash", "bash", NULL);
return 0;
}
int main() {
// namespace 标志
int flags = CLONE_NEWUTS | CLONE_NEWPID | CLONE_NEWNS | SIGCHLD;
pid_t pid = clone(child_func, child_stack + STACK_SIZE, flags, NULL);
if (pid == -1) {
perror("clone");
exit(EXIT_FAILURE);
}
waitpid(pid, NULL, 0);
return 0;
}
- Go 语言则封装了
clone()
方法,通过以上示例:
package main
import(
"log"
"oS"
"os/exec"
"syscall"
)
func main(){
cmd := exec.Command("sh" )
cmd.SysProcAttr=&syscall.SysProcAttr{
Cloneflags:syscall.CLONE_NEWUTS | SyScall.CLONE_NEWIPC
}
cmd.Stdin =os.Stdin
cmd.Stdout =os.Stdout
cmd.Stderr =os.Stderr
if err:=cmdRun();err != nil{
log.Fatal(err)
}
}
可以发现,创建Namespace 的关键操作在于设置标志位。
常用的调试命令
Cgroups 资源管理
Cgroups 可以管理进程组及其子进程,闲置资源占用并可以监控和统计进程信息。Cgroups 包含三个组件:
通过命令行操作Cgroups
Cgroups 是工作在内核中,一般在容器外进行设置。
- 创建和管理cgroup
# 创建并挂载一个hierarchy(cgroup 树)
mkdir cgroup-test # 创建一个挂载点
# 挂载proc
sudo mount -t cgroup -o none,name=cgroup-test cgroup-test ./cgroup-test
ls ./cgroup-test # 可以看到一些默认文件
# cgroup.clone_children cpuset 会读取这个文件,如果是1 子cgroup 会继承cpuset 设置
# cgroup.procs 当前节点的进程组id
# notify_on_release 和release_agent 一起使用,通常用于进程退出后清理cgroup
# tasks 当前节点下的进程id
cd cgroup-test
sudo mkdir cgroup-1
sudo mkdir cgroup-2
tree # 扩展子节点会自动添加默认文件
# 移动进程到cgroup
cd cgroup-1 # 切换到目标cgroup
sudo sh -c "echo $$ >> tasks" # 将当前终端进程移动到cgroup-1
cat /proc/{pid}/cgroup # 打印cgroup 信息
- 通过subsystem 限制cgroup 资源占用
# 系统会默认为每个subsystem(每种计算机资源)创建一个hierarchy(cgroup 树), 例如内存管理
mount | grep memory # 查看memory subsystem
# cgroup on /sys/fs/cgroup/memory type .....
cd /sys/fs/cgroup/memory
sudo mkdir test-limit-memory && cd test-limit-memory # 创建一个cgroup
sudo sh -c "echo \"100m\" > memory.limit_in_bytes" # 设置当前cgroup 中所有进程的总内存占用
sudo sh -c "echo $$ > tasks" # 将当前进程移动到该cgroup
# 运行一个占用200M 内存的stress 进程
stress --vm-bytes 200m --vm-keep -m 1
# 通过top 命令查看该进程实际占用了100M 内存空间
而以内存限制为例,Docker 会为每个容器创建一个相关的cgroup,即使是通过代码实现,也是遵循上面命令的步骤。当然对于不同的计算机资源可以新增更多的cgroup。
Union File System 联合文件系统
写时复制(Copy-on-Write)
,对于同一个文件,可能存在不同的应用场景,而今当该文件被修改时,才会创建新的资源,以减小系统开销,并且文件可以同步最新状态。
AUFS
对UnionFS 1.x 进行了重写,然后是Docker 选用的第一种存储驱动但是因对Linux 的支持不好,因此更推荐选择OverlayFS。 AUFS 具有分层的概念,还是值得学习的:
# cd 至任意文件夹
# 创建文件夹container_layer 文件f1-f4 以及挂载目标目录mnt
sudo mount -t aufs -o dirs=./container-layer:./f1:./f2:./f3:./f4 none ./mnt
# dirs 后跟的第一个路径是可读写的,后面都是只读的
echo -e "\nNew Line!" >> ./mnt/f4
# 因为f4 在mnt 挂载时是只读的,故而在该文件更新时
# 会在container-layer 文件夹中创建一个副本
cat f4 # 仍然会输出原先f4 文件中的内容
cat ./mnt/f4 # 会显示更新后的f4 内容
构造容器
首先是关于/proc
文件系统的基础知识,该系统并不是真正的文件系统,虽然可以以文件系统的形式读取,但是它实际存在于内存中。
实现简单docker run 命令
首先分析该命令的参数docker run [-ti] {command}
,抛开容器和隔离的知识不谈,该命令就是创建一个进程:
run
是二级命令,固定的-ti
表示是否将子进程的输入输出转发到docker 进程command
子进程的启动命令
下面是大致的流程图:
sequenceDiagram
docker->>runCommand: 1. 解析命令行参数
runCommand->>NewParentProcess: 2. 创建Namespace 隔离的容器进程(的对象)cmd
NewParentProcess-->>runCommand: 3. 返回容器进程对象cmd,利用AUFS 初始化系统镜像文件
runCommand-->>docker: 4. 启动容器进程cmd,调用init 二级命令
docker->>docker: 5. 容器内进程调用自己(为初始化子进程环境需要)
docker->>RunContainerProcess: 6. 初始化容器内容,挂载proc 文件系统、系统镜像,最后执行command
RunContainerProcess-->>docker: 7. 容器进程开始运行,转发标准输入输出到docker 进程
下面是几个比较关键的函数的部分节选:
NewParentProcess 创建Namespace 隔离的容器进程
// 2. NewParentProcess 创建Namespace 隔离的容器进程(的对象)cmd
func NewParentProcess(tty bool, command string) *exec.Cmd {
// tty 表示是否转发子进程stdio 这里忽略
args := []string{"init",command}
// 将来该命令会调用自己的init 二级命令,为command 初始化环境
// 所以这里要有一个init 命令用于初始化容器内子进程的运行环境 RunContainerInitProcess()
cmd := exec.Command("/proc/self/exe", args...)
// 隔离Namespace
cmd.SysProcAttr = &syscall.SysProcAttr{
Cloneflags: syscall.CLONE_NEWUNS | syscall.CLONE_NEWUTS | syscall.CLONE_NEWPID |
syscall.CLONE_NEWUIPC | syscall.CLONE_NEWNET
}
return cmd // 这里只创建了对象,并未运行
}
RunContainerInitProcess 初始化容器环境
func RunContainerInitProcess(command string, srgs []string)error{
// 挂载 /proc
defaultMountFlags := syscall.MS_NOEXEC | syscall.MS_NOSUID | syscall.MS_NODEV
/*
* MS_NOEXEC 本文件系统中不允许其他程序运行
* MS_NOSUID 本文件系统运行程序时,不允许,不允许设置userid 和group include
* MD_NODEV 是一个默认参数
*/
syscall.Mount("proc", "/proc", "proc", uintptr(defaultMountFlags), "")
argv := []string{command}
if err:= syscall.Exec(command, argv, os.Environ()); err != nil{
// 错误处理
// 这里syscall.Exce 实际回到用内核的函数:
// int execve(const char *filename, char *const argv[], char *const envp[])
// 可以在执行filename 程序后覆盖掉当前进程的镜像、数据、堆栈信息,包括PID 这种会被重用
}
return nil
}
增加资源限制
通过hierarchy 和subsystem 对容器中的资源进行限制,抛开参数解析和命令执行,修改的点在创建容器进程之后,容器初始化之前:
func Run(tty bool, /* */){
parent, writePipe := NewParentProcess(tty)
// 创建并应用资源管理规则
// 初始化容器
sendInitCommand(/**/, writePipe)
parent.Wait()
}
这一步骤的主要工作是在容器初始化之前封装好subsystem/hierarchy 查找、新建以及删除的方法。工作量很大,但是逻辑很直接,相当于自己实现了一个Cgroups 树和管理器:
sequenceDiagram
CgroupManager-->>Subsystem 实例: 发现并创建Subsystem 实例
CgroupManager->>Subsystem 实例: 在每个Subsystem 对应的hierarchy 上创建cgroup
Subsystem 实例-->>CgroupManager: 创建完成
CgroupManager->>Subsystem 实例: 将容器进程加入cgroup
Subsystem 实例-->>CgroupManager: 完成
管道与环境变量
管道是进程间通信的消息通道,可以按照文件的方式读写,一般有4KB 的缓存区,缓存满时写入会被阻塞。常见的管道类型一般分为:
- 匿名管道:具有亲缘关系间的进程通信
- 具名管道:用于任意进程间通信
因为在启动容器之后需要与容器中子进程通信,于是需要至少有一个匿名的管道来实现:
func NewPipe()(*os.File, *os.File, error){
read, write, err := os.Pipe()
if err != nil {
return nil, nil, err
}
return read, write, nil
}
然后再修改容器进程的构造方法NewParentProcess()
:
// 因为无需执行固定的命令,所以就不需要传入command 了
func NewParentProcess(tty bool) (*exec.Cmd, *os.File) {
read, write, err := NewPipe() // 省略判断为空的语句
// tty 表示是否转发子进程stdio 这里忽略
args := []string{"init"}
// 将来该命令会调用自己的init 二级命令,为command 初始化环境
// 所以这里要有一个init 命令用于初始化容器内子进程的运行环境 RunContainerInitProcess()
cmd := exec.Command("/proc/self/exe", args...)
// 隔离Namespace
cmd.SysProcAttr = &syscall.SysProcAttr{
Cloneflags: syscall.CLONE_NEWUNS | syscall.CLONE_NEWUTS | syscall.CLONE_NEWPID |
syscall.CLONE_NEWUIPC | syscall.CLONE_NEWNET
}
// ※ 重点在这里,该管道的文件描述符在容器进程中的id 会是3
// 因为前面会有标准输入、标准输出和标准错误的id 分别为0,1,2
cmd.ExtraFiles = []*os.File(read)
return cmd // 这里只创建了对象,并未运行
}
因此在子进程中,我们需要一个获取管道并从管道读取指令的方法:
// 读取id 为3 的文件,也就是上面生成的管道
// 并从管道中读取命令,重新组合成字符串数组
func readUserCommand() []string{
pipe := os.NewFile(uintptr(3), "pipe")
msg, err := ioutil.ReadAll(pipe)
// 省略异常处理
msgStr := string(msg)
return strings.Split(msgStr, " ")
}
最后我们需要重新在RunContainerInitProcess()
方法中,去尝试调用管道传入的命令:
func RunContainerInitProcess() error{
cmdArray := readUserCommand()
// 挂载/proc
path, err := exec.LookPath(cmdArray[0]) // 此语句会让子进程
// 在系统PATH 中寻找命令的绝对路径
if err:= syscall.Exec(path, cmdArray[0:], os.Environ()); err != nil{
// 错误处理
}
return nil
}
但是最终还是需要手动调用在主进程写入管道,并且该命令似乎只会执行一次。而我们需要的是主进程可以循环写入,在子进程中可以循环读取并执行。
构造镜像(挂载文件系统)
通过上述步骤虽然创建了独立的pid和ipc,但是子进程中还是能看到父进程所有的挂载点, 这样文件系统便无法隔离,并且该容器也不容易迁移。因此,我们需要将常用的工具打包在一起, 然后在子进程中将这些工具集替换掉系统默认的工具。该操作在容器init进程执行之中,容器内命令执行之前。
pivot_root
在 Linux 中,/(也叫 root 目录)是文件系统的最顶层目录。
所有文件和目录——无论是 /home
、/usr
、还是设备如 /dev
——都在它下面。
/(root)
├── bin/
├── boot/
├── dev/
├── etc/
├── home/
├── lib/
└── ...
pivot_root 是一个系统调用,用以改变当前的root 文件系统:将当前root 移动到put_old 文件夹并将 new_root 设置为新的root,以摆脱对之前root 系统的依赖:
func pivotRoot(root string) error{
if err:= syscall.Mount(root, root, "bind", syscall.MS_BIND|syscall.MS_REC,
""); err != nil{
// 以bind mount 重新挂载老root
// 把 root 目录“绑定挂载”到自己身上。这样是为了确保 root 是挂载点。
// ...
}
pivotDir := filepath.Jion(root, ".pivot_root")
// 创建.pivot_root 文件夹用于临时存储old_root
if err:= syscall.PivotRoot(root, pivotDir); err != nil{
// pivot_root 到新的rootfs
// 旧的root 必须在新root 中
}
if err:= syscall.Chdir("/"); err != nil{
// 修改当前工作目录到根目录,以防进程还停留在旧root,引起错误
}
pivotDir = filepath.Join("/", ".pivot_root")
if err:= syscall.Unmount(pivotDir, syscall.MNT_DETACH); err != nil{
// 卸载并删除旧root
}
return os.Remove(pivotDir)
}
之后,我们可以在容器进程初始化时进行挂载操作:
func setMount(){
pwd, err:= os.Getwd() // 获取当前路径
pivotRoot(pwd) // 将进程当前目录设置为root 目录
defaultMountFlags := syscall.MS_NOEXEC | syscall.MS_NOSUID | syscall.MS_NODEV
syscall.Mount("proc", "/proc", "proc", uintptr(defaultMountFlags), "")
// 挂载 /proc, 不允许执行mount 出来的文件,且不允许访问设备文件
syscall.Mount("tmpfs", "/dev", "tempfs", syscall.MS_NOSUID|syscall.MS_STRICTATIME, "mode=755")
// 通过tmpfs(内存文件系统)挂载/dev 提供设备文件目录,例如:
// /dev/null, /dev/zero, /dev/tty 等
}
整体流程图:
初始: 切换后:
宿主机 容器进程看到的视图
--------- -----------------------
/ → /
├── rootfs/ ├── proc/ (挂载的虚拟 /proc)
│ └── ... ├── dev/ (tmpfs 类型)
├── home/ └── ...
└── etc/
如果当前程序目录包含busybox
这类镜像文件则相当于有了有个崭新的系统环境,对于子进程来说。
通过AUFS 包装busybox
通过AUFS 创建container-init layer 只读文件层和write layer 读写层。以避免容器内操作影响镜像本身。 记住该操作是在宿主机进行的:
func NewWorkSpace(rootURL string, mntURL string){
CreateReadOnlyLayer(rootURL)
// 将busybox.tar 解压到busybox 目录下,作为只读层,
// 就是解压、创建文件夹两步操作
CreateWriteLayer(rootURL)
// 创建一个新的文件夹write layer
CreateMountPoint(rootURL, mntURL)
// 通过mount aufs 挂载目录,rootURL->/root/ mntURL->/root/mnt/
}
// 最后NewParenProcess 中将进程根目录修改为mntURL 即可
// ...
cmd.Dir = mntURL
return cmd, writePipe
// ...
有始有终,在容器进程退出时要清理诸多挂载点和文件层,在parent.Wait()
之后。
实现volume 数据卷
因为容器进程退出时会清理所有系统文件,如果需要持久化容器里面的数据,则需要再挂载一些可写的文件夹:
func MountVolume(rootURL string, mntURL string, volumeURLs []string){
// 1. 读取宿主机给定文件目录,无则创建
parentUrl := volumeURLs[0]
if err := os.Mkdir(parentUrl, 0777); err != nil{
// ...
}
// 2. 在容器中创建挂载点
containerUrl := volumeURLs[1]
containerVolumeUrl := mntURL + containerUrl
if err := os.Mkdir(containerUrl, 0777); err != nil{
// ...
}
// 3. 挂载文件
dirs := "dirs="+parentUrl
cmd := exec.Command("mount", "-t", "aufs", "-o", dirs, "none", containerVolumeUrl)
}
在容器退出时只清理挂载点即可,不要再删除数据。
镜像打包
如果我们需要把当前容器的状态储存成镜像保存下来,则可以通过tar
命令将当前系统镜像打包,需要在宿主机执行、并且要在容器退出前执行,否则可写层的数据会丢失。
参考
- hexo-markmap 为hexo 添加思维导图的功能。
- Linux 系统调用 fork()、vfork() 和 clone()
- Linux资源管理之cgroups简介