Dockerfile
Dockerfile 常用指令
Dockerfile 是 Docker 中用于定义镜像自动化构建流程的配置文件,在 Dockerfile 中,包含了构建镜像过程中需要执行的命令和其他操作。
以官方 getting-started 给的示例为例,总体结构是这样的:
dockerfile1FROM node:18-alpine 2WORKDIR /app 3COPY . . 4RUN yarn install --production 5CMD ["node", "src/index.js"] 6EXPOSE 3000
From
用来指定基础镜像,让 Docker 能够在这个镜像的基础上进行构建的操作。
基础镜像是构建新镜像的根本,Dockerfile 的第一条命令必须是 From 指令。
From 指令支持三种形式
dockerfile1FROM <image> [AS <name>] 2FROM <image>[:<tag>] [AS <name>] 3FROM <image>[@<digest>] [AS <name>]
一个 Dockerfile 可以有多个 From 指令,当 From 指令第二次出现时,表示在此刻构建时,要将当前镜像的内容合并到即将构建的镜像的内容里。
官方也给了一个示例,将前端打包的内容存到 nginx 里
dockerfile1FROM 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 /app/build /usr/share/nginx/html
Run
Run 指令用于向控制台发送命令。
在 Run 指令后,我们直接拼接上需要执行的命令,在构建时,Docker 就会执行这些命令。
dockerfile1RUN <command> 2RUN ["executable", "param1", "param2"]
Run 指令支持\换行,如果单行长度过长,建议对内容进行分割,方便阅读。
CMD
基于镜像的容器,在容器启动后会根据 CMD 定义的内容来执行一个命令。
格式是这样的:
dockerfile1CMD ["executable","param1","param2"] 2CMD ["param1","param2"] 3CMD command param1 param2
EXPOSE
为镜像指定要暴露的端口。
Volume
在构建镜像时,可以先定义一个数据卷,在基于此镜像run一个容器时,自动建立数据卷,不需要使用容器的人手动使用-v去指定数据卷。
COPY 和 ADD
在制作镜像时,我们可能需要一些软件配置、程序代码等直接导入到镜像内的文件系统中,次用 COPY 或 ADD 指令能帮助我们直接从宿主机里拷贝内容到镜像中。
格式是这样的:
dockerfile1COPY <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 自动构建出镜像。
下面有一个示例:
dockerfile1FROM 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。

最后通过 docker build 就可以根据 dockerfile 生成镜像。
bash1docker build -t http-server:hs .
http-server 是镜像名, hs 是镜像的标签。

FROM 是从基础镜像中下载 node 镜像的内容,接着从我们本地拷贝内容,最后是下载 npm 和 http-server。
现在 Desktop 的 images 列表中已经有我们的镜像了。

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

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

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

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

现在我们的基础镜像就跑通了。
打开 Files 查看一下。
这不就是我们在 dockerfile 中指定的 WORKDIR 吗?
里面的两个文件是通过 COPY . . 复制进去的。
指定 VOLUME
如果我想要随时换里面的 index.html 文件怎么办?
我们可以将/app目录设置为挂载点,在生成容器的时候指定宿主机的挂载目录,这样的话修改宿主机挂载目录中的内容会实时影响到容器。
这样修改:

用 -f 指定要 build 的 dockerfile 的文件名。
bash1docker build -t http-server:hs . -f 2.dockerfile
构建完后,RUN 一下这个镜像

是不是就出现 Volumes 选项了。
我把本机 Desktop 的目录挂载到镜像内的/app目录中。

点击 RUN。
往 desktop/123 目录中加一个 index.html。

查看 Docker Desktop 的 Files 栏。

MOUNT 表示这是一个挂载目录。
接着打开http://127.0.0.1:8000/, 现在已经可以访问到了。

在宿主机的修改也可以实时产生变化。
我们修改123目录下的 index.html文件

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

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

为什么要写 VOLUME
其实在 Dockerfile 中不指定 VOLUME,而是在 docker run 生成容器时时用-v参数也可以达到上面的 Bind mounts 效果。
为什么一定要在 Dockerfile 中写出来呢?
这是因为在 dockerfile 里指定 VOLUME 后,如果 docker run 时没有带 -v,docker 会自动帮我们将数据挂载到一个 volume 中。
我们来试试,直接生成容器不填写任何设置项。

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

docker 默认为我们生成了一个随机命名的目录作为数据卷挂载上去了。
在 inspect 中我们能看到数据挂载到这个目录上了。

在 Volumes 里也可以看到它。

