构建及分发自定义 Docker 映像

内容纲要

查看「Docker & Kubernetes 专题」获取更多相关内容


从一个现有容器修改成新映像

如果是想基于一个现有本地上的容器做修改,然后变成自己的映像,就可以使用 docker commit

# docker [container] commit <容器名称或 ID> <映像名称>
docker commit web divineengine/nginx # 基于名为 web 的容器创建名为 divineengine/nginx 的映像

# 然后看看现在的映像
docker images

例如已经启动了一个 NGINX 容器 (命名为 web) 并做了一些修改,然后想要创建成一个映像,就可以使用如上命令,不过一般并不使用该方法来构建映像,但换个思路想想这是一个备份现有容器的办法

使用 Dockerfile 构建新映像

Dockerfile 是一个文本文件,Docker 文件中的每一行都是关于如何更改 Docker 容器的新指令,然后 Docker 通过读取 Dockerfile 中的指令来自动构建映像。

一般来说 Dockerfile 文本配置结构是这样的:

  1. 指定并使用某个基本映像来做事情;
  2. 在映像构建阶段做一些事情,比如安装软件、放置一些必要的文件等等;
  3. 作为容器启动时默认做什么事情;

然后通过命令 docker image build 来构建映像

来看一个例子,首先你需要有一个编辑器 (例如 VSCode 并安装插件 Docker),然后找一个地方用来存放并创建 Dockerfile 文件

编写 Dockerfile

# 基于 Debian stable 的 slim 版本
FROM debian:stable-slim

# 做三件事
# 刷新软件源、升级软件并安装名为 iputils-ping 的包
RUN apt update && \
    apt upgrade -y && \
    apt install iputils-ping -y

# 容器启动后默认使用命令 ping 1.1.1.1
CMD ["ping","1.1.1.1"]

然后使用命令构建映像:

# docker image build [-t 映像名称:[标签]] 构建上下文位置
docker image build -t ping .

然后运行容器:

docker run --rm ping

然后就可以看到一直在 ping IP 为 1.1.1.1 的地址了,当然这看起来没什么意义但重点在于 Dockerfile 的基本结构和使用

FROM

FROM 用于指定基础映像,那么为什么需要基础映像?

基础映像用于帮助你更好的创建映像,例如你很熟悉 Debian 以及它的 APT 包管理器那么就可以找到并使用 Debian 映像作为基础映像来做一些事,或者说你索要运行的应用是基于 Python 的那么可以寻找 Python 映像作为基础映像

在选择基础映像时建议:

  • 选择标有 Docker 官方映像或认证发行商标识的映像;
  • 尽可能选择体积小巧的以便分发;
  • 使用确切的版本 (标签) 而不是 latest,以避免可能存在的基础映像在后续迭代中与你的操作不兼容造成无法使用的问题;

最后,一般所指定的基础映像建议使用具体的版本号而不是 latest,这是为了避免基础映像在未来的某次变更与你的映像不兼容而导致使用者无法正常使用的问题。

RUN、CMD 与 ENTRYPOINT

  • RUN 指令是在构建映像时运行的命令,CMDENTRYPOINT 则为启动容器时默认执行的命令:
  • CMDENTRYPOINT 指令都只能写一个,当写了多个 CMDENTRYPOINT 只生效最后一个;
  • CMDENTRYPOINT 两者的不同在于当容器在启动时指定了命令 (docker run <映像名称> [命令]),CMD 命令将被忽略,以之前 ping 的映像为例:
FROM debian:stable-slim

RUN apt update && \
    apt upgrade -y && \
    apt install iputils-ping -y

ENTRYPOINT ["ping"]
CMD ["1.1.1.1"]
# 构建名为 go 的映像,具体标签忽略
docker image build -t go .

# 运行名为 go 的映像,在运行完成后删除
# 指定 CMD 为 119.29.29.29 以替换默认的 CMD
docker run --rm go 119.29.29.29

现在追踪的就是 119.29.29.29 而不是默认的 1.1.1.1

还有一点在于 CMDENTRYPOINT 存在 shell 与 exec 两种书写格式:

  • shell:CMD besttrace -g cn 1.1.1.1
  • exec:CMD ["besttrace","-g","cn","1.1.1.1"]

WORKDIR、COPY 与 ADD

有的时候会想在容器内指定路径进行工作,那么就可以使用 WORKDIR

WORKDIR /usr/local

COPY 与 ADD 都可以添加到容器内,如果路径中有未存在的目录会自动创建

# 复制 config.json 到容器中的 /usr/local/etc/webserver
COPY ./config.json /usr/local/etc/webserver

# 复制 config.tar.gz 到容器中的 /usr/local/etc/ 并解压
ADD ./config.tar.gz /usr/local/etc/
# 下载 config.tar.gz 到容器中的 /usr/local/etc/
ADD https://example.com/config.tar.gz /usr/local/etc/

COPY 与 ADD 的不同在于,ADD 对于本文文件且是压缩包的可以复制到容器中的指定路径并解压,如果是远程文件则下载到容器的指定路径。

另外,还可以结合 WORKDIR 使用:

FROM debian:stable-slim
WORKDIR /usr/local/etc/
ADD ./config.tar.gz .
CMD ["sh"]

USER

使用特权用户运行容器可能存在一些隐患,例如容器的应用出现了安全漏洞并被利用,所以可以使用普通用户和 USER 指令:

FROM debian:stable-slim

# 如果基础映像没有创建普通用户可以自行创建
RUN addgroup -S node && adduser -S node -G node

# 指定使用特定用户并做一些事
USER node

