Docker Cookbook

Table of content:

About

这是一篇会持续更新并且会很长的文章,致力解决的问题:

  • Docker 的架构和原理
  • 核心概念
  • 我在 Docker 使用过程中遇到过的一些问题,使用场景,以及背后的原理

不包括:

  • Docker 的安装使用和常见命令介绍

关于 Docker

核心架构

  • Docker daemon, 负责容器的创建,运行和监控,还负责镜像的构建和存储。
  • Docker 客户端,通过 HTTP 和 Docker Daemon 通信
  • 寄存服务,存储和发布镜像。默认的寄存服务是 Docker Hub

和虚拟机的比较

底层技术

cgroup

管理容器使用的资源,例如 CPU 和内存的使用。

namescpace

负责容器之间的隔离,确保系统的其他部分与容器的文件系统,主机名,用户,网络和进程是分开的。

union file system

联合文件系统,有时也称为联合挂载。联合文件系统允许多个文件系统叠加,并表现为一个单一的文件系统。文件系统的文件可以来自多个文件系统。但如果有两个文件的路径完全相同,最后挂载的文件则会覆盖较早前挂载的文件。

Docker 是支持多种不同的联合文件系统实现,比如 AUFS, Overlay,devicemapper 等, 详见 https://docs.docker.com/storage/storagedriver/select-storage-driver/

可以通过 docker info 里面的 Storage Driver 来查看。

网络模式

网桥 bridge (todo)

Ref:

主机模式

如果容器以 –net=host 参数启动, 那么它便会共享主机的网络命名空间,还会把自己暴露在公网之上。 这意味着容器与主机必须共用同一个 IP 地址, 不过这就减少了网桥模式中涉及的底层开销,因此速度与常规的主机网络一样快。由于 IP 地址是共享的,需要互相通信的容器必须预先协定使用哪些端口通信,在进行配置的时候必须考虑这一点,而且可能还需要修改程序。

容器模式

未联网模式

核心概念

image 镜像

Docker 的镜像由多个 layer(层) 组成,每一层都是只读的文件系统。Dockerfile 里面每个指令都会创建新的层,这个层会位于前一层之上。

不必要的层会让镜像变得臃肿(并且 AUFS 最多只能有 127 层), 所以很多 Dockerfile 会把多个命令放到一个 RUN 指令中,以减少层的数量。

container 容器

Docker 集群管理方案

swarm

swarm 是原生的 Docker 集群管理工具, 将一组 Docker 主机作为一个虚拟的 Docker 主机来管理。

k8s

mesos

Docker 周边

这部分后续会持续更新

  • docker-compose
  • docker-ssh

原理和思考

镜像是如何生成的

docker build 需要 Dockerfile 以及构建的上下文(上下文可以为空),上下文可以被 COPY 或 ADD 引用。

docker build . 为例, 上下文是 . 即当前目录,在构建镜像的过程中会被打成 tar 文件,然后传给守护进程。如果不想将文件 add 进上下文可以使用 .dockerignore。

Dockerfile 每个指令都会产生一个新的镜像层, 一个新的镜像层的建立,是用上一层的镜像启动容器,然后执行 Dockerfile 的指令后,保存为一个新的镜像。当指令执行成功后,中间使用的容器会被删掉。(可以通过 docker history 来查看组成镜像的所有层)

举一个例子, Dockerfile 如下:

1
2
3
4
FROM busybox:latest

RUN echo "this works"
RUN /bin/bash -c echo 'test'

构建过程如下,并附上了注释

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
$ docker build -t docker-image-test .
Sending build context to Docker daemon 2.048kB # 处理上下文
Step 1/3 : FROM busybox:latest
latest: Pulling from library/busybox
90e01955edcd: Pull complete
Digest: sha256:2a03a6059f21e150ae84b0973863609494aad70f0a80eaeb64bddd8d92465812
Status: Downloaded newer image for busybox:latest # 因为本地没有这个镜像,去拉取了新的
---> 59788edf1f3e # 这一层的镜像 ID
Step 2/3 : RUN echo "this works"
---> Running in de6e54d3e360 # 新起的 container id
this works
Removing intermediate container de6e54d3e360 # 删除中间创建的容器
---> dc529de8a3b5 # 新一层的镜像 ID
Step 3/3 : RUN /bin/bash -c echo 'test'
---> Running in b64f125568b3 # 新起的 container
/bin/sh: /bin/bash: not found
The command '/bin/sh -c /bin/bash -c echo 'test'' returned a non-zero code: 127

