直接抄就能跑的超通用前端 docker 构建流程

529 阅读5分钟

之前写过一篇 前端项目使用 docker 环境变量配置 - 万字完全指南。里边详细介绍了如何把前端镜像完全做成可配置的模式。但是有同学和我私聊说自己的项目里已经有了获取环境变量的后端接口了,没必要在 docker 上做处理来配置环境变量。问有没有一套更简单,更通用的模板方案来处理前端构建的流程呢?

所以这篇文章就来了,这次我们不分析具体的原理,以简单通用出发,来介绍一套能覆盖90%前端项目构建流程的方案。

怎么简化?

其实之前那套方案里可以优化的地方有两个,这两个就是本文要优化的重点:

  • 增加环境变量的操作过于复杂:每次要添加一个新的环境变量,就要从 DockerFile 开始一路改到 index.html,要改很多东西。
  • 构建流程不统一:前端静态资源的打包和 docker 镜像的构建是分开的,需要打包的机器上有前端的开发环境,这实际上对运维、交付、部署同事是有一定阻力的,能不能将其完全合并在一起?

针对问题1,我们分析一下就可以发现,前端的 docker 镜像实际上只有两个环境变量是必填的,就是 部署相对目录后端服务地址为什么这两个是必填的可以看 这篇文章

所以如果你的项目稍微上一点规模,那么还是更推荐把这些搞到后端一个接口里的,这样更灵活,不用重启服务就能更新配置。后面也可以对接到管理页面。这样前端就只需要配置上面那两个必填环境变量就行了。

针对问题2,我们可以使用 Docker 的多阶段打包来把前端资源的构建整合到 docker 镜像的构建流程里。这样只要环境里有 docker 就可以跑出来前端镜像,方便了不少。并且这样更有利于 CICD 的对接,只需要配一下 docker 构建和缓存就可以了。

OK,分析到这里就可以了,下面我们直接进入正题:

1、环境变量配置

这篇文章 我们使用了请求 header 的方案把环境变量从 nginx 转发到了前端环境里。这次我们来实践一个新方案:shell 脚本替换法。两种方案各有优劣,大家按自己喜好选择即可。

首先是常规的 nginx 配置文件,这两个文件会被塞到 docker 镜像里:

nginx/nginx.conf

user  nginx;
worker_processes  auto;
load_module modules/ngx_nchan_module.so;
load_module modules/ngx_http_headers_more_filter_module.so;

error_log  /var/log/nginx/error.log warn;
pid        /var/run/nginx.pid;

events {
    use epoll;
    worker_connections  1024;
}

http {
    include       /etc/nginx/mime.types;
    default_type  application/octet-stream;
    more_clear_headers 'Server';

    log_format  main  '$remote_addr - $remote_user [$time_local] "$request" '
                      '$status $body_bytes_sent "$http_referer" '
                      '"$http_user_agent" "$http_x_forwarded_for"';

    access_log  /var/log/nginx/access.log  main;

    sendfile       on;
    tcp_nopush     on;
    tcp_nodelay    on;
    client_header_timeout 15;
    client_body_timeout 15;
    send_timeout 15;

    keepalive_timeout  65;

    gzip  on;
    gzip_min_length 1k;
    gzip_buffers 4 16k;
    gzip_http_version 1.1;
    gzip_comp_level 6;
    gzip_types text/plain application/javascript application/x-javascript text/javascript text/css application/xml;
    gzip_vary on;

    include /etc/nginx/conf.d/server.conf;
}

nginx/server.conf

server {
    listen       80;
    server_name  localhost;
    client_max_body_size 2048m;
    client_body_buffer_size 10m;
    include mime.types;
    types
    {
        application/javascript mjs;
    }

    location / {
        root   /usr/share/nginx/html;
        index  index.html index.htm index.shtml;
        try_files $uri $uri/ /index.html;

        if ($request_filename ~* .*\.html$) {
            add_header Cache-Control "no-cache, no-store";
        }
    }

    location /webapi/ {
        proxy_pass {HOHO_BACKEND_URL};
        proxy_connect_timeout 240s;
        proxy_read_timeout 240s;
        proxy_send_timeout 240s;
    }

    # 定义静态资源的根目录
    root /app/static/;
    location /static/ {
        alias /app/robot_data/;
        expires 1h;
        add_header Cache-Control "static";
    }

    location /webws/ {   
        proxy_pass {HOHO_BACKEND_URL};
        proxy_http_version 1.1;
        proxy_connect_timeout 240s;
        proxy_read_timeout 240s;
        proxy_send_timeout 240s;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "Upgrade";
        proxy_set_header X-real-ip $remote_addr;
        proxy_set_header X-Forwarded-For $remote_addr;
    }

    error_page  405 =200 $uri;

    error_page   500 502 503 504  /50x.html;
    location = /50x.html {
        root   /usr/share/nginx/html;
    }
}

没什么好说的,其中有一些小细节大家可以参考一下:

  • nginx.conf 开头使用了两个 load_module 来移除请求里的 serve: nginx 响应头。一些安全和合规测试可能会有这个要求。注意要用这个 nginx 插件的话我们用的 docker 基底镜像就不是官方的 nginx 镜像而是 rookiezoe/nginx
  • serve.conf 中提供了三种常见的需求,分别是:代理后端接口、代理静态资源、代理 websocket 服务。其中静态资源是通过 docker 挂载存储卷到 app/static/ 目录的方式实现的。

nginx 的配置中使用了 {HOHO_BACKEND_URL} 来标记了后端服务地址的占位符。我们稍后就会把这个字符串替换为实际的后端地址。


