一个前端眼中的Docker

14,176 阅读16分钟

我正在参加「掘金·启航计划」

Docker 是什么?

Docker 是一个用于开发、运行和发布的应用的开发平台。Docker 能够将应用程序与基础架构分离,通过利用 Docker 快速交付、测试和部署代码的方法,显着减少编写代码和在生产中运行之间的延迟

上面就是官网关于 Docker 的简介。抓住几个关键词来理解 Docker 是什么。

首先分离的作用,Docker 是一种容器技术,一方面 Docker 可以将程序本身同程序的基础架构分离,如同实例之间的关系,只要创建好“类”,就能到处去使用“实例”。另一方面,容器就类似于一个封闭的环境,容器之间也是相互分离的,互不干扰的,保证了环境的统一和安全,就不会出现“测试环境没问题,到了生产环境就报错”这样尴尬的情况。

其次就是快速交付、测试以及部署,说明 Docker 可以用来做 CD/CI,像是自动测试、自动部署以及自动交付代码产物等功能都集成了,所以说 Docker 减少了代码开发和上线之间手动操作导致的效率低下的问题。

image.png 图片来源

上图是 Docker 原理的一个架构图,对于初学者来说,只需要关注最下面的几个概念:cgroups、namespaces、unionfs。这些都是由 linux 的操作系统提供的技术,所以一般 Docker 也是用于 linux 系统比较多,而对于 windows 系统的话,则是需要安装一个虚拟 linux 的环境,这点可以在官网中找到相应的安装方法,就不在此赘述。

前端的构建部署流程

一般部署一个前端应用,普遍的流程就是先运行单元测试,结束以后打包前端应用,将对应的 dist 文件夹给放到 web 服务器上,一般使用 nginx 来作为静态服务器。这个时候访问对应的 IP 就能看到项目了。

无标题-2022-10-23-1252.png

一个前端眼中的docker

对于前端而言,可能大部分都不会用到这个技术,更多是后端会去使用。但是随着前端应用规模越来越大,经常会出现依赖版本不一样,或者线上环境不一样而导致的报错问题,排查起来也非常困难。因此这也是前端使用 Docker 一个最大的契机。

对于一个前端来说,不需要了解太多复杂的概念,只需要了解以下的基本概念即可。

基本概念

  1. 镜像

首先就是开篇提到的类与实例的关系,镜像就是类,镜像类似于系统镜像的概念,对于前端而言,镜像就是包含了代码运行所需要的一切产物、依赖、配置等。这样的话,可以保证每次程序运行的环境一致。构建镜像,一般都是通过一个文本文件来生成。这个文件就是 Dockerfile,文件内容就是一系列的指令集合。

举🌰来说,对于一个简单的前端应用来说,首先需要安装 NodeJS 作为运行环境,其次则是需要安装依赖,最后需要通过npm run build这样的命令来构建应用产物。这个过程在 Dockerfile 中就是一系列的指令集合,后面会具体分析各个指令用法。

  1. 容器

有了镜像以后,可以通过镜像产出容器,这个“容器”就是实例的概念,所以拿到容器以后可以放到任意平台去使用、比如 windows、linux、unix 等,真正做到了一处开发,到处使用的功能。需要注意的是容器并不是虚拟机、它只是一个进程,同普通程序一样,理解这点在启动容器的时候尤为重要。

  1. 仓库

Docker 中的仓库其实和 github、gitee 这样的代码仓库是类似的概念,只是后者是用来存储源代码、而前者是用来存储镜像的,比如前端肯定会使用到的 NodeJS,则是在 Docker Hub 中可以找到。使用的时候,就可以在仓库中找到对应的镜像即可。同样自己写的镜像也可以上传到仓库中,类似于 git 的 push 操作,而 pull 操作则是从仓库中拉取镜像。

  1. Volumes

Volumes 翻译过来为,就是磁盘中的卷的意思,Docker 中的卷主要是用来持久化数据的。当我们生成镜像的时候,需要保持镜像体积尽可能的小,并且镜像中操作数据,下次再去构建时并不会保存操作的数据,因此是不建议在镜像中去操作数据的,如果有操作数据的需要,则可以使用卷关联宿主机上的某个文件夹来持久化保存数据。对于前端而言,这个功能用到的很少。

