去年 11 月,我们组来了第三个前端。他装完项目依赖,顺手提交了一个 yarn.lock,CI 直接红了 —— 他本地 Node 18 生成的 lockfile 跟 CI 的 Node 16 不兼容,2000 多行的 diff 根本没法 review。

那天下午我干了两件事:把 lockfile revert 掉,然后开始写项目的第一个 docker-compose.yml

半年后再回头看,Docker 不是银弹,但当你在一个 5 人前端团队里同时维护 3 个 SPAs、1 个 H5、1 个 Flask 中间层的时候,它能解决的问题比你想象的多得多。这篇文章记录的是我们 KMS 项目从"肉身统一环境"到"容器统一环境"的完整踩坑过程,所有配置都是线上在跑的真实版本。


一、"我机器上能跑"是真的吗?

三年前我刚入职的时候,项目的 README 里有一段"环境要求":

  • Node.js >= 16.0.0
  • npm >= 8.0.0 或 yarn >= 1.22.0
  • 本地需安装 MySQL 5.7+

看起来没什么问题,对吧?到了第二个同事入职,他装的是 Node 18 LTS,跑 yarn install 之后 lockfile 里多了 resolution 字段的变更,review 的人看了半天以为他改了依赖版本。第三个同事用的 M1 Mac,node-sass 直接装不上,折腾了一下午换成 sass(dart-sass)。

这还只是 Node 版本。更隐蔽的问题是:

  • 系统依赖差异:Mac 上用 brew 装的 MySQL 是 ARM 版,Linux CI 跑的是 x86,某个 ORM 的 native binding 在两边的行为不一致,测试环境数据库字段编码问题藏了两个月才被发现。
  • 环境变量泄漏.env.local 里写了数据库密码,某次演示时同事把屏幕投出来,大家看到了他本地的 DB_PASSWORD=root123456。你可能会说他为什么不放 .gitignore —— 放了,但是他用 cp .env.example .env.local 之后顺手改了值,提交代码的时候没注意到这个文件被 IDE 的全局搜索带了出来。
  • "我本地能跑"幻觉:同一个分支,三个人的机器上有三种表现。一个人编译快,因为 Mac Studio M2 Ultra;一个人编译慢,因为 2019 年的 Intel MacBook Pro;还有一个人压根编译不过,因为他的 Node 版本少了 crypto 的某个 polyfill。

我们做了一个简单的统计,在引入 Docker 之前的三个月:

问题类型出现次数平均排查时间
Node 版本不一致导致 lockfile 冲突725 分钟
系统依赖缺失/版本不匹配445 分钟
环境变量配置错误330 分钟
“我本地能跑,你本地跑不了”几乎每天无法统计

三个月加起来,花在环境问题上的时间超过 20 个工时。而这些时间完全可以用一套 Docker 配置消灭掉。

Docker 能解决的核心问题其实就一句:把"我的机器"变成"我们的机器"。Node 版本锁死在 node:20-alpine 镜像里,MySQL 在 compose 里一键拉起,环境变量通过 env_file 统一注入 —— 任何人执行 docker compose up 得到的是完全相同的运行时。

当然,有人会说:“前端开发用什么 Docker?Node 装一下不就完了。” 如果你的团队只有一个人,只维护一个项目,确实不需要。但当你需要同时启动前端、后端、数据库来做联调,当你需要新同事第一天就能跑起项目开始写代码,当你需要确保 CI 环境和本地环境用完全相同的 Node 版本和系统库时 —— Docker 的投入产出比是非常高的。我们团队从决定引入到全部迁移完成只用了三天,但之后省下的时间,远不止三天。

接下来我会把整个配置摊开来讲,从 compose 文件到热更新到 IDE 集成,每一步都带着踩坑的经验。


二、Docker Compose 拉起前端+后端+数据库

先看我们的项目结构。KMS 是一个知识管理平台,前端用 React 18 + Vite + TypeScript + Ant Design,后端用 Flask,数据库是 MySQL 8.0:

kms-workspace/
├── kms-frontend/          # React + Vite 前端
│   ├── Dockerfile
│   ├── package.json
│   └── vite.config.ts
├── kms-backend/           # Flask 后端
│   ├── Dockerfile
│   └── requirements.txt
├── docker-compose.yml
└── .env                   # 公共环境变量

docker-compose.yml

下面是实际在跑的配置文件,我去掉了敏感信息,保留了核心逻辑:

version: "3.8"

