用 Mac mini + Cloudflare Tunnel 搭建零成本 MR 预览环境

2 天前
2

用 Mac mini + Cloudflare Tunnel 搭建零成本 MR 预览环境

每个 Merge Request 自动生成一个在线可访问的预览环境,团队可以直接在浏览器里验收功能,无需本地启动项目。

我们要解决什么问题

前后端全栈项目的 Code Review 有个痛点:光看代码 diff 很难判断功能是否正确。尤其是 UI 变更、API 联调、端到端流程验证,往往需要 Reviewer 本地拉代码、装依赖、启动服务才能验证。这极大地拖慢了 Merge Request 的流转速度。

我们希望做到:创建 MR 的那一刻,就自动生成一个只有这个 MR 代码版本的在线预览环境。Reviewer 打开链接就能看到效果,产品经理也能直接参与验收。

整体架构

GitLab MR ──→ GitLab CI ──→ Mac mini Runner
                                │
                    ┌───────────┼───────────┐
                    │           │           │
              Docker Build  Docker Run  Health Check
                    │           │           │
                    └───────────┼───────────┘
                                │
                     Cloudflare Tunnel API
                                │
              ┌─────────────────┼─────────────────┐
              │                 │                   │
    feature-mr-42.preview.xxx   │   fix-bug-mr-43.preview.xxx
              │                 │                   │
              └─────────────────┼─────────────────┘
                                │
                          用户浏览器访问

核心思路:用一台 Mac mini 作为 GitLab Runner,在上面跑 Docker 容器,再通过 Cloudflare Tunnel 把每个 MR 的容器暴露到公网

为什么选择这个方案

方案 成本 复杂度 限制
每次部署到 K8s 命名空间 高(集群资源) 高(需要 Ingress 配置) 命名空间管理复杂
Vercel/Netlify Preview 只支持纯前端项目
Mac mini + Cloudflare Tunnel 极低 中等 容器数量受单机资源限制

我们的项目是前后端一体部署(Node.js 服务 + Vite 前端打包到 public/),不是纯静态站点,所以 Vercel Preview 不适用。而每次为 MR 单独开 K8s 资源又太重。Mac mini 方案恰到好处:一台机器就能并行跑十几个预览环境。

逐步实现

1. GitLab CI Pipeline 触发

只在 Merge Request 事件时触发预览部署:

workflow:
  rules:
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
    - if: '$CI_PIPELINE_SOURCE == "web"'
    - when: never

预览环境用 resource_group 来串行化隧道操作(Cloudflare Tunnel 配置是共享资源),但允许不同 MR 并行部署:

deploy_preview_mr:
  resource_group: ai-canvas-preview-tunnel
  interruptible: true
  environment:
    name: review/mr-$CI_MERGE_REQUEST_IID
    url: $DYNAMIC_ENVIRONMENT_URL
    on_stop: stop_preview_mr
    auto_stop_in: 7 days

environment 块是关键——它让 GitLab 在 MR 页面显示一个 "View environment" 按钮,点击直接跳转到预览 URL。auto_stop_in: 7 days 确保过期 MR 不会一直占用资源。

2. 生成唯一的预览域名

每个 MR 需要一个唯一的子域名,格式为 {branch-slug}-mr-{iid}.{domain}

SOURCE_BRANCH_SLUG="$(
  printf '%s' "${CI_MERGE_REQUEST_SOURCE_BRANCH_NAME}" |
    tr '[:upper:]' '[:lower:]' |
    sed -E 's/[^a-z0-9-]+/-/g; s/-+/-/g; s/^-//; s/-$//'
)"