看完了后端我们来看一下前端,首先是 index.html,其中通过 base 标签指定了当前的相对文件路径:

<head>
  <base href="%VITE_PATH_BASENAME%" />
  <script>
    window.CONFIG = {
      PATH_BASENAME: '%VITE_PATH_BASENAME%',
    }
  </script>
</head>

这个配置相比于之前的 request header 获取参数的方案可以说是简单了很多。注意其中把配置注入到了 window.CONFIG 上,这样路由就可以配置这个路径了:

src/global.d.ts

declare const CONFIG: {
  /** 前端路由前缀 */
  PATH_BASENAME: string;
};

src/route.ts

import { createBrowserRouter } from 'react-router-dom';

export default createBrowserRouter(routes, {
  basename: CONFIG.PATH_BASENAME,
});

这里你可能会好奇,不是使用 docker 环境变量么?你这用的是 VITE 的啊。确实是这样的,因为我们的 vite env 配置如下:

.env

VITE_PATH_BASENAME="/"

.env.production

# 线上环境的配置由 docker env 提供,所以这里要把本地开发环境使用的配置从包里去掉
VITE_PATH_BASENAME="{HOHO_BASE_URL}"

我们本地开发的时候,相对目录就直接是 \,而生产打包之后,这个 %VITE_PATH_BASENAME% 就会被替换成 {HOHO_BASE_URL},最终在 docker 镜像启动的时候被 shell 脚本替换成环境变量。

注意别忘了把 vite 配置成相对目录模式:

export default defineConfig({
  base: './',
});

OK,现在我们来介绍最终的 docker 启动脚本:

#!/bin/sh

set -e

# 替换 nginx 配置文件中的环境变量
sed -i "s|{HOHO_BACKEND_URL}|${HOHO_BACKEND_URL}|g" /etc/nginx/conf.d/server.conf
sed -i "s|{HOHO_BASE_URL}|${HOHO_BASE_URL}|g" /etc/nginx/conf.d/server.conf
# 替换 index.html 文件中的环境变量
sed -i "s|{HOHO_BASE_URL}|${HOHO_BASE_URL}|g" /usr/share/nginx/html/index.html

# 在控制台打印当前使用的环境变量
echo "[ENV] frontend deploy path: $HOHO_BASE_URL"
echo "[ENV] backend proxy path: $HOHO_BACKEND_URL"

# 显示 nginx 启动成功
echo "[DONE] nginx started successfully!"

# 启动 Nginx
exec nginx -g 'daemon off;'

很简单对吧,用 sed 把上面三个文件中的占位符 {HOHO_BASE_URL}{HOHO_BACKEND_URL} 替换成环境变量中的实际值。最后前台模式启动 nginx。

注意开头的 set -e,不加的话你直接 docker run 以前台模式启动容器时就没办法按 ctrl + c 来关闭服务了。

2、Docker 构建合并前端打包流程

通常情况下,我们都是直接本机执行 npm run build 来打包出 dist 文件,然后 COPY 到 docker context 中。但是实际上我们可以直接在 docker 构建流程中实现这个流程:

DockerFile

FROM node:18.19.1-slim as build-stage

ENV NODE_OPTIONS="--max-old-space-size=4096"

RUN npm install -g pnpm

WORKDIR /app

COPY package.json pnpm-lock.yaml ./

RUN pnpm install --frozen-lockfile

COPY . .

RUN pnpm run build

FROM rookiezoe/nginx:1.24.0 as prod-stage

COPY --from=build-stage /app/dist /usr/share/nginx/html
COPY --from=build-stage /app/package.json /usr/share/nginx/html/node-package.json

ENV HOHO_BASE_URL=/

COPY ./nginx/nginx.conf /etc/nginx/nginx.conf
COPY ./nginx/server.conf /etc/nginx/conf.d/server.conf
COPY ./nginx/start.sh /usr/local/bin/start.sh
RUN chmod +x /usr/local/bin/start.sh

EXPOSE 80

ENTRYPOINT ["/usr/local/bin/start.sh"]

其实就是先启动一个 node 的构建容器,打包好之后把这个容器中的 dist 目录拷贝到基于 nginx 的生产镜像中。这里我使用了 pnpm,还配置了个更大的构建内存,你不用的话直接删掉就行。

注意在生产镜像中给前端的相对目录配置了一个默认值 /,因为大部分前端都是部署在根目录下。这样我们通常情况下只配置一个后端地址 HOHO_BACKEND_URL 即可。

docker build 中的缓存

上面的 DockerFile 中用分层策略进行了一个构建缓存,就是这几行:

COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile

COPY . .
RUN pnpm run build

这样的话就可以实现,如果 package.jsonpnpm-lock.yaml 没有变化的话,就直接 run build 而不下载依赖。这个是 docker 的默认行为,具体可以看 Build cache invalidation | Docker Docs

不过上面这个缓存只会在本机中才能实现,对于 CICD 这种每次构建都是新机器的情况下就没效果了。在 CICD 这种场景,我们可以使用 挂载缓存目录使用外部缓存 这两个方案。具体内容展开就太长了,我们这里就按下不表了。

总结

这套方案实际上基本上可以满足绝大多数的前端项目构建需求了。只要你不是月活百万的 toC 项目,基本上这套方案然后小修小改一下都没问题。

核心就是 docker 跑镜像时用 shell 实时替换环境变量。这个方案的上限其实很高,因为在脚本里能做很多事情,比如环境变量里做一些开关,然后把静态资源的挂载目录切换到一些 CDN 之类的,拓展起来就更方便一些。