文章506
标签266
分类65

Docker原理实战-1:Namespace

讲述Docker底层原理的开篇文章,本文讲述了Linux内核中的Namespace和Docker的关系;

系列文章:

源代码:


Docker原理实战-1:Namespace

前言 - 实验环境

在讲述Docker底层原理之前,先说一下实验的环境吧;

操作系统Ubuntu:

root@jasonkay:~# lsb_release -a
No LSB modules are available.
Distributor ID: Ubuntu
Description:    Ubuntu 20.04.2 LTS
Release:        20.04
Codename:       focal

Linux内核版本:

root@jasonkay:~# uname -a
Linux jasonkay 5.4.0-81-generic #91-Ubuntu SMP Thu Jul 15 19:09:17 UTC 2021 x86_64 x86_64 x86_64 GNU/Linux

Docker版本:

root@jasonkay:~# docker version
Client: Docker Engine - Community
 Version:           20.10.8
 API version:       1.41
 Go version:        go1.16.6
 Git commit:        3967b7d
 Built:             Fri Jul 30 19:54:27 2021
 OS/Arch:           linux/amd64
 Context:           default
 Experimental:      true

Server: Docker Engine - Community
 Engine:
  Version:          20.10.8
  API version:      1.41 (minimum version 1.12)
  Go version:       go1.16.6
  Git commit:       75249d8
  Built:            Fri Jul 30 19:52:33 2021
  OS/Arch:          linux/amd64
  Experimental:     false
 containerd:
  Version:          1.4.9
  GitCommit:        e25210fe30a0a703442421b0f60afac609f950a3
 runc:
  Version:          1.0.1
  GitCommit:        v1.0.1-0-g4144b63
 docker-init:
  Version:          0.19.0
  GitCommit:        de40ad0

Golang版本:

root@jasonkay:~# go version
go version go1.17 linux/amd64

基本上都是目前(2021年08月29日)最新的了!

废话不多说,直接进入正题;

本文作为讲述Docker底层原理的开篇,先来讲述 Docker 实现容器隔离的技术:Namespace;


Linux Namespace

Namespace是Linux Kernel中的一个功能,便于隔离一系列的系统资源,如:

  • PID;
  • User ID;
  • Network;
  • ……

Namespace和chroot命令有些类似,但是比chroot强大的多!

关于chroot:

用途:

例如:Namespace可以做到UID级别的隔离,即:以UID为n的用户,虚拟化出来一个Namespace,在这个Namespace中,用户具有root权限!(但是在真实的物理机器上,他还是UID为n的用户!)

命令空间建立起系统不同的视图,从用户的角度来看:

每一个命名空间都如同一台单独的Linux计算机一样,有自己的init进程(PID为1),其他进程的PID依次递增!

例如下图:

A和B空间均存在PID为1的init进程,子命名空间的进程映射至父命名空间的进程上;

因此:父命名空间可以知道每个子命名空间的运行状态,而子命名空间之间是相互隔离的!

目前,Linux一共实现了6种不同类型的Namespace:

Namespace类型 系统调用参数 内核版本
Mount Namespace CLONE_NEWNS 2.4.19
UTS Namespace CLONE_NEWUTS 2.6.19
IPC Namespace CLONE_NEWIPC 2.6.19
PID Namespace CLONE_NEWPID 2.6.24
Network Namespace CLONE_NEWNET 2.6.29
User Namespace CLONE_NEWUSER 3.8

Namespace API主要使用以下三个系统调用:

  • clone():创建新进程;根据调用参数来判断哪些类型的Namespace被创建,并且由他创建的子进程会被包含到这些Namespace中!
  • unshare():将进程移出某个Namespace;
  • setns():将进程加入到Namespace中;

UTS Namespace

UTS Namespace 用来隔离nodename和domainname两个系统标识;

在UTS Namespace中,每个Namespace允许拥有自己的hostname!

下面是代码:

chapter2_basic/namespace/uts_namespace_demo.go

func main() {
    cmd := exec.Command("sh")
    cmd.SysProcAttr = &syscall.SysProcAttr{
        Cloneflags: syscall.CLONE_NEWUTS,
    }
    cmd.Stdin = os.Stdin
    cmd.Stdout = os.Stdout
    cmd.Stderr = os.Stderr

    if err := cmd.Run(); err != nil {
        log.Fatal(err)
    }
}