# DNS 标签最长 63 字符,预留 "-mr-{iid}" 的空间
MAX_SOURCE_BRANCH_SLUG_LENGTH=$((63 - 4 - ${#CI_MERGE_REQUEST_IID}))
if [ "${#SOURCE_BRANCH_SLUG}" -gt "${MAX_SOURCE_BRANCH_SLUG_LENGTH}" ]; then
  SOURCE_BRANCH_SLUG="$(printf '%s' "${SOURCE_BRANCH_SLUG}" | cut -c "1-${MAX_SOURCE_BRANCH_SLUG_LENGTH}" | sed -E 's/-$//')"
fi

PREVIEW_HOST="${SOURCE_BRANCH_SLUG}-mr-${CI_MERGE_REQUEST_IID}.${PREVIEW_DOMAIN_ROOT}"

例如分支 feat/video-export 的 MR #42 会得到域名 feat-video-export-mr-42.preview.example.com

3. Docker 构建与容器管理

在 Mac mini 上直接构建和运行 Docker 容器:

# 构建时注入 MR 标签,方便后续清理
docker build \
  --label ai-canvas.preview=true \
  --label "ai-canvas.preview.mr=${CI_MERGE_REQUEST_IID}" \
  --label "ai-canvas.preview.sha=${CI_COMMIT_SHORT_SHA}" \
  -t "${PREVIEW_IMAGE}" \
  .

# 运行容器,限制资源
docker run -d \
  --name "${PREVIEW_CONTAINER_NAME}" \
  --restart unless-stopped \
  --network "${PREVIEW_DOCKER_NETWORK}" \
  --memory=1g --memory-swap=1g --cpus=0.5 \
  --label ai-canvas.preview=true \
  --label "ai-canvas.preview.mr=${CI_MERGE_REQUEST_IID}" \
  --env-file "${PREVIEW_RUNTIME_ENV_FILE}" \
  -e "REDIS_KEY_PREFIX=preview:mr-${CI_MERGE_REQUEST_IID}:" \
  -e "BETTER_AUTH_URL=https://${PREVIEW_HOST}" \
  "${PREVIEW_IMAGE}"

注意几个设计细节:

  • 资源限制:每个容器最多 1GB 内存、0.5 CPU,防止单个预览拖垮宿主机
  • Docker Label:用标签标记 MR 编号和 commit SHA,后续精准清理
  • Redis Key Prefix:不同 MR 用不同的 key 前缀,共享 Redis 实例不会互相干扰
  • 动态环境变量BETTER_AUTH_URLCORS_ALLOWED_ORIGINS 等都根据预览域名动态生成

4. 环境变量安全管理

预览环境需要真实的服务端密钥(数据库、Redis、第三方 API 等)。我们把敏感配置存为 GitLab File Variable,在 CI 中通过脚本校验后写入临时文件:

// scripts/gitlab/normalize-preview-env.mjs
// 校验必要的环境变量是否存在、URL 格式是否正确、枚举值是否合法
const requiredKeys = new Set([
  'DATABASE_URL', 'REDIS_URL', 'BETTER_AUTH_SECRET',
  'TOS_ACCESS_KEY_ID', 'FEISHU_APP_ID', ...
])

const missingKeys = [...requiredKeys].filter(key => !env.has(key))
if (missingKeys.length > 0) {
  fail(`Missing required keys: ${missingKeys.join(', ')}.`)
}

// 输出文件权限设为 0600,仅 owner 可读写
writeFileSync(outputPath, ..., { mode: 0o600 })

这样做的好处是:

  • 敏感信息不硬编码在 CI 配置里
  • 有完整的校验逻辑,缺配置时提前报错而不是运行时才挂
  • 共享数据库和 Redis(开发环境实例),不同 MR 通过 key 前缀隔离

5. Cloudflare Tunnel 动态路由

这是整个方案最精巧的部分。我们在 Mac mini 上跑了一个常驻的 cloudflared 容器,它维护着一条到 Cloudflare 的隧道。然后通过 Cloudflare API 动态修改隧道的 ingress 规则,把不同的子域名路由到不同的预览容器:

// scripts/gitlab/preview-tunnel.mjs
async function up() {
  // 获取当前隧道配置
  const current = await getConfiguration()

  // 添加新规则:preview-host → preview-container:3000
  const previewRule = {
    hostname: context.previewHost,
    service: `http://${context.previewContainerName}:3000`,
    originRequest: {
      connectTimeout: 60,
      disableChunkedEncoding: true,
      httpHostHeader: context.previewHost,
    },
  }

  // 替换同域名的旧规则,保留其他 MR 的规则
  config.ingress = [
    ...rules.filter(rule => rule.hostname !== context.previewHost),
    previewRule,
    catchAll,  // 必须以 catch-all 结尾
  ]

  await putConfiguration(config)
}

down 操作则反过来:移除对应域名的 ingress 规则。这样同一个 Cloudflare Tunnel 可以同时服务多个 MR 预览环境,互不干扰。

6. 双重健康检查

部署完成后有两次健康检查,确保用户访问时服务已经完全就绪:

第一轮:容器内部健康检查(Docker 网络内)

# 从另一个容器通过 Docker 网络访问,不经过公网
for i in $(seq 1 60); do
  if docker run --rm --network "${PREVIEW_DOCKER_NETWORK}" curlimages/curl:latest \
    -fsS -H "Host: ${PREVIEW_HOST}" \
    "http://${PREVIEW_CONTAINER_NAME}:3000/api/health" >/dev/null; then
    break
  fi
  sleep 2
done

第二轮:公网健康检查(通过 Cloudflare Tunnel)

# 通过公网 URL 访问,验证端到端链路
for i in $(seq 1 60); do
  if curl -fsS "https://${PREVIEW_HOST}/api/health" >/dev/null; then
    break
  fi
  sleep 2
done

如果第二轮公网检查失败,会自动回滚隧道配置到上一个容器(如果存在的话),或者清理隧道入口,避免指向一个不可用的容器。

7. 自动清理机制

预览环境有完善的清理策略:

# MR 关闭/合并时手动或自动清理
stop_preview_mr:
  environment:
    name: review/mr-$CI_MERGE_REQUEST_IID
    action: stop
  script:
    - node scripts/gitlab/preview-tunnel.mjs down  # 移除隧道路由
    - docker rm -f $(容器列表)                       # 删除容器
    - docker rmi $(镜像列表)                         # 删除镜像

# 手动清理 7 天前的旧镜像
cleanup_preview_images:
  script:
    - docker image prune -a -f --filter label=ai-canvas.preview=true --filter until=168h

此外,每次部署新 commit 时也会自动清理同一个 MR 的旧容器和旧镜像:

# 删除同一 MR 的旧容器
for cid in $(docker ps -aq --filter "label=ai-canvas.preview.mr=${CI_MERGE_REQUEST_IID}"); do
  sha="$(docker inspect -f '{{ index .Config.Labels "ai-canvas.preview.sha" }}' "${cid}")"
  if [ "${sha}" != "${CI_COMMIT_SHORT_SHA}" ]; then
    docker rm -f "${cid}"
  fi
done

8. 容器数量限制

单机资源有限,需要防止预览容器过多:

PREVIEW_MAX_CONTAINERS="${PREVIEW_MAX_CONTAINERS:-10}"
TOTAL_PREVIEW_CONTAINER_COUNT="$(docker ps -q --filter label=ai-canvas.preview=true | wc -l)"

if [ "${TOTAL_PREVIEW_CONTAINER_COUNT}" -ge "${PREVIEW_MAX_CONTAINERS}" ]; then
  # 如果当前 MR 已有预览容器,允许更新
  # 否则拒绝部署,提示先清理旧环境
fi

9. 数据库 Schema 变更检测

因为多个 MR 共享同一个开发数据库,如果某个 MR 修改了数据库 Schema 但没有提前执行迁移,其他 MR 的预览可能会报错。我们在部署时自动检测:

SCHEMA_CHANGED_FILES="$(
  git diff --name-only "${CI_MERGE_REQUEST_DIFF_BASE_SHA}" "${CI_COMMIT_SHA}" |
    grep -E '^(apps/server/drizzle/|apps/server/src/lib/db/schema/)' || true
)"
if [ -n "${SCHEMA_CHANGED_FILES}" ]; then
  echo 'WARNING: This MR changes database schema files.'
  echo 'Apply the matching migration on the shared test DB before validating preview behavior.'
fi

最终效果

开发者的工作流变成这样:

  1. 在分支上开发功能,推送到 GitLab
  2. 创建 Merge Request
  3. GitLab CI 自动在 Mac mini 上构建并部署预览环境
  4. MR 页面出现 "View environment" 按钮,点击即可访问预览
  5. Reviewer 和产品经理直接在预览环境上验收
  6. MR 合并或关闭后,预览环境自动清理
Merge Request #42
├── 💬 Discussion
├── 📝 Code Changes
└── 🌐 View environment → https://feat-video-export-mr-42.preview.example.com

总结

组件 作用
Mac mini Runner 提供 Docker 运行环境,成本极低
Cloudflare Tunnel 免费的内网穿透,动态路由到不同容器
Docker Label 精准管理容器生命周期(按 MR 维度清理)
双重健康检查 确保端到端链路可用
环境变量规范化 安全地共享敏感配置
GitLab Environment MR 页面直接展示预览链接

整个方案的核心开销就是一台 Mac mini 和一个 Cloudflare 免费隧道。对于中小团队来说,这是一个性价比极高的 MR 预览环境方案。


如果你的团队也在做全栈项目、也有 Code Review 验收的痛点,不妨试试这个方案。欢迎交流。

使用社交账号登录

  • Loading...
  • Loading...
  • Loading...
  • Loading...
  • Loading...