容器(Docker)第三章:实战使用

Author: Lijb
Email: lijb1121@163.com
WeChat: ljb1121

走进Docker

Docker File

容器化的第一步就是制作镜像,不过,相较于之前介绍的制作 rootfs 的过程,Docker 为你提供了一种更便捷的方式,叫作 Dockerfile,如下所示。

# 使用官方提供的 Python 开发镜像作为基础镜像 
FROM python:2.7-slim 
 
# 将工作目录切换为 /app 
WORKDIR /app 
 
# 将当前目录下的所有内容复制到 /app 下 
ADD . /app 

# 使用 pip 命令安装这个应用所需要的依赖 
RUN pip install --trusted-host pypi.python.org -r requirements.txt 
 
# 允许外界访问容器的 80 端口 
EXPOSE 80 
 
# 设置环境变量 
ENV NAME World 
 
# 设置容器进程为:python app.py,即:这个 Python 应用的启动命令 
CMD ["python", "app.py"] 
1. Dockerfile是放在项目根目录,和src是同级关系,并且名称就是Dockerfile,我一开始写成了Dockerfile.txt结果完美报错。
2. Dockerfile内容中前面大写的是指令,后面跟参数
		FROM  指明基础镜像是谁
		WORKDIR,意思是在这一句之后,Dockerfile 后面的操作都以这一句指定的 /app 目录作 为当前目录。
		ADD ,它指的是把当前目录(即 Dockerfile 所在的目录)里的文件,复制到指定容器内的目录当中。
		MAINTAINER 指明项目的作者信息
		COPY 复制文件到镜像中 
		RUN 会生成容器,
		CMD 设置容器的启动命令 (有的项目启动时需要加一些指令,例如prometheus启动的时候不指明prometheus.yml的路径时远程无法post访问)
		EXPOSE 指明docker中的应用在运行的时候可以访问的端口号
镜像

读懂这个 Dockerfile 之后,我再把上述内容,保存到当前目录里一个名叫“Dockerfile”的文 件中:

  • 创建镜像
$ docker build -t helloworld .
-t 的作用是给这个镜像加一个 Tag,即:起一个好听的名字
. 代表当前路径

# 说明:docker build 会自动加载 当前目录下的 Dockerfile 文件,然后按照顺序,执行文件中的原语。而这个过程,实际上可以 等同于 Docker 使用基础镜像启动了一个容器,然后在容器中依次执行 Dockerfile 中的原语。
  • 查看镜像
$ docker image ls 
 
REPOSITORY            TAG                 IMAGE ID helloworld         latest              653287cdf998
  • 启动镜像
$ docker run -p 4000:80 helloworld
# 通过 -p 4000:80 告诉了 Docker,请把容器内的 80 端口映射在宿主机的 4000 端口上。
# 这样做的目的是,只要访问宿主机的 4000 端口,我就可以看到容器里应用返回的结果:

$ curl http://localhost:4000 
<h3>Hello World!</h3><b>Hostname:</b> 4ddf4638572d<br/> 
# 否则,我就得先用 docker inspect 命令查看容器的 IP 地址,然后访问“http://< 容器 IP 地址 >:80”才可以看到容器内应用的返回。

因为在 Dockerfile 中已经指定 了 CMD。否则,我就得把进程的启动命令加在后面:

$ docker run -p 4000:80 helloworld python app.py 
  • 把镜像上传到DockerHub上
$ docker tag helloworld lijb/helloworld:v1 

# lijb 是在 Docker Hub 上的用户名,它的“学名”叫镜像仓库 (Repository);“/”后面的 helloworld 是这个镜像的名字,而“v1”则是给这个镜像分 配的版本号。

$ docker push geektime/helloworld:v1 

此外,我还可以使用 docker commit 指令,把一个正在运行的容器,直接提交为一个镜像。一 般来说,需要这么操作原因是:这个容器运行起来后,我又在里面做了一些操作,并且要把操作 结果保存到镜像里,比如:

# docker exec 命令进入到了容器当中
$ docker exec -it 4ddf4638572d /bin/sh  

# 在容器内部新建了一个文件
root@4ddf4638572d:/app# touch test.txt 
root@4ddf4638572d:/app# exit 
 
# 将这个新建的文件提交到镜像中保存 
$ docker commit 4ddf4638572d geektime/helloworld:v2 
  • docker commit
1. docker commit,实际上就是在容器运行起来后,把上层的“可读写层”,加上原先容器镜 像的只读层,打包组成了一个新的镜像。当然,下面这些只读层在宿主机上是共享的,不会占用 额外的空间。

2. 而由于使用了联合文件系统,你在容器里对镜像 rootfs 所做的任何修改,都会被操作系统先复 制到这个可读写层,然后再修改。这就是所谓的:Copy-on-Write。

3. 而正如前所说,Init 层的存在,就是为了避免你执行 docker commit 时,把 Docker 自己对 /etc/hosts 等文件做的修改,也一起提交掉

proc 文件

实际上,Linux Namespace 创建的隔离空间虽然看不见摸不着,但一个进程的 Namespace 信 息在宿主机上是确确实实存在的,并且是以一个文件的方式存在。

  • 查看当前正在运行的 Docker 容器的进程号(PID)是 25686
$ docker inspect --format '{{ .State.Pid }}'  4ddf4638572d 
25686 
  • 查看宿主机的 proc 文件,看到这个 25686 进程的所有 Namespace 对应的文件:

可以看到,一个进程的每种 Linux Namespace,都在它对应的 /proc/[进程号]/ns 下有一个对 应的虚拟文件,并且链接到一个真实的 Namespace 文件上。

$ ls -l  /proc/25686/ns 
total 0 
lrwxrwxrwx 1 root root 0 Aug 13 14:05 cgroup -> cgroup:[4026531835] 
lrwxrwxrwx 1 root root 0 Aug 13 14:05 ipc -> ipc:[4026532278] 
lrwxrwxrwx 1 root root 0 Aug 13 14:05 mnt -> mnt:[4026532276] 
lrwxrwxrwx 1 root root 0 Aug 13 14:05 net -> net:[4026532281] 
lrwxrwxrwx 1 root root 0 Aug 13 14:05 pid -> pid:[4026532279] 
lrwxrwxrwx 1 root root 0 Aug 13 14:05 pid_for_children -> pid:[4026532279] 
lrwxrwxrwx 1 root root 0 Aug 13 14:05 user -> user:[4026531837] 
lrwxrwxrwx 1 root root 0 Aug 13 14:05 uts -> uts:[4026532277] 
  • 结论
1. 因为宿主机的 proc 文件,一个进程可以选择加入到某个进程已有的 Namespace 当中,从而达到“进 入”这个进程所在容器的目的.

2. 一旦一个进程加入到了另一个 Namespace 当中,在宿主机的 Namespace 文件上,也会有所体现,这正是 docker exec 的实现原理
详解Linux Namespace

一个进程可以选择加入到某个进程已有的 Namespace 当中,从而达到“进 入”这个进程所在容器的目的,而这个操作所依赖的,是一个名叫 setns() 的 Linux 系统调用。

  • 调用方法
#define _GNU_SOURCE 
#include <fcntl.h> 
#include <sched.h> 
#include <unistd.h> 
#nclude <stdlib.h> 
#include <stdio.h> 
 
#define errExit(msg) do { perror(msg); exit(EXIT_FAILURE);} while (0) 
 
int main(int argc, char *argv[]) {  

	int fd;          
	fd = open(argv[1], O_RDONLY); 
    
	if (setns(fd, 0) == -1) { 
    	errExit("setns");     
    }     
    
    execvp(argv[2], &argv[2]);      
    errExit("execvp"); 
    
 } 
  • 分析
