docker的使用及原理
引子
如何在内网搭一个私人笔记
什么是docker
下面是从wikipedia和百度百科摘下来描述docker的文字:
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
举例:dockerpullcentos:lastest从index.io获取最新的centos系统镜像到本地,:lastest可以不写,为默认版本。
运行镜像
交互式地运行镜像
dockerrun-t -i$image_name[$cmd]如果$image_name本地不存在,docker会先自动从远程仓库pull镜像。-t表示在新容器内指定一个伪终端或终端,-i表示允许我们对容器内的STDIN进行交互。 $cmd为需要运行的程序。举例:1. dockerrun-t -i centos:lastest /bin/bash
后台运行镜像
dockerrun-d$image_name[$cmd]与交互式的运行镜像不同的是,-d参数表示daemon,即运行守护进程。 可以通过后面介绍的attach和exec命令来操作已经运行的docker容器。举例:1. dockerrun-dcentos:lastest sleep 1000000我们也可以不指定$cmd命令,这时会调用镜像制作时Dockerfile文件中指定的程序,后面会介绍。
操作运行中的容器
docker attach$container_id$container_id可以通过docker ps-a命令看到。 attach到$container后,就相当于打开了一个终端,以及与STDIN进行交互。 这个命令一般用来查看容器的输出logdockerexec-t -i$container_id$cmd通过这个命令可以执行一个运行中的容器中的任何命令,并打开与STDIN的交互。 如: dockerexec-t -i$container_id/bin/bash
给容器取名字
dockerrun--name 容器名 ...默认情况下,docker会给容器自动取一个独一无二的名字,可以通过docker ps -a看到,docker会保证容器名是唯一的。 当然我们也可以通过--name具体指定一个名字。docker attach和exec之类的命令中即可以使用$container_id关联容器,也可以直接通过容器名关联容器,这样更人性化一些。
端口映射
dockerrun-d-P(大写)$image_name[$cmd]docker可以通过Dockerfile在制作镜像时暴露一些端口出来,如web的80端口。 那么就需要在宿主机中指定一个端口映射到容器中的端口。 使用-P参数让容器启动时自动分配宿主机的端口到容器的端口。 举例(gogs是一个go语言实现的git服务,带web): dockerrun-d--name=gogs data:/data -P songlijun/gogs运行后使用docker port查看docker自动分配的端口,如: docker port $contaniner_id22/tcp ->0.0.0.0:321233000/tcp ->0.0.0.0:34222那么在浏览器访问: http://宿主机IP:34222即可访问hawkeye_web页面。dockerrun-d-p(小写) 主机端口:容器暴露的端口$image_name[$cmd]与-P类似,小写的-p可以显示指定主机端口,如: dockerrun-d--name=gogs -p 10080:3000 -p 22222:22 songlijun/gogsdocker port $contaniner_id22/tcp ->0.0.0.0:222223000/tcp ->0.0.0.0:10080
文件映射
dockerrun-d-v 主机目录:容器目录$image_name[$cmd]容器中产生的文件随着容器被删除也会被删除,这时有两种方法来持久化数据:1. 容器删除前做一次commit,将容器的最新状态及数据做成一个镜像,然后更新到镜像仓库。2. 使用这里说的文件映射,将主机中的一个目录映射到容器中,这种方法有很多使用场景,如在宿主机上看到容器的log文件,或者将mysql的data数据关联到宿主机的某个目录下,这样即使是容器被删,数据还是保留着的。举例(注意-v的两个目录都需要为绝对路径):dockerrun-d--name=gogs -v /root/gogs_data:/data -p 10080:3000 -p 22222:22 songlijun/gogs
环境变量
dockerrun-eVAR_1=xxx-eVAR_2=yyy$image_name[$cmd]-e后面可以指定一些环境变量值,在容器运行的时候可以读到这些变量,容器的启动脚本可以使用这些变量做一些宏替换等操作,达到读取启动参数的目的。
容器连接
容器1: dockerrun--name container_1_name ...容器2: dockerrun--link container_1_name:container_1_name_in_container2 ...这样容器2就可以读到容器1的所有环境变量, 以及一些暴露出去的端口信息等。 这种连接方式有效的避免了将一些变量暴露到这两个容器之外。举例:官方的phpmyadmin镜像与mysql数据库就使用这种方式进行关联:dockerrun--name mysql-eMYSQL_ROOT_PASSWORD=my_password-dmysqldockerrun--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 dockerfileDockerfile是一种脚本化描述生成镜像过程的方法,将镜像的生成指令写到文件中,便于分享给其它人,具体语法不展开说,参见官方文档: https://docs.docker.com/engine/reference/builder/
提交镜像
dockerpush$image_name提交名为$image_name的镜像, 镜像名中包含了镜像仓库的地址,默认为docker.io如:dockerpushsonglijun/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#include#include#include#include#include#include/* 定义一个给 clone 用的栈,栈大小1M */#defineSTACK_SIZE (1024 * 1024)staticcharcontainer_stack[STACK_SIZE];char*constcontainer_args[] = {"/bin/bash",NULL};intcontainer_main(void* arg){printf("Container - inside the container!\n");/* 直接执行一个shell,以便我们观察这个进程空间里的资源是否被隔离了 */execv(container_args[0], container_args);printf("Something's wrong!\n");return1;}intmain(){printf("Parent - start a container!\n");/* 调用clone函数,其中传出一个函数,还有一个栈空间的(为什么传尾指针,因为栈是反着的) */intcontainer_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");return0;}
代码比较简单,就是用clone系统调用生成一个新的子进程,并运行container_main函数。
运行结果:
[lijun.slj@e100081001115.zmf /home/lijun.slj/hawkeye_web_docker/samples]$./simple_clone Parent - start a container!Container - inside the container![lijun.slj@e100081001115.zmf /home/lijun.slj/hawkeye_web_docker/samples]$ps aux |headUSER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMANDroot10.0 0.0413763520? Ss 10:25 0:07 /usr/lib/systemd/systemd --switched-root --system --deserialize 21root20.0 0.000? S 10:25 0:00 [kthreadd]root30.0 0.000? S 10:25 0:02 [ksoftirqd/0]root60.0 0.000? S 10:25 0:16 [kworker/u30:0]root70.0 0.000? S 10:25 0:01 [migration/0]root80.0 0.000? S 10:25 0:00 [rcu_bh]root90.0 0.000? S 10:25 0:00 [rcuob/0]root100.0 0.000? S 10:25 0:00 [rcuob/1]root110.0 0.000? S 10:25 0:00 [rcuob/2]
可以看到,在进入了子进程后看到的跟父进程完全一样。
我们往上段代码的clone函数中加入CLONE_NEWUTS flag, 并且在container_main函数中设置主机名:
#define_GNU_SOURCE#include#include#include#include#include/* 定义一个给 clone 用的栈,栈大小1M */#defineSTACK_SIZE (1024 * 1024)staticcharcontainer_stack[STACK_SIZE];char*constcontainer_args[] = {"/bin/bash",NULL};intcontainer_main(void* arg){printf("Container - inside the container!\n"); sethostname("container",10);/* 设置hostname *//* 直接执行一个shell,以便我们观察这个进程空间里的资源是否被隔离了 */execv(container_args[0], container_args);printf("Something's wrong!\n");return1;}intmain(){printf("Parent - start a container!\n");/* 调用clone函数,其中传出一个函数,还有一个栈空间的(为什么传尾指针,因为栈是反着的) */intcontainer_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");return0;}
运行:
[lijun.slj@e010125011037.bja /home/lijun.slj/hawkeye_web_docker/samples]$sudo ./utsParent -startacontainer!Container- inside thecontainer![root@container/home/lijun.slj/hawkeye_web_docker/samples]#hostnamecontainer[root@container/home/lijun.slj/hawkeye_web_docker/samples]#exitexitParent-containerstopped![lijun.slj@e010125011037.bja /home/lijun.slj/hawkeye_web_docker/samples]$
可以看到子进程中的hostname变成了container
我们接着在clone函数中加入CLONE_NEWPID flag, 并在主子进程中都打出pid:
#define_GNU_SOURCE#include#include#include#include#include#include#include/* 定义一个给 clone 用的栈,栈大小1M */#defineSTACK_SIZE (1024 * 1024)staticcharcontainer_stack[STACK_SIZE];char*constcontainer_args[] = {"/bin/bash",NULL};intcontainer_main(void* arg){printf("Container [%5d] - inside the container!\n", getpid()); sethostname("container",10);/* 设置hostname *//* 直接执行一个shell,以便我们观察这个进程空间里的资源是否被隔离了 */execv(container_args[0], container_args);printf("Something's wrong!\n");return1;}intmain(){printf("Parent [%5d] - start a container!\n", getpid());/* 调用clone函数,其中传出一个函数,还有一个栈空间的(为什么传尾指针,因为栈是反着的) */intcontainer_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");return0;}
运行:
[lijun.slj@e010125011037.bja /home/lijun.slj/hawkeye_web_docker/samples]$sudo ./pidParent [17121] - start a container!Container [ 1] - inside the container![root@container /home/lijun.slj/hawkeye_web_docker/samples]#ps aux |headUSER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMANDroot10.0 0.0413763520? Ss 10:25 0:07 /usr/lib/systemd/systemd --switched-root --system --deserialize 21root20.0 0.000? S 10:25 0:00 [kthreadd]root30.0 0.000? S 10:25 0:02 [ksoftirqd/0]root60.0 0.000? S 10:25 0:16 [kworker/u30:0]root70.0 0.000? S 10:25 0:01 [migration/0]root80.0 0.000? S 10:25 0:00 [rcu_bh]root90.0 0.000? S 10:25 0:00 [rcuob/0]root100.0 0.000? S 10:25 0:00 [rcuob/1]root110.0 0.000? S 10: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#include#include#include#include#include#include/* 定义一个给 clone 用的栈,栈大小1M */#defineSTACK_SIZE (1024 * 1024)staticcharcontainer_stack[STACK_SIZE];char*constcontainer_args[] = {"/bin/bash",NULL};intcontainer_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("Something's wrong!\n");return1;}intmain(){printf("Parent [%5d] - start a container!\n", getpid());/* 调用clone函数,其中传出一个函数,还有一个栈空间的(为什么传尾指针,因为栈是反着的) */intcontainer_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");return0;}
运行:
[lijun.slj@e010125011037.bja /home/lijun.slj/hawkeye_web_docker/samples]$sudo ./mountParent [18594] -startacontainer!Container[1] - inside thecontainer![root@container/home/lijun.slj/hawkeye_web_docker/samples]#ps auxUSERPID %CPU %MEM VSZ RSS TTY STATSTARTTIMECOMMANDroot11.30.01167043276pts/0S22:550:00/bin/bashroot300.00.01394921632pts/0R+22:560:00ps aux[root@container/home/lijun.slj/hawkeye_web_docker/samples]#
可以看到,ps也只能看到子进程中的进程了。
这个时候还可以看一下,父进程实际上可以看到子进程的进程,并且pid不为1,而子进程就像一只井底之蛙,只能看到自己被隔离出来的进程。
[lijun.slj@e010125011037.bja /home/lijun.slj/localha3_docker]$ps aux |grep /bin/bashroot203230.0 0.01167043284pts/0 S+ 23:00 0:00 /bin/bashlijun.s+207890.0 0.0112644956pts/3 S+ 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 -Rd1 d2./d1:a b./d2:b c
其中每个文件的值为: 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 ./mntroot@vultr:~/test_aufs/test1# ls mnt/a b croot@vultr:~/test_aufs/test1# cat mnt/bb1
可以看到mnt/b中的值为d1/b中的值,而d2/b被丢掉了。 可见mnt多个目录同一个文件名的文件,只保留按顺序第一次出现的那个。
再尝试着更改文件内容:
root@vultr:~/test_aufs/test1/mnt# echo 'new_a' > aroot@vultr:~/test_aufs/test1/mnt# cat ../d1/anew_aroot@vultr:~/test_aufs/test1/mnt# echo 'new_b' > broot@vultr:~/test_aufs/test1/mnt# cat ../d1/bnew_broot@vultr:~/test_aufs/test1/mnt# cat ../d2/bb2root@vultr:~/test_aufs/test1/mnt# echo 'new_c' > croot@vultr:~/test_aufs/test1/mnt# cat ../d2/ccroot@vultr:~/test_aufs/test1/mnt# cat ../d1/cnew_croot@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端口:80reg.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:http://gitlab.alibaba-inc.com/lijun.slj/docker/tree/master/proxy-clienthttp://gitlab.alibaba-inc.com/lijun.slj/docker/tree/master/proxy-server镜像在国内的仓库也有备份, pull速度要快一些:index.alauda.cn/songlijun/proxy-clientindex.alauda.cn/songlijun/proxy-server
经典游戏
docker run -i-t -p80:80index.alauda.cn/dubuqingfeng/docker-web-game提供了超级玛丽,坦克大战,吃豆人三个经典游戏~
LocalHa3
本打算做一个集成的localha3 docker,但是遇到些感觉是alios5u7镜像bug的问题,没来得及做完。半成品见:http://gitlab.alibaba-inc.com/lijun.slj/localha3_docker/tree/master设计上该docker应该可以用文件映射来接收build的xml数据,并配合一个简易的web系统来控制build及重启,以及往本机curl一个query的功能。