了解以上这些基本概念以后,就可以尝试在项目中使用 Docker 了。

前端项目中如何使用Docker

这里使用了自己的github上的一个项目作为示例,来展示如何使用?

目录结构如下:

image.png

首先下载 Docker,可以去官网中下载,找到对应的操作系统版本下载即可。这里需要注意 windows 系统下,安装 Docker 还需要开启 BIOS 中的配置,因为安装不是本篇的主要内容,这里就不在细说了。一般安装好以后,Docker 就是一个普通的应用,双击打开,可以看到界面是这样的

image.png

这样就可以开始去定制项目的镜像了。

STEP 1 新建 Dockerfile 配置文件

在项目的根目录下面新建一个 Dockerfile 文件,内容如下:

# 第一阶段
FROM node:16-buster-slim as builder

LABEL description="A demo Dockerfile for build Docsify."

COPY . /var/web/

RUN set -x \
  && cd /var/web \
  && npm install \
  && npm run build

# 第二阶段
FROM nginx:1.23.1-alpine as prod

EXPOSE 80
COPY --from=0 /var/web/docs /usr/share/nginx/html
CMD [ "nginx", "-g", "daemon off;" ]

FROM指令可以理解为拉取依赖,对于上面的配置来说,就是我们的项目依赖于 NodeJS 环境,因此需要首先拉取 NodeJS 的镜像,从哪里拉取呢?这就要提到上面说到的仓库了,默认的拉取就是从 docker 官方的 Docker hub上拉取对应的镜像,“:”后面表示该镜像的标签,所谓标签其实就是给镜像的不同版本起了一个名字。

第二行的LABEL指令不对镜像的构建产生作用,只是一个记录信息的指令,按照 key、value 的格式,可以用来记录诸如作者、版本等元数据信息,类比到前端的话,就是和meta标签的作用类似。

COPY指令根据字面就可以知道,这是一个复制文件到指定目录的指令,可以有多个源路径,以空格分开,目的路径只能存在一个,并且源路径也可以是类似 glob 匹配路径的格式,注意这个匹配模式是遵循 go filespath match 规则的。

