docker的使用及原理

摘要: docker的使用及原理 如何在内网搭一个私人笔记

更多Docker学习内容:

点击此处阅读

引子

如何在内网搭一个私人笔记

一步步搭建Leanote笔记使用docker

什么是docker

下面是从wikipedia和百度百科摘下来描述docker的文字:

wikipedia

Docker is an open-source project that automates the deployment of applications inside software containers, by providing an additional layer of abstraction and automation of operating-system-level virtualization on Linux.

Docker implements a high-level API to provide lightweight containers that run processes in isolation.

百度百科

Docker 是一个开源的应用容器引擎,让开发者可以打包他们的应用以及依赖包到一个可移植的容器中,然后发布到任何流行的 Linux 机器上,也可以实现虚拟化。容器是完全使用沙箱机制,相互之间不会有任何接口(类似 iPhone 的 app)。几乎没有性能开销,可以很容易地在机器和数据中心中运行。

docker的特点

资源占用少启动快几乎没有性能损耗镜像脚本化构建快速交付应用程序,隐藏内部细节...

docker的使用

概念

Docker几个概念的生命周期镜像docker镜像从概念上讲类似于vm里面的iso文件,就是一个只读的模板。一个镜像可以包含一个Linux操作系统,里面安装了一系列的软件。镜像可以拷到任何装了docker的机器上运行。 仓库存储docker镜像的地方就是镜像仓库, 全球最大的docker镜像仓库是docker.io, 里面有大量官方和民间的优秀镜像可以直接拿过来使用,如mysql, centos等等。阿里也有自己的docker仓库: docker.alibaba-inc.com 容器容器与镜像的关系有点像进程与程序的关系,运行中的镜像就叫容器。 从原理上讲,容器事实上是镜像上面加了一层读写层,以及一个被隔离的进程空间。 后面原理部分会具体介绍。

常用命令

以下简单整理了一些docker最常用的命令,详细的命令请参考相关手册。 (其中bansheng_hawkeye_web是为这篇文章临时做的一个镜像)

获取镜像

docker pull $image_name

举例: docker pull centos:lastest 从index.io获取最新的centos系统镜像到本地, :lastest可以不写,为默认版本。 运行镜像

交互式地运行镜像

docker run -t -i $image_name [$cmd] 如果$image_name本地不存在,docker会先自动从远程仓库pull镜像。 -t表示在新容器内指定一个伪终端或终端,-i表示允许我们对容器内的STDIN进行交互。 $cmd为需要运行的程序。 举例: 1. docker run -t -icentos:lastest/bin/bash

后台运行镜像

docker run -d $image_name [$cmd] 与交互式的运行镜像不同的是,-d参数表示daemon,即运行守护进程。 可以通过后面介绍的attach和exec命令来操作已经运行的docker容器。 举例: 1. docker run -d centos:lastestsleep 我们也可以不指定$cmd命令,这时会调用镜像制作时Dockerfile文件中指定的程序,后面会介绍。

操作运行中的容器

docker attach $container_id $container_id可以通过docker ps -a命令看到。 attach到$container后,就相当于打开了一个终端,以及与STDIN进行交互。 这个命令一般用来查看容器的输出log docker exec -t -i $container_id $cmd 通过这个命令可以执行一个运行中的容器中的任何命令,并打开与STDIN的交互。 如: docker exec -t -i $container_id /bin/bash

给容器取名字

docker run --name 容器名 ... 默认情况下,docker会给容器自动取一个独一无二的名字,可以通过docker ps -a看到,docker会保证容器名是唯一的。 当然我们也可以通过--name具体指定一个名字。 docker attach和exec之类的命令中即可以使用$container_id关联容器,也可以直接通过容器名关联容器,这样更人性化一些。

端口映射

