Docker 学习笔记


Docker 和虚拟机

容器内的进程是直接运行于宿主内核的,这点和宿主进程一致,只是容器的 userland 不同,容器的 userland 由容器镜像提供,也就是说镜像提供了 rootfs

假设宿主是 Ubuntu,容器是 CentOSCentOS 容器中的进程会直接向 Ubuntu 宿主内核发送 syscall,而不会直接或间接的使用任何 Ubuntuuserland 的库。

这点和虚拟机有本质的不同,虚拟机是虚拟环境,在现有系统上虚拟一套物理设备,然后在虚拟环境内运行一个虚拟环境的操作系统内核,在内核之上再跑完整系统,并在里面调用进程。

还以上面的例子去考虑,虚拟机中,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.04CentOS 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中的EXPOSEdocker 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 自然支持很多分布式存储的驱动,比如 flockerglusterfscephipfs 等等。常用的插件列表可以参考官方文档: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 弄混,contextdocker 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

这样就过滤了 tmpdb 目录,它们不会被作为上下文的一部分发给 dockerd 引擎。

如果你发现你的 docker build 需要发送庞大的 Context 的时候,就需要来检查是不是 .dockerignore 忘了撰写,或者忘了过滤某些东西了。

ENTRYPOINTCMD 的不同

Dockerfile 的目的是制作镜像,换句话说,实际上是准备的是主进程运行环境。那么准备好后,需要执行一个程序才可以启动主进程,而启动的办法就是调用 ENTRYPOINT,并且把 CMD 作为参数传进去运行。也就是下面的概念:

ENTRYPOINT "CMD"

假设有个 myubuntu 镜像 ENTRYPOINTsh -c,而我们 docker run -it myubuntu uname -a。那么 uname -a 就是运行时指定的 CMD,那么 Docker 实际运行的就是结合起来的结果:

sh -c "uname -a"
  • 如果没有指定 ENTRYPOINT,那么就只执行 CMD
  • 如果指定了 ENTRYPOINT 而没有指定 CMD,自然执行 ENTRYPOINT;
  • 如果 ENTRYPOINTCMD 都指定了,那么就如同上面所述,执行 ENTRYPOINT "CMD"
  • 如果没有指定 ENTRYPOINT,而 CMD 用的是上述那种 shell 命令的形式,则自动使用 sh -c 作为 ENTRYPOINT

注意最后一点的区别,这个区别导致了同样的命令放到 CMDENTRYPOINT 下效果不同,因此有可能放在 ENTRYPOINT 下的同样的命令,由于需要 tty 而运行时忘记了给(比如忘记了docker-compose.ymltty:true)导致运行失败。

这种用法可以很灵活,比如我们做个 git 镜像,可以把 git 命令指定为 ENTRYPOINT,这样我们在 docker run 的时候,直接跟子命令即可。比如 docker run git log 就是显示日志。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,324评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,303评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,192评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,555评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,569评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,566评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,927评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,583评论 0 257
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,827评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,590评论 2 320
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,669评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,365评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,941评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,928评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,159评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,880评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,399评论 2 342

推荐阅读更多精彩内容

  • 我是一个懒惫的人,闲坐看云起云落就好。 生活总是有起起落落,平静了30年的人生戛然而止,生活变得翻天覆地起来。毕业...
    子军LH阅读 216评论 0 0
  • 无心赏雪 雪花空中舞,平添几分愁。 关山路途远,把九寒天路。 夫差回家中,几时把家还。 释放 电话铃声响,夫报近家...
    学会知足真正快乐阅读 184评论 0 3
  • 精神分析进行得越迅速,越有效,患者就会越“配合”,这几乎是每位精神分析师都有的共识。我在这里所说的“配合”,意思并...
    暖阳_1332阅读 1,280评论 0 1