Introduction
- This is a book notes on docker 从入门到实践
- book in web
Docker 简介
- 虚拟机(VMs):在 Host OS(主操作系统) 上虚拟化硬件,并使用 Guest OS(虚拟OS) 控制虚拟化硬件
- Hypervisor 将 Host OS 中计算资源进行分配构成虚拟化硬件
- 内核作为 os 的最基本的组成部分,是硬件和软件进行交流的媒介
- 因此一个 VM 由三部分组成:Guest os, Bins/Libs, App
- 容器:容器只包含 App 和它所依赖的环境 Bins/Libs。同时因为容器和 VMs 的主要区别只有内核不同,因此能够实现 VMs 的大部分功能,它相比 VM 还具有以下优点
- 不需要将硬件虚拟化,具有更高的计算资源使用效率
- 不需要启动完整的 os,启动更快
- 更易维护和拓展,可移植性更高
- Docker 在容器的基础上进行再封装,简化了容器的创建和维护,使得
Docker
技术比虚拟机技术更为轻便、快捷。
基本概念
镜像 (image)
- 镜像相当于一个 root 文件系统
- 镜像不包含任何动态数据,其内容在构建之后不再发生改变 (或者说镜像的内容不可写)
- 利用 Union FS 技术,将镜像设计为分层存储的架构,它有以下好处
- 节省存储空间
- 使得镜像的复用、定制更容易
- 参考
- 镜像分层存储与镜像精简 中举例简单介绍了分层存储
- 文件系统分层存储原理 中对分层存储进行了较详细的描述
容器 (Container)
- 从原理上来说,容器是镜像运行时的实体,当容器运行时,以镜像为基础层,并在其上创建一个容器存储层。从功能上来说,容器是独立运行的一组应用,以及它们的运行态环境
- 容器的实质是进程,但与直接在 host 中执行的进程不同,容器进程运行于属于自己的独立的 命名空间,因此容器运行在一个独立于 host 的隔离的环境中
- 按照 Docker 最佳实践的要求,容器不应该向其存储层内写入任何数据,容器存储层要保持无状态化。所有的 (数据) 文件写入操作,都应该使用 数据卷 (Volume)、或者 挂载 host 目录
- 参考
- 有状态 VS 无状态 简单介绍了有状态和无状态应用
仓库 (Repository)
- 仓库是一个集中的存储、分发镜像的服务,例如 Docker Registry
- Docker Registry 公开服务:例如 Docker Hub,可以使用国内镜像提高下载速度
- 私有 Docker Registry
- 一个 Docker Registry 中可以包含多个 仓库(
Repository
);每个仓库可以包含多个 标签(Tag
);每个标签对应一个镜像
安装 Docker
- 介绍了不同操作系统下 Docker 的安装
- 介绍了 Docker Hub 国内镜像的安装
使用镜像
获取镜像
$ docker pull [选项] [Docker Registry 地址[:端口号]/]仓库名[:标签]
- 镜像名称的格式
- Docker 镜像仓库地址:地址的格式一般是
<域名/IP>[:端口号]
。默认地址是 Docker Hub(docker.io
)。 - 仓库名:如之前所说,这里的仓库名是两段式名称,即
<用户名>/<软件名>
。对于 Docker Hub,如果不给出用户名,则默认为library
,也就是官方镜像
- Docker 镜像仓库地址:地址的格式一般是
- 从下载过程中可以看到我们之前提及的分层存储的概念,镜像是由多层存储所构成。下载也是一层层的去下载,并非单一文件
- 运行镜像,例如
$ docker run -it --rm ubuntu:18.04 bash
列出镜像
$ docker image ls
- 镜像 ID 是镜像的唯一标识,相对地,一个镜像可以对应多个 Tag
- 唯一标识意思为一个标识能唯一确定一个镜像,不同镜像拥有不同的标识
- 镜像在下载和上传过程中是保持压缩状态的,Docker Hub 中展示的大小是压缩后的大小 (下载所需流量),而
docker image ls
中显示的大小是展开后各层所占空间的总和 (由于分层存储的优势,实际消耗硬盘空间的大小一般远小于显示的大小) - 虚悬镜像(dangling image)
- 不是所有没有标签的镜像都是虚悬镜像,许多中间层镜像也没有标签。区别是前者是顶层镜像,而后者不是 (增加参数
-a
显示中间层镜像) -
$ docker image ls -f dangling=true
查看虚悬镜像 -
$ docker image prune
删除虚悬镜像
- 不是所有没有标签的镜像都是虚悬镜像,许多中间层镜像也没有标签。区别是前者是顶层镜像,而后者不是 (增加参数
-
$ docker image ls (--filter|-f)
过滤器,列出部分镜像 -
$ docker image ls --format
以特定格式显示镜像
删除镜像
$ docker image rm [选项] <镜像1> [<镜像2> ...]
<镜像>
可以由镜像短ID
,镜像长ID
,镜像名
,镜像摘要(digest)
指定删除镜像实际上在做什么:先
Untagged
再Deleted
当删除一个镜像时,首先将该镜像标签取消,即
Untagged
如果还有别的标签指向该镜像,那么不进行实际的删除
如果该镜像无标签指向,由上层向基础层方向依次进行判断删除:如果没有任何层依赖当前层,执行
Deleted
-
批量删除镜像:
docker image rm $(docker image ls -q <筛选镜像>)
- 类似 Linux Shell,使用
$(
获取) cmd
的标准输出 - 参数
-q
控制docker image ls
的标准输出格式:仅输出镜像短ID
- 类似 Linux Shell,使用
利用 commit 理解镜像构成
当运行一个容器时,我们在容器内 (不使用卷和挂载 host) 所作的任何文件修改都会被记录于容器的存储层里,
docker commit
直接将容器的存储层保存下来成为镜像-
docker commit
有一些特殊的应用场合,但不要用于定制镜像,它有以下缺点- 辅助文件等无关内容也被记录:在容器内修改文件(包括安装和编译等)时,还会有大量无关的内容(例如日志
.log
和临时文件.tmp
)被修改并添加进容器存储层,这会导致镜像非常臃肿 - 黑箱镜像:使用
docker commit
意味着黑箱操作,生成的镜像被称为黑箱镜像。无从得知在该镜像中执行过什么命令、怎么生成的镜像,不易于使用和维护 - 只做加法不做减法:因为镜像是分层存储的,而
docker commit
只是将容器存储层保存为镜像,所删除的上一层的东西不会丢失,因此镜像的每一次后期修改都只会让它更加臃肿
- 辅助文件等无关内容也被记录:在容器内修改文件(包括安装和编译等)时,还会有大量无关的内容(例如日志
使用 Dockerfile 定制镜像
镜像分层存储的结构带来诸多好处的同时,也带来很多问题。因此希望能在 Docker 引擎控制下设计一系列新的指令,并按照我们的需求构建和定制镜像,将记录这些指令的纯文本文件称为 Dockerfile。
Dockerfile 是纯文本文件,其内包含了一条条的 指令(Instruction),每一条指令构建镜像的一层。通过 commit
容器存储层会得到一个黑箱层,相反 Dockerfile 中的指令被设计为在 Docker 引擎下进行镜像定制,因此容易记录镜像多层结构的历史变动(使用 docker history
查看),接下来首先了解两种最基础的 Dockerfile 指令。
FROM 继承自继承镜像
FROM <基础镜像>
,基础镜像的选择可分为三种
直接在官方提供的镜像作为基础镜像进行定制
如果没有合适的官方基础镜像,使用更基础的操作系统镜像,不同操作系统的软件库为我们提供了广阔的扩展空间
-
使用空白的基础镜像
FROM scratch
对于 Linux 下静态编译的程序来说,并不需要有操作系统提供运行时支持,所需的一切库都已经在可执行文件里了,因此直接
FROM scratch
会让镜像体积更加小巧。
RUN 执行命令
观察 RUN
执行命令的一个例子
FROM debian:stretch
RUN set -x; buildDeps='gcc libc6-dev make wget' \
&& apt-get update \
&& apt-get install -y $buildDeps \
&& wget -O redis.tar.gz "http://download.redis.io/releases/redis-5.0.3.tar.gz" \
&& mkdir -p /usr/src/redis \
&& tar -xzf redis.tar.gz -C /usr/src/redis --strip-components=1 \
&& make -C /usr/src/redis \
&& make -C /usr/src/redis install \
&& rm -rf /var/lib/apt/lists/* \ # 清理 apt 缓存文件
&& rm redis.tar.gz \ # 清理下载文件:redis 源码压缩包
&& rm -r /usr/src/redis \ # 清理 redis.tar.gz 的展开
&& apt-get purge -y --auto-remove $buildDeps # 清理 wget 以及依赖关系
-
RUN
有两种格式:shell 格式RUN <命令>
;exec 格式RUN ["可执行文件", "参数1", "参数2"]
- 精简的镜像:记得在
RUN
的最后清理所有无用的文件 - trick of 清理:使用字符串
buildDeps='gcc libc6-dev make wget'
记录软件包,以及使用$buildDeps
展开安装软件的依赖关系,最后干净地清理了wget
构建镜像
$ docker build [选项] <(上下文路径|URL|-)>
传递上下文目录
在 docker build .
中 .
传递的是上下文 context
目录。要理解这一点,Docker 在运行时分为 Docker 引擎和客户端工具。Docker 引擎提供 Docker Remote API,而 docker 命令作为客户端工具通过 API 和 Docker 引擎交互。因此表面上我们在 host 中执行各种 docker 功能,实际上一切都是通过远程调用的形式在服务端(Docker 引擎)完成。
关于命令 docker build
,它首先指定
为上下文目录,并将
下的所有文件打包交给 Docker 引擎。在此之后,诸如 docker copy
, docker add
等指令通过指定基于上下文目录的相对路径移动文件。
注意,Docker 会将上下文目录中的所有文件打包交给 Docker 引擎,即 Docker 认为上下文目录中的所有文件都是构建镜像时需要的文件,因此我们在构建上下文目录时需要谨慎。建议做加法:每次定制镜像时,新建一个文件夹作为上下文目录,然后仅将所需要的文件放进去。此外为了方便维护等需求 .git
, .log
文件也需要放入上下文目录中,使用文本文件 .dockerignore
忽略它们。
指定 Dockerfile
前文提到过应该使用 Dockerfile 定制镜像,在 docker build
指令中,有不同的方式指定 Dockerfile 文件
- 默认情况下,将上下文目录下的名为
Dockerfile
的文件作为 Dockerfile - 通过参数
-f
指定 Dockerfile - 从标准输入中读取 Dockerfile:如果标准输入传入文本文件,则将其视为 Dockerfile,例如
docker build - < Dockerfile
cat Dockerfile | docker build -
此外,docker build
也支持使用不同的方式指定上下文目录
- 指定 Host 中的本地路径
- 从 URL 构建,包括 Git repo 和压缩包(例如
http://
)/context.tar.gz - 从标准输入中读取上下文压缩包
Dockerfile 指令详解
COPY 复制文件
-
COPY
将文件或目录下的文件复制到镜像的路径下[ ...] - 使用参数
--chown=
可修改文件所属用户和所属组:
ADD 更高级的复制文件
ADD 相比 COPY 增加了一些功能,但仅建议在需要自动解压缩的场合使用 ADD
CMD 容器启动命令
CMD
指令是用于指定默认的容器启动命令(显然 CMD
只能指定一次),例如 ubuntu
镜像默认的 CMD
是 /bin/bash
。
关于 CMD
指令格式,推荐使用 exec
格式。实际上,shell
格式会被自动解释为 exec
格式进行执行,下面二者等价
-
shell
格式:CMD echo
-
exec
格式:CMD ["sh", "-c", "
"]
这是因为默认的 SHELL 指令是 ["/bin/sh", "-c"]
,具体见 SHELL 指令一节。
容器是进程
容器的启动程序 CMD
就是容器的主进程,容器就是为了主进程而存在的,当主进程结束,容器就失去了存在的意义,从而退出。因此容器没有后台服务,当有类似需求时应将对应的后台服务以前台形式运行,例如 CMD ["nginx", "-g", "daemon off;"]
。
ENTRYPOINT 入口点
当指定了 ENTRYPOINT
后,CMD
的含义就发生了改变,不再是直接的运行其命令,而是将 CMD
的内容作为参数传给 ENTRYPOINT
指令:
。它的设计目的是进行灵活的定制,它主要应用于
- 让镜像在使用时像命令一样添加可选参数:将原本
CMD
的内容传给ENTRYPOINT
,在运行容器时输入可选参数,它们会自动传给CMD
- 在执行
CMD
前进行一些准备工作:使用ENTRYPOINT
执行一个.sh
脚本,它由两部分组成,执行CMD
前的准备工作以及在最后执行CMD
ENV 设置环境变量
- 设置环境变量,它有两种格式
ENV
和ENV
= - 使用
$
展开环境变量 -
一般使用大写字母表示,提高可读性
ARG 构建参数
-
ARG
设置环境变量,并且它们在容器运行时不存在。在构建完镜像后ARG
环境变量仍是存在的,例如在CMD
中使用ARG
环境变量时就需要这一特性 - 灵活的定制:
ARG
被设计用于定义参数名称以及它的默认值,进而使得在不修改 Dockerfile 的情况下构建出不同的镜像。在docker build
中添加参数--build-arg <参数名>=<值>
修改ARG
环境变量的值,实现灵活的定制 -
ARG
指令有生效范围,它的生效范围由FROM
指令截断
VOLUME 构建匿名卷
举例说明,使用 VOLUME /data
可以将工作目录下的 /data
目录挂载为匿名卷。当运行容器时,如果用户
- 不指定挂载,应用可以将数据文件写入
/data
以保证正常运行,容器删除以后匿名卷中的数据消失 - 需要指定挂载,那么使用
docker run -v mydata:/data
将mydata
挂载到/data
目录下,保存了容器运行时数据的修改
综上,VOLUME
实现了一种 灵活的定制:在配置容器内的应用时,将应用程序的数据写入目录 /data
下。此时使用 VOLUME
构建匿名卷可以使得镜像的定制更加灵活,当用户
- 不需要数据时,不指定挂载,此时不需要的数据不会写入容器存储层
- 需要数据时,只需要将
mydata
挂载到/data
目录下就可以获取数据
EXPOSE 暴露端口
要将 EXPOSE
和在运行时使用 -p <宿主端口>:<容器端口>
区分开来
-
-p
将容器的对应端口服务公开给外界访问 -
EXPOSE
仅声明容器打算使用什么端口,不进行端口映射
WORKDIR 指定工作目录
使用 WORKDIR <工作目录路径>
指定工作目录,如果该目录不存在,则自动建立目录并指定。以后各层(各指令)的当前目录就被改为指定的目录,包括 WORKDIR
指令,例如
WORKDIR /a # 绝对路径 /a
WORKDIR b # 目录 /a 下相对路径 b -> /a/b
WORKDIR c # 目录 /a/b 下相对路径 c -> /a/b/c
RUN pwd # RUN pwd 的工作目录为 /a/b/c
USER 指定当前用户
-
USER
指令和WORKDIR
相似,都是改变环境状态并影响以后的层,改变以后各层执行命令(例如RUN
,CMD
,ENTRYPOINT
)的用户身份 - 如果希望在执行指令期间改变身份,使用
gosu
HEALTHCHECK 健康检查
为什么要进行健康检查
如果没有 HEALTHCHECK,容器只能根据主进程是否退出来判断容器状态是否异常。如果容器内的程序进入死锁或死循环,该容器已经无法提供正常服务了,但是容器无法退出。
如何防止容器进入死循环而不退出
首先每隔一段时间执行检查程序检查容器是否异常,但这是不够的,因为检查程序可能无法正常启动或运行(例如容器崩溃或检查程序进入死循环);因此设置检查程序运行的超时时长;除此之外,一次失败就认为容器异常太过苛刻,允许多尝试几次。以上分别对应了 HEALTHCHECK 的三个参数:时间间隔 --interval
,超时时长 --timeout
,连续失败次数--retries
。
容器的健康状态变化
当运行镜像后,容器最初的状态为 starting
;等待一段时间后迎来第一次 HEALTHCHECK
,如果检查成功则状态变为 healthy
;之后如果检测失败则状态变为 unhealthy
。可以用 docker inspect
查看历史健康状态,有助于维护和排障。
HEALTHCHECK 如何判断健康状态
关于在 HEALTHCHECK CMD <命令>
中 <命令>
返回的值,如果
- 返回 0,认为健康检查结果为成功,即程序健康
- 返回 1,认为健康检查结果为失败,即程序异常
- 返回 2,保留,或者说未失败,但也不标记为成功导致终止连续失败次数的计数
奇怪的是,为什么返回 0 认为成功,而返回 1 认为失败(通常认为 1 表示 true
而 0 表示 false
)。这是因为 HEALTHCHECK
的目的是检查是否有异常,因此返回 0 表示没有异常,返回 1 表示有异常。
补充,进程的 exit
返回值。当进程正常结束时,执行 exit 0
或者说 exit
返回值为 0;相反,当进程出现异常时,为了让使用该程序的人知道异常的发生,执行 exit 1
,即 exit
的返回值为 1。
ONBUILD 构建基础镜像
ONBUILD <#1:其它 Dockerfile 指令>
只有以当前镜像为基础镜像,构建下一级镜像的时候 #1
才会被执行。
为什么我们需要 ONBUILD
- ****基础镜像****:当需要定制许多类似的镜像时,使用基础镜像进行继承更易于维护
- ****灵活的定制****:在基础镜像中使用 ONBUILD,可以将基础镜像写成模板镜像,更灵活地定制镜像
关于书中的例子
FROM node:slim
RUN mkdir /app
WORKDIR /app
ONBUILD COPY ./package.json /app
ONBUILD RUN [ "npm", "install" ]
ONBUILD COPY . /app/
CMD [ "npm", "start" ]
在 ONBUILD COPY
后面的 ./package.json
以及 .
都是基于上下文目录的相对路径,因此这里的上下文目录相当于提供了定制镜像的模板。当我们使用以上例子作为基础镜像时,提供不同的上下文目录,就可以引用不同的 package.json
以及执行不同的应用。
LABEL 为镜像添加元数据
-
LABEL
指令给镜像提供元数据LABEL
= = = ... - 补充:元数据 简单来说就是描述数据的数据
SHELL 指令
SHELL ["executable", "parameters"]
指定默认运行的 Shell 以及运行参数,Linux 中默认为 ["/bin/sh", "-c"]
参考文档
- Dockerfie 官方文档
- Dockerfile 最佳实践文档
- Docker 官方镜像 Dockerfile
Dockerfile 多阶段构建
单个 Dockerfile
上一节中构建镜像时将所有的构建过程(项目及其依赖库的编译、测试、打包等流程)包含在一个 Dockerfile
中,在前面已提过:一个好的 Dockerfile 需要在继续下一层之前清除所有不需要的资源,例如我们在 “利用 Dockerfile 定制镜像” 这一节的 RUN
指令中手动清除下载文件、源码和 apt
缓存。
思考一下构建镜像时的需求:我们需要一个精简的镜像,这个镜像将生成一个容器来运行程序,因此我们的唯一要求就是良好地执行程序。在 “利用 Dockerfile 定制镜像” 这一节中,我们在做减法:手动删除不需要的资源以实现镜像的精简。但这样做是麻烦的,并且也是不足够精简的:因为编译的时候也生成了一些层,它们不是执行程序时所需要的数据。
多个 Dockerfile
相反,参考 软件开发中常见的流程,也可以做加法:每一个阶段结束后只传递必要的文件到下一阶段,即编译结束后仅将可执行程序传给下一阶段。要实现这个目的,可以构建两个镜像 image-build
和 image-server
。在书中 “分散到多个 Dockerfile” 一节的具体例子中得知,我们需要一个 .sh
脚本才能将两个镜像联系起来,它需要做到以下几件事
- 根据镜像
image-build
生成的容器获得可执行程序app
- 将
app
复制到 host,再由 host 复制到image-server
- 运行镜像
image-server
为用户提供服务 - 执行清理工作:删除镜像
image-build
以及在 host 中删除app
这样确实构建了精简的镜像,但也有一些缺点
- 定制多个镜像需要同时维护多个 Dockerfile 文件
- 还需要一个额外的 shell 脚本文件来执行复制和清理工作
多阶段构建
为解决这个问题,Docker v17.05 开始支持多阶段构建 (multistage builds
),使用两个语法 FROM
以及 COPY --from=
就可以完成多阶段构建:在一个镜像中可以使用多个 FROM
,每个 FROM
对应一个镜像构建的阶段,并根据
指定从哪个镜像中获取当前阶段需要的文件。
补充,也可以不指定标签
,通过 --from=0
从上一个阶段复制文件到当前阶段。但是为了提高可读性,建议每个阶段指定标签
进行阶段命名和复制文件。
构建多种系统架构支持的 Docker 镜像
使用镜像创建一个容器,该镜像必须与 Docker 宿主机系统架构一致,例如在 Linux x86_64
架构的系统中只能创建 Linux x86_64
架构的镜像,同时也只能使用 Linux x86_64
的镜像创建容器。
Windows、macOS 除外,其使用了 binfmt_misc 提供了多种架构支持,在 Windows、macOS 系统上 (x86_64) 可以运行 arm 等其他架构的镜像。
因此需要为不同的系统架构准备对应的镜像,但是要做到这一点,需要将镜像的名称加长,例如从 python:3.10
加长为 python:3.10:linux_x86_64
,这样很不方便。我们希望仅提供 python:3.10
,然后 Docker 引擎能够根据系统架构自动获取正确的镜像。
使用 manifest
列表 (manifest list
) 可以记录
对应的不同系统架构镜像的 digest
值。当 pull image
时,Docker 引擎会先查看
是否存在 manifest
- 如果存在,查找对应的
digest
值并获取正确的镜像 - 如果不存在,直接获取当前镜像
操作容器
Again: 容器是独立运行的一个或一组应用,以及它们的运行态环境
- 启动:
docker run
新建并启动;docker container start
启动运行一个已终止(exited
)的容器 -
-d
后台运行:输出容器 ID,并且不会把输出的结果输出到 Host(或者说 stdout 重定向为某个 log 文件,使用docker container log
查看) -
docker container stop
终止容器(但不删除),此外当容器的主程序(CMD
或ENTRYPOINT
)终结时容器自动终止 - 进入在后台运行的容器:
docker attach
将 stdin, stdout, stderr 等附着在容器上,此时若在 stdin 中输入exit
,则容器终止;docker exec
在已运行的容器中执行一条命令,不创建和启动新的容器,在 stdin 中输入CMD exit
容器也不会终止 -
docker export
导出容器快照到本地文件;docker import
从容器快照导入为镜像,容器快照文件将丢弃所有的历史记录和元数据信息(即仅保存容器当时的快照状态) - 删除容器:
docker container rm
删除已终止容器;docker container rm -f
强制删除容器,包括运行中的容器;docker container prune
清理所有已终止容器;docker run --rm
当容器终止时自动删除
访问仓库
Todo
Notes
- 操作系统与内核
- os: Operating System,操作系统
- 内核:参考 内核 in wiki。内核是现代操作系统 (os) 中最基本的组成成分,它接受并管理软件的 I/O 需求,并将这些需求转化为指令交由 CPU, Memory 等硬件进行处理,是软件和硬件进行交流的媒介
- 无标签中间层镜像出现的一种方式
- 使用
base-image:latest
作为基础层构建app-image
- 更新镜像
base-image:latest
。此时旧版本的base-image
不会在镜像的更新中被删除,因为它被app-image
依赖,但是它已经失去了标签,因此旧版本的base-image
成为了无标签中间层镜像
- 使用
- Bash 简介