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 就是显示日志。












网友评论