1. 它一共接收两个参数,第一个参数是 argv[1],即当前进程要加入的 Namespace 文件的路径,比如 /proc/25686/ns/net;而第二个参数,则是你要在这个 Namespace 里运行的进程,比如 /bin/bash。

2. 这段代码的的核心操作,则是通过 open() 系统调用打开了指定的 Namespace 文件,并把这个文件的描述符 fd 交给 setns() 使用。在 setns() 执行后,当前进程就加入了这个文件对应的 Linux Namespace 当中了。

  • 编译执行一下这个程序,加入到容器进程(PID=25686)的 Network Namespace 中:
$ gcc -o set_ns set_ns.c  
$ ./set_ns /proc/25686/ns/net /bin/bash  
$ ifconfig 
eth0      Link encap:Ethernet  HWaddr 02:42:ac:11:00:02             
		  inet addr:172.17.0.2  Bcast:0.0.0.0  Mask:255.255.0.0           
		  inet6 addr: fe80::42:acff:fe11:2/64 Scope:Link           
		  UP BROADCAST RUNNING MULTICAST  MTU:1500  Metric:1           
		  RX packets:12 errors:0 dropped:0 overruns:0 frame:0           
		  TX packets:10 errors:0 dropped:0 overruns:0 carrier:0     
		  collisions:0 txqueuelen:0            
		  RX bytes:976 (976.0 B)   TX bytes:796 (796.0 B) 
 
 
lo        Link encap:Local Loopback             
		  inet addr:127.0.0.1  Mask:255.0.0.0           
		  inet6 addr: ::1/128 Scope:Host           
		  UP LOOPBACK RUNNING  MTU:65536  Metric:1          
          RX packets:0 errors:0 dropped:0 overruns:0 frame:0           
          TX packets:0 errors:0 dropped:0 overruns:0 carrier:0    
          collisions:0 txqueuelen:1000            
          RX bytes:0 (0.0 B)  	TX bytes:0 (0.0 B) 
  • 结果
正如上所示,当我们执行 ifconfig 命令查看网络设备时,我会发现能看到的网卡“变少”了: 只有两个。而我的宿主机则至少有四个网卡。这是怎么回事呢?

实际上,在 setns() 之后我看到的这两个网卡,正是前面启动的 Docker 容器里的网卡。也就是说,新创建的这个 /bin/bash 进程,由于加入了该容器进程(PID=25686)的 Network Namepace,它看到的网络设备与这个容器里是一样的,即:/bin/bash 进程的网络设备视 图,也被修改了。

证实了前面结论:一旦一个进程加入到了另一个 Namespace 当中,在宿主机的 Namespace 文件上,也会有 所体现。

在宿主机上,可以用 ps 指令找到这个 set_ns 程序执行的 /bin/bash 进程,其真实的 PID 是 28499:

# 在宿主机上 
ps aux | grep /bin/bash 

root     28499  0.0  0.0 19944  3612 pts/0    S    14:15   0:00 /bin/bash 
  • 查看一下这个 PID=28499 的进程的 Namespace
$ ls -l /proc/28499/ns/net lrwxrwxrwx 1 root root 0 Aug 13 14:18 /proc/28499/ns/net -> net:[4026532281] 
 
$ ls -l  /proc/25686/ns/net lrwxrwxrwx 1 root root 0 Aug 13 14:05 /proc/25686/ns/net -> net:[4026532281]

在 /proc/[PID]/ns/net 目录下,这个 PID=28499 进程,与我们前面的 Docker 容器进程 (PID=25686)指向的 Network Namespace 文件完全一样。这说明这两个进程,共享了这个 名叫 net:[4026532281] 的 Network Namespace。
此外,Docker 还专门提供了一个参数,可以让你启动一个容器并“加入”到另一个容器的 Network Namespace 里,这个参数就是 -net,比如:

$ docker run -it --net container:4ddf4638572d busybox ifconfig 	