# 使用 --chown= 让特定用户拥有传入的文件
COPY --chown=www:www . .

CMD ["node", "index.js"]

注:上述示例将不会正常运行,因为默认 debian:stable-slim 不包含 Node.js,仅用于示例

ENY 与 ARG

ENY 与 ARG 都可以设置变量

ARG 创建的变量只在镜像构建过程中可见,容器运行时不可见,而 ENV 创建的变量不仅能够在构建镜像的过程中使用,在容器运行时也能够以环境变量的形式被应用程序使用。

ARG VERSION=11
FROM debian:${VERSION}-slim

EXPOSE

EXPOSE 用来声明容器对外服务的端口号

FROM debian:stable-slim

EXPOSE 443
EXPOSE 53/udp

但注意不是声明了就能自动帮你完成在 docker run 时需要加上的 -p 选项及参数,EXPOSE 是告诉使用者 (使用前可以先阅读性 Dockerfile 文件看看它要做什么) 会用到什么端口,然后根据实际需要进行设置

当然如果你使用的是 Docker Desktop 那么在图形化界面点击 RUN 时会看到相关设置:
构建及分发自定义 Docker 映像

Layer 与 Cache

映像内部由多个层 (Layer) 组成,Dockerfile 中的每条指令会生成一个只读的层 (Layer),每一层都是一组文件,多个层会使用 Union FS 技术合并成一个文件系统供容器使用。这种细粒度结构的好处是相同的层可以共享、复用,节约磁盘存储和网络传输的成本,也让构建镜像的工作变得更加容易

容器在运行时会在映像的最上层挂载一个读写层

可以使用命令 docker inspect <映像名称或 ID> 来查看一个映像的层,虽然除了 RUNCOPYADD 外的指令都是临时层,但最好是尽可能的减少层的数量,举个例子可以写在一行的:

RUN apt update && \
    apt upgrade -y && \
    apt install iputils-ping -y

就不要写成这样子了:

RUN apt update
RUN apt upgrade -y
RUN apt install iputils-ping -y

如果命令过多而难以阅读,则可以将命令写在一个 Shell 脚本里,然后使用 RUN 指令运行这个脚本

另外一个需要注意的是缓存问题,在构建或下载映像的时候如果镜像曾已经存在是可以直接利用的,举个例子这里有一个 Node.js 应用,有经常改动的 index.js文件和已经稳定的 package.json 文件,如果是这么写:

FROM node:lts-alpine

WORKDIR /usr/app

COPY ./ ./
RUN npm install

CMD ["npm", "start"]

那么每次 index.js 文件就都需要重新安装依赖,这样就费时费力了,但如果改成如下:

FROM node:lts-alpine

WORKDIR /usr/app

# 先一步只复制 package.json 然后安装依赖
COPY ./package.json ./
RUN npm install
COPY ./ ./

CMD ["npm", "start"]

那么只要 package.json 没有变动,构建时遇到 RUN npm install 这一步就可以使用已缓存的映像层,所以在构建映像时应该尽可能的将会变动的的指令往后放

使用 .dockerignore 忽略文件

有的时候在 Dockerfile 文件同目录下的许多文件都需要通过命令复制到映像中:

COPY . /usr/app/

但其中可能有少量文件是不需要复制的,如果拆开分别用 COPY 指令就很麻烦,这时候就可以新建名为 .dockerignore 的文件,它和 .gitignore 文件类似用于排除掉一些文件,文件内容如下:

# 排除掉 .md 文件除了 README.md
*.md
!README.md

更多使用方法可以查看官方文档:https://docs.docker.com/engine/reference/builder/#dockerignore-file

在线环境:将映像发布到 Docker Hub

如果需要将映像发布到 Docker Hub,首先你需要有一个账号:https://hub.docker.com/

# 登录 Docker Hub
docker login -u <Docker Hub ID>

# 对现有映像打上标签
# docker tag <现有本地映像名称或 ID> <Docker Hub ID>/<映像名称>:<标签>
docker tag nginx divineengine/nginx:1.0

# 推送映像到 Docker Hub
docker push divineengine/nginx:1.0

推送到 Dockerhub 的映像前需要加上你的 Docker Hub ID,因为实际上在使用 Docker 官方映像时也是有 Docker ID 的,如 docker pull nginx 实际上是 docker pull library/nginx,只是因为是官方的可以默认省略掉 library

使用 Docker Buildx 构建多架构映像

如果构建的容器要适用于 x86、ARM 等多架构平台呢?

可以使用 Docker Buildx,在开始前需要先确认已经安装了 Docker Buildx:

docker buildx version

能看到版本号表示安装成功

编写一个 Dockerfile 文件,然后使用 docker buildx 命令:

# 登录到 Docker Hub
docker login

# 多平台特性当前不支持 docker driver
# 使用 docker buildx ls 可以查看当前环境
# --use 表示创建并切换到新创建的环境
docker buildx create --use # 可以加上 --name 指定名字

# docker buildx build --push --platform <platforms> -t <image-name>:<tag> .
# --push 推送到 Docker Hub
# --platform 需要构建的平台
# -t 指定映像标签
docker buildx build --push --platform linux/amd64,linux/arm64 -t divineengine/nginx:1.0 .

然后 Docker 会拉取名为 moby/buildkit 的映像并创建容器来构建不同平台的映像

另外由于目前 buildx 并不支持 --load 所以并不能保存到本地,这也是为什么这里要先登陆到 Docker Hub 然后推送上去的原因

离线环境:导出导入

例如将 NGINX 映像导出:

docker save nginx:latest -o nginx.tar

将导出的映像导入:

docker load -i nginx.tar