如何优雅地使用 Docker

很久很久以前,就曾经尝试过使用 Docker 。但是由于没有足够的动力学习,导致多次半途而废(就像学 vim 一样)。
终于,在想要使用 gitbook 转换开源书籍时,被放弃维护的 gitbook-cli 给教育了。因此重燃起学习 Docker 的动力。


原文发布于 个人博客
同步备份至知否掘金知乎腾讯云、微信公众号(OY_OhYee)、哔哩哔哩

Docker 是什么

容器和虚拟机

容器和虚拟机不同,或者说除了看上去像,他们完全是两个没有关系的东西。

虚拟机是在计算机中模拟另一个计算机的技术,重点在于模拟和另一个计算机。因此虚拟机需要先将物理机的硬件进行封装,并部署一个独立的操作系统。独立的操作系统调用模拟的硬件,实现各种功能。对于运行在虚拟机内的系统来说,它似乎就在一个真正的物理机上运行,不会受到过多的限制。

沙盒,其用途是隔离运行环境,而非模拟计算机。因此它不需要虚拟化硬件,也不需要安装独立的操作系统。早期的沙盒(如 Sandboxie)往往用于运行一些不被信任的软件,在计算机安全等方面大放异彩。运行在沙盒中的软件,即使是攻击性很强的病毒,仍然很难危害到物理机(但就如同虚拟机一样,沙盒也存在被穿透的危险)。可以将其理解成仍然执行在物理机的宿主系统之中,但是内部所有程序的系统调用都被沙盒截取(就像 proxychains 可以修改任意子进程的网络连接一样)替换为自己的虚拟的系统调用。当内部的程序需要写出、读入一个文件(广义上所有东西都是文件)时,实际上操作的是虚拟的文件并不会影响宿主系统。
在较新版本的 Windows 中,有一个叫做 Sandbox 的应用,点击后会弹出一个窗口,窗口内部是一个 Windows 系统,这就是一个 Windows 的沙盒。

Windows 沙盒应用Windows 沙盒应用

而容器则类似于沙盒的增强版,其允许通过配置有目的性地允许某些穿透操作(如将容器端口映射到宿主系统、访问宿主系统的某个目录)。同时,也允许在容器中部署一个与宿主系统相似但不同的操作系统(这里主要指可以诸如在 Arch Linux 使用 Ubuntu 镜像,但是如果是 Windows,其无法直接使用 Linux 镜像,需要先使用 Hyper-V 虚拟一个 Linux)。

所以,相对于虚拟机,容器更为轻量级(只是替换子进程的系统调用,而非模拟硬件且安装完整的操作系统);相对于沙盒,容器可操作性更多(可以有选择性地允许与宿主系统进行交互)。因此也可以将沙盒理解为一种特殊的容器。
这也就是 Docker 在开发中受到广泛推崇的原因,它可以隔离出一个自定义环境、部署快、允许有选择地穿透。刚好满足开发和部署过程中容易遇到的环境不一致问题。

Docker 的分层

Docker 在上述容器的基础上,还有额外的一些优点。在 Docker 中,操作是分层的。试想,你是一个前端工程师,你有两个项目需要开发——React 项目、Vue 项目。假设他们都运行于 Ubuntu,并且使用相同版本的 NodeJS。如果使用下述的图中的链式关系,用户需要维护两份 Ubuntu 环境、两份 NodeJS 环境。而在 Docker 中,对于这些共有的内容,将会将其划分为公共的层。也即,他们都基于 Ubuntu 下的 NodeJS 镜像生成,而非从头开始生成。将会共用前面共同需要的部分。

%3 cluster_1 树形关系 cluster_0 链式关系 Ubuntu Ubuntu NodeJS NodeJS Ubuntu->NodeJS React React NodeJS->React Vue Vue NodeJS->Vue Ubuntu1 Ubuntu NodeJS1 NodeJS Ubuntu1->NodeJS1 Ubuntu2 Ubuntu NodeJS2 NodeJS Ubuntu2->NodeJS2 React1 React NodeJS1->React1 Vue1 Vue NodeJS2->Vue1

环境依赖关系

