Dockerfile

Dockerfile 常用指令

Dockerfile 是 Docker 中用于定义镜像自动化构建流程的配置文件,在 Dockerfile 中,包含了构建镜像过程中需要执行的命令和其他操作。

以官方 getting-started 给的示例为例,总体结构是这样的:

dockerfile
1FROM node:18-alpine
2WORKDIR /app
3COPY . .
4RUN yarn install --production
5CMD ["node", "src/index.js"]
6EXPOSE 3000

From

用来指定基础镜像,让 Docker 能够在这个镜像的基础上进行构建的操作。

基础镜像是构建新镜像的根本,Dockerfile 的第一条命令必须是 From 指令。

From 指令支持三种形式

dockerfile
1FROM <image> [AS <name>]
2FROM <image>[:<tag>] [AS <name>]
3FROM <image>[@<digest>] [AS <name>]

一个 Dockerfile 可以有多个 From 指令,当 From 指令第二次出现时,表示在此刻构建时,要将当前镜像的内容合并到即将构建的镜像的内容里。

官方也给了一个示例,将前端打包的内容存到 nginx 里

dockerfile
1FROM node:18 AS build
2WORKDIR /app
3COPY package* yarn.lock ./
4RUN yarn install
5COPY public ./public
6COPY src ./src
7RUN yarn run build
8
9FROM nginx:alpine
10COPY --from=build /app/build /usr/share/nginx/html

Run

Run 指令用于向控制台发送命令。

在 Run 指令后,我们直接拼接上需要执行的命令,在构建时,Docker 就会执行这些命令。

dockerfile
1RUN <command>
2RUN ["executable", "param1", "param2"]

Run 指令支持\换行,如果单行长度过长,建议对内容进行分割,方便阅读。

CMD

基于镜像的容器,在容器启动后会根据 CMD 定义的内容来执行一个命令。

格式是这样的:

dockerfile
1CMD ["executable","param1","param2"]
2CMD ["param1","param2"]
3CMD command param1 param2

EXPOSE

为镜像指定要暴露的端口。

Volume

在构建镜像时,可以先定义一个数据卷,在基于此镜像run一个容器时,自动建立数据卷,不需要使用容器的人手动使用-v去指定数据卷。

COPY 和 ADD

在制作镜像时,我们可能需要一些软件配置、程序代码等直接导入到镜像内的文件系统中,次用 COPY 或 ADD 指令能帮助我们直接从宿主机里拷贝内容到镜像中。

格式是这样的:

dockerfile
1COPY <src> <dest>
2ADD <src> <dest>

COPY 和 ADD 的区别在于 ADD 支持填入 URL 作为 src 源,并且在源文件被识别为压缩包时,自动进行解压,而 COPY 仅支持没有网络请求或者不希望源文件被解压的场景。

编写 Dockerfile

上节我们通过 desktop 从 docker hub 拉取了 nginx 的镜像,并把它跑了起来。

跑这个镜像的时候指定了映射的端口、挂载的数据卷、环境变量等。

跑起来的容器就已经有可用的 nginx 服务了。

在本节我们将自己制作这样一个镜像。

docker 容器内是一个独立的系统环境,那如果想要在这样一个系统内,启用一个服务(例如 http-server、mysql 等),就需要执行一些命令,将某些文件复制进来,然后启动这个服务。

制作镜像自然也是如此,比较方便的是,我们可以将这个过程用 dockerfile 声明出来,然后使用 docker build 命令,根据 dockerfile 自动构建出镜像。

下面有一个示例:

dockerfile
1FROM node:latest
2
3WORKDIR /app
4
5COPY . .
6
7RUN npm config set registry https://registry.npmmirror.com/
8
9RUN npm install -g http-server
10
11EXPOSE 8080
12
13CMD ["http-server", "-p", "8080"]

这些指令的含义如下:

  • FROM 基于基础镜像
  • WORKDIR 指定当前工作目录
  • COPY 把容器外的内容复制到容器内
  • EXPOSE 声明当前容器的网络端口
  • RUN 在容器内执行命令
  • CMD 在容器启动时执行命令

我们先通过 FROM 拿到 node 环境的基础镜像,里面已经配置了 npm。

通过 WORKDIR 指定当前目录。

