如何优雅地使用 Docker

622 阅读22分钟

如何优雅地使用 Docker

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


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

如果有错误指正或讨论,建议在原博客评论,多平台可能无法保证及时回复。同时无法保证各平台内容保持最新。

Docker 是什么

容器和虚拟机

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

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

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

Windows 沙盒应用

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

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

Docker 的分层

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

环境依赖关系

目前很多镜像实际上都会使用 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来清除

其他操作

服务端配置{#daemon_config}

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 获取远程服务镜像

镜像加速!

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

/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。那么该问题得以确定:容器在服务器执行

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

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

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

Docker 挂载文件夹

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

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

参考资料