这样,我们新启动的这个容器,就会直接加入到 ID=4ddf4638572d 的容器,也就是我们前面的创建的 Python 应用容器(PID=25686)的 Network Namespace 中。所以,这里 ifconfig 返回的网卡信息,跟我前面那个小程序返回的结果一模一样。
而如果指定–net=host,就意味着这个容器不会为进程启用 Network Namespace。这就意味着,这个容器拆除了 Network Namespace 的“隔离墙”,所以,它会和宿主机上的其他普通进程一样,直接共享宿主机的网络栈。这就为容器直接操作和使用宿主机网络提供了一个渠 道。

Volume
  • 思考
  1. 容器里进程新建的文件,怎么才能让宿主机获取到?
  2. 宿主机上的文件和目录,怎么才能让容器里的进程访问到?
  • Volume 机制

Volume 机制,允许你将宿主机上指定的目录或者文 件,挂载到容器里面进行读取和修改操作。在 Docker 项目里,它支持两种 Volume 声明方式,可以把宿主机目录挂载进容器的 /test 目 录当中:

$ docker run -v /test ... 

$ docker run -v /home:/test ... 
这两种声明方式的本质,实际上是相同的:都是把一个宿主机的目录挂载进了容器的 /test 目 录。

1. 在第一种情况下,由于你并没有显示声明宿主机目录,那么 Docker 就会默认在宿主机 上创建一个临时目录 /var/lib/docker/volumes/[VOLUME_ID]/_data,然后把它挂载到容器的 /test 目录上。

2. 第二种情况下,Docker 就直接把宿主机的 /home 目录挂载到容器的 /test 目录上。

当容器进程被创建之 后,尽管开启了 Mount Namespace,但是在它执行 chroot(或者 pivot_root)之前,容器进 程一直可以看到宿主机上的整个文件系统。

而宿主机上的文件系统,也自然包括了我们要使用的容器镜像。这个镜像的各个层,保存在 /var/lib/docker/aufs/diff 目录下,在容器进程启动后,它们会被联合挂载在 /var/lib/docker/aufs/mnt/ 目录中,这样容器所需的 rootfs 就准备好了。

所以,我们只需要在 rootfs 准备好之后,在执行 chroot 之前,把 Volume 指定的宿主机目录 (比如 /home 目录),挂载到指定的容器目录(比如 /test 目录)在宿主机上对应的目录(即 /var/lib/docker/aufs/mnt/[可读写层 ID]/test)上,这个 Volume 的挂载工作就完成了。

更重要的是,由于执行这个挂载操作时,“容器进程”已经创建了,也就意味着此时 Mount Namespace 已经开启了。所以,这个挂载事件只在这个容器里可见。你在宿主机上,是看不见 容器内部的这个挂载点的。这就保证了容器的隔离性不会被 Volume 打破。

注意

这里提到的 " 容器进程 ",是 Docker 创建的一个容器初始化进程 (dockerinit),而不是应用进程 (ENTRYPOINT + CMD)。dockerinit 会负责完成 根目录的准备、挂载设备和目录、配置 hostname 等一系列需要在容器内进行的 初始化操作。后,它通过 execv() 系统调用,让应用进程取代自己,成为容器里 的 PID=1 的进程。

要使用到的挂载技术,就是 Linux 的绑定挂载(Bind Mount)机制。它的主要作用就 是,允许你将一个目录或者文件,而不是整个设备,挂载到一个指定的目录上。并且,这时你在 该挂载点上进行的任何操作,只是发生在被挂载的目录或者文件上,而原挂载点的内容则会被隐 藏起来且不受影响。

其实,如果你了解 Linux 内核的话,就会明白,绑定挂载实际上是一个 inode 替换的过程。在 Linux 操作系统中,inode 可以理解为存放文件内容的“对象”,而 dentry,也叫目录项,就 是访问这个 inode 所使用的“指针”

img