docker run -d -P(大写) $image_name [$cmd] docker可以通过Dockerfile在制作镜像时暴露一些端口出来,如web的80端口。 那么就需要在宿主机中指定一个端口映射到容器中的端口。 使用-P参数让容器启动时自动分配宿主机的端口到容器的端口。 举例(gogs是一个go语言实现的git服务,带web): docker run -d --name=gogsdata:/data -P songlijun/gogs 运行后使用docker port查看docker自动分配的端口,如: docker port $contaniner_id 22/tcp -> 0.0.0.0:32123 3000/tcp -> 0.0.0.0:34222 那么在浏览器访问: http://宿主机IP:34222 即可访问hawkeye_web页面。 docker run -d -p(小写) 主机端口:容器暴露的端口 $image_name [$cmd] 与-P类似,小写的-p可以显示指定主机端口,如: docker run -d --name=gogs-p 10080:3000 -p 22222:22 songlijun/gogs docker port $contaniner_id 22/tcp -> 0.0.0.0:22222 3000/tcp -> 0.0.0.0:10080

文件映射

docker run -d -v 主机目录:容器目录 $image_name [$cmd] 容器中产生的文件随着容器被删除也会被删除,这时有两种方法来持久化数据: 1. 容器删除前做一次commit,将容器的最新状态及数据做成一个镜像,然后更新到镜像仓库。 2. 使用这里说的文件映射,将主机中的一个目录映射到容器中,这种方法有很多使用场景,如在宿主机上看到容器的log文件,或者将mysql的data数据关联到宿主机的某个目录下,这样即使是容器被删,数据还是保留着的。 举例(注意-v的两个目录都需要为绝对路径): docker run -d --name=gogs -v /root/gogs_data:/data -p 10080:3000 -p 22222:22 songlijun/gogs

环境变量

docker run -e VAR_1=xxx -e VAR_2=yyy $image_name [$cmd] -e后面可以指定一些环境变量值,在容器运行的时候可以读到这些变量,容器的启动脚本可以使用这些变量做一些宏替换等操作,达到读取启动参数的目的。

容器连接

容器1: docker run --name container_1_name ... 容器2: docker run --link container_1_name:container_1_name_in_container2 ... 这样容器2就可以读到容器1的所有环境变量, 以及一些暴露出去的端口信息等。 这种连接方式有效的避免了将一些变量暴露到这两个容器之外。 举例:官方的phpmyadmin镜像与mysql数据库就使用这种方式进行关联: docker run --name mysql -e MYSQL_ROOT_PASSWORD=my_password -d mysql docker run --link mysql:mysql -p 1234:80 nazarpc/phpmyadmin 制作镜像

从运行中的容器制作镜像

docker commit $container_id $image_name 即将一个容器(运行中或者Exit状态都可以) 做一次commit, 就可以做成一个本地镜像, $image_name是取的新镜像名。 注:这种方式做镜像比较方便,但是项目的其它成员就不太清楚这个镜像的具体生成细节,不易维护。

使用Dockerfile

docker build -t $image_name -f dockerfile Dockerfile是一种脚本化描述生成镜像过程的方法,将镜像的生成指令写到文件中,便于分享给其它人,具体语法不展开说,参见官方文档: 提交镜像

提交镜像

docker push $image_name 提交名为$image_name的镜像, 镜像名中包含了镜像仓库的地址,默认为docker.io 如: docker push songlijun/proxy-client

docker的原理

技术点比较多,这里挑一些简单的介绍下,更多的知识点参考文末的外部资料链接。

Linux Namespace

docker是一个容器引擎,容器就要求对进程空间、用户空间、网络空间、硬盘空间等等做一些隔离,docker的底层是使用LXC实现的,LXC则使用Linux Namespace技术对各种技术做隔离。

Linux Namespace是Linux提供的一种内核级别环境隔离的方法, 隔离的资源包括:Mount、UTS、IPC、PID、Network、User。 篇幅限制,本文只介绍UTS、PID和Mount的隔离。

网上找来一段代码:

#define _GNU_SOURCE #include <sys/types.h> #include <sys/wait.h> #include <stdio.h> #include <sched.h> #include <signal.h> #include <unistd.h> #include <errno.h> /* 定义一个给 clone 用的栈,栈大小1M */ #define STACK_SIZE (1024 * 1024) static char container_stack[STACK_SIZE]; char* const container_args[] = { "/bin/bash", NULL }; int container_main(void* arg) { printf("Container - inside the container!\n"); /* 直接执行一个shell,以便我们观察这个进程空间里的资源是否被隔离了 */ execv(container_args[0], container_args); printf("Somethings wrong!\n"); return 1; } int main() { printf("Parent - start a container!\n"); /* 调用clone函数,其中传出一个函数,还有一个栈空间的(为什么传尾指针,因为栈是反着的) */ int container_pid = clone(container_main, container_stack+STACK_SIZE, SIGCHLD, NULL); if (container_pid < 0) { fprintf(stderr, "clone failed WTF!!!! %s\n", strerror(errno)); return -1; } /* 等待子进程结束 */ waitpid(container_pid, NULL, 0); printf("Parent - container stopped!\n"); return 0; }

代码比较简单,就是用clone系统调用生成一个新的子进程,并运行container_main函数。

运行结果:

[[email protected] /home/lijun.slj/hawkeye_web_docker/samples] $./simple_clone Parent - start a container! Container - inside the container! [[email protected] /home/lijun.slj/hawkeye_web_docker/samples] $ps aux |head USER PID %CPU %MEMVSZ RSS TTYSTAT START TIME COMMAND root 10.00.0413763520 ?Ss 10:25 0:07 /usr/lib/systemd/systemd --switched-root --system --deserialize 21 root 20.00.00 0 ?S10:25 0:00 [kthreadd] root 30.00.00 0 ?S10:25 0:02 [ksoftirqd/0] root 60.00.00 0 ?S10:25 0:16 [kworker/u30:0] root 70.00.00 0 ?S10:25 0:01 [migration/0] root 80.00.00 0 ?S10:25 0:00 [rcu_bh] root 90.00.00 0 ?S10:25 0:00 [rcuob/0] root100.00.00 0 ?S10:25 0:00 [rcuob/1] root110.00.00 0 ?S10:25 0:00 [rcuob/2]

可以看到,在进入了子进程后看到的跟父进程完全一样。

我们往上段代码的clone函数中加入CLONE_NEWUTS flag, 并且在container_main函数中设置主机名:

#define _GNU_SOURCE #include <sys/types.h> #include <sys/wait.h> #include <stdio.h> #include <sched.h> #include <signal.h> /* 定义一个给 clone 用的栈,栈大小1M */ #define STACK_SIZE (1024 * 1024) static char container_stack[STACK_SIZE]; char* const container_args[] = { "/bin/bash", NULL }; int container_main(void* arg) { printf("Container - inside the container!\n"); sethostname("container",10); /* 设置hostname */ /* 直接执行一个shell,以便我们观察这个进程空间里的资源是否被隔离了 */ execv(container_args[0], container_args); printf("Somethings wrong!\n"); return 1; } int main() { printf("Parent - start a container!\n"); /* 调用clone函数,其中传出一个函数,还有一个栈空间的(为什么传尾指针,因为栈是反着的) */ int container_pid = clone(container_main, container_stack+STACK_SIZE, CLONE_NEWUTS | SIGCHLD, NULL); /*启用CLONE_NEWUTS Namespace隔离 */ if (container_pid < 0) { printf("%d clone failed WTF!!!! %s\n", container_pid, strerror(errno)); return -1; } /* 等待子进程结束 */ waitpid(container_pid, NULL, 0); printf("Parent - container stopped!\n"); return 0; }

运行:

[[email protected] /home/lijun.slj/hawkeye_web_docker/samples] $sudo ./uts Parent - start a container! Container - inside the container! [root@container /home/lijun.slj/hawkeye_web_docker/samples] #hostname container [root@container /home/lijun.slj/hawkeye_web_docker/samples] #exit exit Parent - container stopped! [[email protected] /home/lijun.slj/hawkeye_web_docker/samples] $

可以看到子进程中的hostname变成了container

我们接着在clone函数中加入CLONE_NEWPID flag, 并在主子进程中都打出pid:

#define _GNU_SOURCE #include <sys/types.h> #include <sys/wait.h> #include <stdio.h> #include <sched.h> #include <signal.h> #include <unistd.h> #include <errno.h> /* 定义一个给 clone 用的栈,栈大小1M */ #define STACK_SIZE (1024 * 1024) static char container_stack[STACK_SIZE]; char* const container_args[] = { "/bin/bash", NULL }; int container_main(void* arg) { printf("Container [%5d] - inside the container!\n", getpid()); sethostname("container",10); /* 设置hostname */ /* 直接执行一个shell,以便我们观察这个进程空间里的资源是否被隔离了 */ execv(container_args[0], container_args); printf("Somethings wrong!\n"); return 1; } int main() { printf("Parent [%5d] - start a container!\n", getpid()); /* 调用clone函数,其中传出一个函数,还有一个栈空间的(为什么传尾指针,因为栈是反着的) */ int container_pid = clone(container_main, container_stack+STACK_SIZE, CLONE_NEWUTS | CLONE_NEWPID | SIGCHLD, NULL); /*启用CLONE_NEWUTS Namespace隔离 */ if (container_pid < 0) { printf("%d clone failed WTF!!!! %s\n", container_pid, strerror(errno)); return -1; } /* 等待子进程结束 */ waitpid(container_pid, NULL, 0); printf("Parent - container stopped!\n"); return 0; }

运行:

[[email protected] /home/lijun.slj/hawkeye_web_docker/samples] $sudo ./pid Parent [17121] - start a container! Container [1] - inside the container! [root@container /home/lijun.slj/hawkeye_web_docker/samples] #ps aux |head USER PID %CPU %MEMVSZ RSS TTYSTAT START TIME COMMAND root 10.00.0413763520 ?Ss 10:25 0:07 /usr/lib/systemd/systemd --switched-root --system --deserialize 21 root 20.00.00 0 ?S10:25 0:00 [kthreadd] root 30.00.00 0 ?S10:25 0:02 [ksoftirqd/0] root 60.00.00 0 ?S10:25 0:16 [kworker/u30:0] root 70.00.00 0 ?S10:25 0:01 [migration/0] root 80.00.00 0 ?S10:25 0:00 [rcu_bh] root 90.00.00 0 ?S10:25 0:00 [rcuob/0] root100.00.00 0 ?S10:25 0:00 [rcuob/1] root110.00.00 0 ?S10:25 0:00 [rcuob/2] [root@container /home/lijun.slj/hawkeye_web_docker/samples] #

可以看到子进程的pid变成了1。 这个变化很重要,意味着子进程后面的所有进程,都是挂在这个PID为1的进程后面。看起来就像是一个新的系统,而该子进程就像是pid为1的init进程。

但是上面的ps结果也看到,子进程仍然可以看以父进程的所有进程,原因是主子进程中的ps命令都是去读的/proc文件系统,我们需要对子进程单独mount一个proc文件系统出来。

接着改代码,给clone函数加一个CLONE_NEWNS flag, 并在子进程中运行 mount -t /proc proc /proc命令:

#define _GNU_SOURCE #include <sys/types.h> #include <sys/wait.h> #include <stdio.h> #include <sched.h> #include <signal.h> #include <unistd.h> #include <errno.h> /* 定义一个给 clone 用的栈,栈大小1M */ #define STACK_SIZE (1024 * 1024) static char container_stack[STACK_SIZE]; char* const container_args[] = { "/bin/bash", NULL }; int container_main(void* arg) { printf("Container [%5d] - inside the container!\n", getpid()); sethostname("container",10); /* 设置hostname */ /* 重新mount proc文件系统到 /proc下 */ system("mount -t proc proc /proc"); /* 直接执行一个shell,以便我们观察这个进程空间里的资源是否被隔离了 */ execv(container_args[0], container_args); printf("Somethings wrong!\n"); return 1; } int main() { printf("Parent [%5d] - start a container!\n", getpid()); /* 调用clone函数,其中传出一个函数,还有一个栈空间的(为什么传尾指针,因为栈是反着的) */ int container_pid = clone(container_main, container_stack+STACK_SIZE, CLONE_NEWUTS | CLONE_NEWPID | CLONE_NEWNS | SIGCHLD, NULL); /*启用CLONE_NEWUTS Namespace隔离 */ if (container_pid < 0) { printf("%d clone failed WTF!!!! %s\n", container_pid, strerror(errno)); return -1; } /* 等待子进程结束 */ waitpid(container_pid, NULL, 0); printf("Parent - container stopped!\n"); return 0; }

运行:

[[email protected] /home/lijun.slj/hawkeye_web_docker/samples] $sudo ./mount Parent [18594] - start a container! Container [1] - inside the container! [root@container /home/lijun.slj/hawkeye_web_docker/samples] #ps aux USER PID %CPU %MEMVSZ RSS TTYSTAT START TIME COMMAND root 11.30.0 3276 pts/0S22:55 0:00 /bin/bash root300.00.0 1632 pts/0R+ 22:56 0:00 ps aux [root@container /home/lijun.slj/hawkeye_web_docker/samples] #

可以看到,ps也只能看到子进程中的进程了。这个时候还可以看一下,父进程实际上可以看到子进程的进程,并且pid不为1,而子进程就像一只井底之蛙,只能看到自己被隔离出来的进程。

[[email protected] /home/lijun.slj/localha3_docker] $ps aux |grep /bin/bash root 203230.00.0 3284 pts/0S+ 23:00 0:00 /bin/bash lijun.s+ 207890.00.0956 pts/3S+ 23:02 0:00 grep --color=auto /bin/bash

AUFS文件系统

docker使用的文件系统有aufs、devicemapper等,aufs是docker的首选文件系统,但是可惜没有合到Linux主干代码中,不过主流的系统像ubuntu都是支持的。 而像centos这种系统不支持aufs, 就只能使用devicemapper了。 由于aufs对理解docker的layer(层)的概念更容易一些,这里介绍下aufs文件系统。

闲话不多说,找个ubuntu 12.04版本的系统做如下测试:建两个目录d1,d2 d1中有文件a和b, d2中有文件b和c:

root@vultr:~/test_aufs/test1# ls -R d1 d2 ./d1: ab ./d2: bc

其中每个文件的值为: d1/a -> a, d1/b -> b1, d2/b -> b2, d2/c -> c用d1, d2 mount一个aufs文件系统的目录:

root@vultr:~/test_aufs/test1# mount -t aufs -o dirs=./d1:./d2 none ./mnt root@vultr:~/test_aufs/test1# ls mnt/ abc root@vultr:~/test_aufs/test1# cat mnt/b b1

可以看到mnt/b中的值为d1/b中的值,而d2/b被丢掉了。 可见mnt多个目录同一个文件名的文件,只保留按顺序第一次出现的那个。

再尝试着更改文件内容:

root@vultr:~/test_aufs/test1/mnt# echo new_a > a root@vultr:~/test_aufs/test1/mnt# cat ../d1/a new_a root@vultr:~/test_aufs/test1/mnt# echo new_b > b root@vultr:~/test_aufs/test1/mnt# cat ../d1/b new_b root@vultr:~/test_aufs/test1/mnt# cat ../d2/b b2 root@vultr:~/test_aufs/test1/mnt# echo new_c > c root@vultr:~/test_aufs/test1/mnt# cat ../d2/c c root@vultr:~/test_aufs/test1/mnt# cat ../d1/c new_c root@vultr:~/test_aufs/test1/mnt#

