总结下容器的一些概念和虚拟机的区别以及CMD
和entrypoint
先直接切入主题,容器好处和虚拟机的一些差异以及需要看懂本文的基础知识可以稍微看下面的博客过一遍
http://www.cnblogs.com/linuxops/p/6781047.html
- 容器是利用linux的
cgroup
和namespace
隔离的,在宿主机上本质是个隔离的进程。
因为是进程,一个容器(进程)
要一直运行,那容器里得有个主进程
一直运行。
容器很多东西(命令,文件啥的)和虚拟机有点类似,但是你不能把它当虚拟机用,因为容器本质是解耦的,一个容器一个业务,而且容器无状态
可以这样假想下,拿nginx这个来举例子,nginx其实就是个进程和配置文件还有静态网页啥的+一台服务器(环境+内核),服务器上有其他不需要的东西
容器就是个最小系统(内核+nginx依赖),然后安装了nginx
docker是直接使用宿主机内核,无需像虚拟机那样虚拟化出一个完整的操作系统
依赖和安装nginx体现在docker镜像的分层上
安装了docker后(建议别mac或者docker for win之类的,直接虚拟机linux里玩),一般教程是举例
第一个容器命令是
1 | docker run -ti centos /bin/bash |
也可能是
1 | docker run -d -p 80:80 nginx |
一般来讲入门最先接触的就是docker run
这个命令,这个命令可以理解为先查找本地有没有镜像
没有就从dockerhub拉取,然后镜像运行后就叫做容器
看下命令说明
1 | # docker run --help |
先看命令帮助的一部分,
命令格式是docker run [选项] 镜像名 [命令] [参数]
是说在一个新容器里运行一个命令,但是用法部分显示command和command的arg部分是可选的,选项也是可选的
从上面两个常见的命令看看一个有command,一个没有,选项部分在剩下的docker run –help里
1 | -i, --interactive Keep STDIN open even if not attached |
-i是stdin,-t是tty
tty 就是 Linux 给用户提供的一个常驻小程序,用于接收用户的标准输入,返回操作系统的标准输出。当然,为了能够在 tty 中输入信息,你还需要同时开启 stdin(标准输入流)。.像python,bash,npm,mysql客户端连接后(除了mysql以外前三不带文件名就单独的命令)都是交互+终端,需要-ti一起
-p是把容器的端口映射到宿主机的端口(可以多个-p选项)
前面说了,容器实质是个宿主机上的一个进程,一个进程一直运行容器就必须有主进程
上面两个命令的command部分其实就是所谓的主进程
至于nginx的为啥没有这就从构建镜像来讲
前面说了可以假想下容器是个最小系统安装了你要跑的业务进程
- 那如何构建自己的镜像呢,就是
Dockerfile
有FROM命令,选取一个基础镜像构建,具体的dockerfile一些常用选项可以去上面那个博客去看下,功能不多,挺好记住的
镜像的Dockerfile里FROM一个基础镜像,大多是系统镜像,或者最初的是一个系统镜像(当然也有不是系统镜像,参照最开始的hello-world就是一个可执行的编译完的汇编二进制文件,这里不讨论)
从hub.docker.com上看那些系统镜像的Dockerfile可以看到最开始就是个rootfs
上面说了,镜像是包含了依赖,那实体服务来说其实很多服务啥的依赖都在同一台服务器上,服务都可以用公用一些so啥的,如果镜像都单独用自己的,那么占据了很多空间和重复意义,那么镜像如何做到共享呢,答案是分层缓存
容器死亡(删除)这层读写层就没了,也就是容器`无状态`(数据无法持久,这点要铭记). 也就是使用宿主机内核,cgroup限制资源,读取镜像在namespace(pid,network,ipc啥的)隔离运行一个主进程(容器主进程),然后fork。在有docker下主要有镜像就行了
- 镜像是
AUFS
实现的分层
,容器是只读镜像,然后自己是一层读写层,称为write on read
下面接着说Dockerfile
就是个CMD
和entrypoint
很多人搞不清楚
拿nginx的官方的dockerfile来举例简化成下面的大概样子
1 | FROM xxx:xxx |
nginx镜像的主进程就是这个nginx -g deamon off的nginx前台命令作为默认的CMD
- 何为前台进程?
ssh链接一台linux输入ls,service nginx start和yes看下结果
ls执行完就完了回到shell终端service是启动了nginx就执行完了回到shell终端,yes则会一直前台打印y
- 然后命令最开始加exec
ls和service都替换掉当前shell终端的进程退出了,而exec yes则一直输出y
此时ctrl+c结束yes进程整个终端都退出了,这里的概念能帮我们理解前后台,很多刚接触docker的人都是主进程写个service nginx start然后运行容器容器就停止了
这是因为service nginx start这句shell充当了主进程,fork了一个子进程启动了nginx后这个shell就退出了,nginx是shell的子进程,主进程消亡子进程也就停止了
总结就是前台就是一直运行,你不会回到终端
所以这里nginx必须作为前台跑的主进程
这就是为啥run nginx容器的时候不需要在docker run的结尾写nginx的前台命令,因为run的时候结尾的command为空会默认使用构建镜像写的CMD
默认的command可以通过docker inspect 镜像名 输出的一堆json找Cmd部分查看
1 | docker inspect 镜像id(或者镜像名) | grep -Poz 'Cmd(\s|.)+?]' |
然后docker run命令的结尾有command的话会覆盖掉默认的CMD
- 我的容器为啥运行后就直接退出了?
为啥那个-ti centos /bin/bash这么常见,很多人因为不懂在它的执行结果上把容器当作虚拟机了
先通过非/bin/bash的cmd的效果来讲解
1 | [root@guan ~]# docker run -d centos ls |
上面用centos镜像(-d选项是把容器后台运行,毕竟容器执行命令,你不后台你就要等带容器前台输出并且执行完命令)来执行了两个命令
一个cmd是ls,一个是sleep 10
然后全部后台后用docker ps -a查看所有容器状态信息可以看到ls那个容器已经退出,sleep 10这个没退出,但是10秒过后这个容器也退出了
形象的说明了容器是需要主进程一直执行的
另外可以看到容器里你的主进程pid是1
1 | [root@guan ~]# docker run -d busybox sh -c 'yes >/dev/null' |
- 容器为啥不是虚拟机?
现在说下docker run -ti centos /bin/bash,很多命令需要交互终端来交互最常见的bash(前面说过的单独的python和npm以及mysql链接后的客户端交互),所以一般-ti是成对出现的
你运行了docker run -ti centos /bin/bash后会进入一个生成的新容器里的交互式bash终端,这个时候很多初学者看到这个现象误以为容器是虚拟机
其实这样这个bash充当了主进程的身份而已,你可以ctrl+c退出这个交互式bash容器就退出了
可能有些人就跳出来说-d不是后台吗,我-tid主进程是bash就可以一直不退出了
但是事实上这没啥卵用,容器是跑业务进程的,而不是让你当成虚拟机用,容器无状态,如果你当成虚拟机用后面容器起不来你在容器里所有的操作和文件数据(不挂数据卷的数据)都会没了
- 容器主进程为啥要跑业务进程?
可能另外有人说容器先跑个业务进程然后主进程是个死循环命令,不也可以吗
参照下面的
1 | [root@guan test]# cat entrypoint.sh |
这里的pid为1的是一个shell,结尾部分会引用这里
这样可以是可以的,但是违背了容器的设计理念
- 如果你业务进程挂了,你的容器还会在运行,你得进容器里查看,然后在容器里重启你的业务进程
- 如果你多个服务放在同一个镜像里,后期你正在运行的容器里想更新一个服务怎么更新?一直进容器里去更新那会越来越臃肿,重做镜像那代表这个运行的容器得停止,那么所有服务都得停止了业务会中断
- 如果你的配置文件挂载进去的,而容器的镜像里没有修改命令,你得先宿主机修改文件再进容器你重启你的业务进程.
- 你查看你业务进程的日志不方便,还得进容器或者用exec命令
上面更重要的是后期的容器编排swarm或者k8s,容器挂掉很正常,编排工具检测到容器挂了会去起一个新的,如果你容器里的业务进程挂了容器没挂那就呵呵了
如果业务进程跑的是前台且是主进程且一个容器一个服务
- docker stop或者api来停掉容器的时候业务进程能收到
SIGTERM
信号平滑退出- 业务进程挂了在外面就能看到,在外面就能直接重启容器
- 如果配置文件挂载的话在宿主机上就能修改,容器的镜像不需要有修改命令更精简
- 业务进程日志直接docker logs 容器id就能看的到,且docker有丰富的日志驱动,能够直接把docker logs的日志发送到你的采集server上
- 容器稳定但是业务的运行受外界影响而停止下加个–restart参数会容器和业务进程都会到外界因素正常而自动起来
- 一些服务可能依赖数据库啥的,因为是一个容器一个服务,你这个数据库可以选其他的物理机上的数据库或者本机的实体数据库或者跑个容器的数据库,这样解耦很方便让人选择
还有有些人没玩过容器编排工具,分享自己的Dockerfile
,看了下一个容器里放1个以上的服务,用supervisor管理,这样不符合容器设计理念。并且后续接触swarm或者k8s这样的容器编排工具的时候,一般都有策略(K8S里是叫HPA
,swarm就不知道有没有这个了),在访问量大的时候自动起容器(k8S里是pod,最小单位是pod,一个pod有一个或者一个以上的容器组成)应对压力,如果要起后端的,结果你后端的镜像里有前端的,这样最终你还是得一个镜像一个进程,还有方便收集日志以及解耦性,最好一个容器跑一个业务,且这个业务是主进程
然后说下docker exec,看看docker exec –help输出
格式和docker run差不多
1 | # docker exec --help |
使用容器运行一个命令,但是可以说八成以上的用的都是
1 | docker exec -ti 容器id /bin/bash |
这样是进入了容器内部了,很多非运维的人不看命令帮助,又经常看到别人这么用误以为是exec是进入容器内部
其实是仅仅执行一个命令而已,看下实际出真知
1 | [root@guan ~]# docker run -d nginx |
上面运行了一个nginx容器(然后输出信息是容器的id,使用容器id的地方不需要写全,开头几位以后能表示唯一的就行了)然后用它执行了一个ls命令
不能因为exec多用于进入容器内部的bash而认为exec是进入容器的命令(我刚开始也这么认为,甚至网上很多人博客也这么说)
现在说下CMD
和entrypoint
的关系
两者都可以设定命令作为主进程
两者都存在情况下,CMD
是传递给entrypoint
当作参数的
比如你某个主进程的某些参数需要固定,可以把命令和需要固定的参数部分写在entrypoint
那
例如我有个容器运行一下就是默认列出容器里的根目录,也可以run的时候指定目录
1 | [root@guanvps test]# cat Dockerfile |
例子形象说明了两者存在下CMD
是entrypoint
当作参数,而docker run命令时候的结尾的command部分
会覆盖掉镜像的CMD
- CMD和entrypoint有两种写法
- XXXX [“part1”,”part2”]
- XXXX part1 part2
- 前者不支持变量解析,后者支持变量
- 前者是exec格式,后者是sh -c形式
看下面例子1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44[root@guan_tx test]# docker build -t guan:1 .
Sending build context to Docker daemon 2.048kB
Step 1/3 : FROM busybox
latest: Pulling from library/busybox
d070b8ef96fc: Pull complete
Digest: sha256:c7b0a24019b0e6eda714ec0fa137ad42bc44a754d9cea17d14fba3a80ccc1ee4
Status: Downloaded newer image for busybox:latest
---> f6e427c148a7
Step 2/3 : ENV guan=zhang
---> Running in ea3956d0b8c0
Removing intermediate container ea3956d0b8c0
---> 6376a491fd35
Step 3/3 : CMD echo $guan
---> Running in 8a845904a6d1
Removing intermediate container 8a845904a6d1
---> f9eab63f79b5
Successfully built f9eab63f79b5
Successfully tagged guan:1
[root@guan_tx test]# cat Dockerfile
FROM busybox
ENV guan=zhang
CMD echo $guan
[root@guan_tx test]# docker run guan:1
zhang
[root@guan_tx test]# vim Dockerfile
[root@guan_tx test]# cat Dockerfile
FROM busybox
ENV guan=zhang
CMD ["echo","$guan"]
[root@guan_tx test]# docker build -t guan:2 .
Sending build context to Docker daemon 2.048kB
Step 1/3 : FROM busybox
---> f6e427c148a7
Step 2/3 : ENV guan=zhang
---> Using cache
---> 6376a491fd35
Step 3/3 : CMD ["echo","$guan"]
---> Running in d2d6cd04d57e
Removing intermediate container d2d6cd04d57e
---> 653be28a996b
Successfully built 653be28a996b
Successfully tagged guan:2
[root@guan_tx test]# docker run guan:2
$guan
对比exec和sh -c格式
1 | [root@k8s-m1 temp]# cat Dockerfile |
如上面所示,pid为1的是一个sh的进程
另外docker run结尾的command部分是以 exec 方式启动的
另外docker stop
, docker service rm
在停止容器时,都会先发 SIGTERM
信号,等待一段时间(默认为 10 秒)后,如果程序没响应,则强行 SIGKILL 杀掉进程。
这样应用进程就有机会平滑退出,在接收到 SIGTERM 后,可以去 Flush 缓存、完成文件读写、关闭数据库连接、释放文件资源、释放锁等等,然后再退出。所以试图截获 SIGTERM 信号的做法是对的。
但是,可能在截获 SIGTERM 时却发现,却发现应用并没有收到 SIGTERM,于是盲目的认为 Docker 不支持平滑退出,其实并非如此。
还记得我们提到过,Docker 不是虚拟机,容器只是受限进程,而一个容器只应该跑一个主进程的说法么?如果你发现你的程序没有截获到 SIGTERM,那就很可能你没有遵循这个最佳实践的做法。因为 SIGTERM 只会发给主进程,也就是容器内 PID 为 1 的进程。
至于说主进程启动的那些子进程,完全看主进程是否愿意转发 SIGTERM 给子进程了。所以那些把 Docker 当做虚拟机用的,主进程跑了个 bash,然后 exec 进去启动程序的,或者来个 & 让程序跑后台的情况,应用进程必然无法收到 SIGTERM。
还有一种可能是在 Dockerfile 中的 CMD 那行执行的一个脚本,脚本里去前台跑一个命令,这样pid为1的是脚本这个进程而非你业务进程。
另外实际应用场景里,用户最初要docker run执行其他命令(例如不启动业务进程,配置好环境变量后进去看配置文件正确否,然后手动启业务进程),你entrypoint写命令的话,你docker run command的时候,你的command部分将会被entrypoint设定的命令(例如上面的ls -l)当作选项(虽然docker run –entrypoint可以覆盖),此时你设定的entrypoint命令是不可能达到这种功能的,各种镜像官方的entrypoint大多都是shell脚本
因为shell脚本可以接受参数来写逻辑代码(虽然其他也可以,但是shell自带的)来让缺省运行业务的预期进程
下面讲解下过程
先默认docker run的时候没带comand,使用是镜像自带的CMD,CMD会给entrypoint当作参数
此时$@就是CMD的内容了,但是用户docker run或者exec的时候命令也会传递进来,希望也可以执行其他所有命令那肯定会执行exec "$@"
所以entrypoint里肯定会先判断$@的结果是不是你设定的CMD,是的话前台执行主进程(当然也可以前面做一些容器启动后主进程启动前一些命令),不是的话此步跳过,结尾有句执行exec $@
另外为啥实际场景里应用得多,是因为到现在dockerfile都是固定的,或者说某些步骤需要依赖用户挂载的文件或者总结说在容器启动后主进程启动前来执行
一个dockerfile只能有一个CMD和entrypoint,所以entrypoint使用shell脚本就能解决这个需求
我用python来举例子
一个标准的python项目目录里一个有个requirements.txt写有了所有的依赖扩展,虽然你可以在写dockerfile的时候COPY进去然后RUN pip install -r requirements.txt安装随后启动
这样是可以构建固化的镜像,但是你可能经常接触的话想构建一个通用型镜像,所以你需要在容器启动后主进程启动前pip安装用户挂载的项目文件夹里的requirements.txt里的依赖,这个时候用CMD和entrypoint配合就能完美实现了
先看看redis官方的做法,他们是一个shell脚本作为entrypoint的命令,脚本内容如下
1 | #!/bin/sh |
$1就是接收到的第一部分参数,也就是CMD里第一部分,如果run的command是sleep 10,那么第一个参数就是sleep了
脚本逻辑是如果第一部分参数是redis-server(也就是docker run …. redis redis-server)并且脚本执行的用户是root,配置权限后exec前台执行redis服务,然后不是的画就是最后
一行的那个exec “$@”了,$@在shell里表示收到的所有参数,也就是exec前台执行用户run 镜像名的最后的所有cmd部分
那现在写一个通用的容器的话,先了解几点,dockerfile的WORKDIR后面目录是在镜像里创建的,没有就创建并且进入到这个目录
所以所有的cmd都是在WORKDIR里执行的,如果你把文件挂载到了这个目录(官方也建议挂载到这个目录)可以不用写绝对路径
写通用型entrypoint流程是大概下面这样
1 | #!/bin/sh |
然后我镜像CMD是app run默认就是启动项目了,但是我输入其他命令就直接跳到exec $@了,也支持其他命令
从前面的entrypoint的实例的pid那可以看出你主进程也就是pid为1的,如果你用entrypoint的话你的主进程(也就是PID为1的)其实是这个shell,exec执行一个命令会让这个命令去替换掉这个shell本身去充当pid为1的角色
从下面代码举例
1 | [root@guan_tx ~]# echo $$ |
第一个打印当前终端这个shell的PID
第二个打印这个终端的父级PID
第三个是终端shell fork了一个子进程,这个子进程打印了它的父级PID,也就是这个终端的PID
第四个是这个sh的进程顶替了终端shell,打印它的父级PID,由于终端shell被顶替掉了,这个sh进程执行完毕就会断开ssh
大概上面这样,其实不确定流程是啥样或者想从某个基础镜像构建自己需要的镜像的话一个是可以去dockerhub上参照别人或者官方写的dockerfile
然后可以用docker -ti 镜像名 /bin/bash (如果你的业务需要网络做自己做端口映射,同理配置文件挂载进去)先进容器里
安装一些东西后把你的进程起来后(这个时候容器主进程是bash,无所谓你的业务进程是否是前后台,不过最好前台,可以看输出)再把这些流程写成dockerfile(做成通用型镜像或者需要在容器启动后执行而且构建镜像的时候执行的就写到entrypoint脚本里)
PS:还有构建镜像用了entrypoint脚本记住一个经常容易踩坑的地方是windows无法保存linux的文件可执行权限,所以Dockerfile里构建记得COPY脚本进去后写个RUN chmod u+x entrypoint.sh
另外容器里可能很多命令没有,你把相应的配置文件挂载进去你可以在宿主机上面修改
前不久制作了一个npm的通用型镜像过程在地址https://github.com/zhangguanzhang/docker-compose_nodejs
是nodejs和mysql的,里面有我写的entrypoint脚本,dockerfile参考官方的增加了entrypoint部分,用的是docker-compose,docker-compose是个容器编排工具,根据你写的yml文件内容来启动和设置容器的属性参数啥的,很多方便