代码解释:

  • exec.Command("sh")指定被fork出来的新进程内的初始命令sh
  • 使用CLONE_NEWUTS创建新的UTS命名空间并将clone()系统调用后的新的子进程加入;
  • cmd.StdXXX = os.StdXXX:当前进程和子进程输入输出流绑定;

运行代码:

root@jasonkay:~/workspace/my_docker/chapter2_basic# go run namespace/uts_namespace_demo.go 
#

进入一个sh运行环境中;

查看进程关系:

# pstree -pl
systemd(1)─┬─VGAuthService(843)
├─sshd(941)───sshd(1095)─┬─bash(4096)───go(15135)─┬─uts_namespace_d(15232)─┬─sh(15237)───pstree(15246)
           │                        │                        │                        ├─{uts_namespace_d}(15233)
           │                        │                        │                        ├─{uts_namespace_d}(15234)
           │                        │                        │                        ├─{uts_namespace_d}(15235)
           │                        │                        │                        └─{uts_namespace_d}(15236)
           │                        │                        ├─{go}(15136)
……

## 输出当前PID
# echo $$ 
15237

验证父子进程不在同一个UTS命名空间中:

# readlink /proc/15237/ns/uts
uts:[4026532644]
# readlink /proc/15232/ns/uts
uts:[4026531838]

可以看到,的确不在同一个UTS命名空间!

查看hostname:

# 查看hostname
# hostname
jasonkay

# 修改hostname
# hostname -b zk

# 再次查看hostname
# hostname
zk

在宿主机启动另一个Shell,并查看hostname:

root@jasonkay:~# hostname
jasonkay

可以看到:外部的hostname并没有被内部的修改所影响!


IPC Namespace

IPC命名空间用来隔离System V IPC和 POSIX message queues;

chapter2_basic/namespace/ipc_namespace_demo.go

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 := cmd.Run(); err != nil {
        log.Fatal(err)
    }
}

代码增加了CLONE_NEWIPC,代表同时创建IPC命名空间;

下面进行测试(需要同时在宿主机上打开两个Shell);

# 查询现有ipc message queues
root@jasonkay:~# ipcs -q

------ Message Queues --------
key        msqid      owner      perms      used-bytes   messages    

# 创建一个message queue
root@jasonkay:~# ipcmk -Q
Message queue id: 0

# 再次查看
root@jasonkay:~# ipcs -q

------ Message Queues --------
key        msqid      owner      perms      used-bytes   messages    
0x5336d6db 0          root       644        0            0           

此时已经存在一个queue了!

使用另外一个shell运行程序:

root@jasonkay:~/workspace/my_docker/chapter2_basic# go run namespace/ipc_namespace_demo.go 
# ipcs -q

------ Message Queues --------
key        msqid      owner      perms      used-bytes   messages    

可以发现,新的命名空间,看不到宿主机的消息队列!


PID Namespace

PID命名空间用来隔离进程ID:

同一个进程在不同的PID命名空间可以拥有不同的PID!

例如:

在Docker容器中使用ps -ef可以发现:容器内前台运行的进程PID为1,但是在容器外却是不同的PID;

代码如下:

chapter2_basic/namespace/pid_namespace_demo.go

func main() {
    cmd := exec.Command("sh")
    cmd.SysProcAttr = &syscall.SysProcAttr{
        Cloneflags: syscall.CLONE_NEWUTS | syscall.CLONE_NEWIPC | syscall.CLONE_NEWPID,
    }
    cmd.Stdin = os.Stdin
    cmd.Stdout = os.Stdout
    cmd.Stderr = os.Stderr

    if err := cmd.Run(); err != nil {
        log.Fatal(err)
    }
}

下面进行测试:

# 启动子进程 
root@jasonkay:~/workspace/my_docker/chapter2_basic# go run namespace/pid_namespace_demo.go 
#

# 宿主机查看
root@jasonkay:~# pstree -pl
systemd(1)─┬─VGAuthService(843)
          ├─sshd(941)───sshd(1095)─┬─bash(4096)───go(16024)─┬─pid_namespace_d(16119)─┬─sh(16124)
           │                        │                        │                        ├─{pid_namespace_d}(16120)
           │                        │                        │                        ├─{pid_namespace_d}(16121)
           │                        │                        │                        ├─{pid_namespace_d}(16122)
           │                        │                        │                        └─{pid_namespace_d}(16123)
           │                        │                        ├─{go}(16025)