COPY . /var/web/ # 复制当前目录到/var/web/下面
COPY ./src/pages/ ./src/routes / # 复制src/pages和src/routes下面的文件夹及文件到根目录下面
COPY ./src/pages/* /var/www/ # 复制src/pages下面的所有文件到/var/www/下面,如果pages下面还存在目录。则会复制该目录下的文件,而不是文件夹

RUN 指令则是执行 linux 命令,可以使用 && 连接多个命令,换行的时候需要添加转移符号\

在 Dockerfile 中,每一行的指令都可以理解为一个单独的 layer,因此指令之间都是独立存在的,也就是下面这样的语句其实是无效的

RUN cd ./src

COPY docs /var/web

此时如果构建镜像的话,就会提示找不到docs目录,原因就是上面所说的 Dockerfile 中的每一级的指令都是独立的,虽然第一行是 cd src 了,但是到了 COPY 指令,实际还是在根目录下,根目录下面并没有docs的文件夹,因此才会报错了。

这个时候可以使用 WORKDIR 指令,指定接下来的所有指令的基础路径。

WORKDIR ./src

# 下面的所有指令都是基于src的文件夹去执行的

紧接着下面又是一个FROM指令,这里就要说到 docker 的一个多阶段构建的一个功能。所谓多阶段,就是把构建流程分为了多个阶段,上面的配置文件中,第一阶段就是生成目标文件夹,也就是打包以后的文件,第二阶段就是把生成好的文件放到 nginx 服务器中。所以可以看到第二阶段,也是以 FROM 指令开头,这次是依赖了 nginx 的镜像,表示这次需要在 nginx 的环境下运行。

可以看到FROM指令最后有一个as xxx这种形式的声明,它表示每一阶段构建的名称,利用这个名称,可以只构建某一阶段的镜像:

docker build --target builder -t docify/blog .

接着EXPOSE指令暴露了80端口,注意这里只是表明了暴露的端口是多少,并不是设置了端口,因为实际的端口实际是容器中 nginx 端口。该指令只是起到了一个说明的作用。

COPY指令上面已经说过了,区别就在于这里多了一个form=0,这表示从第0阶段生成的结果中拿到 install 好的文件去拷贝到 nginx 的目录下。

最后的CMD指令,则是相当于打开了 CMD 的终端,然后输入对应的命令。

至此 Dockerfile 的配置文件常用的指令就讲完了,接下来就可以构建镜像了。

STEP 2 构建镜像

关于 Docker 的核心操作,其实算起来只有两个:构建镜像和启动容器。

首先进入项目根目录下,打开终端,输入命令:

docker build -t docify/blog .

image.png

终端上可以看到镜像构建花费的时间,以及执行指令的过程等信息。这样就可以在 Docker Desktop 中看到构建好的镜像:

image.png

docker build命令后面的 -t 参数表示 tag 标签的意思,就是给镜像起了一个名字叫做 docify/blog

注意命令最后还有一个 .非常重要,这个 . 表示把当前目录下的文件及文件夹作为 Dockerfile 配置文件中的上下文。什么意思呢?Dockerfile 文件中所要做的事,其实就是把项目进行打包构建,但是项目是在本地环境下,并不在 Docker 的环境中,所以就需要这个 . 将当前的项目传输到 Docker 的环境中,也就是所谓的上下文中,这样才会在一开始的配置文件中出现了这么一句COPY . /var/web/,这里的 . 实际就是构建镜像的时候,指定的当前目录下的内容。

关于这一点,也是整个 Docker 学习中的一个难点。一定要理解上下文的概念,这样才不会在构建的时候,找不到对应的构建目录。

STEP 3 创建容器

接下来,可以选择点击上图中 RUN 的按钮来创建容器,也可以使用命令来创建容器。输入以下命令:

docker run -d -p 80:80 --name blog docify/blog

docker run命令则是根据已有的镜像来创建一个容器。

-d 参数表示后台运行容器。对于前端应用来说,是启用了 nginx 作为 web 服务器,nginx 是一直需要保持后台运行,而不是只启动一次就结束了,所以需要加上 -d 的参数。

一般来说,创建好容器以后,可能需要去访问容器中运行的项目,所以需要把容器中的端口对外暴露出来,这样才可以在宿主机上面访问前端应用,此时可以使用 -p 参数。这里就表示容器中的80端口暴露到了宿主机的80端口,就可以访问到前端应用了。

再往后 --name 很好理解,就是设置容器的名字。如果没有该参数,则 Docker 会自动生成一个名字。

最后的 docify/blog 表示上一步中镜像的名字,也就是容器是基于镜像来创建的,这个参数也是一个必不可少的参数。

image.png

打开浏览器,输入地址localhost(nginx 配置文件是默认本地的地址),可以看到部署好的应用:

image.png

总结一下,镜像和容器的关系就像 JavaScript 中的类与实例,镜像就是规定了容器该“长成什么样”。容器就是镜像最终的成果。一个镜像可以生成多个容器,容器也可以放到任意环境中运行,就像一个便捷的笔记本电脑,可以到处运行。

Docker在 CD/CI 中的应用

如果想要把 Docker 的构建流程和 CD/CI 结合起来,以 github action 为例,类似的 gitlab ci、jenkins pipeline 都是类似的,便不再赘述。整个流水线的流程还是按照文章开头给出的流程图,结合文章中给出的步骤,还是比较简单的。

首先创建一个工作流的 yaml 文件:

# This is a basic workflow to help you get started with Actions
name: Learning Route CI
# Controls when the workflow will run
on: [push]
# A workflow run is made up of one or more jobs that can run sequentially or in parallel
jobs:
  # This workflow contains a single job called "build"
  build:
    # The type of runner that the job will run on
    runs-on: ubuntu-latest

    # Steps represent a sequence of tasks that will be executed as part of the job
    steps:
      # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it
      - uses: actions/checkout@v3

      # Runs a single command using the runners shell
      - name: Build docker image
        run: docker build -t blog .

      # Runs a set of commands using the runners shell
      - name: Create container by image
        run: docker run -d -p 80:80 blog --name blog

      - name: Start container log
        run: docker container ls

整个工作流分为几个部分,触发事件、任务以及步骤。一个任务下面可以包含多个步骤,步骤下面则是具体的执行过程。

这里需要基于 ubuntu 的虚拟机作为运行 CI 的环境,也就是上面 runs-on 的配置。接着就是需要下载项目的源码,uses 这里是使用了 github 上自带的 action,可以帮助来下载源码。后面的步骤则是与上一节提到的步骤是一致的,就不再赘述。

以上流水线只是作为一个示例,实际部署到服务器上,或者是部署到 github pages 上,步骤有所不同,但是流程都是类似的。

Docker 镜像的优化

注意构建镜像花费的时间,1400多秒,随着项目体积的日益增长,这个时间可能还会不停的增加,那么如何去优化镜像的构建时间呢?

一个方法就是分开构建,下载的依赖作为一个单独的 Dockerfile,再以这个镜像作为基础镜像,去打包项目源码。这么做可以不需要每次构建镜像的时候,都 install 一下,只需要打包的时间就可以。缺点也很明显,就是如果依赖发生了改变,就需要优先更新依赖包的镜像,然后再进行 build 打包工作。

另外一个方法就是上面提到的多阶段构建,虽然第一次构建的时间比较长,但是好处就是,构建阶段是会存在缓存,二次构建的时候,时间也会大大缩短:

image.png

此时构建时间只需要80多秒了。如果需要跳过缓存来构建镜像,可以添加 --no-cache 的参数:

docker build -t docify/blog . --no-cache

针对上面的多阶段构建,还可以进一步优化,就是将下载依赖和打包的动作分离开,这样就结合了上面的依赖基础镜像的优点,保证了依赖不变的时候,继续使用缓存,也可以缩小构建镜像的时间。完整的配置如下:

FROM node:16-buster-slim as dependency

LABEL description="A demo Dockerfile for build Docsify."

COPY . /var/web/

WORKDIR /var/web

RUN set -x \
&& npm install

FROM node:16-buster-slim as builder

COPY --from=0 /var/web /var/web

WORKDIR /var/web

RUN set -x \
&& npm run build

FROM nginx:1.23.1-alpine as prod

EXPOSE 80

COPY --from=1 /var/web/docs /usr/share/nginx/html

CMD [ "nginx", "-g", "daemon off;" ]

image.png

这里加上了 --no-cache 来跳过缓存,构建时间也是相对于一开始的而言大大减少了。

除此以外,还存在可以优化的地方,那就是上面提到的上下文。构建镜像的时候,是把整个项目中所有的文件都作为上下文传输到 Docker 中了,实际上有些构建的脚本文件是不需要的,可以把它排除出上下文。添加.dockerignore文件实现排除不需要传入上下文的文件,这和 .gitignore 是类似的行为。

image.png

这是没有添加.dockerignore的上下文的大小以及加载时间,虽然花费的时间并不多,但是本着极致优化的原则,将原本不需要的文件排除出上下文:

.github/
.husky/
.vscode/
bin/
node_modules/
docs/
scripts/

image.png

体积和加载时间都减少了,又再一次优化了构建时间!

最后可能会有人存在疑问:为什么要使用 FROM 指令来分割这么多步骤,不能放到一起去执行吗?结合上面提到的每一个都是镜像中的一层,所有的步骤都一次性执行的话,layer 会比较大,最终导致整个镜像的体积也会比较大,比如例子中的项目,如果全部放到一起去构建镜像,最终的镜像大小可能有几百MB,甚至更大,而多阶段构建的镜像,却只有几十MB。

前端眼中的 Docker

总结一下 Docker 之于前端的意义:

  1. 保证运行环境的统一
  2. 一次构建,到处使用

Docker 还有很多细节没有提到,容器化也是未来技术发展的一个方向,学不学习,主要看自己是否需要,并一定要追寻所谓的最新的技术,技术最终还是为了去服务真实的业务而存在的。