context 上下文是一组本地文件和目录,可以被 Dockerfile 的 ADD 或 COPY 指令所引用。

在调试出错的一层的时候,可以将上一层跑起来之后测试。

Docker 卷,在容器访问宿主机文件, 以及文件权限问题

很多场景下都会有想要在容器中访问宿主机文件的需求,比如本地开发方案,本地写代码,容器中调试;访问一个大型的中央式数据库。而这一切是可以通过卷来做到的。

比如 docker run -v /var/db/tables:/var/data1 -it debian bash 表示将宿主机的 /var/db/tables 挂载到容器的 /var/data1 。如果外部和容器的目录都不存在则会创建。

但是挂载可能会遇到这样一些问题:

  • 挂载的地址如果已经有文件的情况

结合上面的例子,如果容器中在 /var/data1 下已经有内容了,那之前的内容会消失。

  • 不同系统之间的大小写问题
    比如宿主机是 Mac OS,而容器是 Linux。Linux 系统是大小写敏感的,而 Windows 系统和 Mac 系统正好相反,大小写不敏感。所以很可能会出现的情况是物理机上能跑起来的服务,在容器里面会报找不到包之类的。

  • Docker mount 之后的文件权限问题

Linux 内核使用 UID 和 GID 来识别用户,并决定他们的访问权限。容器中的 UID 和 主机上的 UID 是相同的,但是在容器内创建的用户和用户组不会影响宿主机,这个会让访问权限变得很混乱。

Docker 的缓存策略是什么样子

Docker 为了加快构建的速度,会将每个镜像层缓存下来。

但是指令必须要同时满足以下的条件:

  • 上一个指令要能在缓存中找到
  • 缓存中存在一个镜像层,它的指令和你的指令一模一样,父层也完全相同(即使出现一些无关紧要的空格也会让缓存失效)

还会有的问题是那些每次调用结果都不一样的 RUN 命令,也仍然会被缓存,比如要下载文件, 执行 apt-get update 或者源码,都需要注意。

如果在构建镜像时不想使用缓存,可以加上 –no-cache 的选项。详见官方文档

Dockerfile 的最佳实践

Docker 文档 里面提到了几点,这里做一点总结和延伸:

  1. Create ephemeral containers
    The Twelve-factor App 里面提到的一样,让创建,销毁,重新构建的配置和启动工作量最小化。

  2. Understand build context, Exclude with .dockerignore
    在上面的「镜像是如何生成」的部分提到了 context,在构建镜像的日志中,类似 Sending build context to Docker daemon xxxkB 这里能看到 context 的大小, 结合 .dockerfile 忽略一些无用的目录来合理控制 context 大小,能控制镜像大小和构建的速度。

  3. Minimize the number of layers, Sort multi-line arguments
    减不必要的层会让镜像变得臃肿(并且 AUFS 最多只能有 127 层), 所以一个方式是控制层数。
    同时将依赖分成多行,按照字母排序, \ 前面增加一个空格能帮助 review,同时减少重复。

    1
    2
    3
    4
    5
    6
    RUN apt-get update && apt-get install -y \
    bzr \
    cvs \
    git \
    mercurial \
    subversion
  4. Use multi-stage builds
    multi-stage builds 是在 Docker 17.05 支持的,详见 文档, 这个很有意思,举一个例子:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    FROM golang:1.9.2-alpine3.6 AS build

    # Install tools required for project
    # Run `docker build --no-cache .` to update dependencies
    RUN apk add --no-cache git
    RUN go get github.com/golang/dep/cmd/dep

    # List project dependencies with Gopkg.toml and Gopkg.lock
    # These layers are only re-built when Gopkg files are updated
    COPY Gopkg.lock Gopkg.toml /go/src/project/
    WORKDIR /go/src/project/
    # Install library dependencies
    RUN dep ensure -vendor-only

    # Copy the entire project and build it
    # This layer is rebuilt when a file changes in the project directory
    COPY . /go/src/project/
    RUN go build -o /bin/project

    # This results in a single layer image
    FROM scratch
    COPY --from=build /bin/project /bin/project
    ENTRYPOINT ["/bin/project"]
    CMD ["--help"]

    看起来就是让编译出来的文件单独在一个镜像中,能效果非常明显地减少镜像体积。

  5. 如果有安装依赖包,要指定版本
    这条是我遇到过的坑,重新构建 Dockerfile 的时候,没有指定版本就会默认装最新稳定版,导致一些版本升级带来的不可预知的问题。