目前很多镜像实际上都会使用 Ubuntu 作为操作系统,并且使用官方的一些环境作为开发环境。因此用户可能会使用很多的 Docker 镜像来部署自己的服务, 但实际上由于他们在底层共用了相同镜像,因此空间占用近似于部署在物理机(只浪费了部分 Docker 本身所占用的空间和资源)

该设计原理上很巧妙,但实际使用中,特别是作为镜像的发布者而非使用者,还是需要花费功夫考虑设计的。

安装

对于正常环境(如 Windows、Linux)可以直接在官网安装 Docker 即可。
而如果想要在 WSL2 中使用 Docker,则需要参考 Docker Desktop WSL 2 backendUsing Docker in WSL2

Docker 分为两部分:服务端、客户端。
所有的容器都会保存、运行在服务端,客户端仅仅用于控制。以 WSL2 为例,实际上 Docker 运行在 Hyper-V 的虚拟机中,客户端在 WSL 中操作 Windows 下的 Docker 控制虚拟机中的 Docker。在大部分情况下可能不需要考虑这些关系,但是在需要通过 IP 端口互联时,需要确定到底要连到哪一个局域网 IP。

如果要通过 Docker 连回服务端所在设备,可以使用host.docker.internal

镜像

镜像是对于一些环境的封装(打包好的环境)。可以将其理解成安装包、压缩包,其本身是不可改动的。一般而言,镜像会基于官方提供的一些系统为基础(如常用的是 Ubuntu,也可以基于没有操作系统的 scratch),安装相应依赖程序为某些特定程序提供服务。

镜像信息查看

使用docker images可以获取所有本地存在的镜像,包含 5 列信息:

  • 镜像名称(包含用户名、镜像名)
  • 标签(版本)
  • 镜像 ID(哈希值)
  • 创建时间(镜像本身创建时间,而非下载或本地生成时间)
  • 镜像大小(本镜像所有分层总大小)

由于前面提到的分层概念,实际上这里的镜像大小之和应该大于或等于实际占用大小(多个镜像可能包含相同的分层)。

镜像的拉取

如果需要获取某个镜像,可以使用docker pull <用户名>/<镜像名>:<版本号>。这里用户名和镜像名针对于官方 Docker 仓库,如果省略镜像名,将会从官方维护的镜像中检索;如果省略版本号,将会使用最新版本latest
如果需要从私有仓库拉去镜像,则可以直接 pull 对应的 URL

镜像导出、导入

无论是使用 Dockerfile 生成,还是直接从仓库获取分层,都需要花费时间下载、消耗性能生成。而本地多设备要部署相同的镜像,也可以直接将整个镜像导出成单文件,再在另一台设备上导入。这样可以更方便地在本地之间传输 Docker 镜像。
导出后的镜像文件类似于 ghost 备份,相当于直接把系统保存成为一个单文件环境。

export/import

要导出一个镜像(这里实际上是将容器导出成镜像),可以使用docker export [容器名称] > xxx.tar

要将镜像导入 Docker,使用docker import [文件名] [镜像名]。如果文件名为-,也可以使用重定向符从 stdin 读入文件。
使用 export/import 将会丢失镜像的历史,仅仅保留最终状态的快照(也因此会更小)。一般来说,可以用于发布基础镜像(用户不需要使用历史记录等信息)

save/load

另一种方案则是基于 save/load 命令。导出镜像与export类似,使用docker save [镜像名称] > xxx.tar。如果想要导出多个镜像,也可以使用 docker save xxx.tar xxx1 xxx2
要重新载入,使用docker load < xxx.tar
相对于前面的 export/import,save/load 更类似于“存档”的概念,其包含镜像的所有信息(包括历史),因此也无法修改镜像名称,同时其支持将多个镜像保存到一个文件中。因此其更适用于同步设备之间的状态。

Dockerfile

Dockerfile 是一种特殊的文件,其可以被docker build识别,用于生成镜像。在很多情况下,配置一个环境所需要的可能只是简单的配置,如果每一个环境都导出一份镜像将会耗费大量空间。对于这种情况,只提供一个短短几行的 Dockerfile,由用户设备自动进行配置更为方便。

每一个镜像都是由多个分层构成,每个分层相对于上一分层也仅仅是通过某个命令进行文件的增删改。因此只要将这些命令保存下来,即可描述一个镜像。而有幸的是,Linux 的各种命令(特别是 busybox),完全可以实现绝大部分所需要的行为。

