Docker 从入门到实践

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 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) 指定

  • 删除镜像实际上在做什么:先 UntaggedDeleted

  • 当删除一个镜像时,首先将该镜像标签取消,即 Untagged

  • 如果还有别的标签指向该镜像,那么不进行实际的删除

  • 如果该镜像无标签指向,由上层向基础层方向依次进行判断删除:如果没有任何层依赖当前层,执行 Deleted

  • 批量删除镜像:docker image rm $(docker image ls -q <筛选镜像>)

    • 类似 Linux Shell,使用 $() 获取 cmd 的标准输出
    • 参数 -q 控制 docker image ls 的标准输出格式:仅输出 镜像短ID

利用 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:/datamydata 挂载到 /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-buildimage-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 as 以及 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 终止容器(但不删除),此外当容器的主程序(CMDENTRYPOINT)终结时容器自动终止
  • 进入在后台运行的容器:docker attach 将 stdin, stdout, stderr 等附着在容器上,此时若在 stdin 中输入 exit,则容器终止;docker exec CMD 在已运行的容器中执行一条命令,不创建和启动新的容器,在 stdin 中输入 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 简介

你可能感兴趣的:(Docker 从入门到实践)