services:
  # ==========================================
  # MySQL 数据库
  # ==========================================
  mysql:
    image: mysql:8.0
    container_name: kms-mysql
    restart: unless-stopped
    environment:
      MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD:-root123456}
      MYSQL_DATABASE: ${DB_NAME:-kms_dev}
      MYSQL_USER: ${DB_USER:-kms}
      MYSQL_PASSWORD: ${DB_PASSWORD:-kms123456}
    ports:
      - "${DB_PORT:-3306}:3306"
    volumes:
      # 持久化数据库数据,容器删除后数据不丢失
      - mysql_data:/var/lib/mysql
      # 初始化 SQL 脚本,首次启动时自动执行
      - ./kms-backend/init.sql:/docker-entrypoint-initdb.d/init.sql
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
      interval: 10s
      timeout: 5s
      retries: 5
      # 只有当 MySQL 真正准备好接受连接时才算 healthy
      start_period: 30s

  # ==========================================
  # Flask 后端
  # ==========================================
  backend:
    build:
      context: ./kms-backend
      dockerfile: Dockerfile
    container_name: kms-backend
    restart: unless-stopped
    ports:
      - "${BACKEND_PORT:-5001}:5001"
    volumes:
      # 挂载源码,支持热更新
      - ./kms-backend:/app
      # 匿名卷保护虚拟环境,避免被 bind mount 覆盖
      - /app/venv
    environment:
      - FLASK_ENV=development
      - DB_HOST=mysql
      - DB_PORT=3306
      - DB_USER=${DB_USER:-kms}
      - DB_PASSWORD=${DB_PASSWORD:-kms123456}
      - DB_NAME=${DB_NAME:-kms_dev}
    depends_on:
      mysql:
        condition: service_healthy
    command: python app.py

  # ==========================================
  # React + Vite 前端
  # ==========================================
  frontend:
    build:
      context: ./kms-frontend
      dockerfile: Dockerfile
    container_name: kms-frontend
    restart: unless-stopped
    ports:
      - "${FRONTEND_PORT:-5002}:5002"
    volumes:
      # 挂载源码
      - ./kms-frontend:/app
      # 关键:匿名卷保护 node_modules,防止被 bind mount 覆盖
      - /app/node_modules
    environment:
      - VITE_API_BASE_URL=http://localhost:5001
      - VITE_APP_TITLE=KMS知识管理平台
    depends_on:
      - backend
    command: yarn dev --host 0.0.0.0 --port 5002

volumes:
  mysql_data:
    driver: local

这里有几个关键决策值得展开说。

为什么 depends_on 不够,必须加 healthcheck

很多人写 depends_on: - mysql 就以为万事大吉。实际上 depends_on 只确保容器启动了,不管 MySQL 是否准备好接受连接。容器启动了但 MySQL 还在初始化,Flask 连上去就会报 Connection refused

condition: service_healthy 配合 MySQL 的 healthcheck,才能保证后端启动时数据库已经 ready。这个细节在开发环境不致命(反正你可以手动重启),但养成习惯后,写生产配置时就会少踩一个坑。

node_modules 的卷挂载策略

这一行是整篇配置里最容易踩坑的地方:

volumes:
  - ./kms-frontend:/app        # bind mount:宿主机源码映射到容器
  - /app/node_modules          # 匿名卷:保护容器内的 node_modules

为什么需要两行?Docker 的卷挂载顺序决定了:匿名卷 /app/node_modules 会覆盖 bind mount 里对应的 ./kms-frontend/node_modules 路径。如果只有第一行,容器内 /app/node_modules 映射到宿主机 ./kms-frontend/node_modules —— 但宿主机这个目录在构建阶段已经被 docker compose build 生成了,里面的 native binary(如 esbuild)是 Linux 架构的,如果在 Mac 上 rebuild 了镜像,可能跟宿主机已有的 Mac 版 node_modules 冲突。

更安全的做法是:构建阶段在镜像内装好依赖,运行时用匿名卷保护起来,不让宿主机的 node_modules 入侵。这个我们第五章会详细拆解。

开发阶段的 Dockerfile

前端的开发 Dockerfile,关键区别在于:不要多阶段构建

# kms-frontend/Dockerfile(开发环境)
FROM node:20-alpine

# 安装系统依赖(如果项目用了 sharp、canvas 等需要 native 编译的库)
RUN apk add --no-cache python3 make g++

WORKDIR /app

# 先复制依赖描述文件,利用 Docker 层缓存
# 只要 package.json 和 yarn.lock 不变,这层就不会重新构建
COPY package.json yarn.lock ./