……

# 容器内sh查看
# echo $$
1

可以看到,PID被映射到了1上;

注:此时不能使用ps命令查看!

因为pstop命令使用的是/proc中的内容,而此时我们没有修改挂载(Mount)命名空间!


Mount Namespace

Mount命名空间用来隔离各个进程看到的挂载点视图:

在不同的命名空间中的进程看到的文件系统层次是不同的,并且在新的Mount命名空间中调用mount()unmount仅仅影响当前命名空间内的文件系统,而对全局的文件系统没有影响!

这个命名空间的功能类似于chroot,但是实现比这个系统调用更加灵活和安全!

Mount命名空间的系统调用参数为NEWNS

这是因为,Mount命名空间是Linux第一个实现的命名空间类型,当时还没有意识到还有其他更多类型的命名空间出现!

下面是代码:

chapter2_basic/namespace/mount_namespace_demo.go

func main() {
    cmd := exec.Command("sh")
    cmd.SysProcAttr = &syscall.SysProcAttr{
        Cloneflags: syscall.CLONE_NEWUTS | syscall.CLONE_NEWIPC |
            syscall.CLONE_NEWPID | syscall.CLONE_NEWNS,
    }
    cmd.Stdin = os.Stdin
    cmd.Stdout = os.Stdout
    cmd.Stderr = os.Stderr

    if err := cmd.Run(); err != nil {
        log.Fatal(err)
    }
}

首先运行代码,并查看/proc目录中的内容:

root@jasonkay:~/workspace/my_docker/chapter2_basic# go run namespace/mount_namespace_demo.go 
# ls /proc
1      130    144    163    2    272  310  331  4096  57    810   9441         iomem         pressure
10     1302   145    16385  20   273  311  332  41    58    812   951          ioports       sched_debug
10720  13095  146    164    206  274  312  333  413   582   813   957          irq           schedstat
1095   13098  147    16434  21   275  313  334  42    59    814   966          kallsyms      scsi
11     131    148    165    22   276  314  335  438   6     815   991          kcore         self
1100   13369  149    16522  23   277  315  336  44    60    839   acpi         keys          slabinfo
1102   13370  15     16527  24   278  316  337  45    64    843   asound       key-users     softirqs
11129  13384  150    16530  258  279  317  338  46    65    845   buddyinfo    kmsg          stat
117    134    151    166    259  28   318  339  47    66    877   bus          kpagecgroup   swaps
11713  135    152    167    26   280  319  34   478   67    879   cgroups      kpagecount    sys
118    137    153    168    260  281  32   35   479   6758  893   cmdline      kpageflags    sysrq-trigger
119    138    154    169    261  282  320  36   48    68    899   consoles     loadavg       sysvipc
12     13873  155    17     262  283  321  366  480   69    9     cpuinfo      locks         thread-self
12563  13874  156    172    263  284  322  38   50    786   901   crypto       mdstat        timer_list
12570  13875  157    173    264  285  323  384  51    787   907   devices      meminfo       tty
126    139    158    174    265  286  324  385  514   788   908   diskstats    misc          uptime
12608  14     159    175    266  287  325  387  52    789   909   dma          modules       version
127    140    16     176    267  29   326  388  521   790   920   driver       mounts        version_signature
1275   141    160    178    268  3    327  389  53    799   928   execdomains  mpt           vmallocinfo
128    14132  16013  179    269  30   328  39   538   800   9284  fb           mtrr          vmstat
1286   142    161    18     27   304  329  4    54    802   929   filesystems  net           zoneinfo
129    143    162    190    270  308  33   40   554   803   941   fs           pagetypeinfo
13     1432   16252  193    271  309  330  401  56    805   9440  interrupts   partitions

/proc是一个文件系统,提供额外机制,通过内核和内核模块将信息发送给进程!

此时/proc为宿主机的,因此看到里面会比较乱;

下面将/proc mount 至自己的Namespace下:

# mount -t proc proc /proc
# ls /proc
1          consoles     fb           kcore        locks    net           slabinfo       timer_list
5          cpuinfo      filesystems  keys         mdstat   pagetypeinfo  softirqs       tty
acpi       crypto       fs           key-users    meminfo  partitions    stat           uptime
asound     devices      interrupts   kmsg         misc     pressure      swaps          version
buddyinfo  diskstats    iomem        kpagecgroup  modules  sched_debug   sys            version_signature
bus        dma          ioports      kpagecount   mounts   schedstat     sysrq-trigger  vmallocinfo
cgroups    driver       irq          kpageflags   mpt      scsi          sysvipc        vmstat
cmdline    execdomains  kallsyms     loadavg      mtrr     self          thread-self    zoneinfo

可以看到少了非常多!

此时再使用ps查看系统进程:

# ps -ef
UID          PID    PPID  C STIME TTY          TIME CMD
root           1       0  0 20:36 pts/1    00:00:00 sh
root           6       1  0 20:40 pts/1    00:00:00 ps -ef

可以看到,当前命名空间中 sh 进程为PID为 1 的进程,而当前的 Mount命名空间和外部是隔离的!

Docker Volumn也是利用了这个特性!

注:试验结束后,需要在容器中执行umount /proc取消挂载,否则会影响到外部挂载!

(这是受systemd影响的!后面会介绍如何避免受影响!)

  # umount /proc
  # ls /proc
  1      130    144    163    193  271  309  330  401   56    805   9440         interrupts    partitions
  10     1302   145    164    2    272  310  331  4096  57    810   9441         iomem         pressure
  10720  13095  146    16434  20   273  311  332  41    58    812   951          ioports       sched_debug
  1095   13098  147    165    206  274  312  333  413   582   813   957          irq           schedstat
  11     131    148    16522  21   275  313  334  42    59    814   966          kallsyms      scsi
  1100   13369  149    16527  22   276  314  335  438   6     815   991          kcore         self
  1102   13370  15     166    23   277  315  336  44    60    839   acpi         keys          slabinfo
  11129  13384  150    167    24   278  316  337  45    64    843   asound       key-users     softirqs
  117    134    151    16706  258  279  317  338  46    65    845   buddyinfo    kmsg          stat
  11713  135    152    16716  259  28   318  339  47    66    877   bus          kpagecgroup   swaps
  118    137    153    16755  26   280  319  34   478   67    879   cgroups      kpagecount    sys
  119    138    154    168    260  281  32   35   479   6758  893   cmdline      kpageflags    sysrq-trigger
  12     13873  155    169    261  282  320  36   48    68    899   consoles     loadavg       sysvipc
  12563  13874  156    17     262  283  321  366  480   69    9     cpuinfo      locks         thread-self
  12570  13875  157    172    263  284  322  38   50    786   901   crypto       mdstat        timer_list
  126    139    158    173    264  285  323  384  51    787   907   devices      meminfo       tty
  12608  14     159    174    265  286  324  385  514   788   908   diskstats    misc          uptime
  127    140    16     175    266  287  325  387  52    789   909   dma          modules       version
  1275   141    160    176    267  29   326  388  521   790   920   driver       mounts        version_signature
  128    14132  16013  178    268  3    327  389  53    799   928   execdomains  mpt           vmallocinfo
  1286   142    161    179    269  30   328  39   538   800   9284  fb           mtrr          vmstat
  129    143    162    18     27   304  329  4    54    802   929   filesystems  net           zoneinfo
  13     1432   16252  190    270  308  33   40   554   803   941   fs           pagetypeinfo

umount后,挂载恢复!


User Namespace

User命名空间用于隔离用户的用户组,即:

一个进程的UserId和GroupId在User命名空间内外可以是不同的!

例如:

在宿主机上以一个非root用户创建一个User命名空间,而在User命名空间里面映射为root!

注:在 Linux Kernel 3.8开始,非root进程也可以创建User命名空间了!

代码如下:

chapter2_basic/namespace/user_namespace_demo.go

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,
        /*
            以下两种情况,会导致UidMappings/GidMappings中设置了非当前进程所属UID和GID的相关数值:
            1. HostID非本进程所有(与Getuid()和Getgid()不等)
            2. Size大于1 (则肯定包含非当前进程的UID和GID)
            则需要Host机使用Root权限才能正常执行此段代码。

            Issue #3 error about User Namespace:
                https://github.com/xianlubird/mydocker/issues/3
        */
        UidMappings: []syscall.SysProcIDMap{
            {
                ContainerID: 1,
                HostID:      syscall.Getuid(),
                Size:        1,
            },
        },
        GidMappings: []syscall.SysProcIDMap{
            {
                ContainerID: 1,
                HostID:      syscall.Getgid(),
                Size:        1,
            },
        },
    }

    cmd.Stdin = os.Stdin
    cmd.Stdout = os.Stdout
    cmd.Stderr = os.Stderr

    if err := cmd.Run(); err != nil {
        log.Fatal(err)
    }

    os.Exit(-1)
}

需要注意的是:这里配置了Uid和Gid的映射;

不同的操作系统可能要求是不同的,这里建议使用Ubuntu操作系统!

下面进行测试:

# 宿主机查看当前用户和用户组
root@jasonkay:~# id
uid=0(root) gid=0(root) groups=0(root)

可以看到,此时是root用户;

运行程序:

root@jasonkay:~/workspace/my_docker/chapter2_basic# go run namespace/user_namespace_demo.go 
$ id
uid=1(daemon) gid=1(daemon) groups=1(daemon)

可以看到,他们的UID是不同的!


Network Namespace

Network命名空间用来隔离网络设备、IP地址等;

Network命名空间可以让每个容器拥有自己独立的(虚拟)网络设备,并且容器内的应用可以绑定到自己的端口,并且不会冲突!

代码如下:

chapter2_basic/namespace/network_namespace_demo.go

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 | syscall.CLONE_NEWNET,
        UidMappings: []syscall.SysProcIDMap{
            {
                ContainerID: 1,
                HostID:      syscall.Getuid(),
                Size:        1,
            },
        },
        GidMappings: []syscall.SysProcIDMap{
            {
                ContainerID: 1,
                HostID:      syscall.Getgid(),
                Size:        1,
            },
        },
    }

    cmd.Stdin = os.Stdin
    cmd.Stdout = os.Stdout
    cmd.Stderr = os.Stderr

    if err := cmd.Run(); err != nil {
        log.Fatal(err)
    }

    os.Exit(-1)
}

首先,在宿主机查看网络设备:

root@jasonkay:~# ifconfig 
ens33: flags=4163<UP,BROADCAST,RUNNING,MULTICAST>  mtu 1500
        inet 192.168.24.135  netmask 255.255.255.0  broadcast 192.168.24.255
        inet6 fe80::20c:29ff:fe4d:11db  prefixlen 64  scopeid 0x20<link>
        ether 00:0c:29:4d:11:db  txqueuelen 1000  (Ethernet)
        RX packets 980680  bytes 94756175 (94.7 MB)
        RX errors 0  dropped 0  overruns 0  frame 0
        TX packets 1697074  bytes 2161080469 (2.1 GB)
        TX errors 0  dropped 0 overruns 0  carrier 0  collisions 0

lo: flags=73<UP,LOOPBACK,RUNNING>  mtu 65536
        inet 127.0.0.1  netmask 255.0.0.0
        inet6 ::1  prefixlen 128  scopeid 0x10<host>
        loop  txqueuelen 1000  (Local Loopback)
        RX packets 1650  bytes 131668 (131.6 KB)
        RX errors 0  dropped 0  overruns 0  frame 0
        TX packets 1650  bytes 131668 (131.6 KB)
        TX errors 0  dropped 0 overruns 0  carrier 0  collisions 0

运行程序,并在容器中查看:

root@jasonkay:~/workspace/my_docker/chapter2_basic# go run namespace/network_namespace_demo.go 
$ ifconfig
$

此时,容器中没有任何设备,即:容器和宿主机直接网络是隔离的!


小结

本篇作为开篇,讲述了Docker中容器隔离所依赖的技术 Namespace,主要包括六个部分:

  • UTS Namespace
  • IPC Namespace
  • PID Namespace
  • Mount Namespace
  • User Namespace
  • Network Namespace

下一篇将会介绍 Docker 和 K8S 中限制容器内硬件资源的技术:Cgroups;


附录

系列文章:

源代码:



本文作者:Jasonkay
本文链接:https://jasonkayzk.github.io/2021/08/29/Docker原理实战-1:Namespace/
版权声明:本文采用 CC BY-NC-SA 3.0 CN 协议进行许可