前几个都好理解,注意往mnt/c中写一段内容后,d2/c的内容并没有改变,反而在d1目录下面出现了一个c,内容为mnt/c的内容,好诡异,这是什么逻辑呢。

原来mount aufs文件系统的目录时,最前面的目录是可写的,而后面的都是只读的,往mnt下面的文件写内容时,会先找到第一个可写的目录,然后更新其内容, 如果文件不存在则会建一个。 d2/c被mnt成只读的了不会改变内容,而且d1目录是可写的,所以会在d1下面新生成一个c文件。

我们还可以试着mount aufs时在目录后面加上:rw, :ro来表示读写和只读,会有不同的结果,但是原理与上段描述的一样,可以猜猜结果会是怎样。

不知道大家有没有用过Ubuntu或Fedora的live系统盘,只要插上光盘就可以运行系统,而且还可以写数据,只不过系统退出后变更的文件就找不到了。当时觉得很神奇,现在想想也正是使用了aufs这种文件系统的特性,只要将光盘和硬盘mount在一起,就可以看上去在光盘上读写数据了。

回到docker,docker的镜像其实就是一些只读层,而容器是在docker镜像上加了一层读写层,这样就可以在不更改镜像的基础上还能像普通vm一样读写数据。 网上有张图比较好:

重新理解Docker的各种命令

我们了解了docker的文件系统及namespace功能后,再试图重新理解一下docker的几个命令:

docker images : 列出所有顶层的只读镜像docker run : 先是利用只读的镜像外加一层可读写的层,并且加了一个被隔离的进程空间来创建了一个容器,然后运行指定的程序。docker stop : 保留可读写层,收回隔离的进程空间。docker ps -a : 列出所有包含读写层的容器,包含stop(Exit)状态的。docker commit : 将当前容器的只读层加可读写层一起产生一个新的只读层做为镜像。

几个例子

HawkeyeWeb

docker run --name hawkeye_web_test -d -p 主机http端口:80 reg.docker.alibaba-inc.com/bansheng_hawkeye-web 运行后浏览器访问:http://主机ip:给定的主机http端口 即可访问 此镜像默认使用hawkeye_web对应的idb线下数据库,可以指定MYSQL_IP,MYSQL_PORT,MYSQL_DBNAME,MYSQL_USER,MYSQL_PASSWORD几个环境变量来连接特定的线下数据库,方便线下开发测试。

代理服务

PS: 能看到最后的都是好汉, 分享点好东西犒劳下...

做了两个镜像(songlijun/proxy-client, songlijun/proxy-server)用于翻墙,使用很久了比较稳定,有兴趣可以参考README: 镜像在国内的仓库也有备份, pull速度要快一些: index.alauda.cn/songlijun/proxy-client index.alauda.cn/songlijun/proxy-server

经典游戏

docker run -i -t -p 80:80 index.alauda.cn/dubuqingfeng/docker-web-game 提供了超级玛丽,坦克大战,吃豆人三个经典游戏~

LocalHa3

本打算做一个集成的localha3 docker,但是遇到些感觉是alios5u7镜像bug的问题,没来得及做完。 半成品见: 设计上该docker应该可以用文件映射来接收build的xml数据,并配合一个简易的web系统来控制build及重启,以及往本机curl一个query的功能。

更多Docker学习内容:

点击此处阅读

引用

10张图带你深入理解Docker容器和镜像Docker基础技术:Linux Namespace(上)Docker基础技术:Linux Namespace(下)Docker基础技术:AUFSDocker基础技术:DeviceMapperDocker 中文教程非常详细的 Docker 学习笔记