# 安装依赖
RUN yarn install --frozen-lockfile

# 源码通过 volume 挂载,不 COPY 到镜像里
# 这样修改代码不需要重新 build 镜像

EXPOSE 5002

CMD ["yarn", "dev", "--host", "0.0.0.0", "--port", "5002"]

值得注意的是 CMD 而不是 RUN。很多人习惯把启动命令写死在 Dockerfile 里,但 docker-compose.yml 的 command 可以覆盖 CMD,这样同一个 Dockerfile 可以在 compose 里用 command 灵活调整参数,不影响镜像本身。

生产环境的 Dockerfile 完全不同:需要多阶段构建,用 nginx 作为 base image,COPY 构建产物而不是挂载卷。但开发环境的核心诉求是"改代码立刻生效",所以源码通过 volume 挂载进来、依赖装在镜像里、启动命令外挂在 compose 里。这套配置在生产环境不适用,但在开发阶段是最优解。

后端的 Dockerfile 思路一样,这里不展开了:

# kms-backend/Dockerfile(开发环境)
FROM python:3.11-slim

WORKDIR /app

# 系统依赖
RUN apt-get update && apt-get install -y --no-install-recommends \
    gcc default-libmysqlclient-dev && \
    rm -rf /var/lib/apt/lists/*

COPY requirements.txt .
RUN pip install -r requirements.txt

EXPOSE 5001

CMD ["python", "app.py"]

到这里,一个新同事只需要:

git clone xxx
cd kms-workspace
cp .env.example .env
docker compose up -d

三分钟后,前端在 localhost:5002,后端在 localhost:5001,数据库 ready。不需要装 Node、Python、MySQL,不需要纠结版本。接下来我们看热更新怎么配。


三、热更新、卷挂载与环境变量

Docker 开发环境最大的体验差距是:改一行代码,要不要重新 build 镜像?答案是 不要。如果每次改 CSS 都要跑 docker compose build && docker compose up,那这玩意儿还不如不用。

Vite HMR 在 Docker 里的配置

Vite 的热更新依赖 WebSocket。在 Docker 里跑 Vite dev server 需要两件事:

1. --host 0.0.0.0

不加这个参数,Vite 默认只监听 localhost,容器的 localhost 不等于宿主机的 localhost,浏览器根本连不上。

2. HMR WebSocket 端口

Vite 默认 HMR 走 dev server 的同端口(比如 5002),但如果你的反向代理或 Docker 网络做了特殊配置,可能需要单独指定:

// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
  server: {
    host: true,        // 等同于 --host 0.0.0.0
    port: 5002,
    // Docker 环境下 HMR 配置
    watch: {
      usePolling: true,     // 某些文件系统(如 macOS + Docker Desktop)
      interval: 1000,       // 轮询间隔,默认值即可
    },
    // 如果需要单独配置 HMR
    hmr: {
      host: 'localhost',
      port: 5002,
    },
  },
});

usePolling: true 是一个容易忽略但至关重要的选项。Docker Desktop on macOS 的卷挂载基于 osxfs,文件变更事件的传递有时候会延迟或丢失。启用轮询后,Vite 会主动检查文件变化而不是被动等待 inotify 事件。代价是 CPU 占用会高一点(约 2-5%),但换来的稳定性绝对值得。

我们最初没开这个选项,经常出现改了代码页面不更新、需要手动刷新的情况。三个前端每人每天手动刷新几十次,开了轮询之后这个问题完全消失。

卷挂载的坑:node_modules 覆盖问题(详细拆解)

这是 Docker 开发环境最常见也最隐秘的问题。让我们从头推演一遍。

你的 compose 文件里有这两行:

volumes:
  - ./kms-frontend:/app
  - /app/node_modules

完整的执行顺序是这样的:

  1. docker compose build → 在镜像内的 /app 下执行 yarn install,生成 /app/node_modules(Linux 二进制)
  2. docker compose up → bind mount 把宿主机 ./kms-frontend 映射到容器 /app
  3. 此时容器内 /app/node_modules 被 bind mount 覆盖,变成宿主机 ./kms-frontend/node_modules
  4. 但!匿名卷 /app/node_modules 随后又把容器内这个路径覆盖回来

第四步是关键。如果没有匿名卷声明,容器运行时使用的 node_modules 就是宿主机上的那份。宿主机上可能:

  • 根本没有 node_modules(没有本地安装过依赖)
  • 有 Mac 版本的 node_modules(native binary 跟 Linux 不兼容)
  • 有旧版本的 node_modules(跟 lockfile 不一致)

无论哪种情况,vite 都找不到正确的依赖,表现就是容器启动后报 Cannot find package 'vite'esbuild 段错误。

解决方案的核心逻辑:依赖写在镜像里(build 阶段),运行时不碰它(匿名卷保护)。源码挂在外面(volume mount),修改立即生效。两者互不干扰。

我们团队还尝试过另一种方案:把 node_modules 也挂载出来,在宿主机上执行 yarn install。优点是 dev server 启动更快(不需要进容器装依赖),缺点是你又回到了"宿主机环境依赖"的老路上 —— M1 Mac 和 Intel Mac 的 native binary 又不一样了。所以我们最终选择了"依赖全在镜像里"的方案。

.env 管理策略

KMS 项目有三套环境变量:

kms-frontend/
├── .env                  # 公共默认值,提交到 Git
├── .env.development      # Docker 开发环境专用
├── .env.local            # 本地敏感信息,不提交 Git
└── .env.production       # 生产环境

在 Docker compose 里,我们通过 env_file 注入:

frontend:
  env_file:
    - ./kms-frontend/.env.development
  environment:
    - VITE_API_BASE_URL=http://localhost:5001

注意 env_fileenvironment 的区别:env_file 指向一个文件,environment 是内联的 key-value。前者适合大量公共变量,后者适合少量需要覆盖或动态计算的变量。两者可以同时使用,environment 的优先级更高。

Vite 对 VITE_ 前缀的变量有特殊处理:只有 VITE_ 开头的变量会暴露给客户端代码(import.meta.env.VITE_XXX)。这是安全机制,防止你不小心把数据库密码暴露到前端 bundle 里。所以前端的 API 地址必须叫 VITE_API_BASE_URL,不能叫 API_BASE_URL

有一个隐秘的点:Docker compose 里的 environment 是在容器启动时注入的,而 Vite 的 import.meta.env 是在构建时确定的。开发环境下 Vite 以 dev server 模式运行,每次请求时会重新读取环境变量,所以 runtime 注入没有问题。但如果你在 Docker 里跑 yarn build,环境变量必须 build time 注入,否则客户端代码读到的是 undefined。这个坑第五章会单独讲。

前后端联调:CORS 与 network

后端用 Flask,默认只响应同源请求。在 docker-compose 里,前端和后端虽然在同一个 network 上,但浏览器访问前端是 localhost:5002,前端 JS 发请求到 localhost:5001,这已经是跨域了。

Flask 开发环境的 CORS 配置:

# kms-backend/app.py
from flask import Flask
from flask_cors import CORS

app = Flask(__name__)
CORS(app, origins=["http://localhost:5002"], supports_credentials=True)

docker-compose 内部有三个东西互联:

  • frontend 容器(端口映射 5002:5002)
  • backend 容器(端口映射 5001:5001)
  • mysql 容器(端口映射 3306:3306)

Compose 会自动创建一个 {project}_default 的 bridge network,三个容器可以通过 service name 互相通信。所以后端连接数据库用的是 DB_HOST=mysql 而不是 localhost。这一点新手经常搞混 —— 在容器内部,localhost 指向容器自己,mysql 才是 MySQL 容器的地址。

整个请求链路如下:

在这里插入图片描述

每一段都在 Docker 网络层做了隔离,不会污染宿主机环境。


四、Dev Container:把 IDE 也标准化

环境统一了,代码跑起来了,但还有一个细节:插件和格式化规则

我们组的 ESLint 规则是统一的(.eslintrc.cjs),Prettier 规则也是统一的(.prettierrc),但真正在编辑器里生效,依赖每个人自己装插件、调设置。有人用的 VS Code,有人用的 Cursor,有人用的 WebStorm,有人 Prettier 设成了 format on save,有人设成了 format on paste。

一个典型的场景:同事 A 用 Cursor,没有装 ESLint 插件,他写的代码在本地看着没问题,推到 GitLab 之后 CI eslint 报 15 个 error。因为他的编辑器根本没有提示。

Dev Container 可以彻底解决这个问题:把 VS Code 插件列表写进配置,任何人打开项目,插件自动安装、格式化规则自动生效

.devcontainer.json 配置

{
  "name": "KMS Development",
  "dockerComposeFile": "../docker-compose.yml",
  "service": "frontend",
  "workspaceFolder": "/app",
  "customizations": {
    "vscode": {
      "extensions": [
        "dbaeumer.vscode-eslint",
        "esbenp.prettier-vscode",
        "dsznajder.es7-react-js-snippets",
        "bradlc.vscode-tailwindcss",
        "ms-vscode.vscode-typescript-next"
      ],
      "settings": {
        "editor.formatOnSave": true,
        "editor.defaultFormatter": "esbenp.prettier-vscode",
        "editor.codeActionsOnSave": {
          "source.fixAll.eslint": "explicit"
        },
        "typescript.tsdk": "node_modules/typescript/lib",
        "typescript.enablePromptUseWorkspaceTsdk": true,
        "files.eol": "\n"
      }
    }
  },
  "features": {
    "ghcr.io/devcontainers/features/git:1": {}
  },
  "forwardPorts": [5002, 5001],
  "postCreateCommand": "yarn install --frozen-lockfile"
}

这份配置做了几件事:

  1. dockerComposeFile 指向已有的 compose 文件,Dev Container 不会新建容器,而是 attach 到 compose 启动的 frontend 服务里。
  2. extensions 统一了 VS Code 插件列表,同事打开的瞬间自动安装。
  3. settings 强制开启 formatOnSave、ESLint auto fix,TypeScript 版本锁定在项目的 node_modules 里。这避免了 VS Code 自带 TypeScript 版本和项目版本不一致导致的类型检查差异。
  4. forwardPorts 自动转发端口,容器内的 5002 和 5001 直接从宿主机浏览器访问。
  5. postCreateCommand 确保容器启动后的依赖完整。

真正的"第一天"体验

有了这套配置,一个新同事加入团队的流程变成了:

  1. 装 Docker Desktop(一次性)
  2. git clone <repo-url>
  3. VS Code 打开项目文件夹 → 右下角弹出 “Reopen in Container” → 点一下
  4. 等几分钟(拉镜像、装依赖)
  5. 浏览器打开 http://localhost:5002 → 开始写代码

不需要装 Node,不需要装 MySQL,不需要配置 ESLint,不需要手动设置 Prettier。环境问题从"半个工作日的噩梦"变成了"Docker 拉镜像时去泡杯咖啡"的时间。

你可能会说:“那我用 Cursor / WebStorm 怎么办?” Dev Container 目前在 VS Code 系(VS Code、Cursor、GitHub Codespaces)支持最好。如果你团队混用多种编辑器,至少可以做到 compose + Dockerfile 统一运行时,插件部分各管各的。我们组的实际操作是:在 compose 配置上强制统一(否则联调不通),Dev Container 作为"推荐配置"而非强制。

我觉得这是一个务实的平衡。工具统一要追求的是消除能导致 bug 的差异(Node 版本、依赖版本、运行时行为),而不是无差别地消灭一切差异(谁用什么编辑器、代码主题颜色)。后者属于个人偏好,不值得花精力去统一。


五、开发环境的 5 个踩坑清单

这一章是我们在实际使用中踩过的 5 个坑,每个都按"现象 → 根因 → 解决"的结构展开。

坑 1:node_modules 覆盖导致 Vite 找不到依赖

现象docker compose up 之后,前端容器日志里报:

Error: Cannot find module 'vite'

明明 Dockerfile 里写了 yarn install,构建时也确认安装成功了。

根因:bind mount ./kms-frontend:/app 把宿主机的空目录(没有 node_modules)覆盖了容器内的 /app/node_modules

解决:在 compose 的 volumes 里加一行匿名卷:

volumes:
  - ./kms-frontend:/app
  - /app/node_modules    # 这一行保护容器内的 node_modules

匿名卷的优先级高于 bind mount,容器启动后 /app/node_modules 会被恢复为镜像里的版本。如果还是不行,删掉匿名卷重新创建:

docker compose down -v   # -v 会删除匿名卷
docker compose up -d     # 重新创建

坑 2:端口冲突排查

现象docker compose up 报错 port is already allocated

根因:三种可能 ——

  1. 宿主机已经有进程占用了 5002 或 5001(比如之前手动跑的 yarn dev
  2. 之前的 Docker 容器没有正常退出,端口还在占用
  3. macOS 的 AirPlay Receiver 默认占用 5000 端口(我们遇到了)

解决

# 1. 检查宿主机端口占用
lsof -i :5002
# 如果是 AirPlay:系统设置 → 通用 → 隔空播放接收器 → 关闭

# 2. 检查 Docker 容器
docker ps -a | grep kms
docker rm -f <container-id>

# 3. 如果都不行,换端口
# docker-compose.yml 中改为:
#   ports:
#     - "${FRONTEND_PORT:-5003}:5002"

建议把 5000-5002 这段端口留给 Docker 用,本地不要再手动跑 dev server。我们在 .husky/pre-commit 里加了一个检查:

#!/bin/sh
# .husky/pre-commit
if lsof -i :5002 > /dev/null 2>&1; then
  echo "❌ 端口 5002 被占用,请检查是否有本地 dev server 在运行"
  echo "   提示:使用 docker compose up 启动开发环境"
  exit 1
fi

坑 3:构建缓存不生效

现象:每次 docker compose build 都要重新下载依赖,耗时 3-5 分钟。

根因:Dockerfile 中 COPY . . 放在依赖安装之前,任何文件改动都会导致缓存失效。

错误写法

WORKDIR /app
COPY . .                       # 先复制所有文件
RUN yarn install               # 后装依赖 —— 只要任何文件变了,缓存全部失效

正确写法

WORKDIR /app
COPY package.json yarn.lock ./  # 先复制锁文件
RUN yarn install --frozen-lockfile  # 装依赖 —— 这层会缓存
COPY . .                        # 最后复制源码 —— 只有这层会失效

利用 Docker 的分层缓存机制:每一层都是独立的缓存单元。package.jsonyarn.lock 不常变,对应的 yarn install 层就几乎不会被重新构建。真正频繁变化的是源码 COPY . .,但这一层极快,不需要重新下载依赖。

这个优化让我们从每次 build 5 分钟降到了 10 秒(依赖层命中缓存)。开发阶段频繁 rebuild 的时候,体验提升非常明显。

坑 4:Docker Desktop 默认内存不足

现象docker compose up 之后前端容器频繁被 OOM Kill,日志里出现 exit code 137(典型的 OOM kill 信号)。

或者 Vite 启动时报 JavaScript heap out of memory

根因:Docker Desktop on macOS/Windows 默认分配的内存是 2GB。我们同时跑 MySQL(约 500MB)+ Flask(约 200MB)+ Vite dev server + HMR(约 800MB-1.2GB),高峰期轻松超过 2GB。

解决

  1. Docker Desktop → Settings → Resources → Memory → 调到 6GB 或更高(16GB 以上的机器建议 8GB)
  2. 如果内存紧张,可以在 compose 里限制单个容器的内存:
frontend:
  deploy:
    resources:
      limits:
        memory: 2g
      reservations:
        memory: 512m
  1. Vite 开发阶段可以手动设置 Node 内存上限:
frontend:
  command: node --max-old-space-size=2048 node_modules/.bin/vite --host 0.0.0.0 --port 5002

我们团队 5 个人,2 台 16GB MacBook Pro、2 台 32GB MacBook Pro、1 台 64GB Mac Studio。16GB 的同事开了 Docker + VS Code + Chrome 十几个 tab + Figma 之后确实紧张。最后定了一个最低配置标准:开发机 16GB 以下的不建议用全套 Docker 开发环境,可以用 compose 只拉数据库和后端,前端在本地跑。

坑 5:环境变量在 build time vs runtime 的差异

现象:在 Docker compose 的 environment 里设置了 VITE_API_BASE_URL=http://localhost:5001,但跑 docker compose exec frontend yarn build 之后,生产包里 import.meta.env.VITE_API_BASE_URLundefined

根因:Vite 的 import.meta.env.VITE_XXX 是在构建时yarn build)被静态替换为具体值的,不是在运行时读取的。Compose 里的 environment 是运行时注入,构建时不存在。

解决:区分两种场景:

开发环境yarn dev):Vite dev server 在运行时动态读取环境变量,environment 注入完全没问题。这也是我们日常开发的方式。

构建环境yarn build):必须在构建时设置环境变量。两种做法:

  1. 在 Dockerfile 里用 ARG + ENV
ARG VITE_API_BASE_URL
ENV VITE_API_BASE_URL=$VITE_API_BASE_URL

构建时传入:

docker build --build-arg VITE_API_BASE_URL=https://api.kms.example.com .
  1. 在 compose 里创建一个 build 专用的 service,用 args 传递:
frontend-build:
  build:
    context: ./kms-frontend
    args:
      VITE_API_BASE_URL: ${VITE_API_BASE_URL}

我们实际采用的方案是:日常开发用 environment(runtime 注入),CI/CD 构建用 build args(build time 注入)。两者不混用,避免在这个细节上浪费时间排查。


更多推荐