以一个启动一个 Nginx 服务,并显示特定页面的镜像为例,只需要如下部分:

FROM nginx
RUN echo '<h1>Hello Docker</h1>' > /usr/share/nginx/html/index.html

这里,使用FROM指定了基础镜像——官方发布的 Nginx 镜像,并在其基础上执行echo '<h1>Hello Docker</h1>' > /usr/share/nginx/html/index.html。如果对 Nginx 有所了解,应该可以很容易看出这就是修改了 Nginx 的基础页面。

Dockerfile 使用各种操作实现了各种操作

命令 解释 备注
FROM 使用的基础镜像 除去常见的系统镜像外,如果只需要运行某个程序,也可以使用不包含系统的 scratch 直接执行二进制程序,以减小镜像大小
一个Dockerfile 可以存在多个FROM,每个FROM作为一个构建阶段形成一个单独的镜像(可以使用FROM xxx as xxx来设定阶段名称,使用docker build --target只构建该阶段)
RUN 要执行的命令 其包含两种格式RUN <shell 命令>RUN ["可执行文件路径","参数1","参数2",...]
由于每一行命令都会被认为是单独的一层,因此通常需要尽可能使用&来连接多个命令
COPY 复制文件 包含两种格式COPY <源路径> ... <目标路径>COPY ["<源路径1>", ..."<目标路径>"]
可以同时复制多个文件,且支持通配符。复制后会保留权限等元数据
ADD 增加文件 某种特殊形式的复制,其源路径可以是互联网上的文件地址。由于其会在网络下载,因此可以实时更新,但也会使得构建缓存失效
CMD 容器启动默认命令 RUN相同的两种形式,用于指定 Docker 启动后的默认命令(可能会被docker run覆盖掉)
由于 Docker 容易的存活依赖于前台程序,因此诸如启动 Nginx 需要直接执行 nginx 二进制文件,而不应该使用systemctl
ENTRYPOINT 入口点 RUN相同的两种格式。与CMD功能相似,在配置ENTRYPOINT后,默认的执行程序将会形如<ENTRYPOINT> "<CMD>"
如果镜像功能为调用某个程序,并传递某个参数,可以使用该方案来在docker run时配置参数(可参考curl镜像)
用户可以用--entrypoint覆盖
ENV 设置环境变量 格式为ENV <key> <value>ENV <key1>=<value1> <key2>=<value2>
ARGS 构建参数 ENV类似,但ARGS设置的环境变量只会在构建时期存在,用户可以使用docker build --build-arg <参数值>=<值>覆盖
VOLUME 匿名卷定义 格式为VOLUME ["路径1","路径2"...]VOLUME <路径>。预先将可能被修改的目录挂载为匿名卷,如果用户在未挂载时删除,仍然可以保留数据
EXPOSE 声明端口 EXPOSE <端口1> [<端口2>...],声明将会映射出的端口。
仅仅只是声明,不会进行任何映射操作,用户需要使用-p <宿主端口>:<容器端口>指定映射,或使用-P自动随机映射
WORKDIR 指定工作目录 Dockerfile 的每一行都处于独立的运行环境,因此在cd只会作用于单个RUN。如果需要修改后续所有命令的执行目录,使用WORKDIR <路径>
USER 指定运行用户 切换到某个已存在的用户执行后续命令,需要使用RUN预先建立好用户
HEALTCHECK 健康检查 检查容器健康状态,有两种模式HEALTHCHECK [选项] CMD <命令>HEALTCHECK NONE。分别为设置检查的命令与不使用检查
参数包括间隔(--interval)、时长(--timeout)、次数(--retries),根据结束码判断是否存活
ONBUILD 只在构建下级镜像时执行 该部分不会在构建当前镜像时执行,只会在构建以该镜像为基础镜像时会执行

上述命令中,所有形如["aaa","bbb","ccc"]的命令都应该使用双引号",因为这些命令将会以 JSON 的形式被读入 Docker,而 JSON 规定的字符串使用双引号。

上面有提到应该尽可能使用&来连接命令。以apt install为例,尽管大部分情况下可以直接下载二进制文件,但是某些程序可能需要本地编译,从而产生很多中间缓存的文件。如果不及时清理,则会将这些缓存也存入分层数据中(而这显然是不必的)。因此,大部分情况下,RUN应该是类似下面的形式