通过 COPY 将 Dockerfile 同级目录下的内容复制到容器内,最后的.就是/app目录。

之后通过 RUN 执行 npm install,全局安装 http-server。

通过 EXPOSE 指定要暴露的端口。

CMD 指定容器跑起来之后执行的命令,这里就是执行 http-server 把服务跑起来。

把这个文件保存为 Dockerfile,然后在同级添加一个 index.html。

image-20240323191607822

最后通过 docker build 就可以根据 dockerfile 生成镜像。

bash
1docker build -t http-server:hs .

http-server 是镜像名, hs 是镜像的标签。

image-20240323191952113

FROM 是从基础镜像中下载 node 镜像的内容,接着从我们本地拷贝内容,最后是下载 npm 和 http-server。

现在 Desktop 的 images 列表中已经有我们的镜像了。

image-20240323192236383

点击 RUN,就会弹出这个界面。

image-20240323192343824

指定一下容器名,映射的端口号后,点击 run。

image-20240323192647424

然后就可以看到容器内的日志,服务启动成功了。

image-20240323192820898

这里打印的是容器内的端口号,我们在宿主机中访问时,要用映射的 8000 端口访问。

image-20240323192946122

现在我们的基础镜像就跑通了。

打开 Files 查看一下。image-20240323193107567

这不就是我们在 dockerfile 中指定的 WORKDIR 吗?

里面的两个文件是通过 COPY . . 复制进去的。

指定 VOLUME

如果我想要随时换里面的 index.html 文件怎么办?

我们可以将/app目录设置为挂载点,在生成容器的时候指定宿主机的挂载目录,这样的话修改宿主机挂载目录中的内容会实时影响到容器。

这样修改:

image-20240323194100446

用 -f 指定要 build 的 dockerfile 的文件名。

bash
1docker build -t http-server:hs . -f 2.dockerfile

构建完后,RUN 一下这个镜像

image-20240323194422971

是不是就出现 Volumes 选项了。

我把本机 Desktop 的目录挂载到镜像内的/app目录中。

image-20240323194641061

点击 RUN。

desktop/123 目录中加一个 index.html。

image-20240323194925420

查看 Docker Desktop 的 Files 栏。

image-20240323195037495

MOUNT 表示这是一个挂载目录。

接着打开http://127.0.0.1:8000/, 现在已经可以访问到了。

image-20240323195131221

在宿主机的修改也可以实时产生变化。

我们修改123目录下的 index.html文件

image-20240323195331704

刷新一下浏览器,可以看到内容也随之变化了。

image-20240323195343814

在 Bind mounts 中也可以看到挂载目录。

image-20240323195628815

为什么要写 VOLUME

其实在 Dockerfile 中不指定 VOLUME,而是在 docker run 生成容器时时用-v参数也可以达到上面的 Bind mounts 效果。

为什么一定要在 Dockerfile 中写出来呢?

这是因为在 dockerfile 里指定 VOLUME 后,如果 docker run 时没有带 -v,docker 会自动帮我们将数据挂载到一个 volume 中。

我们来试试,直接生成容器不填写任何设置项。

image-20240323201031371

在容器生成后,打开 Files 看一眼。

image-20240323201127921

docker 默认为我们生成了一个随机命名的目录作为数据卷挂载上去了。

在 inspect 中我们能看到数据挂载到这个目录上了。

image-20240323201344784

在 Volumes 里也可以看到它。

image-20240323201507238

这样子就算你把容器给删除了,也可以从这里找回数据。

设想下,如果你跑了个 mysql 容器,存了很多数据,但是跑容器的时候没指定数据卷。有一天,你把容器删了,所有数据都没了,可不可怕?

为了避免这种情况,dockerfile 里是必须声明 volume 的,这样就算你没通过 -v 指定数据卷,将来也可以找回数据。

dockerignore

docker 支持通过 .dockerignore 声明哪些文件不会进入构建范围内。

.dockerignore 是这样写的:

csharp
1*.md
2!README.md
3node_modules/
4[a-c].txt
5.git/
6.DS_Store
7.vscode/
8.dockerignore
9# 注释
10.eslintignore
11.eslintrc
12.prettierrc
13.prettierignore

*.md 就是忽略所有 md 结尾的文件, !README.md 就是其中不包括 README.md。

node_modules/ 就是忽略 node_modules 下 的所有文件。

[a-c].txt 是忽略 a.txt、b.txt、c.txt 这三个文件。

.DS_Store 是 mac 的用于指定目录的图标、背景、字体大小的配置文件,这个一般都要忽略。

eslintprettier 的配置文件在构建镜像的时候也用不到。

#表示这是注释。

这些就是.dockerignore的全部语法。

docker build 时,会先解析 .dockerignore,把该忽略的文件忽略掉,然后把剩余文件打包发送给 docker daemon 作为上下文来构建产生镜像。

忽略这些用不到的文件,是为了让构建更快、镜像体积更小。

Nest 项目编写 dockerfile

新建一个 nest 项目。

bash
1nest new dockerfile-test -p pnpm

编写.dockerignore

csharp
1*.md
2node_modules/
3.git/
4.DS_Store
5.vscode/
6.dockerignore

编写 dockerfile

dockerfile
1FROM node:18
2
3WORKDIR /app
4
5COPY package.json .
6
7RUN npm config set registry https://registry.npmmirror.com/
8
9RUN npm install
10
11COPY . .
12
13RUN npm run build
14
15EXPOSE 3000
16
17CMD [ "node", "./dist/main.js" ]
  • 基于 node 18 镜像
  • docker 内工作目录为 /app
  • 把 package.json 复制到容器内,再执行 npm install 安装依赖
  • 其余文件复制进去,执行 npm run build 打包
  • 暴露 3000 端口
  • 容器跑起来后执行node ./dist/main.js,这是启动打包后的 nest app

执行docker build

bash
1docker build -t nest:01 .

docker run,接着写入要映射的宿主机端口号。

image-20240324202248563

生成容器成功。

image-20240324202822981

浏览器访问一下也没有问题:

image-20240324202949519

现在我们已经成功用 docker 把 nest 应用跑起来了。

但现在 docker 镜像是不完美的。

  1. 首先是镜像的体积太大:

    image-20240324203459070

    这个问题好解决,主要原因是基础的 node 镜像太大导致的。

    我们只需要使用 alpine版本的 node 镜像即可。

    dockerfile
    1FROM node:18.17.0-alpine
    2...
  2. 生成出来的容器仅会运行打包后的 dist 目录内的文件,那源代码目录还留着岂不占用内存?

image-20240324204002142

构建的时候是需要用到 src目录的,但是容器运行后就不需要用到它了。

那怎么办呢?

docker 也想到了这个问题,它给出来的答案是多阶段构建

多阶段构建

所谓多阶段构建,就是把构建过程分成多个阶段。

以上面为例:

  1. 第一阶段:通过 npm run build生成 dist 目录。
  2. 第二阶段:把第一阶段的 dist 目录拷贝过来,并安装所有需要的的依赖包即可。

下面是多阶段构建的 Dockerfile 示例:

dockerfile
1# build stage
2FROM node:18.17.0-alpine as build-stage
3
4WORKDIR /app
5
6COPY package.json .
7
8RUN npm config set registry https://registry.npmmirror.com/
9
10RUN npm install
11
12COPY . .
13
14RUN npm run build
15
16# production stage
17FROM node:18.17.0-alpine as production-stage
18
19COPY --from=build-stage /app/dist /app
20COPY --from=build-stage /app/package.json /app/package.json
21
22WORKDIR /app
23
24RUN npm config set registry https://registry.npmmirror.com/
25
26RUN npm install --production
27
28EXPOSE 3000
29
30CMD node /app/main.js

上面的 dockerfile 分为了两个阶段:

  1. build stage 主要是 buildnest.js 的生产环境目录。

  2. production stage 主要是将build stage的产出拷贝过来,还有把 package.json 也拷贝过来。

    COPY --from=build-stage /app/dist /app > COPY --from=build-stage /app/package.json /app/package.json

执行 docker build 试试

bash
1docker build -t nest:02 .

用了node:18.17.0-alpine作为基础镜像后产生的镜像体积小了很多:

image-20240324210900731

run 出来的容器里也只剩下构建后的产物啦:

image-20240324211127497

使用 docker init 简化难度

个人认为 docker 非常难学的一个重要原因是 dockerfile 没有官方标准,各有各的写法,而且从 0 到 1 去写是非常有挑战的一件事。

好在最近 docker 推出了辅助编写 Docker 模版文件的命令—— docker init

docker init 是 docker 推出的辅助构建工具,它能够预生成一套标准的模版文件,模版文件包括 dockerfile、.dockerigonore、docker-compose 等。

运行docker init

根据它的提问输入对应的内容后,提示生成模版文件成功。

image-20240324213325663

我们来看看生成后的Dockerfile有何玄机:

dockerfile
1ARG NODE_VERSION=18.17.0
2ARG PNPM_VERSION=8.14.1
3
4# stage 1
5FROM node:${NODE_VERSION}-alpine as base
6
7WORKDIR /usr/src/app
8
9RUN --mount=type=cache,target=/root/.npm \
10    npm install -g pnpm@${PNPM_VERSION}
11
12# stage 2
13FROM base as deps
14
15RUN --mount=type=bind,source=package.json,target=package.json \
16    --mount=type=bind,source=pnpm-lock.yaml,target=pnpm-lock.yaml \
17    --mount=type=cache,target=/root/.local/share/pnpm/store \
18    pnpm install --prod --frozen-lockfile
19
20# stage 3
21FROM deps as build
22
23RUN --mount=type=bind,source=package.json,target=package.json \
24    --mount=type=bind,source=pnpm-lock.yaml,target=pnpm-lock.yaml \
25    --mount=type=cache,target=/root/.local/share/pnpm/store \
26    pnpm install --frozen-lockfile
27
28COPY . .
29
30RUN pnpm run build
31
32# stage 4
33FROM base as final
34
35ENV NODE_ENV production
36
37USER node
38
39COPY package.json .
40
41COPY --from=deps /usr/src/app/node_modules ./node_modules
42COPY --from=build /usr/src/app/dist ./dist
43
44EXPOSE 3000
45
46CMD node ./dist/main.js
  1. ARG 代表参数,可以传入 FROM 用来指定基础镜像的版本。
  2. WORKDIR /usr/src/app表示现在工作目录是/usr/src/app
  3. FROM node:${NODE_VERSION}-alpine as base 的意思 基于 node:18.17.0-alpine基础镜像安装 pnpm。在注释中,我们将它视作 stage 1。
  4. stage 2 的意思是在stage 1 的基础上通过bind mount的方式将 package.jsonpnpm-lock.yaml挂载过来,然后执行 pnpm install --prod --frozen-lockfile 来安装 node_modules
  5. stage 3 的意思是在 stage 2 的基础上产生一个叫 build 的阶段,用pnpm install --frozen-lockfile的方式安装依赖,并且拷贝源代码,最后执行pnpm run build
  6. stage 4 是最后一个阶段,它做的内容是从 stage 2中把 /usr/src/app/node_modules拷贝到./node_modules,从stage 3中把 /usr/src/app/dist 目录拷贝到./dist
  7. 最好暴露 3000 端口,在容器启动时执行node ./dist/main.js

思考:stage 4 的./dist对应容器的什么目录?

现在执行 docker build 试试:

bash
1docker build -t nest:03 .

产生的 docker image 更小了:

image-20240324220254848

生成容器看看:

image-20240324220345959

查看它的工作目录:

image-20240324220642065

里面仅仅是使用 nest 打包后的 dist 文件以及依赖包和package.json

docker init是不是将编写 Dockerfile 的工作变得超级简单?

总结

images 是通过 dockerfile 构建出来的。

我们写了第一个 dockerfile,通过 FROM、WORKDIR、COPY、RUN、EXPOSE、CMD 等指令声明了一个 http-server 提供静态服务的镜像。

docker run 这个镜像就可以生成容器,指定映射的端口、挂载的数据卷、环境变量等。

VOLUME 指令看起来没啥用,但能保证你容器内某个目录下的数据一定会被持久化,能保证没挂载数据卷的时候,数据不丢失。

docker 在 build 时需要忽略掉某些文件,这些都得写到.dockerignore里。

我们用Nest项目编写了多个 Dockerfile。

为了让容器仅保留构建产物,我们使用了多阶段构建。

写 dockerfile 是一件麻烦的事情,我们使用docker init简化了难度。使用 docker init生成出来的模版,代表业界的最佳实践,但是它并不是万能的,我们需要根据自己的项目情况来动态调整它。

代码示例