正如上图所示,mount --bind /home /test,会将 /home 挂载到 /test 上。其实相当于将 /test 的 dentry,重定向到了 /home 的 inode。这样当我们修改 /test 目录时,实际修改的是 /home 目录的 inode。这也就是为何,一旦执行 umount 命令,/test 目录原先的内容就会恢 复:因为修改真正发生在的,是 /home 目录里。
所以,在一个正确的时机,进行一次绑定挂载,Docker 就可以成功地将一个宿主机上的目录或 文件,不动声色地挂载到容器中。

这样,进程在容器里对这个 /test 目录进行的所有操作,都实际发生在宿主机的对应目录(比 如,/home,或者 /var/lib/docker/volumes/[VOLUME_ID]/_data)里,而不会影响容器镜像 的内容。
那么,这个 /test 目录里的内容,既然挂载在容器 rootfs 的可读写层,它会不会被 docker commit 提交掉呢?
也不会。

这个原因其实我们前面已经提到过。容器的镜像操作,比如 docker commit,都是发生在宿主 机空间的。而由于 Mount Namespace 的隔离作用,宿主机并不知道这个绑定挂载的存在。所 以,在宿主机看来,容器中可读写层的 /test 目录(/var/lib/docker/aufs/mnt/[可读写层 ID]/test),始终是空的。

不过,由于 Docker 一开始还是要创建 /test 这个目录作为挂载点,所以执行了 docker commit 之后,你会发现新产生的镜像里,会多出来一个空的 /test 目录。毕竟,新建目录操 作,又不是挂载操作,Mount Namespace 对它可起不到“障眼法”的作用。

  • 验证
  • 首先,启动一个 helloworld 容器,给它声明一个 Volume,挂载在容器里的 /test 目录上
$ docker run -d -v /test helloworld 

cf53b766fa6f
  • 来查看一下这个 Volume 的 ID
$ docker volume ls DRIVER              

VOLUME NAME 

local  cb1c2f7221fa9b0971cc35f68aa1034824755ac44a034c0c0a1dd318838d3a6d 
  • 然后,使用这个 ID,可以找到它在 Docker 工作目录下的 volumes 路径
$ ls /var/lib/docker/volumes/cb1c2f7221fa/_data/ 

# 这个 _data 文件夹,就是这个容器的 Volume 在宿主机上对应的临时目录了。
  • 在容器的 Volume 里,添加一个文件 text.txt
$ docker exec -it cf53b766fa6f /bin/sh

cd test/ touch 

text.txt 
  • 再回到宿主机,就会发现 text.txt 已经出现在了宿主机上对应的临时目录里
$ ls /var/lib/docker/volumes/cb1c2f7221fa/_data/ 

text.txt 
  • 如果你在宿主机上查看该容器的可读写层,虽然可以看到这个 /test 目录,但其内容是空 的
$ ls /var/lib/docker/aufs/mnt/6780d0778b8a/test 
  • 结论
容器 Volume 里的信息,并不会被 docker commit 提交掉;但这个挂载点目录 /test 本身,则会出现在新的镜像当中
  • Docker总结

Docker全景图

img

1. 这个容器进程“python app.py”,运行在由 Linux Namespace 和 Cgroups 构成的隔离环境里;

2. 而它运行所需要的各种文件,比如 python,app.py,以及整个操作系统文件,则由多个联 合挂载在一起的 rootfs 层提供。

3. 这些 rootfs 层的下层,是来自 Docker 镜像的只读层。在只读层之上,是 Docker 自己添加的 Init 层,用来存放被临时修改过的 /etc/hosts 等文件。

4. 而 rootfs 的上层是一个可读写层,它以 Copy-on-Write 的方式存放任何对只读层的修改, 容器声明的 Volume 的挂载点,也出现在这一层。

Docker是一个由Linux Namespace 、Linux Cgroups和rootfs三大核心技术实现的虚拟化容器技术,其中Linux Namespace实现了Docker进程的隔离性、Linux Cgroups达到了Docker使用资源的限制性、rootfs保证了Docker运行环境的一致性。
  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值