这样子就算你把容器给删除了,也可以从这里找回数据。
设想下,如果你跑了个 mysql 容器,存了很多数据,但是跑容器的时候没指定数据卷。有一天,你把容器删了,所有数据都没了,可不可怕?
为了避免这种情况,dockerfile 里是必须声明 volume 的,这样就算你没通过 -v 指定数据卷,将来也可以找回数据。
dockerignore
docker 支持通过 .dockerignore 声明哪些文件不会进入构建范围内。
.dockerignore 是这样写的:
csharp1*.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 的用于指定目录的图标、背景、字体大小的配置文件,这个一般都要忽略。
eslint、prettier 的配置文件在构建镜像的时候也用不到。
#表示这是注释。
这些就是.dockerignore的全部语法。
docker build 时,会先解析 .dockerignore,把该忽略的文件忽略掉,然后把剩余文件打包发送给 docker daemon 作为上下文来构建产生镜像。
忽略这些用不到的文件,是为了让构建更快、镜像体积更小。
Nest 项目编写 dockerfile
新建一个 nest 项目。
bash1nest new dockerfile-test -p pnpm
编写.dockerignore:
csharp1*.md 2node_modules/ 3.git/ 4.DS_Store 5.vscode/ 6.dockerignore
编写 dockerfile:
dockerfile1FROM 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
bash1docker build -t nest:01 .
docker run,接着写入要映射的宿主机端口号。

生成容器成功。

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

现在我们已经成功用 docker 把 nest 应用跑起来了。
但现在 docker 镜像是不完美的。
-
首先是镜像的体积太大:

这个问题好解决,主要原因是基础的 node 镜像太大导致的。
我们只需要使用
alpine版本的 node 镜像即可。dockerfile1FROM node:18.17.0-alpine 2... -
生成出来的容器仅会运行打包后的 dist 目录内的文件,那源代码目录还留着岂不占用内存?

构建的时候是需要用到 src目录的,但是容器运行后就不需要用到它了。
那怎么办呢?
docker 也想到了这个问题,它给出来的答案是多阶段构建。
多阶段构建
所谓多阶段构建,就是把构建过程分成多个阶段。
以上面为例:
- 第一阶段:通过
npm run build生成dist目录。 - 第二阶段:把第一阶段的 dist 目录拷贝过来,并安装所有需要的的依赖包即可。
下面是多阶段构建的 Dockerfile 示例:
dockerfile1# 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 /app/dist /app 20COPY /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 分为了两个阶段:
-
build stage 主要是
build出nest.js的生产环境目录。 -
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 试试
bash1docker build -t nest:02 .
用了node:18.17.0-alpine作为基础镜像后产生的镜像体积小了很多:

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

使用 docker init 简化难度
个人认为 docker 非常难学的一个重要原因是 dockerfile 没有官方标准,各有各的写法,而且从 0 到 1 去写是非常有挑战的一件事。
好在最近 docker 推出了辅助编写 Docker 模版文件的命令—— docker init
docker init 是 docker 推出的辅助构建工具,它能够预生成一套标准的模版文件,模版文件包括 dockerfile、.dockerigonore、docker-compose 等。
运行docker init
根据它的提问输入对应的内容后,提示生成模版文件成功。

我们来看看生成后的Dockerfile有何玄机:
dockerfile1ARG 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 \ 10 npm install -g pnpm@${PNPM_VERSION} 11 12# stage 2 13FROM base as deps 14 15RUN 1617 \ 18 pnpm install --prod --frozen-lockfile 19 20# stage 3 21FROM deps as build 22 23RUN 2425 \ 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 /usr/src/app/node_modules ./node_modules 42COPY /usr/src/app/dist ./dist 43 44EXPOSE 3000 45 46CMD node ./dist/main.js
ARG代表参数,可以传入FROM用来指定基础镜像的版本。WORKDIR /usr/src/app表示现在工作目录是/usr/src/app。FROM node:${NODE_VERSION}-alpine as base的意思 基于node:18.17.0-alpine基础镜像安装pnpm。在注释中,我们将它视作 stage 1。- stage 2 的意思是在
stage 1的基础上通过bind mount的方式将package.json和pnpm-lock.yaml挂载过来,然后执行pnpm install --prod --frozen-lockfile来安装node_modules - stage 3 的意思是在
stage 2的基础上产生一个叫 build 的阶段,用pnpm install --frozen-lockfile的方式安装依赖,并且拷贝源代码,最后执行pnpm run build。 - stage 4 是最后一个阶段,它做的内容是从
stage 2中把/usr/src/app/node_modules拷贝到./node_modules,从stage 3中把/usr/src/app/dist目录拷贝到./dist。 - 最好暴露 3000 端口,在容器启动时执行
node ./dist/main.js。
思考:stage 4 的
./dist对应容器的什么目录?
现在执行 docker build 试试:
bash1docker build -t nest:03 .
产生的 docker image 更小了:

生成容器看看:

查看它的工作目录:

里面仅仅是使用 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生成出来的模版,代表业界的最佳实践,但是它并不是万能的,我们需要根据自己的项目情况来动态调整它。