学习自《自己动手写Docker》
作者:陈显鹭(花名:遥鹭)-阿里云高级研发工程师等
京东购买链接:https://item.jd.com/10033552355433.html
其他资料:
- https://www.cnblogs.com/heyanan/p/7800284.html
- https://www.cnblogs.com/Philip-Tell-Truth/p/6284475.html
文章目录我的主机环境:
- 内核版本:Linux version 4.15.0-48-generic (buildd@lgw01-amd64-036)
- Ubuntu 7.3.0-16ubuntu3
- 一、容器与开发语言
- 什么是Kernel?
- docker容器特点:
- 容器与虚拟机
- 二、基础技术
- 2.1 容器的启动过程
- 2.2 Linux namespace
- 1. 基础介绍
- 查看进程所有命名空间
- 单独查看命名空间
- 2. 命名空间API
- 3. UTS Namespace
- 4. IPC Namespace
- 1. 进程间通讯IPC基本概念
- 2. 实践
- 5. PID Namespace
- 6. Mount Namespace
- 7. User Namespace
- 8. Network Namespace
docker已经成为了容器化技术的代表名词,即使是k8s大行其道的今天,docker也是k8s的基石。所以打算跟着书一起用一个系列从零开始学习编写docker引擎。
前提:学习过docker基本操作使用、golang的基本使用
记录重点与实际遇到的难点、bug
一、容器与开发语言 docker最直观的理解:一个隔离的虚拟环境,封装了所有应用程序需要的一切在其中(代码、工具、系统依赖等),这样就可以称为“容器”,并且这样的容器是可以复制的(都是一个模子里刻出来的),而生成容器的模版就是“镜像”,镜像可以将所有系统级别依赖打包成为一个文件,所有镜像共享一个Kernel(在同一个宿主机下)。
什么是Kernel?计算机系统的结构:
- **硬件:**物理机(这是系统的底层结构或基础)是由内存(RAM)、处理器(或 CPU)以及输入/输出(I/O)设备(例如存储、网络和图形)组成的。其中,CPU 负责执行计算和内存的读写操作。
- Linux 内核:操作系统的核心。(明白了吗?内核正居于核心位置。)它是驻留在内存中的软件,用于告诉 CPU 要执行哪些操作。
- **用户进程:**这些是内核所管理的运行程序。用户进程共同构成了用户空间。用户进程有时也简称为进程。内核还允许这些进程和服务器彼此进行通信(称为进程间通信或 IPC)。
- 轻量级: 占用内存少、磁盘使用率高、启动快 (相比于虚拟机)
- 开放:基于开放标准,可运行在主流linux、windows操作系统
- 安全:隔离保护
虚拟机(也被称为guest os)是一种模拟系统,即在软件层面上通过模拟硬件的输入和输出,让虚拟机的操作系统得以运行在没有物理硬件的环境中(也就是宿主机的操作系统上),其中能够模拟出硬件输入输出,让虚拟机的操作系统可以启动起来的程序,被叫做hypervisor, hypervisor能够创建出虚拟硬件。
虚拟机的缺点所在:
- 每个虚拟机一般都有自己的kernel,并且开启之前需要先做开机自检,启动kernel,启动用户进程等一系列行为,启动很慢
- 虚拟机还需要模拟硬件的输入输出,效率很差
- 虚拟机需要包含用户的程序、函数库、整个客户操作系统,占用空间大
docked的改进:
- docker所有容器(包括宿主机)之间共享内核kernel,各个容器在宿主机上相互隔离在用户态(cpu低级访问权限,一般程序的权限)下运行。docker的kernel version由宿主机决定
因为共享kernel,所以不需要费大精力模拟硬件的输入输出,只需要模拟kernel的输入输出(因为共享),所以这种虚拟化也叫做操作系统层虚拟化 Operating-system-level virtualization
- docker通过隔离容器不让容器使用宿主机的文件、进程、内存等系统实现封闭的环境(具体操作会在后面详述),让用户感受到容器有自己的文件、进程等系统(类似于虚拟机)
- 容器不与任何基础设施绑定,移植性好
虚拟机与docker架构对比:
虚拟机:
docker容器:
正因为这些特点,docker能够加速开发效率,隔离移植、使用环境,容器的合作分享可以使用Docker Hub(类似于github,能够管理、更新docker镜像的仓库)
二、基础技术 2.1 容器的启动过程linux进程实现的步骤:
- 在内存中将主进程复制一份得到子进程,此时主、子进程上下文完全一致
- 设置子进程的pid、parent_pid等其他与主进程不一样的内容(所以子进程大部分资源还是与主进程一致的)
docker需要制造一个虚拟的进程,所以进程的实现需要多做几步:
- 自定义rootfs(根文件系统),将宿主机的一个文件目录设置为虚拟机的根目录,例如rootfs=/root/ubuntu,在容器中其就是/
- 将自身的pid映射为0,并让其看不到其他任何进程pid,让其在容器中唯一
- 用户名隔离,可以把用户名变为root
- hostname隔离,可以领取一个hostname
- IPC隔离,隔离进程之间的相互通信
- 网络隔离,隔离进程与主机之间的网络
这些隔离方式都是调用linux系统内置(kernel)的隔离方法:clone(2) - Linux manual page
**docker是内核的搬运工:**docker实现这些隔离就是调用内核kernel支持的已有的内置隔离资源的方法,当然在其上也有一些拓展,例如资源隔离等
所以出现了docker两个特性:
- 启动速度快:因为本质上容器中的进程与宿主机进程没有很大区别,本质上docker启动的就是一个被隔离的进程,共享了很多资源(虽然多了很多步骤)
- docker对linux内核版本有需求(版本号大于3.10),因为需要内核支持一些隔离特性的方法
对于容器启动后再新创建的进程,因为在创建容器时已经实现了与宿主机的资源隔离,所以在容器中新创建的进程天然就与宿主机实现了资源隔离!所以只需要刚开始的一次隔离(上面的6步)后面的进程就不需要再做了。
=> 找到启动快的原因:
linux启动流程如下图:
问:启动容器需要执行以上几步? 答:0步
2.2 Linux namespace 1. 基础介绍Linux实现隔离的方式: namespaces(7)
命名空间将全局系统资源包装在一个抽象中 使名称空间内的进程看起来拥有自己的全局资源的独立实例。全局资源的修改对同一个命名空间下的其他进程是可见的,对于其他命名空间是不可见的。命名空间的一个用途是实现容器。
Linux Namespace各种命名空间:
第二列显示了用于在各种api中指定名称空间类型的标志值(系统调用参数),第三列标识了手册页中关于命名空间的详细信息,最后一列标识命名空间对应隔离的资源
Namespace能够实现在同一台主机下UID级别的隔离,给每一个UID的用户虚拟化出一个Namespace,这样多个用户之间可以访问系统资源的同时互相之间还实现了隔离。
从每个用户的角度看,每个命名空间都像一台单独的linux一样,有自己的init进程,并且pid不断递增
例如上图:
用户A在命名空间A中认为自己的1号进程就是init进程,同理B如此。但是实际上都是分别映射到主进程3,4进程,从host的角度看只是3、4号进程虚拟化出来的一个空间而已。
查看进程所有命名空间查看当前进程的所有命名空间:ls -l /proc/$$/ns | awk '{print $1, $9, $10, $11}'
total lrwxrwxrwx cgroup -> cgroup:[4026531835] lrwxrwxrwx ipc -> ipc:[4026531839] lrwxrwxrwx mnt -> mnt:[4026531840] lrwxrwxrwx net -> net:[4026531993] lrwxrwxrwx pid -> pid:[4026531836] lrwxrwxrwx pid_for_children -> pid:[4026531836] lrwxrwxrwx user -> user:[4026531837] lrwxrwxrwx uts -> uts:[4026532271]单独查看命名空间
例如查看UTS: readlink /proc/$$/ns/uts
# readlink /proc/$$/ns/uts uts:[4026532271]2. 命名空间API
目前Namespace的API的系统调用:
| API | 官方解释 | 简单解释 |
|---|---|---|
| clone(2) | 系统调用创建一个新进程。如果调用的flags参数指定了上面列出的一个或多个CLONE_NEW*标志,则新的命名空间是为每个标志创建,并且子进程被创建为 这些命名空间的成员。 | 创建一个新进程,可以通过CLONE_NEW*参数指定哪些命名空间被创建,并且他们的子进程也会被包含在这些Namespace中 |
| setns(2) | 系统调用允许调用进程加入现有的命名空间。 | 将一个进程加入现有命名空间 |
| unshare(2) | 系统调用的调用进程移动到 新的命名空间。如果调用的标志参数 指定列出的一个或多个CLONE_NEW*标志上面,然后为每个标志创建新的命名空间,并且调用进程成为这些命名空间的成员。(这个系统调用还实现了许多功能与命名空间无关。) | 调用进程移动到新的命名空间 |
| ioctl(2) | 发现有关命名空间的信息。 | 输出命名空间的信息 |
主要用于隔离Hostname、Domainname系统标识(主机与域名)
go实现UTS命名空间的调用
// +build linux
package main
import (
"log"
"os"
"os/exec"
"syscall"
)
func main() {
cmd := exec.Command("sh") // 被复制出来的新进程的初始命令,我们使用sh执行
cmd.SysProcAttr = &syscall.SysProcAttr{
Cloneflags: syscall.CLONE_NEWUTS, // 使用CLONE标志创建一个UTS Namespace
}
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
log.Fatal(err)
}
}
这段代码执行后我们会进入一个sh运行环境中,在这个环境中我们就实现了一个新的UTS Namespace
验证效果:
- 查看当前宿主机进程之间的关系:pstree -pl (我的运行文件为test)
- 查看当前sh的pid: echo $$
$ echo $$ 6372
- 验证父进程与子进程是否在同一个UTS Namespace: /proc/xxx/ns/uts (xxx是进程pid)
$ readlink /proc/6369/ns/uts // 父进程就是go可执行文件test uts:[4026531838] $ readlink /proc/6372/ns/uts uts:[4026532271]
- 测试修改Hostname(正常情况下修改此环境内的hostname是不会影响外部主机的)
$ hostname -b bird // 修改主机名为bird $ hostname bird
在另一个终端中查看宿主机的hostname是否改变$ hostname wenjie
并没有因此改变,所以实现了主机名隔离
IPC Namespace用来隔离System V IPC 和POSIX message queues,每一个IPC Namespace都拥有自己唯一的System V IPC 和POSIX message queues
1. 进程间通讯IPC基本概念IPC(Inter-Process Communication)进程间通信有三种信息共享方式:1.随文件系统 2.随kernel内核 3.随共享内存
相对的IPC的持续性(Persistence of IPC Object)也有三种:
- 随进程持续的(Process-Persistent IPC)
IPC对象一直存在,直到最后拥有他的进程被关闭为止,典型的IPC有pipes(管道)和FIFOs(先进先出对象) - 随内核持续的(Kernel-persistent IPC)
IPC对象一直存在直到内核被重启或者对象被显式关闭为止,在Unix中这种对象有System v 消息队列,信号量,共享内存。(注意***Posix消息队列,信号量和共享内存***被要求为至少是内核持续的,但是也有可能是文件持续的,这样看系统的具体实现)。 - 随文件系统持续的(FileSystem-persistent IPC)
除非IPC对象被显式删除,否则IPC对象会一直保持(即使内核才重启了也是会留着的)。如果Posix消息队列,信号量,和共享内存都是用内存映射文件的方法,那么这些IPC都有着这样的属性。
不同的Unix IPC的持续性:
- 随进程
Pipe, FIFO, Posix的mutex(互斥锁), condition variable(条件变量), read-write lock(读写锁),memory-based semaphore(基于内存的信号量) 以及 fcntl record lock,TCP和UDP套接字,Unix domain socket - 随内核
Posix的message queue(消息队列), named semaphore(命名信号量), System V Message queue, semaphore, shared memory。
要注意的是,虽然上面所列的IPC并没有随文件系统的,但是我们就像我们刚才所说的那样,Posix IPC可能会跟着系统具体实现而不同(具有不同的持续性),举个例子,写入文件肯定是一个文件系统持续性的操作,但是通常来说IPC不会这样实现。很少有IPC会实现文件系统持续,因为这会降低性能,不符合IPC的设计初衷。
System V IPC与Posix IPC是两种IPC的标准,后者在前者之上进行了改进,但是基本的概念都是差不多
具体的差别可见:系统V IPC与POSIX IPC
System V IPC是UNIX系统上广泛使用的三种进程间通信机制的名称:消息队列、信号量和共享内存。是随内核持续的,直到内核被重启或者对象被显性关闭为止。
- System V 消息队列:
System V 消息队列允许数据以称为消息的单位交换,每个消息都可以有一个关联的优先级。POSIX消息队列提供了实现相同结果的替代API - System V 信号量:
System V信号量允许进程同步它们的动作。系统V的信号量被分配到称为集合的组中;集合中的每个信号量都是一个计数信号量。POSIX信号量提供了实现相同结果的替代API。 - System V 共享内存:
系统V共享内存允许进程共享一个区域一个内存(一个scegment)。POSIX共享内存是实现相同结果的另一种API
实践其实很简单,在刚刚的程序上增加一个flag即可:
Cloneflags: syscall.CLONE_NEWUTS | syscall.CLONE_NEWIPC,
再次编译启动:
查看现有的ipc消息队列: ipcs -q
$ ipcs -q ------ Message Queues -------- key msqid owner perms used-bytes messages
创建一个消息队列
$ ipcmk -Q // 创建一个消息队列 Message queue id: 0 $ ipcs -q // 查看 ------ Message Queues -------- key msqid owner perms used-bytes messages 0xcc4f9f77 0 root 644 0 0
使用另一个终端查看消息队列:
$ ipcs -q ------ Message Queues -------- key msqid owner perms used-bytes messages
无法查看到,说明实现了消息队列的隔离
5. PID Namespacepid Namespace就是用于隔离进程ID的,同样一个进程在不同的PID Namespace可以拥有不同的进程ID
同样的修改一下代码中的flag:
Cloneflags: syscall.CLONE_NEWUTS | syscall.CLONE_NEWIPC | syscall.CLONE_NEWPID,
编译启动后,首先查看自己真实的PID
当前的Pid是10166
然后查看自己当前隔离之后的ID:
$ echo $$ 1
(注意,这里不能使用ps、top等命令查看,因为会调用/proc内容,后面会解决这个问题)
6. Mount Namespacemount_namespaces(7)
负责隔离各个进程看到的挂载点视图,让每一个进程看到的文件系统层次是不同的。这也是Linux第一个实现的Namespace类型,所以注意他的flag是CLONE_NEWNS(new Namespace的缩写)
修改代码:
Cloneflags: syscall.CLONE_NEWUTS | syscall.CLONE_NEWIPC | syscall.CLONE_NEWPID | syscall.CLONE_NEWNS,
编译启动:
查看一下/proc的文件内容
/proc是一个文件系统,可以通过特殊的机制将内核和内核信息发送给进程
$ ls /proc 1 19510 21767 21800 21833 21866 21900 21942 21975 22006 22039 22072 22105 22138 22172 22206 24 5299 driver softirqs 10 19570 21768 21801 21834 21867 21901 21943 21976 22007 22040 22073 22106 22139 22173 22207 24819 5300 execdomains stat 10143 19615 21769 21802 21835 21868 21902 21944 21977 22008 22041 22074 22107 22140 22174 22208 249 584 fb swaps 11 2 21770 21803 21836 21869 21903 21945 21978 22009 22042 22075 22108 22141 22175 22209 25 589 filesystems sys 115 20 21771 21804 21837 21870 21904 21946 21979 22010 22043 22076 22109 22142 22176 22210 25538 6 fs sysrq-trigger 12 20444 21772 21805 21838 21871 21905 21947 21980 22011 22044 22077 22110 22143 22177 22211 258 6021 interrupts sysvipc 13 20474 21773 21806 21839 21872 21906 21948 21981 22012 22045 22078 22111 22144 22178 22212 26 672 iomem thread-self ....
这是宿主机的/proc,我们还需要手动的将/proc mount(挂载)到我们自己的Namespace下
使用命令mount -t proc proc /proc将宿主机的proc文件系统挂载到自己的Namespace下的/proc目录上
mount 命令用来挂载文件系统。其基本命令格式为:
mount -t type [-o options] device dir
device:指定要挂载的设备,比如磁盘、光驱等。
dir:指定把文件系统挂载到哪个目录。
type:指定挂载的文件系统类型,一般不用指定,mount 命令能够自行判断。
options:指定挂载参数,比如 ro 表示以只读方式挂载文件系统。
再次查看/proc文件系统:
$ ls /proc 1 cgroups devices fb ioports key-users loadavg modules partitions slabinfo sysrq-trigger uptime zoneinfo 5 cmdline diskstats filesystems irq kmsg locks mounts sched_debug softirqs sysvipc version acpi consoles dma fs kallsyms kpagecgroup mdstat mtrr schedstat stat thread-self version_signature buddyinfo cpuinfo driver interrupts kcore kpagecount meminfo net scsi swaps timer_list vmallocinfo bus crypto execdomains iomem keys kpageflags misc pagetypeinfo self sys tty vmstat
相比宿主机的/proc已经少了很多内容
使用ps -ef查看进程:
$ ps -ef UID PID PPID C STIME TTY TIME CMD root 1 0 0 22:21 pts/1 00:00:00 sh root 8 1 0 22:34 pts/1 00:00:00 ps -ef
可以看到这时候就只能看到自己Namespace下的进程了,因为依赖的/proc文件已经被隔离了
mount实现了和外部空间的隔离,在本Namespace下挂载的文件系统不回影响外部,所以这也是docker数据卷的特性原因之一
7. User NamespaceUser Namespace主要隔离用户的用户组ID。比较常见的做法是将宿主机上的一个非root用户在新建的User Namespace中映射成一个root用户,这意味着这个进程在User Namespace内部是有root权限的。
在Linux Kernel 3.8开始非root进程也可以创建User Namespace了,并且实现了在新创的User Namespace中拥有root权限
修改代码如下:
func main() {
cmd := exec.Command("sh")
cmd.SysProcAttr = &syscall.SysProcAttr{
Cloneflags: syscall.CLONE_NEWUTS | syscall.CLONE_NEWIPC | syscall.CLONE_NEWPID | syscall.CLONE_NEWNS | syscall.CLONE_NEWUSER,
}
// 设置凭证
cmd.SysProcAttr.Credential = &syscall.Credential{
Uid: uint32(1),
Gid: uint32(1),
}
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
log.Fatal(err)
}
}
首先查看当前宿主机的用户与用户组: id
$ id uid=0(root) gid=0(root) groups=0(root)
接下来运行程序再同样执行:
报错:fork/exec /bin/sh: operation not permitted
原因:https://github.com/xianlubird/mydocker/issues/3
Linux kernel 在 3.19 以上的版本中对 user namespace做了些修改
解决:删除掉cmd.SysProcAttr.Credential
注意:centos不支持user namspace需要额外的设置,见原因链接
再次运行:
$ id uid=65534(nobody) gid=65534(nogroup) groups=65534(nogroup)
可以看到UID已经不同了,因此User Namespace生效了
8. Network Namespace用于隔离网络设备、IP地址端口等网络栈的Namespace。可以让每个容器拥有自己独立的(虚拟的)网络设备,并且容器可以绑定到自己端口,每个Namespace内的端口都不会互相冲突。
在宿主机上搭建网桥后就可以很方便的实现容器之间的通信,并且不同的容器可以使用相同的端口!
同样的添加flag:
Cloneflags: syscall.CLONE_NEWUTS | syscall.CLONE_NEWIPC | syscall.CLONE_NEWPID | syscall.CLONE_NEWNS | syscall.CLONE_NEWUSER | syscall.CLONE_NEWNET,
首先查看宿主机的网路设备情况:
$ ifconfig docker0: flags=4099mtu 1500 inet 172.18.0.1 netmask 255.255.0.0 broadcast 172.18.255.255 ether 02:42:df:81:27:f6 txqueuelen 0 (Ethernet) RX packets 585 bytes 140849 (140.8 KB) RX errors 0 dropped 0 overruns 0 frame 0 TX packets 611 bytes 987099 (987.0 KB) TX errors 0 dropped 0 overruns 0 carrier 0 collisions 0 eth0: flags=4163 mtu 1500 inet 172.17.59.2 netmask 255.255.192.0 broadcast 172.17.63.255 ether 00:16:3e:0e:2d:b8 txqueuelen 1000 (Ethernet) RX packets 1168249932 bytes 1131213773283 (1.1 TB) RX errors 0 dropped 0 overruns 0 frame 0 TX packets 683891978 bytes 296842597629 (296.8 GB) TX errors 0 dropped 0 overruns 0 carrier 0 collisions 0 lo: flags=73 mtu 65536 inet 127.0.0.1 netmask 255.0.0.0 loop txqueuelen 1000 (Local Loopback) RX packets 67191390 bytes 8889749352 (8.8 GB) RX errors 0 dropped 0 overruns 0 frame 0 TX packets 67191390 bytes 8889749352 (8.8 GB) TX errors 0 dropped 0 overruns 0 carrier 0 collisions 0
错误:宿主机的/proc不见了 /proc is empty (not mounted ?)
解决:在宿舍机上重新挂载mount -t proc proc /proc
运行程序,在新的Network Namespace中查看网络:
运行的结果是:啥也没有。所以已经处于隔离状态了。
下一节将会继续学习基础原理中的Linux Cgroups,今天也留下一个疑问等待明天处理:
疑问:为什么创建了新的Mount Namespace中子进程挂载了/proc, 宿主机主进程Namespace就失去了/proc的挂载了呢?不是隔离了吗?
觉得不错的话,请点赞关注呦~~你的关注就是博主的动力
关注公众号,查看更多go开发、密码学和区块链科研内容:



