用 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_URL、CORS_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
最终效果
开发者的工作流变成这样:
- 在分支上开发功能,推送到 GitLab
- 创建 Merge Request
- GitLab CI 自动在 Mac mini 上构建并部署预览环境
- MR 页面出现 "View environment" 按钮,点击即可访问预览
- Reviewer 和产品经理直接在预览环境上验收
- 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 验收的痛点,不妨试试这个方案。欢迎交流。