RUN buildDeps='gcc libc6-dev make'
    && apt-get update \
    && apt-get install -y $buildDeps \
    && wget -I xxx.tar \
    && tar -xzf xxx.tar -C xxx \
    && make -C xxx \
    && make -C xxx install \
    && rm -rf xxx \
    && rm xxx.tar \
    && apt-get purge -y --autoremove $buildDeps

在编写 Dockerfile 的时候,必须时刻明确自己的目的——不是在写 Shell,而是在执行某个明确的操作,应该避免在分层中引入无关的内容。

对于一个已经编写完成的 Dockerfile 文件,使用docker build -f ./dockerfile -t xxx:v1 .来将其生成为一个镜像。
这里,-f参数可以忽略,忽略后默认使用当前目录的Dockerfile文件;-t参数也可以忽略,表示不指定名称和标签;最后的.表示构建上下文目录,也即 Dockerfile 中COPYADD命令的相对目录。
Docker 在使用 Dockefile 构建镜像时,将会把上下文目录的所有东西载入到镜像中。因此很多情况下,会直接将 Dockerfile 放在其所需要的上下文目录中。同时,这也意味着上下文目录(或者说 Dockerfile 目录)不应该有其他文件,否则将会浪费额外的空间。如果不得不存在其他文件,可以使用.dockerignore以类似.gitignore的形式避免文件被导入至 Docker 中

为了方便使用,用户可以直接针对一个 URL 连接进行构建。这个 URL 可以是一个 Git 仓库,也可以是一个 tar 压缩包。Docker 会自动拉取、下载对应内容,并将其作为构建上下文进行构建。如果传入-,则会从 stdin 读入要编译的 Dockerfile 内容、

镜像历史

使用docker history <镜像名>可以查看镜像的提交历史(这可能会暴露镜像历史中的命令,造成安全隐患)

镜像删除

对于不再使用的镜像,可以使用docker rmi [镜像名称/ID] 来删除镜像。删除镜像将会释放未被其他镜像使用的分层,同时会导致所有依赖该镜像的容器无法直接运行。

容器

执行的镜像称为容器,可以理解为类与实例之间的区别。在任何情况下,都应该确保容器是无状态的——容器可以随意的关闭、删除、重启,而不会影响业务功能。
对于容器中需要保存的状态,使用存储卷来存储

要基于某个镜像运行容器,使用docker run [选项] 镜像名 [命令] [参数...]。最常见的形式为docker run -it -p 80:80 -v ./data:/data xxxx /bin/bash
如果要启动的镜像不存在,将会自动调用pull命令下载镜像。
使用docker help run可以获取详细的解释,这里只介绍常用的一些参数。

参数 解释 备注
-d 后台运行 容器在后台运行,所有输出将会输出至日志。可以使用docker container logs <容器名>查看
-e 环境变量 设置环境变量
--gpus 使用 GPU
-i 保持 stdin 激活 程序将使用宿主的 stdin
--name 设置容器名称 默认会随机一个名字
-p 映射的端口号 格式为-p <宿主机端口>:<容器端口>,可以多次传该参数映射多个端口
-P 随机映射端口号 将容器内开放的端口全部映射到宿主机的随机端口
--read-only 设置容器只读
--rm 容器结束后自动删除
-t 连接到容器后使用的终端 需要绝对路径
-u 使用指定用户
-v 挂载的存储卷 格式为-v <宿主机路径>:<容器绝对路径>,可以多次传该参数挂载多个存储卷(宿主机路径使用相对路径时,会基于存储卷目录)
-w 默认工作目录

容器状态

容器存在有运行、停止两种状态。对于已停止的容器,可以使用docker container start <容器名>再次启动它。而对于正在运行中的容器,使用docker container stop <容器名>终止。
对于用户使用-it连接的容器,当用户使用exit或是 CTRL+D 退出后,会立即终止。容器中没有正在运行的前台程序时,也会立即终止。

可以使用docker psdocker container list查看正在运行的容器状态,添加-a则可以查看所有(包括已停止)的容器状态

进入容器

