Docker 和虚拟机
容器内的进程是直接运行于宿主内核
的,这点和宿主进程一致,只是容器的 userland
不同,容器的 userland
由容器镜像提供,也就是说镜像提供了 rootfs
。
假设宿主是 Ubuntu
,容器是 CentOS
。CentOS
容器中的进程会直接向 Ubuntu
宿主内核发送 syscall
,而不会直接或间接的使用任何 Ubuntu
的 userland
的库。
这点和虚拟机有本质的不同,虚拟机是虚拟环境,在现有系统上虚拟一套物理设备,然后在虚拟环境内运行一个虚拟环境的操作系统内核,在内核之上再跑完整系统,并在里面调用进程。
还以上面的例子去考虑,虚拟机中,CentOS
的进程发送 syscall
内核调用,该请求会被虚拟机内的 CentOS
的内核接到,然后 CentOS
内核访问虚拟硬件时,由虚拟机的服务软件截获,并使用宿主系统,也就是 Ubuntu
的内核及 userland
的库去执行。
而且,Linux 和 Windows 在这点上非常不同。Linux 的进程是直接发 syscall
的,而 Windows 则把 syscall
隐藏于一层层的 DLL
服务之后,因此 Windows 的任何一个进程如果要执行,不仅仅需要 Windows 内核,还需要一群服务来支撑,所以如果 Windows 要实现类似的机制,容器内将不会像 Linux 这样轻量级,而是非常臃肿。看一下微软移植的 Docker 就非常清楚了。
所以不要把 Docker 和虚拟机弄混,Docker 容器只是一个进程而已,只不过利用镜像提供的 rootfs
提供了调用所需的 userland
库支持,使得进程可以在受控环境下运行而已,它并没有虚拟出一个机器出来。
CentOS 7 配置加速器(或其它使用 Systemd 的系统)
Ubuntu 16.04
和 CentOS 7
这类系统都已经开始使用 systemd
进行系统初始化管理了,对于使用 systemd
的系统,应该通过编辑服务配置文件 docker.service
来进行加速器的配置。
在启用服务后
$ sudo systemctl enable docker
可以直接编辑 /etc/systemd/system/multi-user.target.wants/docker.service
文件来进行配置。
sudo vi /etc/systemd/system/multi-user.target.wants/docker.service
在文件中找到 ExecStart=
这一行,并且在其行尾添加上所需的配置。假设我们的加速器地址为 https://registry.docker-cn.com
,那么可以这样配置:
ExecStart=/usr/bin/dockerd --registry-mirror=https://registry.docker-cn.com
注: Docker 1.12 之前的版本,
dockerd
应该换为docker daemon
,更早的版本则是docker -d
。
保存退出后,重新加载配置并启动服务:
sudo systemctl daemon-reload
sudo systemctl restart docker
确认一下配置是否已经生效:
sudo ps -ef | grep dockerd
如果配置成功,生效后就会在这里看到自己所配置的加速器。
在 1.13
版本以后,可以直接 docker info
查看,如果配置成功,加速器 Registry Mirror
会在最下面列出来。
如果重启后发现无法启动 docker
服务,检查一下服务日志,看看是不是之前执行过那些加速器网站的脚本,如果有做过类似的事情,检查一下是不是被建立了 /etc/docker/daemon.json
以配置加速器,如果是的话,删掉这个文件,然后在重启服务。
使用配置文件是件好事,比如修改配置不必重启服务,只需发送 SIGHUP
信号即可。但需要注意,目前在 dockerd
中使用配置文件时,无法输出当前生效配置,并且当 dockerd
的参数和 daemon.json
文件中的配置有所重复时,并不是一个优先级覆盖另一个,而是会直接导致引擎启动失败。很多人发现配了加速器后 Docker 启动不起来了就是这个原因。解决办法很简单,去掉重复项。不过在这些问题解决前,建议使用修改 docker.service
这类做法来实现配置,而不是使用配置文件 daemon.json
。方便 ps -ef | grep dockerd
一眼看到实际配置情况。
关于permission denied
没权限
在 Linux 环境下,一些新装了 docker 的用户,特别是使用了 sudo
命令安装好了 Docker 后,发现当前用户一执行 docker
命令,就会报没权限的错误:
dial unix /var/run/docker.sock: permission denied
官方安装文档:只需要将操作 docker
的用户,加入 docker
组,那么该用户既拥有了操作 docker
的权限。
因此,只需要执行:
sudo usermod -aG docker $USER
就可以把当前用户加入 docker
组,退出、重新登录系统后,执行 docker info
看一下,就会发现可以不用 sudo
直接执行 docker
命令了。
如果需要添加别的用户,将其中的 $USER
换成对应的用户名即可。
Dockerfile
中的EXPOSE
和 docker run -p
Docker中有两个概念,一个叫做 EXPOSE
,一个叫做 PUBLISH
。
-
EXPOSE
是镜像/容器声明要暴露该端口,可以供其他容器使用。这种声明,在没有设定--icc=false
的时候,实际上只是一种标注,并不强制。也就是说,没有声明EXPOSE
的端口,其它容器也可以访问。但是当强制--icc=false
的时候,那么只有EXPOSE
的端口,其它容器才可以访问。 -
PUBLISH
则是通过映射宿主端口,将容器的端口公开于外界,也就是说宿主之外的机器,可以通过访问宿主IP及对应的该映射端口,访问到容器对应端口,从而使用容器服务。
EXPOSE
的端口可以不 PUBLISH
,这样只有容器间可以访问,宿主之外无法访问。而 PUBLISH
的端口,可以不事先 EXPOSE
,换句话说 PUBLISH
等于同时隐式定义了该端口要 EXPOSE
。
docker run
命令中的 -p
, -P
参数,以及 docker-compose.yml
中的 ports
部分,实际上均是指 PUBLISH
。
小写 -p
是端口映射,格式为 [宿主IP:]<宿主端口>:<容器端口>
,其中宿主端口和容器端口,既可以是一个数字,也可以是一个范围,比如:1000-2000:1000-2000
。对于多宿主的机器,可以指定宿主IP,不指定宿主IP时,守护所有接口。
大写 -P
则是自动映射,将所有定义 EXPOSE
的端口,随机映射到宿主的某个端口。
如何让一个容器连接两个网络?
如果是使用 docker run
,那很不幸,一次只可以连接一个网络,因为 docker run
的 --network
参数只可以出现一次(如果出现多次,最后的会覆盖之前的)。不过容器运行后,可以用命令 docker network connect
连接多个网络。
假设我们创建了两个网络:
$ docker network create mynet1
$ docker network create mynet2
然后,我们运行容器,并连接这两个网络。
$ docker run -d --name web --network mynet1 nginx
$ docker network connect mynet2 web
但是如果使用 docker-compose
那就没这个问题了。因为实际上,Docker Remote API
是支持一次性指定多个网络的,但是估计是命令行上不方便,所以 docker run
限定为只可以一次连一个。docker-compose
直接就可以将服务的容器连入多个网络,没有问题。
version: '2'
services:
web:
image: nginx
networks:
- mynet1
- mynet2
networks:
mynet1:
mynet2:
Docker 多宿主网络怎么配置?
Docker 跨节点容器网络互联,最通用的是使用 overlay
网络。
使用 Swarm -- Docker Swarm Mode
,非常简单,只要 docker swarm init
建立集群,其它节点 docker swarm join
加入集群后,集群内的服务就自动建立了 overlay
网络互联能力。
需要注意的是,如果是多网卡环境,无论是 docker swarm init
还是 docker swarm join
,都不要忘记使用参数 --advertise-addr
指定宣告地址,否则自动选择的地址很可能不是你期望的,从而导致集群互联失败。格式为 --advertise-addr <地址>:<端口>
,地址可以是 IP 地址,也可以是网卡接口,比如 eth0
。端口默认为 2377
,如果不改动可以忽略。
此外,这是供服务使用的 overlay
,因此所有 docker service create
的服务容器可以使用该网络,而 docker run
不可以使用该网络,除非明确该网络为 --attachable
。
虽然默认使用的是 overlay
网络,但这并不是唯一的多宿主互联方案。Docker 内置了一些其它的互联方案,比如效率比较高的 macvlan
。如果在局域网络环境下,对 overlay
的额外开销不满意,那么可以考虑 macvlan
以及 ipvlan
,这是比较好的方案。
https://docs.docker.com/engine/userguide/networking/get-started-macvlan/
此外,还有很多第三方的网络可以用来进行跨宿主互联,可以访问官网对应文档进一步查看:https://docs.docker.com/engine/extend/legacy_plugins/#/network-plugins
容器无状态
容器存储层的无状态
服务层面的无状态
容器存储层的无状态
这里提到的存储层是指用于存储镜像、容器各个层的存储,一般是 Union FS
,如 AUFS
,或者是使用块设备的一些机制(如 snapshot
)进行模拟,如 devicemapper
。
Union FS
这类存储系统,相当于是在现有存储上,再加一层或多层存储,这类存储的读写性能并不好。并且对于 CentOS
这类只能使用 devicemapper
的系统而言,存储层的读写还经常出 bug。因此,在 Docker 使用过程中,要避免存储层的读写。频繁读写的部分,应该使用卷
。需要持久化的部分,可以使用命名卷进行持久化。由于命名卷的生存周期和容器不同,容器消亡重建,卷不会跟随消亡。所以容器可以随便删了重新run
,而其挂载的卷
则会保持之前的数据。
服务层面的无状态
使用卷持久化容器状态,虽然从存储层的角度看,是无状态的,但是从服务层面看,这个服务是有状态的。
从服务层面上说,也存在无状态服务。就是说服务本身不需要写入任何文件。比如前端 nginx
,它不需要写入任何文件(日志走Docker日志驱动),中间的 php
, node.js
等服务,可能也不需要本地存储,它们所需的数据都在 redis
, mysql
, mongodb
中了。这类服务,由于不需要卷,也不发生本地写操作,删除、重启、不保存自身状态,并不影响服务运行,它们都是无状态服务
。这类服务由于不需要状态迁移,不需要分布式存储,因此它们的集群调度更方便。
之前没有 docker volume
的时候,有些人说 Docker 只可以支持无状态服务,原因就是只看到了存储层需求无状态,而没有 docker volume
的持久化解决方案。
现在这个说法已经不成立,服务可以有状态,状态持久化用 docker volume
。
当服务可以有状态后,如果使用默认的 local
卷驱动,并且使用本地存储
进行状态持久化的情况,单机服务、容器的再调度运行没有问题。但是顾名思义,使用本地存储
的卷,只可以为当前主机提供持久化的存储,而无法跨主机。
但这只是使用默认的 local
驱动,并且使用 本地存储
而已。使用分布式/共享存储就可以解决跨主机的问题。docker volume
自然支持很多分布式存储的驱动,比如 flocker
、glusterfs
、ceph
、ipfs
等等。常用的插件列表可以参考官方文档:https://docs.docker.com/engine/extend/legacy_plugins/#/volume-plugins
在镜像的 Dockerfile
制作中,加入初始化部分
官方镜像 mysql
中可以使用 Dockerfile
来添加初始化脚本,并且会在运行时判断是否为第一次运行,如果确实需要初始化,则执行定制的初始化脚本。
假设我们使用这种方法将 hello.txt
在初始化的时候加入到 mydata
卷中去。
首先我们需要写一个进入点的脚本,用以确保在容器执行的时候都会运行,而这个脚本将判断是否需要数据初始化,并且进行初始化操作。
#!/bin/bash
# entrypoint.sh
if [ ! -f "/data/hello.txt" ]; then
cp /source/hello.txt /data/
fi
exec "$@"
名为 entrypoint.sh
的这个脚本很简单,判断一下 /data/hello.txt
是否存在,如果不存在就需要初始化。初始化行为也很简单,将实现准备好的 /source/hello.txt
复制到 /data/
目录中去,以完成初始化。程序的最后,将执行送入的命令。
我们可以这样写 Dockerfile
:
FROM nginx
COPY hello.txt /source/
COPY entrypoint.sh /
VOLUME /data
ENTRYPOINT ["/entrypoint.sh"]
CMD ["nginx", "-g", "daemon off;"]
当我们构建镜像、启动容器后,就会发现 /data
目录下已经存在了 hello.txt
文件了,初始化成功了。
关于Docker 容器里运行数据库
Docker Volume
可以解决持久化问题,从本地目录绑定、受控存储空间、块设备、网络存储到分布式存储,Docker Volume
都支持
Docker 不是虚拟机,使用数据卷是直接向宿主写入文件,不存在性能损耗。而且卷的生存周期独立于容器,容器消亡卷不消亡,重新运行容器可以挂载指定命名卷,数据依然存在,也不存在无法持久化的问题。
Dockerfile
与镜像
docker commit
Docker 提供了很好的 Dockerfile
的机制来帮助定制镜像,可以直接使用 Shell 命令,非常方便。而且,这样制作的镜像更加透明,也容易维护,在基础镜像升级后,可以简单地重新构建一下,就可以继承基础镜像的安全维护操作。
使用 docker commit
制作的镜像被称为黑箱镜像
,换句话说,就是里面进行的是黑箱操作,除本人外无人知晓。即使这个制作镜像的人,过一段时间后也不会完整的记起里面的操作。那么当有些东西需要改变时,或者因基础镜像更新而需要重新制作镜像时,会让一切变得异常困难,就如同重新安装调试配置服务器一样,失去了 Docker 的优势了。
使用
commit
的场合是一些特殊环境,比如入侵后保存现场等等,这个命令不应该成为定制镜像的标准做法。
shell 脚本
Dockerfile
不等于.sh
脚本
Dockerfile
确实是描述如何构建镜像的,其中也提供了 RUN
这样的命令,可以运行 shell 命令。但是和普通 shell 脚本还有很大的不同。
Dockerfile
描述的实际上是镜像的每一层要如何构建,所以每一个RUN
是一个独立的一层。所以一定要理解“分层存储”的概念。上一层的东西不会被物理删除,而是会保留给下一层,下一层中可以指定删除这部分内容,但实际上只是这一层做的某个标记,说这个路径的东西删了。但实际上并不会去修改上一层的东西。每一层都是静态的,这也是容器本身的 immutable
特性,要保持自身的静态特性。
Dockerfile
确的写法应该是把同一个任务的命令放到一个 RUN
下,多条命令应该用 &&
连接,并且在最后要打扫干净所使用的环境。比如下面这段摘自官方 redis
镜像 Dockerfile
的部分:
RUN buildDeps='gcc libc6-dev make' \
&& set -x \
&& apt-get update && apt-get install -y $buildDeps --no-install-recommends \
&& rm -rf /var/lib/apt/lists/* \
&& wget -O redis.tar.gz "$REDIS_DOWNLOAD_URL" \
&& echo "$REDIS_DOWNLOAD_SHA1 *redis.tar.gz" | sha1sum -c - \
&& mkdir -p /usr/src/redis \
&& tar -xzf redis.tar.gz -C /usr/src/redis --strip-components=1 \
&& rm redis.tar.gz \
&& make -C /usr/src/redis \
&& make -C /usr/src/redis install \
&& rm -r /usr/src/redis \
&& apt-get purge -y --auto-remove $buildDeps
context
context
,上下文,是 docker build
中很重要的一个概念。构建镜像必须指定 context
:
docker build -t xxx <context路径>
或者 docker-compose.yml
中的
app:
build:
context: <context路径>
dockerfile: dockerfile
这里都需要指定 context
。
context
是工作目录,但不要和构建镜像的Dockerfile
中的 WORKDIR
弄混,context
是 docker build
命令的工作目录。
docker build
命令实际上是客户端,真正构建镜像并非由该命令直接完成。docker build
命令将 context
的目录上传给 Docker
引擎,由它负责制作镜像。
在 Dockerfile 中如果写 COPY ./package.json /app/
这种命令,实际的意思并不是指执行 docker build
所在的目录下的 package.json
,也不是指 Dockerfile
所在目录下的 package.json
,而是指 context
目录下的 package.json
。
这就是为什么有人发现 COPY ../package.json /app
或者 COPY /opt/xxxx /app
无法工作的原因,因为它们都在 context
之外,如果真正需要,应该将它们复制到 context
目录下再操作。
docker build -t xxx .
中的这个.
,实际上就是在指定 Context
的目录,而并非是指定 Dockerfile
所在目录。
默认情况下,如果不额外指定 Dockerfile
的话,会将 Context
下的名为 Dockerfile
的文件作为 Dockerfile
。所以很多人会混淆,认为这个 .
是在说 Dockerfile
的位置,其实不然。
一般项目中,Dockerfile
可能被放置于两个位置。
- 一个可能是放置于项目顶级目录,这样的好处是在顶级目录构建时,项目所有内容都在上下文内,方便构建;
- 另一个做法是,将所有 Docker 相关的内容集中于某个目录,比如
docker
目录,里面包含所有不同分支的Dockerfile
,以及docker-compose.yml
类的文件、entrypoint 的脚本等等。这种情况的上下文所在目录不再是Dockerfile
所在目录了,因此需要注意指定上下文的位置。
此外,项目中可能会包含一些构建不需要的文件,这些文件不应该被发送给 dockerd
引擎,但是它们处于上下文目录下,这种情况,我们需要使用 .dockerignore
文件来过滤不必要的内容。.dockerignore
文件应该放置于上下文顶级目录下,内容格式和 .gitignore
一样。
tmp
db
这样就过滤了 tmp
和 db
目录,它们不会被作为上下文的一部分发给 dockerd
引擎。
如果你发现你的
docker build
需要发送庞大的 Context 的时候,就需要来检查是不是.dockerignore
忘了撰写,或者忘了过滤某些东西了。
ENTRYPOINT
和 CMD
的不同
Dockerfile
的目的是制作镜像,换句话说,实际上是准备的是主进程运行环境。那么准备好后,需要执行一个程序才可以启动主进程,而启动的办法就是调用 ENTRYPOINT
,并且把 CMD
作为参数传进去运行。也就是下面的概念:
ENTRYPOINT "CMD"
假设有个 myubuntu
镜像 ENTRYPOINT
是 sh -c
,而我们 docker run -it myubuntu uname -a
。那么 uname -a
就是运行时指定的 CMD
,那么 Docker 实际运行的就是结合起来的结果:
sh -c "uname -a"
- 如果没有指定
ENTRYPOINT
,那么就只执行CMD
; - 如果指定了
ENTRYPOINT
而没有指定CMD
,自然执行ENTRYPOINT
; - 如果
ENTRYPOINT
和CMD
都指定了,那么就如同上面所述,执行ENTRYPOINT "CMD"
; - 如果没有指定
ENTRYPOINT
,而CMD
用的是上述那种 shell 命令的形式,则自动使用sh -c
作为ENTRYPOINT
。
注意最后一点的区别,这个区别导致了同样的命令放到 CMD
和 ENTRYPOINT
下效果不同,因此有可能放在 ENTRYPOINT
下的同样的命令,由于需要 tty
而运行时忘记了给(比如忘记了docker-compose.yml
的 tty:true
)导致运行失败。
这种用法可以很灵活,比如我们做个 git
镜像,可以把 git
命令指定为 ENTRYPOINT
,这样我们在 docker run
的时候,直接跟子命令即可。比如 docker run git log
就是显示日志。