《自己动手写Docker》书摘之四: 构造简单容器

本文涉及的产品
容器服务 Serverless 版 ACK Serverless,317元额度 多规格
容器镜像服务 ACR,镜像仓库100个 不限时长
容器服务 Serverless 版 ACK Serverless,952元额度 多规格
简介: 构造简单容器 本章我们即将开始真正踏上构造自己的容器道路上。我们会基于当前的操作系统创建一个与宿主机隔离的容器环境,并且配置上基本的文件系统与网络。最后会在容器内运行一些应用来使用我们的容器,是不是很激动,下面我们开始吧。

构造简单容器

本章我们即将开始真正踏上构造自己的容器道路上。我们会基于当前的操作系统创建一个与宿主机隔离的容器环境,并且配置上基本的文件系统与网络。最后会在容器内运行一些应用来使用我们的容器,是不是很激动,下面我们开始吧。

Linux proc 文件系统介绍

在开始之前,我们还是需要稍微补充一些基本知识,如果你已经对于这些基本知识很熟悉,请直接略过。Linux 下的/proc 文件系统是由内核提供,它其实不是一个真正的文件系统,只包含了系统运行时信息(比如系统内存,mount 设备信息,一些硬件配置等等,它只存在于内存中,而不占用外存空间。它是以文件系统的形式为访问内核数据的操作提供接口。实际上,很多系统工具都是简单去读取这个文件系统的某个文件内容,比如lsmod 其实就是 cat /proc/modules

当你去遍历这个目录的时候会发现很多数字,这些都是为每个进程创建的空间,数字就是他们的 PID。

:~# ls /proc/
1      1216   1320   154  20006  22     27462  32     37   44   51  60   78   868  acpi       devices      interrupts  keys           meminfo       sched_debug  sys            version_signature
10     12180  1336   155  20007  23     27478  32025  38   45   52  61   780  896  buddyinfo  diskstats    iomem       kmsg           misc          schedstat    sysrq-trigger  vmallocinfo
100    12192  13567  16   20008  24     28     33     387  47   53  62   8    9    bus        dma          ioports     kpagecount     modules       scsi         sysvipc        vmstat
10684  12728  14     17   20011  25     29     337    39   48   54  63   818  944  cgroups    driver       ipmi        kpageflags     mounts        self         timer_list     xen
10698  1279   145    18   20012  26     3      34     40   49   55  7    859  954  cmdline    execdomains  irq         latency_stats  mtrr          slabinfo     timer_stats    zoneinfo
11     13     15     19   20013  27     30     343    41   5    57  715  861  961  consoles   fb               kallsyms    loadavg        net           softirqs     tty
1170   1301   1505   2    20042  27396  31     35     42   50   58  75   864  99   cpuinfo    filesystems  kcore       locks          pagetypeinfo  stat         uptime
12     13131  1508   20   21     27443  31809  36     43   501  59  77   865  991  crypto     fs           key-users   mdstat         partitions    swaps        version

下面介绍几个比较重要的部分

/proc/N                     pid为N的进程信息
/proc/N/cmdline             进程启动命令
/proc/N/cwd                 链接到进程当前工作目录
/proc/N/environ                进程环境变量列表
/proc/N/exe                 链接到进程的执行命令文件
/proc/N/fd                     包含进程相关的所有的文件描述符
/proc/N/maps                 与进程相关的内存映射信息
/proc/N/mem                 指代进程持有的内存,不可读
/proc/N/root                 链接到进程的根目录
/proc/N/stat                 进程的状态
/proc/N/statm                 进程使用的内存的状态
/proc/N/status                 进程状态信息,比stat/statm更具可读性
/proc/self                     链接到当前正在运行的进程

实现 run 命令

我们先会实现一个简单版本的 run 命令,类似 docker run -ti [command]。然后在后面的章节逐步添加network, mount filesystem 等功能。为了方便了解 docker 启动容器的原理,我们实现的简单版本是参考 runc 的实现。

目前的代码文件结构如下。

mydocker
    ├── container
    │   ├── container_process.go
    │   └── init.go
    ├── Godeps
    │   ├── Godeps.json
    │   └── Readme
    ├── main_command.go
    ├── main.go
    ├── mydocker
    ├── run.go
    └── vendor

首先来看一下入口main文件

package main

import (
    log "github.com/Sirupsen/logrus"
    "github.com/urfave/cli"
    "os"
)

const usage = `mydocker is a simple container runtime implementation.
               The purpose of this project is to learn how docker works and how to write a docker by ourselves
               Enjoy it, just for fun.`

func main() {
    app := cli.NewApp()
    app.Name = "mydocker"
    app.Usage = usage

    app.Commands = []cli.Command{
        initCommand,
        runCommand,
    }

    app.Before = func(context *cli.Context) error {
        // Log as JSON instead of the default ASCII formatter.
        log.SetFormatter(&log.JSONFormatter{})

        log.SetOutput(os.Stdout)
        return nil
    }

    if err := app.Run(os.Args); err != nil {
        log.Fatal(err)
    }
}

使用github.com/urfave/cli 提供的命令行工具, 我们定义了mydocker 的几个基本的命令,包括runCommand, initCommand ,然后在app.Before 内初始化了一下logrus的日志配置。
下面我们来看一下子命令的具体定义.


//这里定义了 runCommand的Flags,类似于我们运行命令的时候--指定的参数
var runCommand = cli.Command{
    Name:  "run",
    Usage: `Create a container with namespace and cgroups limit
            mydocker run -ti [command]`,
    Flags: []cli.Flag{
        cli.BoolFlag{
            Name:        "ti",
            Usage:       "enable tty",
        },
    },
    /*
    这里是run命令执行的真正函数。
    1. 判断参数是否包含command
    2. 获取用户指定的command
    3. 调用Run function 去准备启动容器
     */
    Action: func(context *cli.Context) error {
        if len(context.Args()) < 1 {
            return fmt.Errorf("Missing container command")
        }
        cmd := context.Args().Get(0)
        tty := context.Bool("ti")
        Run(tty, cmd)
        return nil
    },
}

//这里定义了initCommand的具体操作,此操作为内部方法,禁止外部调用
var initCommand = cli.Command{
    Name:    "init",
    Usage:    "Init container process run user's process in container. Do not call it outside",
    /*
    1. 获取传递过来的command参数
    2. 执行容器初始化操作
    */
    Action:    func(context *cli.Context) error {
        log.Infof("init come on")
        cmd := context.Args().Get(0)
        log.Infof("command %s", cmd)
        err := container.RunContainerInitProcess(cmd, nil)
        return err
    },
}

我们先来看一下run 函数干了一些什么。


/*
这里是父进程也就是我们当前进程执行的内容,根据我们上一章介绍的内容,应该比较容易明白
1.这里的/proc/self/exe 调用,其中/proc/self指的是当前运行进程自己的环境,exec其实就是自己调用了自己,我们使用这种方式实现对创建出来的进程进行初始化
2.后面args是参数,其中 init 是传递给本进程的第一个参数,这在本例子中,其实就是会去调用我们的initCommand去初始化进程的一些环境和资源
3. 下面的clone 参数就是去 fork 出来的一个新进程,并且使用了namespace隔离新创建的进程和外部的环境。
4. 如果用户指定了-ti 参数,我们就需要把当前进程的输入输出导入到标准输入输出上
*/
func NewParentProcess(tty bool, command string) *exec.Cmd {
    args := []string{"init", command}
    cmd := exec.Command("/proc/self/exe", args...)
    cmd.SysProcAttr = &syscall.SysProcAttr{
        Cloneflags: syscall.CLONE_NEWUTS | syscall.CLONE_NEWPID | syscall.CLONE_NEWNS |
        syscall.CLONE_NEWNET | syscall.CLONE_NEWIPC,
    }
    if tty {
        cmd.Stdin = os.Stdin
        cmd.Stdout = os.Stdout
        cmd.Stderr = os.Stderr
    }
    return cmd
}

/*
这里的Start方法是真正开始前面创建好的command的调用,他会首先 clone
出来一个 namespace 隔离的进程,然后在子进程中,调用/proc/self/exe 也就是自己,发送 init 参数,调用我们写的init方法,去初始化容器的一些资源
*/
func Run(tty bool, command string) {
    parent := container.NewParentProcess(tty, command)
    if err := parent.Start(); err != nil {
        log.Error(err)
    }
    parent.Wait()
    os.Exit(-1)
}

那么 Init 函数里面干了啥呢

/*
这里的init函数执行是在容器内部的,也就是说,代码执行到这里后,其实容器所在的进程已经创建出来了,我们是本容器执行的第一个进程。
1.使用mount 先去挂载proc 文件系统,方便我们通过ps等系统命令去查看当前进程资源情况
*/
func RunContainerInitProcess(command string, args []string) error {
    logrus.Infof("command %s", command)

    defaultMountFlags := syscall.MS_NOEXEC | syscall.MS_NOSUID | syscall.MS_NODEV
    syscall.Mount("proc", "/proc", "proc", uintptr(defaultMountFlags), "")
    argv := []string{command}
    if err := syscall.Exec(command, argv, os.Environ()); err != nil {
        logrus.Errorf(err.Error())
    }
    return nil
}

这里的MountFlag的意思如下

  • MS_NOEXEC 在本文件系统中不允许运行其他程序
  • MS_NOSUID 在本系统中运行程序的时候不允许set-user-ID或者set-group-ID
  • MS_NODEV 这个参数是自从Linux 2.4以来所有 mount 的系统都会默认设定的参数

本函数的最后一句话syscall.Exec是最为重要的一句黑魔法,正是这个系统调用实现了将我们初始化动作完成并且运行起来用户的进程这个操作。你可以能会问,这有什么神奇的,不就是运行一下程序嘛,这里我们来解释一下这句话的神奇之处。

首先我们使用 docker 创建起来一个容器之后,会发现容器内的第一个程序也就是 PID 为1的那个进程是我们指定的前台进程。那么根据我们前面讲的过程发现,我们的容器创建之后,执行的第一个进程并不是用户的进程,而是我们的 init 初始化进程,那么这时候如果通过ps命令查看就会发现容器内第一个进程变成了我们自己的init,这个是预想的是不一样的。那么你可能会想,大不了我把第一个进程给kill了.这里又有一个头疼的问题,PID 为1的进程是不能 kill 的,如果你给 kill了,那么我们的容器也就推出了,那么有什么办法呢,这里的execve 系统调用就大显神威了。

我们使用的syscall.Exec这个方法其实最终调用了kernel的
`int execve(const char filename, char const argv[],
char *const envp[]);`
这个系统函数。他的作用是执行当前filename对应的程序。它会覆盖当前进程镜像,当前进程的数据,堆栈等信息包括 PID 都会被我们将要运行的进程给覆盖。也就是说,我们调用这个方法,将用户指定的进程运行起来,把最初的 init 进程给替换掉,这样当我们进入到容器内部的时候,就会发现容器内的第一个程序就是我们指定的进程了。这其实也是目前docker使用的容器引擎runc的实现方式之一。

好,代码差不多讲完了,下面我们来编译运行一下。


#使用go build .在mydocker 目录下进行编译
root@ubuntu:[mydocker]# go build .
#我们使用./mydocker run -ti sh命名,其中-ti表示我们想要以交互式运行容器,/bin/sh为我们指定的容器内运行的第一个进程
root@ubuntu:[mydocker]# ./mydocker run -ti /bin/sh
{"level":"info","msg":"init come on","time":"2016-11-27T06:15:44Z"}
{"level":"info","msg":"command /bin/sh","time":"2016-11-27T06:15:44Z"}
# ps -ef
UID        PID  PPID  C STIME TTY          TIME CMD
root         1     0  0 06:15 pts/0    00:00:00 /bin/sh
root         4     1  0 06:16 pts/0    00:00:00 ps -ef

我们在容器运行ps -ef可以发现/bin/sh进程是容器内的第一个进程,PID 为1,我们的ps -ef是PID为1的父进程创建出来的。我们对比一下 docker 运行的容器的效果

root@iZ254rt8xf1Z:~# docker run -ti ubuntu /bin/sh
# ps -ef
UID        PID  PPID  C STIME TTY          TIME CMD
root         1     0  1 06:25 ?        00:00:00 /bin/sh
root         5     1  0 06:25 ?        00:00:00 ps -ef

是不是有些类似。这里的/bin/sh是一个会在前台一直跑着的进程,那么我们试试如果指定一个运行完就会退出的进程会是什么效果呢。

root@ubuntu:[mydocker]# ./mydocker run -ti /bin/ls
{"level":"info","msg":"init come on","time":"2016-11-27T06:28:31Z"}
{"level":"info","msg":"command /bin/ls","time":"2016-11-27T06:28:31Z"}
container  Godeps  main_command.go  main.go  mydocker  run.go  vendor

由于我们没有chroot,所以目前我们的系统文件系统是继承自我们的父进程的,这里我们运行了一下ls命令,发现容器启动起来以后,打印出来了当前目录的内容,然后退出了,这个和docker要求容器必须有一个一直在前台跑着的进程的要求一致。

小结

本节我们构造了一个简单的容器,具有基本的namesapce隔离,并且确定了我们的基本开发架构。基本创建流程如下

_

下面我们会继续完善这个容器。

相关图书推荐<<自己动手写 docker>>

_Docker_01_

目录
相关文章
|
3天前
|
JSON JavaScript 开发者
Composerize神器:自动化转换Docker运行命令至Compose配置,简化容器部署流程
【8月更文挑战第7天】Composerize神器:自动化转换Docker运行命令至Compose配置,简化容器部署流程
Composerize神器:自动化转换Docker运行命令至Compose配置,简化容器部署流程
|
1天前
|
Docker 容器
|
1天前
|
网络协议 Linux 网络安全
遇到Docker容器网络隔断?揭秘六种超级实用解决方案,轻松让Docker容器畅游互联网!
【8月更文挑战第18天】Docker容器内网络不通是开发者常遇问题,可能因网络配置错、Docker服务异常或防火墙阻碍等原因引起。本文提供六种解决策略:确认Docker服务运行状态、重启Docker服务、检查与自定义Docker网络设置、验证宿主机网络连接、临时禁用宿主机IPv6及检查防火墙规则。通过这些步骤,多数网络问题可得以解决,确保容器正常联网。
7 1
|
3天前
|
安全 Ubuntu Docker
深度挖掘Docker 容器
【8月更文挑战第16天】Docker容器间的连接是容器化技术的关键,支持容器与宿主机的数据交换。主要方法包括:1) 利用Docker网络驱动创建自定义网络,使容器相连通信;2) 采用Docker Compose通过配置文件简化多容器应用的部署与互联;3) 虽不推荐,早期使用--link参数实现容器互联;4) 通过环境变量配置连接信息;5) 共享卷支持文件共享和间接通信。推荐使用Docker网络和Docker Compose以实现高效灵活的容器间通信。
13 3
|
3天前
|
Ubuntu Linux Docker
使用Docker进行容器化:从零开始的技术博文
【8月更文挑战第16天】从零开始掌握Docker容器化技术:本文详细介绍Docker基本概念、安装配置流程及核心组件。涵盖Docker镜像与容器管理、镜像加速配置,以及如何利用Dockerfile自动化构建镜像,助您快速入门并高效运用Docker进行软件开发与部署。
|
5天前
|
监控 Ubuntu Docker
如何在Docker容器启动时自动运行脚本
【8月更文挑战第13天】在Docker容器启动时自动运行脚本可通过以下方式实现:1) 使用`ENTRYPOINT`或`CMD`指令在Dockerfile中直接指定启动脚本,如`ENTRYPOINT [&quot;/startup.sh&quot;]`;2) 启动容器时通过`--entrypoint`参数指定脚本路径;3) 利用supervisor等进程管理工具自动启动与监控脚本,确保其稳定运行。确保脚本具有执行权限并正确设置依赖资源路径。
|
4天前
|
Docker 容器
Docker - 网络模式与容器网络互连
Docker的网络模式包括桥接模式、主机模式和覆盖网络模式,以及如何通过Docker的网络操作命令实现容器网络互连。
9 0
|
4天前
|
Linux Docker 容器
在CentOS操作系统上使用yum安装/使用/卸载Docker容器引擎
在CentOS操作系统上安装、配置、使用和卸载Docker容器引擎的详细步骤,包括配置Docker镜像加速的方法。
24 0
|
4天前
|
存储 Ubuntu Linux
如何在 Ubuntu 上使用 Docker 容器化和部署多个 WordPress 应用程序
如何在 Ubuntu 上使用 Docker 容器化和部署多个 WordPress 应用程序
11 0
|
4天前
|
Ubuntu NoSQL 关系型数据库
在Ubuntu操作系统上安装/使用/卸载Docker容器引擎
这篇文章详细介绍了在Ubuntu操作系统上安装、配置、使用、基本操作以及卸载Docker容器引擎的步骤,包括配置Docker镜像加速和使用Docker部署Nginx、MySQL和Redis服务器的方法。
21 0