对于后台运行的容器,可以使用docker attachdocker exec来进入容器。这两种的区别在于使用attach进入后退出,将会导致容器停止;而docker exec不会导致容器停止。
前者类似于直接挂入正在执行的前台程序,而后者更类似于 SSH 新建一个终端(可以使用-it指定使用的终端)

容器导出

容器与镜像一样,也可以使用docker export导出,不过其原理上是先将容器存储为镜像,再将镜像导出。因此使用import导入后,得到的是镜像, 而非容器。

容器转换为镜像

对于无状态的容器,可以将其提交为镜像。使用 docker commit [选项] <容器名> [镜像名[:标签]]可以将一个容器转换为镜像。与 Git 的 commit 类似,这实际上是一个提交,用户可以使用-m填写提交信息,使用-a填写用户名,使用-p在提交时暂停容器。

这是一种较为简单的镜像建立方案,但是正如同前文 Dockerfile 部分强调的,这种操作会建立并不会实际需要的分层,因此并不是较为优雅的实现方案。
在转换为镜像前,可以使用docker diff <容器名>查看容器的改动,来确定这是不是一个优雅的新镜像。

删除容器

使用docker container rm可以删除处于终止状态的容器。而对于正在使用中的容器,则可以使用docker container rm -rf强行删除(会在删除前先停止容器)

如果想要删除所有未运行的容器,可以使用docker container prune

存储卷(目录挂载)

在 Docker 中,存储卷(volume)或者说宿主机文件/目录挂载实际上是一个东西——将宿主机的特定文件夹/文件挂载到容器中,以方便容器内部读写。唯一的不同在于,目录挂载对应的宿主机目录往往是用户指定的,而存储卷存放于 Docker 指定的特殊权限目录(可能在/var/lib/docker/volumes

由于前面提到容器应该是无状态的,因此所有持久化的数据应该被存放在存储卷中,也即宿主机中。这很好理解,容器可能会被删除,甚至 Docker 都可能会被删除,但是起码宿主机本身的文件夹还是较为安全的。

存储卷中文件的状态将和容器内部完全一致。比如如果在容器内部使用特殊用户建立一个文件,那么宿主机中看到的也将是对应的用户的 UID(宿主机可能不存在该用户)。

空间管理

Docker 所占用的空间包含四部分:

  • 镜像
  • 容器
  • 本地卷
  • 缓存

使用下述命令可以检查 Docker 所占用的空间

docker system df -v

如果希望对空间进行清理,可以

  • 使用docker container prune可以清除所有终止的容器
  • 使用docker system prune可以在上述基础上,清除未被使用的网络、悬空的镜像和缓存
  • 使用docker system prune -a可以在上述基础上,清除所有未被使用的镜像和所有缓存
  • 对于未被使用的存储卷,需要使用docker volume prune来清除

其他操作

服务端配置

Docker 的服务端的配置存放在/etc/docker/daemon.json中(需要严格遵守 JSON 格式撰写,如列表的最后一项不带逗号)。

但是,大概率在很多情况下,直接改动daemon.json会导致 Docker 无法启动。造成这个问题的原因是:官方认为,如果systemctl启动项和daemon.json有冲突,说明用户配置不当,可能会造成意想不到的错误,因此在冲突时会直接报错。
理论上这似乎没什么毛病,但是一般而言,systemctl默认会携带一些参数(如监听的地址),而这些参数可能又是我们会经常改动的,这么就会导致无法启动的概率会非常大。

要解决该问题也很简单,只需要修改systemctl启动参数即可。按照上面的链接,修改/etc/systemd/system/docker.service.d/override.conf文件为

[Service]
ExecStart=
ExecStart=/usr/bin/dockerd

接着使用下面的代码重载配置并重启 docker 即可

sudo systemctl daemon-reload
sudo service docker start

在这之后,由于systemctl未传递任何参数,因此无论daemon.json有哪些配置,都不会产生冲突导致出错。

调用远程服务端

上文提到过,Docker 的服务端和客户端实际上是分离的,因此这里主要讲一下如何在本地调用远程 Docker 服务。需要注意的是,尽管结果上与使用 SSH 到服务端后使用服务端上的 Docker 客户端结果一样,但是仍然在某些特殊情况下存在意义(见下文)

使用上述的服务端配置部分,允许从daemon.json配置后。写入

{
    "hosts": [
        "unix:///var/run/docker.sock",
        "tcp://0.0.0.0:2375"
    ]
}

这里配置两种连接到 Docker 服务端的方式:

  • 使用本地 Unix 域连接
  • 使用开放到公网2375端口的 TCP 连接(如果是127.0.0.1,则只允许本机访问)

需要特别注意的是,如果开放了公网连接,那么需要自行进行安全性防护。因为任何人都可以尝试连接到该服务,甚至可以借助端口扫描工具扫到你的服务器存在开放的 docker 服务。这将造成 安全隐患(存在自动扫描工具挂马)。因此建议只在测试环境或局域网中开放远程连接。

在这之后,即可在另一台电脑使用 IP 和端口进行远程连接了。

Docker 获取远程服务镜像Docker 获取远程服务镜像

镜像加速!

众所周知,由于网络原因,国内使用位于海外的官方源会非常慢。因此往往需要使用国内的镜像源。

/etc/docker/daemon.json内配置如下内容(Windows 可以直接在图形界面内配置),即可选择使用百度、网易、腾讯的镜像。享受高速的下载

{
    "registry-mirrors": [
        "https://mirror.baidubce.com",
        "https://hub-mirror.c.163.com",
        "https://mirror.ccs.tencentyun.com"
    ]
}

有趣的想法和测试

在 Docker 跑数据库?

按照上述思路以及 Docker 的一些数据库镜像。可能会有这样的想法:
将数据库在 Docker 中运行,持久化数据挂载到宿主机中。这样部署只需要做好数据库持久化文件即可

看上去似乎没什么毛病,但是具体执行起来可能存在一些问题。首先是数据库的重要性应该是高于程序的。程序挂了,重启即可,丢失的状态有限。而数据库挂了,不仅仅会导致短时间所有程序无法使用,还存在数据丢失的隐患。当引入 Docker 这一额外因素后,Docker 本身故障也将会增加数据库故障的概率。而数据库的持久化也不是实时的,仍然存在数据丢失甚至损坏的可能性。
尽管数据库也有隔离的需求,但是更好的办法是将其运行在单独的物理机上,这样还可以确保数据的安全。

也有人提出数据库将会被 IO 瓶颈限制,不过这更多应该是针对于同一个设备运行多个数据库 Docker。个人认为这实际上并不能作为一个理由。

如果用这里一直强调的内容来看,更本质的原因在于使用 Docker 跑数据库并不优雅——数据库是有状态的,即使挂载存储卷仍然有状态。这其实更类似于个可以但没必要的情况,Docker 提供的优势有限主要在于部署方便,这对于相对较为确定的数据库(市面上常用的数据库非常固定,相对于程序运行环境的复杂度而言,约等于一键部署)并没有什么意义。与其增加其他风险,不如直接宿主机跑。但是,如果是为其他用户提供一个快速部署的 Demo,那么使用 Docker 部署数据库还是极为优雅的。

Docker 容器在本地还是服务器执行?

要验证很容易,既然是两个设备,那么他们的公网 IP 必然是不同的。

我们分别在本机和服务器获取公网 IP

分别在本机和远程服务容器获取公网 IP分别在本机和远程服务容器获取公网 IP

很明显,前者(本机)是教育网 IP,而后者(服务器上的容器)是腾讯云 IP。那么该问题得以确定:容器在服务器执行

可以近似将其看作一个 SSH 连接,我们只是连接到服务器上执行操作而已。

Docker 挂载的目录在本地还是服务器?

同上, 可以将/home挂载到 Docker 容器中,根据挂载后的内容即可分辨到底挂载的是什么目录。

Docker 挂载文件夹Docker 挂载文件夹

本地的用户名为 ohyee,而服务端的用户名为 ubuntu。那么很明显,这里实际上挂载的还是服务端的目录。

但是,这是存在例外的。 Docker 为 WSL 提供了特供版,在这个特供版里,Docker 挂载的将会是 WSL 内的目录,而非存在于 Windows 的服务端目录。
另外值得一提的是,Windows 中的 Docker 实际上是运行于虚拟机的,因此挂载/目录实际上挂在但是 Hyper-V 的 Docker 虚拟机目录。如果需要挂载某些 Windows 特定文件夹,可以使用/c/Users/...,当然也可以在 WSL 中使用/mnt/c/Users/...

参考资料