笔者一直想学前端来着,然而终于拖到了现在还是啥也不会。

2021 年的 CNSS Recruit 平台搭建工作由我负责后端和运维工作, esp 哥哥负责前端开发, 然而我的电脑上没有前端需要的开发环境, 单纯为了测试部署一整套环境还是挺麻烦的, 所以这回我打算用 Docker 容器搞一个环境用来 Build 前端的代码.

摸索了一个下午, 大概尝试了三种方案, 各有利弊, 记录以备后用:

  1. Docker 命令行 + entrypoint
  2. Docker-compose + dockerfile

Docker 命令行 + entrypoint.sh

前端是用 Vue 写的, 生成代码需要用 Node.js 打包, 原版 Node.js 的镜像怎样才能为我所用呢?

很显然, 我们在原版的 Node.js 镜像基础上, 修改它的 entrypoint.sh 就可以了.

  1. 首先需要有一个 entrypoint.sh 放在和项目文件同级的目录下, 里面写好想要执行的命令:

    npm config set registry https://registry.npm.taobao.org
    npm install --legacy-peer-deps
    npm run build
  2. 将项目文件挂载到容器内(这里是 /app)

    docker run -v absolute/path:/app -w /app --rm -it --entrypoint /bin/sh node:alpine /app/entrypoint.sh
  3. 若挂载正确, 脚本无误的话就会看到容器内正在运行 entrypoint.sh 中的任务.
  4. 运行完毕之后就会看到 ./dist 中已经有了打包好的文件.

命令行参数的具体含义可以参考 Docker Docs, 这里额外做一点解释:

  • -v 是设置容器的挂载卷, 相当于把宿主机的某个目录 (: 前 ) 和容器内的某个目录 (: 后 ) 连接起来实现数据互通, 注意这里必须使用绝对路径.
  • -w 是设置容器中的工作目录, 因为我们后面输入的所有指令都是在这个工作目录下执行的, 所以我们要把项目文件挂载到这里.
  • -it 是返回一个交互式终端, 如果不打算实时看到容器里面的情况的话就不必添加这个参数.
  • --rm 是当容器内的命令运行完毕之后自动销毁容器, 实现 "把一个容器当一条命令" 的效果.
  • --entrypoint 是指定我们要运行的 entrypoint.sh, 无视镜像内定义的 entrypoint.shCMD 指令.

    按理来说, 我们应该运行的命令是 /bin/sh /app/entrypoint.sh , 但是由于 docker 的机制, 我们 --entrypoint 后面需要跟一个可执行文件, 而 /app/entrypoint.sh 作为传递给可执行文件 /bin/sh 的参数, 则应当放在命令的最后.

    所以 --entrypoint /app/entrypoint.sh 是根本不管用的.

这种方案的优点非常明显, 灵活, 简单, 快捷. 写好一次 entrypoint.sh , 以后只要简单执行一下命令就可以了.

缺点也存在, 即部署时需要单独起一个 nginx 容器, 另外手动把编译好的网页拷贝到 nginx 的目录中, 自动化程度略低.

docker-compose 和 dockerfile 搭配

这是一种集成程度更高的部署方案, 大体思路如下:

  1. node 容器负责从项目源代码构建网页;
  2. nginx 容器接收连接;
  3. 通过 docker-compose 实现和其他服务的连接;

关于 1 和 2, 我们用到了 docker 的多阶段构建.

FROM node:16-alpine AS builder

WORKDIR /app

COPY package*.json ./
RUN npm config set registry https://registry.npm.taobao.org && \
    npm install --legacy-peer-deps

COPY ./ .
RUN npm run build

FROM nginx:alpine AS prod

COPY --from=builder /app/dist /usr/share/nginx/html/
COPY nginx.conf /etc/nginx/nginx.conf

通过上面的这个 dockerfile 我们可以方便的定制出一个 nginx 容器, 其中已经包含了我们的网页和 nginx 相关配置文件.

然后我们通过 docker-compose 进一步和其他服务做好互通:

version: '3'

services: 
    db:
        # some database
    
    nginx:
        build: ./src/recruit_cnss_2021_frontend
        container_name: nginx
        restart: always
        environment: 
            TZ: Asia/Shanghai
        ports: 
            - 80:80
            - 443:443
        volumes:
            - ./nginx/logs:/var/log/nginx
            # - ./nginx/www:/usr/share/nginx/html
            # - ./nginx/config:/etc/nginx
        depends_on: 
            - backend
        networks: 
            recruit_net:
            
    backend:
        # some backend

networks: 
  recruit_net:

相较于前一种方案, 第二个的集成度很高, 可以说是开箱即用, 一个 docker-compose up --build 直接从源码构建并完成部署.

但是缺点也很明显, 如果只是想改一下 nginx.conf , 或者是前端代码有更新, 都需要重新 build 镜像, 时间消耗比较大, 且不太容易搭配 CI/CD 工具.

一个小坑

这个问题困扰了我好几个小时,场景是这样的:

你在一个 dockerfile 里面 COPY/生成 了一些文件到容器内目录 A, 然后在启动容器阶段, 你将容器内的目录 A 挂载到了宿主机的目录 B 上, 那么在 B 中你能否看到 dockerfile 构建过程中释放到 A 中的文件呢?

答案是不能的.

以上面第二种方案为例, 如果把 compose 文件中的那几行注释取消掉, 服务是无法正常运行的. 原因和 docker 的原理相关, 如果要简单理解的话(因为我也不懂), 可以认为是:

构建在容器启动之前, 而容器启动时会把对应目录挂载上, 所以原本释放出的文件都无了, 取而代之的是宿主机对应目录的内容.

所以不要指望 dockerfile 里面写几条 RUN 然后把释放出的文件一挂载取回宿主机就删容器这种操作啦.

麻了我也太菜了, 基础不牢地动山摇, 这些小细节纠结太多花费太多时间, 本质上还是对底层原理了解太不到位!

参考文献

  1. Docker run reference | Docker Documentation
  2. 如何通过docker编译前端项目 (zhengjianfeng.cn)
  3. [docker]docker run指定entrypiont - _毛台 - 博客园 (cnblogs.com)