遇到过的问题和使用场景

Docker 异常重启问题(todo)

有时发现某些异常会导致机器重启,并且重启之后,Docker Daemon 无法起起来的情况。还还没开始排查原因。

docker kill hang 住(未查明原因)

经常会遇到一些 JAVA 的容器在回收的时候 hang 住

Docker deamon 的日志是:

1
time="2018-11-14T22:56:18.614293666+08:00" level=info msg="Container 7dcf4bb3f841 failed to exit within 10 seconds of kill - trying direct SIGKILL"

而尝试 ssh 到容器中,日志如下:

1
time="2018-11-14T23:00:55.454937469+08:00" level=error msg="Error running exec in container: rpc error: code = 13 desc = invalid header field value \"oci runtime error: exec failed: container_linux.go:247: starting container process caused \\\"process_linux.go:83: executing setns process caused \\\\\\\"exit status 16\\\\\\\"\\\"\\n\""
1
docker inspect --format {{.State.Pid}}  {container_id}  # 查看 pid

尝试直接 kill -9 掉容器中对应的 PID 也是没有成功

issue 里面提到重启 Docker 可以解决, 重启之后确实对应的容器就不见了, 问题绕过。但是问题的根本原因还没能查到。

docker build 时, FROM 里面的基础镜像是否会被自动更新

在 docker build 时, docker daemon 会查看 FROM 里面指定的基础镜像,如果本地没有这个镜像,docker 会试图下载它。

但是如果本地已经有这个镜像了,docker 不会去检查是否有更新的版本可用。所以如果你想要升级 FROM 里面指定的基础镜像,还需要显式 docker pull 一下。

磁盘占用问题以及清理方式

在一段时间之后,docker 会占据非常大的磁盘使用,一些清理策略包括:

  • 遗留的镜像 dangling images

没有用处的镜像,也没有被其他镜像引用,docker 没有垃圾回收机制,没有用的镜像会一直存在, 可以通过 docker images -f dangling=true 来查看到, 这里的 -f 是指的 filter,

docker image -q 是显示对应的 image id。完整的就是: docker images -q -f dangling=true | xargs --no-run-if-empty docker rmi

  • 数据卷
    在主机的一个或者多个容器之间共享文件的一种使用方式是创建数据容器。比如,通过
    docker run --name dbdata postgres echo "data only container" 将 postgres 镜像创建一个容器,并初始化镜像中定义的数据卷,最后执行 echo 并退出。然后通过 --volumns-from 使其他容器能使用这个数据卷。
    docker run -d --volumns-from dbdata --name db1 postgres

数据卷只会在以下集中场景被删除:

  • docker rm -v 删除
  • docker run 时带有 –rm 选项

Ref:

将 docker image 从 server 之间转移(比如无法访问 docker hub 的情况)

可以通过 docker save 和 docker load 来解决, docker save -o <image path> <image name>, -o 是保存为的文件

1
2
docker save -o image_file_name image_name:latest
docker load -i image_file_name

这样的方式也可以用来分发和备用镜像。

限制容器的资源使用(todo)

https://docs.docker.com/config/containers/resource_constraints/

不使用 sudo 执行 docker

Docker 运行时需要特殊权限,所以在执行时前面都要加上 sudo,官方文档里面提供了一种方法, 也就是把用户放到 Docer 组里面

1
$ sudo usermod -aG docker $USER

但是这样会带来一些安全隐患: https://docs.docker.com/engine/security/security/#docker-daemon-attack-surface

Docker cp 是否支持 wildcard ?

Docker cp 的基本使用是:

1
docker cp [OPTIONS] CONTAINER:SRC_PATH DEST_PATH

不支持使用 wildcard,比如使用会报这样的错:

1
2
$ docker cp 80af2e29a69a:/home/*.png .
Error response from daemon: lstat /data1/data/lib2/docker/overlay2/b11052041f09ce57b595f39fd7a129506691645feb3af1577276a9ba2d9f337e/merged/home/*.png: no such file or directory

虽然在 issue 里面很多人提议, 但是官方没有太多回应,不过有一些 workaround 的方式:

  • 使用 volumn, 也就是直接挂载,不用 cp 了。
  • docker cp 复制整个目录,比如 docker cp container_id:screenshot/*.png . 换为 copy screenshot
  • 不直接使用 docker cp, 或者挨个 docker cp

从容器复制文件到物理机的场景:
先 docker exec 将对应文件放到对应目录,最后 docker cp 整个目录, link

1
2
$ docker exec <id> bash -c "mkdir /extract; mv /path/to/fileset* /extract"
$ docker cp <id>:/extract .

从宿主机复制文件到容器的场景:

1
for f in data/*txt; do docker cp $f sandbox_web_1:/usr/src/app/data/; done

Docker cp 之后的文件权限是什么样的 ?(todo)

各种网络模式之间的差异和性能对比 (todo)

什么场景下需要禁用 iptables ?(todo)

run task in background and foreground

run command in foreground

1
2
3
4
docker exec -i CONTAINER_NAME bash <<'EOF'
cat /dev/null > /usr/local/tomcat/logs/app.log
exit
EOF

run command in the background

1
$ docker exec -d ubuntu_bash touch /tmp/execWorks

ref:

Docker exec stdout, stderr

When you tell Docker to set up a TTY, both stdout and stderr are directed to that pseudo-TTY

1
2
3
4
5
$docker exec -t 09b24cd7fa69 ls nosuchfile 1>docker.out 2>docker.err 
$cat docker.out
ls: cannot access 'nosuchfile': No such file or directory
$cat docker.err
$

是否可以在 Dockerfile 中起一些持久的进程

由于镜像的生成策略是每个指令产生一个新的镜像层,中间每层镜像启动的容器在命令执行成功之后都会被删掉,所以命令中任何运行的程序都会被停掉。

也就是你在 RUN 指令中执行的一些持久进程,比如数据库或者 SSH 服务,到了处理下一个指令或者启动容器的时候,他们已经不再运行。

如果需要在容器启动的同时运行一个服务或者进程,可以通过 ENTRYPOINT 或者 CMD。

获得镜像或者容器的详细信息

通过 docker image inspect, example:

1
docker image inspect {image_id}

在一些低版本的 Docker 上是不支持的,这里可以用 docker inspect

1
docker inspect --size {image_id}

最后再使用 format 取出想要的字段 https://docs.docker.com/config/formatting/ , 一个例子

1
2
# 获得镜像大小
docker image inspect --format "{{.Size}}" {image_id}

SizeRootFs, SizeRw 的区别是什么

在 docker inspect {container_id} 之后能拿到的一系列的值,其中包括

docker exec 里面包含中文报错

一种解决办法是:

1
2
3
4
5
$ docker exec -it fe9bb26bd7aa env LANG=C.UTF-8  /bin/bash
root@xxx/# python
>>> a = "在"
>>> a
'\xe5\x9c\xa8'

容器里面涉及到一些敏感信息问题应该怎么处理

比如各种 token, key, 私钥应该怎么更好的处理

  • 放在镜像中(这个非常不安全,还是别想了..)
  • 通过环境变量,但是环境变量对于子进程,docker inspect 和连接的容器都可见,他们都不具备能看见密钥的充分理由
  • 使用数据卷挂载, 这个稍微优雅一点,但唯一的问题是密钥要以文件的形式存在
  • 使用 kv 存储,这个是当前最好的方案

比如:

一些其他的思路:

  • 在 docker build 时从远程获取,然后 wget -O
  • 使用 docker secret, 但是这个的前提是用 docker swarm

物理机和远程容器之间的文件共享方案

  1. 一个简单粗暴的思路是先 docker cp 到宿主机,然后从这台机器 scp link

  2. 使用 netcat

    1
    2
    3
    4
    5
    6
    # 假设要将容器里面的 a.txt 复制到 A 物理机
    # 在 A 物理机上执行
    nc -l -p <port> > a.txt

    # 然后在容器中
    nc -w 3 A_host_name <port> < a.txt
  3. 容器里面支持 ssh (todo)

Docker NAT iptables 实现

默认情况下,容器可以主动访问到外部网络的连接,但是外部网络无法访问到容器,容器访问外部实现

容器所有到外部网络的连接,源地址都会被 NAT 成本地系统的 IP 地址(即docker0地址)。这是使用 iptables 的源地址伪装操作实现的

References

Docker on GPU

Reference & Recommendation

更新日志

由于这个是一个会长期更新的文章,准备试试加上更新日志。

  • 2018-11-24 一些大纲, Dockerfile 最佳实践
  • 2018-11-26 docker cp 有 wildcard 场景的一些补充
  • 2018-12-01 增加了在 Dockerfile 锁定版本作为一个最佳实践
  • 2018-12-13 增加了容器敏感文件的一些解决方案

关于头图

拍摄自纽约

How short is life really
Twelve Factor App