Skip to content
CloudZun
Go back

从原型到产品:vibe-ecommerce 迭代系列(五)— Docker 三容器本地开发环境

编辑此文章

这是 vibe-ecommerce 迭代系列的第五篇。

上一篇记录了 Phase 5 的安全加固和性能优化——express-validator 输入校验、helmet CSP 定制、API 内存缓存、图片懒加载。

Phase 6 原本计划做 SDD 重构。但在动手之前,我改变了主意。

一、为什么改变计划

Phase 5 结束时,代码库一共 1,972 行。按照 LAB-14 的框架,SDD 的触发阈值是 5,000 行——「当你开始有『我不敢改这段代码』的感受时」。

这个项目还没到那个点。

更重要的是,这个项目的定位是教学参考实现,配套 LAB-14 毕业项目。对学员来说,「能快速在本地跑起来」比「代码架构更优雅」更有价值。

所以 Phase 6 的目标改成了:让任何人都能用一条命令复现完整环境

git clone https://github.com/cloudzun/vibe-ecommerce.git
cd vibe-ecommerce
make up

三个容器自动启动,数据库自动初始化,访问 http://localhost 就能看到完整的电商应用。

二、架构设计

三容器分工

学员本地机器 (Docker Compose)
┌─────────────────────────────────────────────────────┐
│  Docker network: vibe-network                        │
│                                                      │
│  ┌──────────────┐  ┌──────────────┐  ┌───────────┐  │
│  │  frontend    │  │  backend     │  │    db     │  │
│  │ nginx:alpine │  │ node:22-alpine│  │postgres:16│  │
│  │  port 80     │  │  port 3001   │  │ port 5432 │  │
│  └──────┬───────┘  └──────┬───────┘  └─────┬─────┘  │
│         │                 │                │        │
│  学员访问 http://localhost  │  容器内通信     │        │
└─────────────────────────────────────────────────────┘

frontend(nginx:alpine):服务静态文件,同时承担反向代理——把 /api/* 请求转发给 backend 容器。选 nginx 而不是 node serve,因为 nginx 是生产标准的静态文件服务器,镜像更小(~5MB vs ~50MB),且能同时承担代理角色。

backend(node:22-alpine):Express API,不暴露端口到宿主机。所有流量经过 nginx 统一入口,更接近真实生产架构。

db(postgres:16-alpine):PostgreSQL 数据库,同样不暴露端口。只有 backend 容器能访问。

关键约束:生产环境不动

这个设计有一个硬性约束:生产环境(Azure VM + SQLite + pm2)完全不受影响

生产代码已经在跑,有真实用户。Phase 6 只能新增文件,不能修改任何现有逻辑。

这个约束带来了一个有趣的问题:前端 JS 里硬编码了生产 API 地址:

const API_BASE = 'https://shop-api.huaqloud.com';

Docker 环境里,API 请求应该走 nginx 代理(/api/*),而不是直接打生产服务器。怎么在不修改源码的情况下解决这个问题?

三、nginx sub_filter:在传输层做 URL 重写

答案是 nginx 的 sub_filter 指令。

location ~* \.js$ {
    sub_filter 'https://shop-api.huaqloud.com' '';
    sub_filter_once off;
}

这段配置的效果:当 nginx 向浏览器传输 .js 文件时,把 https://shop-api.huaqloud.com 替换成空字符串。

浏览器收到的 JS 变成了:

const API_BASE = '';  // 原来是 'https://shop-api.huaqloud.com'

API_BASE 变成空字符串,fetch(API_BASE + '/api/products') 就变成了 fetch('/api/products')——相对路径,由 nginx 代理到 backend 容器。

磁盘上的源文件没有任何改动。Vercel 拿到的还是原始 JS,生产环境行为不变。只有经过 Docker nginx 传输的文件被实时改写。

这是「在正确的层做事」的典型案例:不改代码,在基础设施层解决环境差异。

完整的 nginx 配置:

server {
    listen 80;

    # 传输 JS 文件时重写 API base URL
    location ~* \.js$ {
        root /usr/share/nginx/html;
        sub_filter 'https://shop-api.huaqloud.com' '';
        sub_filter_once off;
        add_header Cache-Control "no-cache";
    }

    # API 请求代理到 backend 容器
    location /api/ {
        proxy_pass http://backend:3001;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
    }

    # SPA fallback
    location / {
        root /usr/share/nginx/html;
        try_files $uri $uri/ /index.html;
    }
}

四、数据库双模式

server/db.js 需要同时支持 SQLite(生产)和 PostgreSQL(Docker)。Knex 的抽象层让这件事变得很简单:

const isPg = !!process.env.DATABASE_URL;

const knex = require('knex')(isPg ? {
  client: 'pg',
  connection: process.env.DATABASE_URL,
} : {
  client: 'better-sqlite3',
  connection: { filename: './data/shop.db' },
  useNullAsDefault: true
});

一行判断,两种行为。DATABASE_URL 由 Docker Compose 注入,生产环境没有这个变量,永远走 SQLite。

所有路由代码(routes/products.jsroutes/orders.jsroutes/auth.js)完全不需要修改——它们只知道 knex,不知道底层是什么数据库。

这是 Phase 3 选择 Knex 而不是直接用 better-sqlite3 的回报。当时的决策理由是「Knex 抽象层保证未来迁移只需改配置」,Phase 6 验证了这个判断。

五、docker-compose.yml 设计

services:
  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_DB: ${POSTGRES_DB}
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    volumes:
      - postgres_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER}"]
      interval: 5s
      timeout: 5s
      retries: 5

  backend:
    build: ./docker/backend
    environment:
      DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
      JWT_SECRET: ${JWT_SECRET}
    depends_on:
      db:
        condition: service_healthy

  frontend:
    build: ./docker/frontend
    ports:
      - "${FRONTEND_PORT:-80}:80"
    depends_on:
      - backend

volumes:
  postgres_data:

几个设计决策:

healthcheck + depends_on:PostgreSQL 启动后需要几秒才能接受连接。condition: service_healthy 确保 backend 在 db 真正就绪后才启动,避免连接失败。

只有 frontend 暴露端口:backend 和 db 只在 Docker 内部网络可访问。学员不需要直接访问这两个容器,统一走 nginx 入口。

FRONTEND_PORT:-80:端口默认 80,可以通过 .env 覆盖。在服务器上验证时,80 端口被 Nginx Proxy Manager 占用,8080 被 SearxNG 占用,用 8081 测试。学员本地机器通常 80 端口空闲,不需要改配置。

六、Makefile:学员操作入口

.PHONY: up down logs reset ps help

up:
	@if [ ! -f .env ]; then cp .env.example .env; fi
	@if command -v docker-compose > /dev/null 2>&1; then \
		docker-compose up -d --build; \
	else \
		docker compose up -d --build; \
	fi

down:
	@if command -v docker-compose > /dev/null 2>&1; then \
		docker-compose down; \
	else \
		docker compose down; \
	fi

reset:
	@if command -v docker-compose > /dev/null 2>&1; then \
		docker-compose down -v && docker-compose up -d --build; \
	else \
		docker compose down -v && docker compose up -d --build; \
	fi

logs:
	docker compose logs -f

ps:
	docker compose ps

make up 做了两件事:

  1. 如果 .env 不存在,自动从 .env.example 复制——学员第一次运行不需要任何额外步骤
  2. 自动检测 docker-compose(v1 独立安装)vs docker compose(v2 插件)——兼容不同环境

make reset 用于演示重置:down -v 清空 volume(包括数据库数据),然后重新构建启动,数据库会重新初始化并 seed。

七、验收结果

所有 7 个任务,OpenCode 一次性完成:

测试预期结果
make up 启动三容器全部 Up
前端页面HTTP 200
nginx sub_filter 生效API_BASE = ''
商品列表(nginx → backend → PostgreSQL)10 products
注册 → 登录 → 下单 → 订单历史完整流程
make reset 清空重建数据重新 seed
生产 API 回归status: ok
无 Docker 模式(SQLite)正常运行
npm audit0 vulnerabilities

验证 sub_filter 是否生效:

curl http://localhost:8081/js/data.js | grep API_BASE
# 输出:const API_BASE = '';
# 磁盘上的文件:const API_BASE = 'https://shop-api.huaqloud.com';

八、系列回顾

六个阶段,每个阶段回答一个核心问题:

Phase核心问题答案
1AI 能多快生成可用原型?40 分钟,927 行,能跑
2如何在 AI 代码上做有质量的迭代?7-Gate 流程
3如何引入后端而不破坏前端?向后兼容设计 + Knex 抽象层
4如何在 AI 协作中保持架构师控制权?清晰的角色边界 + 约束头部
5如何硬化安全而不降低性能?express-validator + 缓存 + lazy load
6如何让任何人都能一键复现?Docker 三容器 + nginx sub_filter + 双模式

最终交付的是一个从 Vibe Coding 原型演进到完整工程实践的全栈应用:完整购物流程、用户认证、后端安全、前端性能、双运行环境、完整文档。

这个系列本身也是一个工程决策的记录——哪些决策在后期得到了回报(Knex 抽象层),哪些计划在执行前被推翻(SDD 重构),以及为什么推翻是正确的。


项目地址:github.com/cloudzun/vibe-ecommerce
Live Demo:vibe-ecommerce-seven.vercel.app
API:shop-api.huaqloud.com


编辑此文章
Share this post on:

📚 相关文章推荐


Previous Post
真钱买假模型:Shadow API 中的欺骗行为系统性审计
Next Post
从原型到产品:vibe-ecommerce 迭代系列(四)— 安全加固、性能优化与图片修复