sync: d8d-alipay-preview 新增 + d8d-release 大幅演进 + uni-app-compatibility

新增技能:
- d8d-alipay-preview: 支付宝小程序构建预览

d8d-release 更新:
- 新增 ci.py CI 管理工具
- 新增 Phase 06 (DNS + NPM + SSL)
- 新增 ACR 镜像仓库模式支持
- 新增资源组参数 (RG_PARAM) 兼容 RAM 子帐号
- 新增铁律 9 (DNS 域名绑定) 和铁律 10 (NPM 代理持久化)
- CI 监控改用 CronCreate 替代 sleep
- MinIO + NPM 容器规格更新

d8d-dev 更新:
- 新增 uni-app-compatibility 规则
This commit is contained in:
zyh320888
2026-05-07 12:32:11 +00:00
parent 9d93f79ebf
commit bd37a7ef07
16 changed files with 1153 additions and 400 deletions

View File

@@ -0,0 +1,69 @@
---
name: d8d-alipay-preview
description: 支付宝小程序构建+预览+上传体验版。支持 minidev preview 生成预览二维码和 minidev upload 上传提审版本并设置体验版。
---
## Agent 执行方式
本技能运行在 D8D Agent 环境中,通过 d8d-platform MCP 工具操作远程沙箱容器。
所有文件操作使用以下 MCP 工具:
- `exec_command(containerId, command)` — 在容器内执行命令
- `read_file(containerId, path)` — 读取容器内文件
- `write_file(containerId, path, content)` — 写入容器内文件
- `list_directory(containerId, path)` — 列出容器目录
# 支付宝小程序预览与上传
一键构建支付宝小程序,支持两个模式:
1. **preview 预览** — 生成真机扫码预览二维码,快速验证效果
2. **upload 上传** — 上传版本到开放平台并设为体验版,供提审或团队体验
## 适用场景
- 代码修改后快速验证真机效果 → preview
- 需要生成预览二维码给测试人员 → preview
- 功能完成后上传体验版给团队验收 → upload
- 准备提交审核前上传正式版本 → upload
## 前置条件
- 项目包含 `packages/alipay-miniapp/` 目录
- 容器内已安装 minidev 依赖(`devDependencies.minidev`
- minidev 身份密钥已配置在 `~/.minidev/config.json`
- 支付宝小程序 AppID 已配置在 `manifest.json`
## 执行流程
加载本技能后 MUST 立即读取 [workflow.md](workflow.md) 按步骤执行。
## 关键参数
| 参数 | 值 | 来源 |
|------|------|------|
| 项目路径 | `packages/alipay-miniapp` | 项目结构 |
| 构建命令 | `pnpm run build:mp-alipay` | package.json scripts |
| 产物路径 | `dist/build/mp-alipay` | uni-app 默认输出 |
| AppID | `2021003141646173` | manifest.json mp-alipay.appid |
| 身份密钥 | `~/.minidev/config.json` | minidev CLI 配置 |
## 模式路由
根据用户意图自动选择模式:
| 用户意图 | 模式 | 命令 |
|---------|------|------|
| "预览" / "preview" / "二维码" | preview | `minidev preview` |
| "上传" / "upload" / "体验版" / "提审" | upload | `minidev upload --experience` |
如果用户未明确指定,默认执行 preview 模式。
## 触发方式
当用户消息匹配以下意图时加载本技能:
- "预览支付宝小程序" / "支付宝预览" / "minidev 预览"
- "上传支付宝小程序" / "支付宝上传" / "minidev upload"
- "设置体验版" / "上传体验版" / "提审"
- "构建支付宝小程序" / "生成支付宝预览二维码"
- "alipay preview" / "alipay upload" / "预览版" / "体验版"
⛔ 加载后 MUST 立即读取 workflow.md 按流程执行。

View File

@@ -0,0 +1,114 @@
# 支付宝小程序预览与上传工作流
## 模式路由
根据用户意图选择执行模式:
- **preview**(默认):用户说"预览"、"preview"、"二维码"、"看效果"
- **upload**:用户说"上传"、"upload"、"体验版"、"提审"、"设置体验版"
## 参数自动检测
```
APPID → packages/alipay-miniapp/src/manifest.json → mp-alipay.appid
IDENTITY_KEY → ~/.minidev/config.json (存在即表示已配置)
PROJECT_DIR → packages/alipay-miniapp
DIST_DIR → packages/alipay-miniapp/dist/build/mp-alipay
```
## Step 1: 环境检查
验证项目结构和依赖是否就绪:
```bash
cd /home/d8d-claude/workspace
ls packages/alipay-miniapp/package.json
ls ~/.minidev/config.json
```
- `~/.minidev/config.json` 不存在 → 提示用户先配置 minidev 身份密钥
- `packages/alipay-miniapp/` 不存在 → 提示当前项目不包含支付宝小程序
## Step 2: 构建小程序
```bash
cd /home/d8d-claude/workspace/packages/alipay-miniapp && pnpm run build:mp-alipay
```
- 超时120000ms
- 构建失败 → 检查错误日志报告用户
- 成功后产物在 `dist/build/mp-alipay/`
## Step 3: 执行preview 或 upload
### 3A: preview — 生成预览二维码
```bash
cd /home/d8d-claude/workspace/packages/alipay-miniapp && npx minidev preview --app-id 2021003141646173 --project dist/build/mp-alipay --identity-key-path /home/d8d-claude/.minidev/config.json
```
- 超时180000ms
- 云端构建,耗时约 30-60 秒
输出提取:
- **预览二维码链接**`https://mobilecodec.alipay.com/show.htm?code=XXXXX`
- **预览版本号**`0.1.YYMMDDHHMM.NN`
展示格式:
```
✅ 预览构建成功
📱 预览链接https://mobilecodec.alipay.com/show.htm?code=XXXXX
📦 版本号0.1.YYMMDDHHMM.NN
👉 用支付宝 App 扫码体验
```
### 3B: upload — 上传版本并设置体验版
```bash
cd /home/d8d-claude/workspace/packages/alipay-miniapp && npx minidev upload --app-id 2021003141646173 --project dist/build/mp-alipay --identity-key-path /home/d8d-claude/.minidev/config.json --experience
```
关键参数:
- `--app-id`:支付宝小程序 APPID
- `--project`:构建产物目录
- `--identity-key-path`:身份密钥路径
- `--version`:可选,格式 x.y.z如 0.0.1)。不提供则自动在最新版本号 patch+1
- `--experience`:上传成功后自动设置为体验版本
- 超时180000ms
输出提取:
- **上传版本号**`x.y.z` 格式
- **是否设为体验版**success/fail
展示格式:
```
✅ 上传成功
📦 版本号x.y.z
🌐 体验版:已设置
👉 支付宝开放平台 → 版本管理 查看
👉 团队成员可用支付宝扫码体验体验版
```
如果用户指定了版本号,加上 `--version x.y.z` 参数:
```bash
npx minidev upload --app-id 2021003141646173 --project dist/build/mp-alipay --identity-key-path /home/d8d-claude/.minidev/config.json --experience --version 1.0.0
```
## 错误处理
| 错误 | 原因 | 解决方式 |
|------|------|---------|
| `MODULE_NOT_FOUND` | 依赖未安装 | 执行 `pnpm install` 后重试 |
| 构建失败 | 代码语法错误 | 报告具体错误行号,建议修复后重试 |
| `authentication failed` | minidev 密钥过期/无效 | 提示用户更新 `~/.minidev/config.json` |
| `app not found` | AppID 不正确 | 检查 manifest.json 中的 appid |
| `version format error` | 版本号格式错误 | 版本号必须为 x.y.z 格式x/y/z 为纯数字 |
## 注意事项
- preview 每次生成新版本号,支付宝后台可查看历史预览版
- upload 会创建正式版本记录,可在开放平台版本管理中查看
- `--experience` 让上传后自动设为体验版,团队成员可直接扫码体验
- 版本号格式必须为 `x.y.z`,不提供则自动 patch+1
- 构建产物 `dist/` 目录已在 `.gitignore` 中,不会被提交到 Git

View File

@@ -0,0 +1,327 @@
#!/usr/bin/env python3
"""
Gitea Actions CI 管理工具
用法:
ci.py status # 显示最近构建状态
ci.py log <run_id> [job_id] # 查看构建日志
ci.py log <run_id> --raw # 输出原始 JSON
ci.py log <run_id> -o log.txt # 日志写入文件
ci.py build [version] # 推送 tag 触发构建(如 build 0.2.0
ci.py tags # 列出已推送的 tag
"""
import subprocess
import sys
import os
import re
import json
import argparse
from pathlib import Path
# 自动安装缺失依赖
try:
import requests
except ImportError:
print("正在安装 python3-requests ...")
subprocess.check_call(["sudo", "apt-get", "update", "-qq"],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
subprocess.check_call(["sudo", "apt-get", "install", "-y", "python3-requests"],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
import requests
def load_config():
script_dir = Path(__file__).resolve().parent
project_dir = script_dir.parent
# 优先从 eci.conf 读取 Gitea 配置
eci_conf = project_dir / "eci.conf"
config = {}
for conf_file in [eci_conf]:
if conf_file.exists():
with open(conf_file) as f:
for line in f:
line = line.strip()
if line and not line.startswith('#') and '=' in line:
key, value = line.split('=', 1)
config[key.strip()] = value.strip()
return {
"gitea_url": config.get("GITEA_URL", os.getenv("GITEA_URL", "https://gitea.d8d.fun")),
"gitea_username": config.get("GITEA_USERNAME", os.getenv("GITEA_USERNAME", "")),
"gitea_password": config.get("GITEA_PASSWORD", os.getenv("GITEA_PASSWORD", "")),
"repo_owner": config.get("GITEA_REPO_OWNER", os.getenv("REPO_OWNER", "d8dfun")),
"repo_name": config.get("GITEA_REPO_NAME", os.getenv("REPO_NAME", "")),
}
CONFIG = load_config()
STATUS_ICONS = {
"success": "OK", "failure": "FAIL", "running": "...",
"waiting": "WAIT", "cancelled": "SKIP", "skipped": "SKIP",
"pending": "...",
}
def _time_ago(iso_str):
from datetime import datetime, timezone
try:
dt = datetime.fromisoformat(iso_str.replace("Z", "+00:00"))
delta = datetime.now(timezone.utc) - dt
mins = int(delta.total_seconds() / 60)
if mins < 60:
return f"{mins}m ago"
hours = mins // 60
if hours < 24:
return f"{hours}h ago"
return f"{hours // 24}d ago"
except Exception:
return ""
class GiteaClient:
def __init__(self):
self.session = requests.Session()
self.csrf_token = None
def login(self):
login_url = f"{CONFIG['gitea_url']}/user/login"
resp = self.session.get(login_url)
m = re.search(r'<input type="hidden" name="_csrf" value="([^"]+)"', resp.text, re.IGNORECASE)
if not m:
m = re.search(r"csrfToken: '([^']+)'", resp.text)
if not m:
print("Cannot get CSRF token")
return False
self.csrf_token = m.group(1)
resp = self.session.post(
login_url,
data={"user_name": CONFIG["gitea_username"], "password": CONFIG["gitea_password"], "_csrf": self.csrf_token},
headers={"Content-Type": "application/x-www-form-urlencoded", "X-Csrf-Token": self.csrf_token},
allow_redirects=True,
)
if resp.status_code != 200:
print("Login failed")
return False
# 登录成功后从 cookie 更新 CSRF
for cookie in self.session.cookies:
if cookie.name == "_csrf":
self.csrf_token = cookie.value
break
return True
def _headers(self):
h = {"Content-Type": "application/json"}
if self.csrf_token:
h["X-Csrf-Token"] = self.csrf_token
return h
def list_runs(self, limit=10):
if not self.login():
return []
url = f"{CONFIG['gitea_url']}/api/v1/repos/{CONFIG['repo_owner']}/{CONFIG['repo_name']}/actions/tasks"
resp = self.session.get(url, params={"limit": limit}, auth=(CONFIG["gitea_username"], CONFIG["gitea_password"]))
if resp.status_code != 200:
print(f"API error: HTTP {resp.status_code}")
return []
return resp.json().get("workflow_runs", [])
def get_log(self, run_id, job_id="0"):
if not self.login():
return {"error": "login failed"}
url = f"{CONFIG['gitea_url']}/{CONFIG['repo_owner']}/{CONFIG['repo_name']}/actions/runs/{run_id}/jobs/{job_id}"
resp = self.session.post(url, json={"logCursors": []}, headers=self._headers())
if resp.status_code != 200:
return {"error": f"HTTP {resp.status_code}"}
data = resp.json()
steps = data.get("state", {}).get("currentJob", {}).get("steps", [])
all_logs = []
for idx in range(len(steps)):
resp2 = self.session.post(
url,
json={"logCursors": [{"step": idx, "cursor": None, "expanded": True}]},
headers=self._headers(),
)
if resp2.status_code == 200:
for sl in resp2.json().get("logs", {}).get("stepsLog", []):
if sl.get("step") == idx:
entries = sl.get("lines") or sl.get("content") or []
all_logs.append({
"step": idx,
"summary": steps[idx].get("summary", ""),
"status": steps[idx].get("status", ""),
"entries": entries,
})
break
return {"state": data.get("state", {}), "logs": all_logs}
def cmd_status(_args):
client = GiteaClient()
runs = [r for r in client.list_runs(limit=30) if r.get("status") != "skipped"][:10]
if not runs:
print("No builds found")
return
print(f"\n {'Run':>4} {'Status':8s} {'Branch':16s} {'Ago':10s} Title")
print(f" {'----':>4} {'------':8s} {'------':16s} {'---':10s} -----")
for r in runs:
icon = STATUS_ICONS.get(r.get("status", ""), "?")
ago = _time_ago(r.get("run_started_at") or r.get("created_at", ""))
title = (r.get("display_title") or "")[:50]
status = r.get("status", "")
branch = r.get("head_branch", "")
print(f" #{r['run_number']:>3} {icon:8s} {branch:16s} {ago:10s} {title}")
print(f"\n View log: python3 scripts/ci.py log <run_number>")
def cmd_log(args):
parser = argparse.ArgumentParser(add_help=False)
parser.add_argument("run_id")
parser.add_argument("job_id", nargs="?", default="0")
parser.add_argument("--raw", action="store_true")
parser.add_argument("-o", "--output", help="Write to file")
parsed = parser.parse_args(args)
client = GiteaClient()
result = client.get_log(parsed.run_id, parsed.job_id)
if isinstance(result, dict) and "error" in result:
print(f"Error: {result['error']}")
return
if parsed.raw:
text = json.dumps(result, indent=2, ensure_ascii=False)
else:
text = _format_log(result)
if parsed.output:
with open(parsed.output, "w", encoding="utf-8") as f:
f.write(text)
print(f"Written: {parsed.output}")
else:
print(text)
def _format_log(data):
run_info = data.get("state", {}).get("run", {})
job_info = data.get("state", {}).get("currentJob", {})
logs = data.get("logs", [])
lines = []
lines.append("=" * 80)
lines.append(f"Run: {run_info.get('title', 'N/A')}")
lines.append(f"Status: {run_info.get('status', 'N/A')}")
commit = run_info.get("commit", {})
if commit:
lines.append(f"Commit: {commit.get('shortSHA', '')} Branch: {commit.get('branch', {}).get('name', '')}")
lines.append("=" * 80)
steps = job_info.get("steps", [])
if steps:
lines.append("")
for idx, s in enumerate(steps):
icon = STATUS_ICONS.get(s.get("status", ""), "?")
lines.append(f" [{idx}] {icon:6s} {s.get('summary', '')} ({s.get('duration', '')})")
if logs:
lines.append("")
lines.append("-" * 80)
for item in logs:
icon = STATUS_ICONS.get(item.get("status", ""), "?")
lines.append(f"\n{icon} Step {item['step']}: {item['summary']}")
entries = item.get("entries", [])
for e in entries[:200]:
msg = e.get("message", str(e)) if isinstance(e, dict) else str(e)
lines.append(f" {msg}")
if len(entries) > 200:
lines.append(f" ... {len(entries) - 200} more lines")
else:
lines.append("\nNo log content")
return "\n".join(lines)
def cmd_build(args):
version = args[0] if args else None
if not version:
print("Usage: ci.py build <version> (e.g. ci.py build 0.2.0)")
return False
tag = f"v{version}"
print(f"Creating tag {tag} ...")
r = subprocess.run(["git", "rev-parse", tag], capture_output=True, text=True)
if r.returncode == 0:
print(f"Tag {tag} already exists, deleting and recreating...")
subprocess.run(["git", "tag", "-d", tag], capture_output=True)
subprocess.run(["git", "push", "origin", f":refs/tags/{tag}"], capture_output=True)
r = subprocess.run(["git", "tag", "-a", tag, "-m", f"Release {tag}"], capture_output=True, text=True)
if r.returncode != 0:
print(f"Failed to create tag: {r.stderr}")
return False
r = subprocess.run(["git", "push", "origin", tag], capture_output=True, text=True)
if r.returncode != 0:
print(f"Failed to push tag: {r.stderr}")
subprocess.run(["git", "tag", "-d", tag], capture_output=True)
return False
print(f"Pushed: {tag}")
print(f"View: {CONFIG['gitea_url']}/{CONFIG['repo_owner']}/{CONFIG['repo_name']}/actions")
return True
def cmd_tags(_args):
result = subprocess.run(["git", "tag", "-l", "v*"], capture_output=True, text=True)
tags = sorted(t for t in result.stdout.strip().split("\n") if t)
if not tags:
print("No tags found")
return
print(f"Tags ({len(tags)} total):")
for tag in tags[-20:]:
print(f" {tag}")
if len(tags) > 20:
print(f" ... showing last 20 of {len(tags)}")
print(f" Latest: {tags[-1]}")
def main():
if len(sys.argv) < 2 or sys.argv[1] in ("-h", "--help"):
print(__doc__)
return
commands = {
"status": lambda a: cmd_status(a),
"log": lambda a: cmd_log(a),
"build": lambda a: cmd_build(a),
"tags": lambda a: cmd_tags(a),
}
cmd = sys.argv[1]
if cmd not in commands:
print(f"Unknown command: {cmd}\n")
print(__doc__)
return
commands[cmd](sys.argv[2:])
if __name__ == "__main__":
main()

View File

@@ -1,10 +1,18 @@
#!/bin/bash
# ECI 容器组部署脚本 - 使用私有 Registry 内网拉取镜像
# 用法: ./scripts/eci-deploy.sh
# ECI 容器组部署脚本
# 用法: ./scripts/eci-deploy.sh <image-tag>
# 依赖: aliyun CLI 已配置, eci.conf 存在
set -e
if [ $# -eq 0 ]; then
echo "错误: 请提供镜像标签"
echo "用法: $0 <image-tag>"
exit 1
fi
IMAGE_TAG="$1"
# 加载配置
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/../eci.conf"
@@ -16,66 +24,47 @@ aliyun configure set --mode AK \
--region "$ECI_REGION"
echo "🚀 开始部署 ECI 容器组"
echo "📦 镜像: $ECI_REGISTRY_INTRANET_URL/d8d-user-release:$IMAGE_TAG"
echo "🌐 EIP: $ECI_EIP_ADDRESS"
echo "📦 镜像仓库: 私有 Registry (内网地址: $ECI_REGISTRY_INTRANET_URL)"
echo "🏪 Registry (内网): $ECI_REGISTRY_INTRANET_URL"
echo "🔐 协议: HTTP (PlainHttpRegistry)"
# NAS 目录准备:确保 postgres 数据目录存在且权限正确UID 70 = postgres 用户)
echo "📁 准备 NAS 目录..."
NAS_FS_ID=$(echo "$ECI_NAS_SERVER" | cut -d'.' -f1)
aliyun nas CreateDir \
--FileSystemId "$NAS_FS_ID" \
--RootDirectory "/postgres-data" \
--OwnerUserId 70 \
--OwnerGroupId 70 \
--Permission "0755" \
--RegionId "$ECI_REGION" 2>/dev/null && echo "✅ NAS 目录 /postgres-data 已就绪" || echo "✅ NAS 目录已存在"
# 检查并删除同名容器组(列出所有容器组后按名称匹配)
# 检查容器组是否存在
echo "🔍 检查现有容器组..."
ALL_GROUPS=$(aliyun eci DescribeContainerGroups \
--RegionId "$ECI_REGION" 2>/dev/null || true)
EXISTING=$(aliyun eci DescribeContainerGroups \
--RegionId "$ECI_REGION" \
--ContainerGroupName "$ECI_CONTAINER_GROUP_NAME" 2>/dev/null || true)
# 按 ContainerGroupName 匹配,提取对应的 ContainerGroupId
EXISTING_IDS=$(echo "$ALL_GROUPS" | python3 -c "
import sys, json
data = json.load(sys.stdin)
for g in data.get('ContainerGroups', []):
if g.get('ContainerGroupName') == '$ECI_CONTAINER_GROUP_NAME':
print(g['ContainerGroupId'])
" 2>/dev/null || true)
EXISTING_ID=$(echo "$EXISTING" | grep -o '"ContainerGroupId":"[^"]*"' | cut -d'"' -f4 || true)
if [ -n "$EXISTING_IDS" ]; then
for EXISTING_ID in $EXISTING_IDS; do
echo "🗑️ 删除旧容器组: $EXISTING_ID"
aliyun eci DeleteContainerGroup \
if [ -n "$EXISTING_ID" ]; then
echo "🗑️ 删除旧容器组: $EXISTING_ID"
aliyun eci DeleteContainerGroup \
--RegionId "$ECI_REGION" \
--ContainerGroupId "$EXISTING_ID"
echo "⏳ 等待容器组删除..."
for i in {1..60}; do
STATUS=$(aliyun eci DescribeContainerGroups \
--RegionId "$ECI_REGION" \
--ContainerGroupId "$EXISTING_ID"
echo "⏳ 等待容器组删除..."
for i in {1..60}; do
STATUS=$(aliyun eci DescribeContainerGroups \
--RegionId "$ECI_REGION" \
--ContainerGroupId "$EXISTING_ID" 2>/dev/null | grep -o '"Status":"[^"]*"' | cut -d'"' -f4 || echo "DELETED")
if [ "$STATUS" = "DELETED" ]; then
echo "✅ 容器组已删除"
break
fi
sleep 2
done
--ContainerGroupId "$EXISTING_ID" 2>/dev/null | grep -o '"Status":"[^"]*"' | cut -d'"' -f4 || echo "DELETED")
if [ "$STATUS" = "DELETED" ]; then
break
fi
sleep 2
done
else
echo "✅ 无同名容器组,直接创建"
fi
# 环境变量CI 环境无 .env密码从 eci.conf 或使用默认值)
POSTGRES_PASSWORD="${ECI_POSTGRES_PASSWORD:-postgres}"
# 从 .env 加载环境变量传递给容器
ENV_FILE="$SCRIPT_DIR/../.env"
# PostgreSQL 密码
POSTGRES_PASSWORD=$(grep "^POSTGRES_PASSWORD=" "$ENV_FILE" 2>/dev/null | cut -d'=' -f2 || echo "postgres")
# 构建 DATABASE_URL (连接到同容器组的 PostgreSQL)
DATABASE_URL="postgresql://postgres:$POSTGRES_PASSWORD@127.0.0.1:5432/map_stores?schema=public&connection_limit=20&pool_timeout=60"
echo "🐘 创建新容器组(应用 + PostgreSQL 两个容器)..."
# Container 1: Next.js 应用 (从私有 Registry 内网拉取,速度快)
# Container 2: PostgreSQL (从私有 Registry 内网拉取,预热镜像)
set +e
echo "🐘 创建新容器组..."
RESULT=$(aliyun eci CreateContainerGroup \
--RegionId "$ECI_REGION" \
--SecurityGroupId "$ECI_SECURITY_GROUP_ID" \
@@ -86,26 +75,40 @@ RESULT=$(aliyun eci CreateContainerGroup \
--RestartPolicy Always \
--EipInstanceId "$ECI_EIP_ID" \
--PlainHttpRegistry "$ECI_REGISTRY_INTRANET_URL" \
`# NAS 数据卷` \
--Volume.1.Name nas-data \
--Volume.1.Type NFSVolume \
--Volume.1.NFSVolume.Server "$ECI_NAS_SERVER" \
--Volume.1.NFSVolume.Path "/" \
--Volume.2.Name nas-pgdata \
--Volume.2.Type NFSVolume \
--Volume.2.NFSVolume.Server "$ECI_NAS_SERVER" \
--Volume.2.NFSVolume.Path "/postgres-data" \
--Container.1.Name app \
--Container.1.Image "$ECI_REGISTRY_INTRANET_URL/d8d-user-release:${1:-latest}" \
--Volume.1.NFSVolume.Path / \
`# Container 1: Next.js 应用(从私有 Registry 内网拉取,速度快)` \
--Container.1.Name map-collector \
--Container.1.Image "$ECI_REGISTRY_INTRANET_URL/d8d-user-release:$IMAGE_TAG" \
--Container.1.Cpu 2 \
--Container.1.Memory 2 \
--Container.1.Port.1.Protocol TCP \
--Container.1.Port.1.Port 3001 \
--Container.1.ImagePullPolicy Always \
--Container.1.EnvironmentVarHide true \
--Container.1.EnvironmentVar.1.Key DATABASE_URL \
--Container.1.EnvironmentVar.1.Value "$DATABASE_URL" \
`# 从 .env 加载 API keys` \
--Container.1.EnvironmentVar.2.Key ANTHROPIC_API_KEY \
--Container.1.EnvironmentVar.2.Value "${ECI_ANTHROPIC_API_KEY:-}" \
--Container.1.EnvironmentVar.3.Key AMAP_API_KEY \
--Container.1.EnvironmentVar.3.Value "${ECI_AMAP_API_KEY:-}" \
--Container.1.EnvironmentVar.2.Value "$(grep "^ANTHROPIC_API_KEY=" "$ENV_FILE" 2>/dev/null | cut -d'=' -f2 || echo "")" \
--Container.1.EnvironmentVar.3.Key ANTHROPIC_BASE_URL \
--Container.1.EnvironmentVar.3.Value "$(grep "^ANTHROPIC_BASE_URL=" "$ENV_FILE" 2>/dev/null | cut -d'=' -f2 || echo "")" \
--Container.1.EnvironmentVar.4.Key ANTHROPIC_MODEL \
--Container.1.EnvironmentVar.4.Value "$(grep "^ANTHROPIC_MODEL=" "$ENV_FILE" 2>/dev/null | cut -d'=' -f2 || echo "")" \
--Container.1.EnvironmentVar.5.Key AMAP_API_KEY \
--Container.1.EnvironmentVar.5.Value "$(grep "^AMAP_API_KEY=" "$ENV_FILE" 2>/dev/null | cut -d'=' -f2 || echo "")" \
--Container.1.EnvironmentVar.6.Key BAIDU_API_KEY \
--Container.1.EnvironmentVar.6.Value "$(grep "^BAIDU_API_KEY=" "$ENV_FILE" 2>/dev/null | cut -d'=' -f2 || echo "")" \
--Container.1.EnvironmentVar.7.Key TENCENT_API_KEY \
--Container.1.EnvironmentVar.7.Value "$(grep "^TENCENT_API_KEY=" "$ENV_FILE" 2>/dev/null | cut -d'=' -f2 || echo "")" \
--Container.1.EnvironmentVar.8.Key NEXT_PUBLIC_AMAP_JS_KEY \
--Container.1.EnvironmentVar.8.Value "$(grep "^NEXT_PUBLIC_AMAP_JS_KEY=" "$ENV_FILE" 2>/dev/null | cut -d'=' -f2 || echo "")" \
--Container.1.EnvironmentVar.9.Key NEXT_PUBLIC_AMAP_JS_SECRET \
--Container.1.EnvironmentVar.9.Value "$(grep "^NEXT_PUBLIC_AMAP_JS_SECRET=" "$ENV_FILE" 2>/dev/null | cut -d'=' -f2 || echo "")" \
`# Container 2: PostgreSQL sidecar从内网 Registry 拉取预热镜像)` \
--Container.2.Name postgres \
--Container.2.Image "$ECI_REGISTRY_INTRANET_URL/postgres:17-alpine" \
--Container.2.Cpu 0.5 \
@@ -118,29 +121,20 @@ RESULT=$(aliyun eci CreateContainerGroup \
--Container.2.EnvironmentVar.2.Value postgres \
--Container.2.EnvironmentVar.3.Key POSTGRES_PASSWORD \
--Container.2.EnvironmentVar.3.Value "$POSTGRES_PASSWORD" \
--Container.2.VolumeMount.1.Name nas-pgdata \
--Container.2.VolumeMount.1.MountPath "/var/lib/postgresql/data" \
--Container.2.VolumeMount.1.ReadOnly false 2>&1)
CREATE_EXIT=$?
set -e
--Container.2.VolumeMount.1.Name nas-data \
--Container.2.VolumeMount.1.MountPath /var/lib/postgresql/data \
--Container.2.VolumeMount.1.ReadOnly false)
if [ $CREATE_EXIT -ne 0 ]; then
echo "❌ CreateContainerGroup 失败 (exit=$CREATE_EXIT)"
echo "$RESULT"
exit 1
fi
CONTAINER_GROUP_ID=$(echo "$RESULT" | python3 -c "import sys,json; print(json.load(sys.stdin)['ContainerGroupId'])" 2>/dev/null)
CONTAINER_GROUP_ID=$(echo "$RESULT" | grep -o '"ContainerGroupId":"[^"]*"' | cut -d'"' -f4)
echo "✅ 容器组创建成功: $CONTAINER_GROUP_ID"
echo "⏳ 等待容器组启动(内网拉取镜像,速度快,预计 30 秒-2 分钟)..."
for i in {1..60}; do
echo "⏳ 等待容器组启动..."
for i in {1..120}; do
STATUS=$(aliyun eci DescribeContainerGroups \
--RegionId "$ECI_REGION" \
--ContainerGroupIds "[\"$CONTAINER_GROUP_ID\"]" 2>/dev/null | python3 -c "import sys,json; data=json.load(sys.stdin); print(data['ContainerGroups'][0]['Status'])" 2>/dev/null || echo "Pending")
--ContainerGroupId "$CONTAINER_GROUP_ID" 2>/dev/null | grep -o '"Status":"[^"]*"' | cut -d'"' -f4 || echo "Pending")
if [ "$STATUS" = "Running" ]; then
echo "✅ 容器组运行中"
break
fi
@@ -149,112 +143,8 @@ for i in {1..60}; do
exit 1
fi
echo " 状态: $STATUS (等待 $((i*5)) 秒)"
sleep 5
done
# === 部署后验证 ===
# 层 1: 容器级状态检查Ready + RestartCount
echo "🔍 检查容器就绪状态..."
sleep 3
CONTAINER_DETAIL=$(aliyun eci DescribeContainerGroups \
--RegionId "$ECI_REGION" \
--ContainerGroupIds "[\"$CONTAINER_GROUP_ID\"]" 2>/dev/null)
NOT_READY=$(echo "$CONTAINER_DETAIL" | python3 -c "
import sys, json
data = json.load(sys.stdin)
for c in data['ContainerGroups'][0].get('Containers', []):
if not c.get('Ready', False):
print(f' ❌ {c[\"Name\"]} Ready=False')
rc = c.get('RestartCount', 0)
if rc > 0:
print(f' ⚠️ {c[\"Name\"]} RestartCount={rc}')
" 2>/dev/null)
if [ -n "$NOT_READY" ]; then
echo "$NOT_READY"
echo "⚠️ 有容器未就绪或正在重启,继续等待..."
# 再等一轮
for i in {1..12}; do
sleep 5
CONTAINER_DETAIL=$(aliyun eci DescribeContainerGroups \
--RegionId "$ECI_REGION" \
--ContainerGroupIds "[\"$CONTAINER_GROUP_ID\"]" 2>/dev/null)
ALL_READY=$(echo "$CONTAINER_DETAIL" | python3 -c "
import sys, json
data = json.load(sys.stdin)
ok = True
for c in data['ContainerGroups'][0].get('Containers', []):
if not c.get('Ready', False):
ok = False
if c.get('RestartCount', 0) > 0:
ok = False
print('yes' if ok else 'no')
" 2>/dev/null)
if [ "$ALL_READY" = "yes" ]; then
echo "✅ 所有容器就绪"
break
fi
done
else
echo "✅ 所有容器就绪 (Ready=True, RestartCount=0)"
fi
# 层 2: 等待 PostgreSQL 就绪 + Schema 迁移
echo "⏳ 等待 PostgreSQL 就绪..."
for i in {1..30}; do
PG_READY=$(aliyun eci ExecContainerCommand \
--RegionId "$ECI_REGION" \
--ContainerGroupId "$CONTAINER_GROUP_ID" \
--ContainerName postgres \
--Command '["/bin/sh", "-c", "pg_isready -h 127.0.0.1 -p 5432"]' 2>/dev/null || echo "not_ready")
if echo "$PG_READY" | grep -q "accepting connections"; then
echo "✅ PostgreSQL 就绪"
break
fi
sleep 2
done
echo "📦 执行 Prisma Schema 迁移..."
set +e
MIGRATE_RESULT=$(aliyun eci ExecContainerCommand \
--RegionId "$ECI_REGION" \
--ContainerGroupId "$CONTAINER_GROUP_ID" \
--ContainerName app \
--Command '["/bin/sh", "-c", "cd /app && npx prisma db push --skip-generate 2>&1"]' 2>&1)
MIGRATE_EXIT=$?
set -e
if [ $MIGRATE_EXIT -ne 0 ]; then
echo "⚠️ Schema 迁移执行异常 (exit=$MIGRATE_EXIT)"
echo "$MIGRATE_RESULT"
else
echo "✅ Schema 迁移完成"
fi
# 层 3: HTTP 健康检查
echo "🔍 验证应用健康..."
for i in {1..30}; do
HEALTH=$(curl -s -o /dev/null -w "%{http_code}" "http://$ECI_EIP_ADDRESS:3001/api/health" 2>/dev/null || echo "000")
if [ "$HEALTH" = "200" ]; then
echo "✅ 应用健康检查通过"
break
fi
if [ "$i" = "30" ]; then
echo "⚠️ 应用健康检查超时 (HTTP $HEALTH)"
echo "📋 容器事件:"
echo "$CONTAINER_DETAIL" | python3 -c "
import sys, json
data = json.load(sys.stdin)
for e in data['ContainerGroups'][0].get('Events', [])[-5:]:
print(f' [{e.get(\"Type\")}] {e.get(\"Message\")}')
" 2>/dev/null
else
echo " 健康检查: HTTP $HEALTH (等待 $((i*5)) 秒)"
sleep 5
fi
echo " 状态: $STATUS"
sleep 3
done
echo ""

View File

@@ -8,6 +8,10 @@ set -e
source "$(dirname "$0")/../eci.conf"
REGION=${ECI_REGION:-cn-beijing}
# 资源组参数RAM 子帐号绑定时必填)
RG_PARAM=""
[ -n "$ECI_RESOURCE_GROUP_ID" ] && RG_PARAM="--ResourceGroupId $ECI_RESOURCE_GROUP_ID"
CONTAINER_GROUP_NAME="d8d-eci-registry"
CPU=0.5
MEMORY=1.0
@@ -22,21 +26,24 @@ echo ""
# 检查容器组是否已存在
echo "检查容器组是否存在..."
EXISTING=$(aliyun eci DescribeContainerGroups --region "$REGION" \
$RG_PARAM \
--ContainerGroupName "$CONTAINER_GROUP_NAME" 2>/dev/null \
| grep -c '"ContainerGroupId"' || true)
if [ "$EXISTING" -gt 0 ]; then
echo "容器组已存在,正在删除..."
CONTAINER_GROUP_ID=$(aliyun eci DescribeContainerGroups --region "$REGION" \
$RG_PARAM \
--ContainerGroupName "$CONTAINER_GROUP_NAME" 2>/dev/null \
| grep -o '"ContainerGroupId":"[^"]*"' | cut -d'"' -f4)
aliyun eci DeleteContainerGroup --region "$REGION" --ContainerGroupId "$CONTAINER_GROUP_ID" >/dev/null
aliyun eci DeleteContainerGroup --region "$REGION" $RG_PARAM --ContainerGroupId "$CONTAINER_GROUP_ID" >/dev/null
# 等待删除完成
echo "等待容器组删除..."
for i in {1..30}; do
STATUS=$(aliyun eci DescribeContainerGroups --region "$REGION" \
$RG_PARAM \
--ContainerGroupName "$CONTAINER_GROUP_NAME" 2>/dev/null \
| grep -o '"Status":"[^"]*"' | cut -d'"' -f4 || true)
if [ -z "$STATUS" ]; then
@@ -50,6 +57,7 @@ fi
# 创建新容器组
echo "创建 Registry 容器组Spot 实例)..."
RESULT=$(aliyun eci CreateContainerGroup --region "$REGION" \
$RG_PARAM \
--ContainerGroupName "$CONTAINER_GROUP_NAME" \
--VSwitchId "$ECI_VSWITCH_ID" \
--SecurityGroupId "$ECI_SECURITY_GROUP_ID" \
@@ -88,6 +96,7 @@ echo "等待容器组启动..."
# 等待容器组 Running
for i in {1..60}; do
STATUS=$(aliyun eci DescribeContainerGroups --region "$REGION" \
$RG_PARAM \
--ContainerGroupId "$CONTAINER_GROUP_ID" 2>/dev/null \
| grep -o '"Status":"[^"]*"' | cut -d'"' -f4)
@@ -106,6 +115,7 @@ echo ""
echo "获取容器组内网 IP..."
for i in {1..30}; do
INTRANET_IP=$(aliyun eci DescribeContainerGroups --region "$REGION" \
$RG_PARAM \
--ContainerGroupId "$CONTAINER_GROUP_ID" 2>/dev/null \
| grep -o '"IntranetIp":"[^"]*"' | cut -d'"' -f4)

View File

@@ -14,13 +14,23 @@ ECI_NAS_SERVER=
ECI_EIP_ID=
ECI_EIP_ADDRESS=
# 私有 Registry EIP 和地址ECI Spot 实例
# 镜像仓库模式: acr阿里云 ACR| eci-registry自建 ECI Registry
# acr: 帐号已开通 ACR 时使用,稳定可靠
# eci-registry: 无 ACR 时使用Spot 实例自建私有 Registry成本更低
REGISTRY_MODE=eci-registry
# ACR 配置REGISTRY_MODE=acr 时使用)
ACR_REGISTRY=registry-vpc.cn-beijing.aliyuncs.com
ACR_NAMESPACE=d8dcloud
ACR_IMAGE_NAME=
# ECI Registry 配置REGISTRY_MODE=eci-registry 时使用)
ECI_REGISTRY_EIP_ID=
ECI_REGISTRY_EIP_ADDRESS=
ECI_REGISTRY_PORT=5000
ECI_REGISTRY_URL=
# 私有 Registry 内网 IP用于应用 ECI 内网拉取镜像,由 eci-registry.sh 自动填充)
# ECI Registry 内网 IP由 eci-registry.sh 自动填充)
ECI_REGISTRY_INTRANET_IP=
ECI_REGISTRY_INTRANET_URL=
@@ -28,6 +38,9 @@ ECI_REGISTRY_INTRANET_URL=
ALI_ACCESS_KEY_ID=
ALI_ACCESS_KEY_SECRET=
# 资源组 IDRAM 子帐号绑定资源组时必填,为空则忽略)
ECI_RESOURCE_GROUP_ID=
# Gitea 配置(用于 Actions 日志查询)
GITEA_URL=https://gitea.d8d.fun
GITEA_USERNAME=d8dfun
@@ -39,3 +52,14 @@ GITEA_REPO_NAME=
ECI_CONTAINER_GROUP_NAME=map-collector
ECI_CPU=2.5
ECI_MEMORY=3.5
# NPM 管理员配置
NPM_ADMIN_EMAIL=
NPM_ADMIN_PASSWORD=
# DNS 配置
DNS_DOMAIN=d8d.fun
DNS_DOMAINS=
# NPM 代理配置(格式: 域名:端口:WebSocket
NPM_PROXY_CONFIG=

View File

@@ -35,13 +35,13 @@ jobs:
REPO_NAME=$(echo "${{ gitea.repository }}" | sed 's|/|-|g')
echo "REPO_NAME=$REPO_NAME" >> $GITHUB_ENV
# 从 eci.conf 读取私有 Registry 地址
# 从 eci.conf 加载 Registry 地址
source eci.conf
echo "REGISTRY_URL=$ECI_REGISTRY_URL" >> $GITHUB_ENV
echo "提取的版本号:$VERSION"
echo "处理后的仓库名:$REPO_NAME"
echo "私有 Registry 地址:$ECI_REGISTRY_URL"
echo "Registry 地址:$ECI_REGISTRY_URL"
- name: 设置 Docker Buildx支持 HTTP 私有 Registry
uses: docker/setup-buildx-action@v3
@@ -54,12 +54,6 @@ jobs:
http = true
insecure = true
- name: 登录私有 Registry从 eci.conf 读取)
run: |
source eci.conf
# 私有 Registry 使用 HTTP无需认证
echo "使用私有 Registry: $ECI_REGISTRY_URL"
- name: 构建并推送应用镜像到私有 Registry
uses: docker/build-push-action@v5
with:
@@ -72,16 +66,12 @@ jobs:
- name: 预热 postgres:17-alpine 镜像到私有 Registry
run: |
source eci.conf
# 检查镜像是否已存在
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" "http://$ECI_REGISTRY_URL/v2/postgres/tags/list")
if [ "$HTTP_CODE" = "200" ]; then
echo "⏭️ postgres:17-alpine 已存在于私有 Registry跳过预热"
exit 0
fi
# 通过 buildx 推送复用步骤10已配置的 insecure registry
TMPDIR=$(mktemp -d)
printf 'FROM postgres:17-alpine\n' > $TMPDIR/Dockerfile
docker buildx build --push -t $ECI_REGISTRY_URL/postgres:17-alpine $TMPDIR
# 从 Docker Hub 拉取官方 postgres:17-alpine
docker pull postgres:17-alpine
# 重命名为私有 Registry 地址
docker tag postgres:17-alpine $ECI_REGISTRY_URL/postgres:17-alpine
# 推送到私有 Registry
docker push $ECI_REGISTRY_URL/postgres:17-alpine
echo "postgres:17-alpine 镜像预热完成"
- name: 安装 aliyun CLI用于 ECI 部署)
@@ -101,19 +91,3 @@ jobs:
chmod +x scripts/eci-deploy.sh
IMAGE_TAG="${{ env.REPO_NAME }}-${{ env.VERSION }}"
./scripts/eci-deploy.sh "$IMAGE_TAG"
- name: 验证应用健康
run: |
source eci.conf
echo "🔍 CI 最终健康检查..."
for i in {1..30}; do
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" "http://$ECI_EIP_ADDRESS:3001/api/health" 2>/dev/null || echo "000")
if [ "$HTTP_CODE" = "200" ]; then
echo "✅ 应用健康检查通过 (HTTP 200)"
exit 0
fi
echo " 等待中... HTTP $HTTP_CODE ($((i*5))秒)"
sleep 5
done
echo "❌ 健康检查超时,应用可能未正常启动"
exit 1

View File

@@ -19,6 +19,7 @@
| Phase 03 | [phases/phase-03-ci-setup.md](../phases/phase-03-ci-setup.md) | CI 工作流部署 |
| Phase 04 | [phases/phase-04-deploy.md](../phases/phase-04-deploy.md) | 触发发布、ECI 容器组部署 |
| Phase 05 | [phases/phase-05-verify.md](../phases/phase-05-verify.md) | 部署验证、健康检查 |
| Phase 06 | [phases/phase-06-dns-npm-ssl.md](../phases/phase-06-dns-npm-ssl.md) | DNS + NPM 代理 + SSL 证书 |
---
@@ -35,6 +36,8 @@
| postgres 预热 | ✅ | ✅ |
| ECI 容器组重建 | ✅ | ✅ |
| 健康检查 | ✅ | ✅ |
| DNS 配置 | ✅ | ⏭️ 跳过(已存在) |
| NPM 代理 + SSL | ✅ | ⏭️ 复用已有(自动续期) |
---
@@ -42,9 +45,19 @@
在标准部署模式中,以下优化自动生效:
### 1. 内网镜像拉取
### 0. 镜像仓库模式选择
**原理**
根据 `eci.conf` 中的 `REGISTRY_MODE` 选择镜像仓库方案
| 模式 | 值 | 适用场景 | 基础设施 |
|------|-----|----------|----------|
| ACR | `acr` | 帐号已开通 ACR个人版/企业版) | 无需额外 ECI |
| 自建 Registry | `eci-registry` | 无 ACR或需要完全自托管 | 需创建 Registry ECI + EIP |
**ACR 模式优势**:稳定可靠,无需维护 Registry 容器ACR 个人版免费
**自建 Registry 优势**完全自托管Spot 实例成本极低,不依赖外部服务
### 1. 内网镜像拉取
- 应用 ECI 和 Registry ECI 在同一 VPC 内
- 应用通过内网 IP 拉取镜像,不走公网
@@ -97,8 +110,10 @@
| 操作 | 命令 |
|------|------|
| 发布新版本 | `./scripts/release_tag.sh x.y.z` |
| 查看已有 tag | `./scripts/release_tag.sh -l` |
| 发布新版本 | `python3 scripts/ci.py build x.y.z``./scripts/release_tag.sh x.y.z` |
| 查看构建状态 | `python3 scripts/ci.py status` |
| 查看构建日志 | `python3 scripts/ci.py log <run_id>` |
| 查看已有 tag | `python3 scripts/ci.py tags``./scripts/release_tag.sh -l` |
| 查看 Registry 镜像 | `curl http://{registry-ip}:5000/v2/_catalog` |
| 数据同步 | 调用 `/d8d-release` 选择「数据同步模式」 |

View File

@@ -2,6 +2,18 @@
按名称标记 `d8d-eci-*`**不存在则创建,存在则复用**。所有资源复用同一账号下的项目。
## 前置:资源组参数
当 RAM 子帐号绑定资源组时,所有资源创建和查询 API 必须传 `--ResourceGroupId`。在执行前初始化:
```bash
source eci.conf
RG_PARAM=""
[ -n "$ECI_RESOURCE_GROUP_ID" ] && RG_PARAM="--ResourceGroupId $ECI_RESOURCE_GROUP_ID"
```
> 后续所有 `aliyun` 命令中 `$RG_PARAM` 均指此变量。为空时不传,兼容无资源组的 RAM 帐号。
## 资源清单
| 序号 | 资源 | 名称标记 | 关键配置 |
@@ -20,7 +32,7 @@
**查询是否存在**
```bash
aliyun vpc DescribeVpcs --VpcName d8d-eci-vpc
aliyun vpc DescribeVpcs $RG_PARAM --VpcName d8d-eci-vpc
```
**如不存在则创建**
@@ -28,7 +40,8 @@ aliyun vpc DescribeVpcs --VpcName d8d-eci-vpc
aliyun vpc CreateVpc \
--RegionId cn-beijing \
--VpcName d8d-eci-vpc \
--CidrBlock 192.168.0.0/16
--CidrBlock 192.168.0.0/16 \
$RG_PARAM
```
**记录**`ECI_VPC_ID` → eci.conf
@@ -51,6 +64,8 @@ aliyun vpc CreateVSwitch \
--CidrBlock 192.168.1.0/24
```
> 注意:`CreateVSwitch` 不支持 `--ResourceGroupId` 参数,资源继承自 VPC 的资源组。
**记录**`ECI_VSWITCH_ID` → eci.conf
---
@@ -62,7 +77,8 @@ aliyun ecs CreateSecurityGroup \
--RegionId cn-beijing \
--VpcId $ECI_VPC_ID \
--SecurityGroupName d8d-eci-sg \
--Description "ECI deployment security group"
--Description "ECI deployment security group" \
$RG_PARAM
```
**添加入站规则**
@@ -94,7 +110,8 @@ aliyun nas CreateFileSystem \
--RegionId cn-beijing \
--ProtocolType NFS \
--StorageType Performance \
--Description d8d-eci-nas
--Description d8d-eci-nas \
$RG_PARAM
```
**创建挂载点**
@@ -114,6 +131,24 @@ ECI_NAS_SERVER=${MountTargetDomain}
→ eci.conf
**创建挂载目录**ECI NFS 挂载子路径时,目录必须存在):
```bash
NAS_FS_ID=$(echo "$ECI_NAS_SERVER" | cut -d'-' -f1)
# 根据实际挂载路径创建目录,以下为示例
for dir in /d8d-next /d8d-next/pgdata /d8d-next/minio-data \
/d8d-next/npm /d8d-next/npm/data /d8d-next/npm/letsencrypt; do
aliyun nas CreateDir \
--FileSystemId "$NAS_FS_ID" \
--RootDirectory "$dir" \
--OwnerUserId 0 --OwnerGroupId 0 --Permission 0755 \
--RegionId "$ECI_REGION"
done
```
> **铁律**ECI NFS 挂载指定子路径时,该路径必须在 NAS 上存在,否则 `MountVolume.SetUp` 失败。如果目录已存在,`CreateDir` 不会报错。
---
## 5. 应用 EIP 创建

View File

@@ -17,14 +17,45 @@
---
## 2. CI 构建流程
## 2. 监控 CI 构建
Tag 推送后,使用 **CronCreate 定时任务** 监控 CI 构建状态(不要用 `sleep`
```
CronCreate: 每 2 分钟检查一次(一次性)
cron: "*/2 * * * *", recurring: false
prompt: "检查 CI 构建 <run_number> (<version>) 状态python3 scripts/ci.py status。
success → 部署并进入 Phase 05failure → 查看日志报告错误running → 报告状态继续等待。"
```
或手动查询:
```bash
python3 scripts/ci.py status
```
**等待策略**
- **running** → 用 CronCreate 设置 2 分钟后再次检查Docker 构建通常 5-10 分钟)
- **success** → CI 构建成功ECI 部署由 CI 自动完成,进入 Phase 05 验证
- **failure** → 查看构建日志排查:
```bash
python3 scripts/ci.py log <run_id>
```
> **关键**:必须等待 CI 构建成功后才能进入 Phase 05。如果 CI 失败,修复问题后重新推送 tag。
> **禁止使用 `sleep`**:用 CronCreate 定时任务代替 bash sleep 来轮询 CI 状态。
---
## 3. CI 构建流程(参考)
Gitea Actions 自动执行以下步骤:
### 阶段 A: 构建应用镜像
1. **检出代码**actions/checkout@v4
2. **设置 Docker Buildx**docker/setup-buildx-action@v3(配置 insecure registry 支持 HTTP
2. **设置 Docker Buildx**docker/setup-buildx-action@v3
3. **提取版本信息**:解析 tag 名称、仓库名称
4. **构建并推送应用镜像**
- Docker 多阶段构建standalone Next.js
@@ -32,21 +63,14 @@ Gitea Actions 自动执行以下步骤:
### 阶段 B: postgres 镜像预热
**先检查是否已存在**(避免重复推送)
**从 Docker Hub 拉取官方镜像**
```bash
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" "http://$ECI_REGISTRY_URL/v2/postgres/tags/list")
if [ "$HTTP_CODE" = "200" ]; then
echo "⏭️ postgres:17-alpine 已存在,跳过"
exit 0
fi
docker pull postgres:17-alpine
docker tag postgres:17-alpine $ECI_REGISTRY_URL/postgres:17-alpine
docker push $ECI_REGISTRY_URL/postgres:17-alpine
```
不存在时通过 buildx 推送(支持 HTTP insecure registry
```bash
TMPDIR=$(mktemp -d)
printf 'FROM postgres:17-alpine\n' > $TMPDIR/Dockerfile
docker buildx build --push -t $ECI_REGISTRY_URL/postgres:17-alpine $TMPDIR
```
> **优化效果**:首次拉取慢(约 1 分钟),但 ECI 部署时内网拉取 < 1 秒。
---
@@ -54,68 +78,36 @@ docker buildx build --push -t $ECI_REGISTRY_URL/postgres:17-alpine $TMPDIR
CI 自动调用 `scripts/eci-deploy.sh`
### 前置准备NAS 目录预创建
**确保 postgres 数据目录存在且权限正确**UID 70 = postgres 用户):
```bash
NAS_FS_ID=$(echo "$ECI_NAS_SERVER" | cut -d'.' -f1)
aliyun nas CreateDir \
--FileSystemId "$NAS_FS_ID" \
--RootDirectory "/postgres-data" \
--OwnerUserId 70 \
--OwnerGroupId 70 \
--Permission "0755" \
--RegionId "$ECI_REGION"
```
> **为什么需要预创建**postgres 容器以 UID 70 运行NAS 默认目录 owner 为 root。
> 不预创建会导致 `mkdir: Permission denied`postgres CrashLoopBackOff。
### 删除旧容器组(如存在)
**按名称匹配查找**`DescribeContainerGroups` 不支持名称过滤,需 python3 过滤):
```bash
ALL_GROUPS=$(aliyun eci DescribeContainerGroups --RegionId "$ECI_REGION")
EXISTING_IDS=$(echo "$ALL_GROUPS" | python3 -c "
import sys, json
data = json.load(sys.stdin)
for g in data.get('ContainerGroups', []):
if g.get('ContainerGroupName') == '$ECI_CONTAINER_GROUP_NAME':
print(g['ContainerGroupId'])
")
aliyun eci DeleteContainerGroup \
--RegionId "$ECI_REGION" \
--ContainerGroupId "$EXISTING_ID"
```
轮询等待删除完成。
### 创建新容器组
**错误捕获**:使用 `set +e` 包裹,捕获完整错误信息:
```bash
set +e
RESULT=$(aliyun eci CreateContainerGroup ... 2>&1)
CREATE_EXIT=$?
set -e
if [ $CREATE_EXIT -ne 0 ]; then
echo "❌ CreateContainerGroup 失败 (exit=$CREATE_EXIT)"
echo "$RESULT"
exit 1
fi
aliyun eci CreateContainerGroup \
--RegionId "$ECI_REGION" \
--SecurityGroupId "$ECI_SECURITY_GROUP_ID" \
--VSwitchId "$ECI_VSWITCH_ID" \
--ContainerGroupName "$ECI_CONTAINER_GROUP_NAME" \
--Cpu "$ECI_CPU" \
--Memory "$ECI_MEMORY" \
--RestartPolicy Always \
--EipInstanceId "$ECI_EIP_ID" \
--PlainHttpRegistry "$ECI_REGISTRY_INTRANET_URL" \
--Volume.1.Name nas-data \
--Volume.1.Type NFSVolume \
--Volume.1.NFSVolume.Server "$ECI_NAS_SERVER" \
--Volume.1.NFSVolume.Path "/" \
... # 两个容器配置
```
**两个 NAS 卷挂载**
| 卷名 | NAS 路径 | 用途 | 挂载到 |
|------|---------|------|--------|
| nas-data | / | 通用存储 | - |
| nas-pgdata | /postgres-data | PostgreSQL 数据(权限 UID 70 | postgres: /var/lib/postgresql/data |
> **为什么独立卷**postgres 需要目录 owner 为 UID 70。NAS 根目录 owner 为 root 不可改,
> 独立卷通过 `CreateDir` 指定 ownerUid=70 解决权限问题。
#### Container 1: Next.js 应用
| 配置项 | 值 |
@@ -135,7 +127,7 @@ fi
| Memory | 1.5 GiB |
| Port | TCP 5432 |
| 环境变量 | POSTGRES_DB, POSTGRES_USER, POSTGRES_PASSWORD |
| Volume Mount | nas-pgdata → /var/lib/postgresql/data |
| Volume Mount | NAS 挂载到 `/var/lib/postgresql/data` |
**总计**2.5 vCPU + 3.5 GiB 内存
@@ -144,42 +136,12 @@ fi
**内网拉取优化后预估时间**30 秒 - 2 分钟
```bash
STATUS=$(aliyun eci DescribeContainerGroups --ContainerGroupIds ... | \
python3 -c "import sys,json; data=json.load(sys.stdin); print(data['ContainerGroups'][0]['Status'])")
# 轮询查询状态
while true; do
STATUS=$(aliyun eci DescribeContainerGroups --ContainerGroupId $ID | grep Status)
if [ "$STATUS" = "Running" ]; then break; fi
sleep 5
done
```
### 部署后验证(四层检查)
容器组 Running 后自动执行四层检查:
**层 1: 容器级状态**Ready + RestartCount
```bash
CONTAINER_DETAIL=$(aliyun eci DescribeContainerGroups --ContainerGroupIds ... )
# 检查每个容器的 Ready=True 和 RestartCount=0
```
**层 2: PostgreSQL 就绪**
```bash
aliyun eci ExecContainerCommand --ContainerName postgres \
--Command '["/bin/sh", "-c", "pg_isready -h 127.0.0.1 -p 5432"]'
```
**层 3: Schema 迁移**
```bash
aliyun eci ExecContainerCommand --ContainerName app \
--Command '["/bin/sh", "-c", "cd /app && npx prisma db push --skip-generate"]'
```
**层 4: HTTP 健康检查**
```bash
curl -s -o /dev/null -w "%{http_code}" "http://$ECI_EIP_ADDRESS:3001/api/health"
# 预期: HTTP 200
```
健康检查超时时自动输出容器 Events 帮助诊断。
### CI 最终验证
`release.yaml` 在 deploy 步骤后追加独立的健康检查步骤,确保 CI 以应用可用为成功标准。
→ 进入 [Phase 05: 部署验证](phase-05-verify.md)

View File

@@ -2,38 +2,51 @@
验证 ECI 容器组成功启动,应用可访问。
## 0. 确认 CI 构建成功
在验证容器状态前,先确认 CI 构建已完成:
```bash
python3 scripts/ci.py status
```
如果最近的 run 状态不是 success`python3 scripts/ci.py log <run_id>` 排查。CI 失败时容器组可能使用旧镜像或不存在。
## 1. 验证容器组状态
```bash
aliyun eci DescribeContainerGroups --ContainerGroupName map-collector
aliyun eci DescribeContainerGroups \
--RegionId $ECI_REGION \
$RG_PARAM \
--ContainerGroupName $ECI_CONTAINER_GROUP_NAME
```
**预期状态**`Status: Running`
## 2. 容器级状态检查
## 1.5 查看 ECI 事件(容器 Pending 时排查)
**检查每个容器的 Ready 和 RestartCount**
如果容器组长时间 Pending查看事件排查原因
```bash
aliyun eci DescribeContainerGroups --ContainerGroupIds "[\"$CONTAINER_GROUP_ID\"]"
aliyun eci DescribeContainerGroupEvents \
--RegionId $ECI_REGION \
--ContainerGroupIds '["'$CONTAINER_GROUP_ID'"]'
```
**预期结果**
- `Containers[].Ready = true`(每个容器)
- `Containers[].RestartCount = 0`(无崩溃重启
重点关注 `FailedMount`NAS 目录不存在)、`Failed`(镜像拉取失败)等事件。
## 1.6 查看容器日志Running 但不健康时排查
**RestartCount > 0** 说明容器曾崩溃,检查 Events 和日志:
```bash
# 查看 Events
aliyun eci DescribeContainerGroups ... | python3 -c "
import sys, json
data = json.load(sys.stdin)
for e in data['ContainerGroups'][0].get('Events', [])[-5:]:
print(f'[{e.get(\"Type\")}] {e.get(\"Message\")}')
"
aliyun eci DescribeContainerLog \
--RegionId $ECI_REGION \
--ContainerGroupId $CONTAINER_GROUP_ID \
--ContainerName d8d-next
```
## 3. 健康检查端点
可查看各容器的启动日志和运行时错误。
## 2. 健康检查端点
**调用 API**
```bash
@@ -45,7 +58,7 @@ curl http://$ECI_EIP_ADDRESS:3001/api/health
{ "status": "ok", "timestamp": ... }
```
## 4. 页面访问验证
## 3. 页面访问验证
**浏览器访问**
```
@@ -56,36 +69,16 @@ http://$ECI_EIP_ADDRESS:3001
- 页面标题:地图门店采集平台
- 正常渲染页面内容
## 5. 数据库表检查(首次部署必检)
## 4. PostgreSQL 连接验证
**检查 Schema 是否完整**
通过 ECI exec 检查:
**容器内验证**(可选,通过 ECI exec
```bash
aliyun eci ExecContainerCommand \
--ContainerGroupId "$CONTAINER_GROUP_ID" \
--ContainerName postgres \
--Command '["/bin/sh", "-c", "psql -U postgres -d map_stores -c \"SELECT count(*) FROM information_schema.tables WHERE table_schema = \\\"public\\\";\""]'
pg_isready -h 127.0.0.1 -p 5432 -U postgres
```
临时开放 5432 端口检查:
```bash
PGPASSWORD=postgres psql -h $ECI_EIP_ADDRESS -U postgres -d map_stores \
-c "SELECT count(*) FROM information_schema.tables WHERE table_schema = 'public';"
```
通过应用日志验证数据库连接正常。
**预期结果**:至少 6 张表
| 表数 | 含义 | 操作 |
|------|------|------|
| ≥ 6 | Schema 完整 | 正常 |
| 0 | 空数据库 | → 触发数据同步migrate 子模式) |
| 1-5 | Schema 不完整 | → 检查 prisma db push 是否执行成功 |
**首次部署自动检测**eci-deploy.sh 已内置 `prisma db push`,正常情况下表会自动创建。
如表数 = 0说明 Schema 迁移失败,需排查原因。
## 6. 部署报告
## 5. 部署报告
验证完成后,输出最终部署信息:
@@ -97,12 +90,11 @@ PGPASSWORD=postgres psql -h $ECI_EIP_ADDRESS -U postgres -d map_stores \
📍 Registry 内网: {ECI_REGISTRY_INTRANET_IP}:5000
🐘 PostgreSQL: 已就绪(数据持久化在 NAS
💾 容器规格: 2.5 vCPU + 3.5 GiB
📊 数据库: {N} 张表,{M} 条数据
💡 后续部署: 打 tag 触发 CI 即可IP 地址永久不变
```
## 7. 可选:数据同步
## 6. 可选:数据同步
如需同步本地开发数据到生产:
@@ -120,5 +112,3 @@ PGPASSWORD=postgres psql -h $ECI_EIP_ADDRESS -U postgres -d map_stores \
- ✅ 两个固定 EIP永久不变
- ✅ 内网镜像拉取,速度 < 1
- NAS 持久化容器重建不丢失数据
- 部署后自动 Schema 迁移 + 健康检查
- 四层验证容器 Ready PG 就绪 Schema 迁移 HTTP 健康

View File

@@ -0,0 +1,251 @@
# Phase 06: DNS + NPM 代理 + SSL 证书配置
部署完成后配置域名解析、Nginx Proxy Manager 反向代理和 SSL 证书,实现 HTTPS 域名访问。
## 前置条件
- Phase 05 验证通过,容器组 Running
- EIP 地址已分配且固定(`ECI_EIP_ADDRESS`
- NPM 容器已启动(端口 81 可访问)
- 阿里云 AK/SK 有 Alidns 管理权限
---
## 1. DNS A 记录配置
使用阿里云 Alidns API 将域名指向固定 EIP。
### 1.1 读取配置
```bash
source eci.conf
# DNS_DOMAINS=www.d8d.fun,next-socket.d8d.fun,next-minio.d8d.fun
# DNS_DOMAIN=项目主域名(如 d8d.fun
```
### 1.2 配置 DNS 记录
对每个域名,检查是否存在 A 记录,不存在则创建,存在则更新:
```bash
for SUB in $(echo $DNS_DOMAINS | tr ',' ' '); do
RR=$(echo $SUB | sed "s/\.$DNS_DOMAIN$//" | sed "s/\.$//")
# 查询现有记录
RECORD=$(aliyun alidns DescribeDomainRecords \
--DomainName "$DNS_DOMAIN" \
--RRKeyWord "$RR" \
--Type A)
RECORD_ID=$(echo "$RECORD" | grep -o '"RecordId":"[^"]*"' | head -1 | cut -d'"' -f4)
if [ -n "$RECORD_ID" ]; then
echo "更新 DNS 记录: $RR.$DNS_DOMAIN -> $ECI_EIP_ADDRESS"
aliyun alidns UpdateDomainRecord \
--RecordId "$RECORD_ID" \
--RR "$RR" \
--Type A \
--Value "$ECI_EIP_ADDRESS"
else
echo "创建 DNS 记录: $RR.$DNS_DOMAIN -> $ECI_EIP_ADDRESS"
aliyun alidns AddDomainRecord \
--DomainName "$DNS_DOMAIN" \
--RR "$RR" \
--Type A \
--Value "$ECI_EIP_ADDRESS"
fi
done
```
### 1.3 等待 DNS 生效
```bash
echo "等待 DNS 传播..."
sleep 30
```
---
## 2. NPM 管理员初始化
NPM 默认帐号 `admin@example.com` / `changeme`,需改为自定义帐号。
### 2.1 读取配置
```bash
source eci.conf
# NPM_ADMIN_EMAIL=xxx@xxx.com
# NPM_ADMIN_PASSWORD=xxxxx
```
### 2.2 登录并修改帐号
```bash
EIP=$ECI_EIP_ADDRESS
# 用默认帐号登录
TOKEN=$(curl -s -X POST "http://$EIP:81/api/tokens" \
-H "Content-Type: application/json" \
-d '{"identity":"admin@example.com","secret":"changeme"}' | jq -r '.token')
if [ "$TOKEN" = "null" ] || [ -z "$TOKEN" ]; then
echo "默认帐号已修改,使用自定义帐号登录"
TOKEN=$(curl -s -X POST "http://$EIP:81/api/tokens" \
-H "Content-Type: application/json" \
-d "{\"identity\":\"$NPM_ADMIN_EMAIL\",\"secret\":\"$NPM_ADMIN_PASSWORD\"}" | jq -r '.token')
else
# 修改管理员帐号
curl -s -X PUT "http://$EIP:81/api/users/1" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d "{\"email\":\"$NPM_ADMIN_EMAIL\",\"name\":\"Administrator\",\"password\":\"$NPM_ADMIN_PASSWORD\",\"passwordConfirm\":\"$NPM_ADMIN_PASSWORD\"}"
echo "NPM 管理员帐号已更新"
# 用新帐号重新登录
TOKEN=$(curl -s -X POST "http://$EIP:81/api/tokens" \
-H "Content-Type: application/json" \
-d "{\"identity\":\"$NPM_ADMIN_EMAIL\",\"secret\":\"$NPM_ADMIN_PASSWORD\"}" | jq -r '.token')
fi
```
---
## 3. SSL 证书申请
通过 NPM API 申请 Let's Encrypt 免费 SSL 证书。
> **前提**: DNS A 记录已生效且指向 EIP否则 Let's Encrypt 域名验证会失败。
### 3.1 为每个域名申请证书
```bash
for DOMAIN in $(echo $DNS_DOMAINS | tr ',' ' '); do
echo "申请 SSL 证书: $DOMAIN"
CERT_RESULT=$(curl -s -X POST "http://$EIP:81/api/nginx/certificates" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d "{
\"provider\": \"letsencrypt\",
\"nice_name\": \"$DOMAIN\",
\"domain_names\": [\"$DOMAIN\"],
\"meta\": {
\"letsencrypt_email\": \"$NPM_ADMIN_EMAIL\",
\"letsencrypt_agree\": true
}
}")
CERT_ID=$(echo "$CERT_RESULT" | jq -r '.id // empty')
if [ -n "$CERT_ID" ]; then
echo " 证书申请成功: ID=$CERT_ID"
else
echo " 证书申请失败: $CERT_RESULT"
fi
sleep 5
done
```
---
## 4. NPM 代理主机配置
创建反向代理主机,将域名转发到容器组内部服务,并启用 SSL。
### 4.1 获取证书 ID 映射
```bash
CERTS=$(curl -s "http://$EIP:81/api/nginx/certificates" \
-H "Authorization: Bearer $TOKEN")
```
### 4.2 创建代理主机
根据 eci.conf 中的代理配置,自动创建代理主机。代理规则从 `NPM_PROXY_CONFIG` 读取:
```bash
# NPM_PROXY_CONFIG 格式: 域名:端口:WebSocket
# 例如: www.d8d.fun:3001:false,next-socket.d8d.fun:3002:true,next-minio.d8d.fun:9000:false
```
```bash
for PROXY in $(echo $NPM_PROXY_CONFIG | tr ',' ' '); do
DOMAIN=$(echo $PROXY | cut -d: -f1)
PORT=$(echo $PROXY | cut -d: -f2)
WS=$(echo $PROXY | cut -d: -f3)
CERT_ID=$(echo "$CERTS" | jq -r "[.[] | select(.domain_names[]==\"$DOMAIN\")] | .[0].id // empty")
if [ -z "$CERT_ID" ]; then
echo "警告: $DOMAIN 无 SSL 证书,跳过代理创建"
continue
fi
BODY=$(jq -n \
--arg domain "$DOMAIN" \
--argjson port "$PORT" \
--argjson ws "$WS" \
--argjson cert_id "$CERT_ID" \
'{
domain_names: [$domain],
forward_host: "127.0.0.1",
forward_port: $port,
ssl_forced: true,
certificate_id: $cert_id,
http2_support: true,
block_exploits: true,
websockets_support: $ws,
hsts_enabled: (if $ws then false else true end),
hsts_subdomains: false
}')
RESULT=$(curl -s -X POST "http://$EIP:81/api/nginx/proxy-hosts" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d "$BODY")
HOST_ID=$(echo "$RESULT" | jq -r '.id // empty')
if [ -n "$HOST_ID" ]; then
echo "代理主机创建成功: $DOMAIN -> 127.0.0.1:$PORT (SSL, WS=$WS)"
else
echo "代理主机创建失败: $RESULT"
fi
done
```
---
## 5. 验证 HTTPS 访问
```bash
for DOMAIN in $(echo $DNS_DOMAINS | tr ',' ' '); do
STATUS=$(curl -s -o /dev/null -w "%{http_code}" "https://$DOMAIN" 2>/dev/null || echo "000")
if [ "$STATUS" != "000" ]; then
echo "https://$DOMAIN - HTTP $STATUS"
else
echo "https://$DOMAIN - 不可达DNS 可能尚未生效)"
fi
done
```
---
## 本阶段输出
| 状态项 | 说明 |
|--------|------|
| ✅ DNS A 记录 | 所有域名指向固定 EIP |
| ✅ NPM 管理员 | 自定义帐号已配置 |
| ✅ SSL 证书 | Let's Encrypt 证书已申请 |
| ✅ 代理主机 | HTTPS 反向代理已配置 |
---
## 首次部署 vs 增量部署
| 操作 | 首次部署 | 增量部署 |
|------|----------|----------|
| DNS A 记录 | ✅ 创建 | ⏭️ 跳过(已存在) |
| NPM 帐号初始化 | ✅ 修改默认帐号 | ⏭️ 跳过 |
| SSL 证书申请 | ✅ 申请新证书 | ⏭️ 复用已有(自动续期) |
| 代理主机创建 | ✅ 创建 | ⏭️ 复用已有 |
→ 返回 [workflow.md](../workflow.md)

View File

@@ -4,6 +4,32 @@
## 1. 版本发布
### CI 管理工具
使用 `scripts/ci.py` 统一管理 CI 构建:
```bash
# 查看最近构建状态
python3 scripts/ci.py status
# 查看构建日志
python3 scripts/ci.py log <run_id>
# 查看构建日志(原始 JSON
python3 scripts/ci.py log <run_id> --raw
# 日志写入文件
python3 scripts/ci.py log <run_id> -o log.txt
# 推送 tag 触发构建
python3 scripts/ci.py build 0.2.0
# 列出已推送的 tag
python3 scripts/ci.py tags
```
> **配置来源**:从 `eci.conf` 的 `GITEA_*` 字段读取,无需额外配置文件。自动安装缺失的 `python3-requests` 依赖。
### 创建发布标签
```bash
@@ -328,6 +354,16 @@ fi
## 8. 配置验证
### 资源组参数初始化
> 当 RAM 子帐号绑定资源组时,所有 `aliyun` 资源操作需要传 `--ResourceGroupId`。执行前先初始化:
```bash
source eci.conf
RG_PARAM=""
[ -n "$ECI_RESOURCE_GROUP_ID" ] && RG_PARAM="--ResourceGroupId $ECI_RESOURCE_GROUP_ID"
```
### 检查 eci.conf 配置完整性
```bash

View File

@@ -8,12 +8,12 @@
| 配置项 | 值 | 说明 |
|--------|-----|------|
| 总 CPU | 2.5 vCPU | 应用 2.0 + PostgreSQL 0.5 |
| 总内存 | 3.5 GiB | 应用 2.5 + PostgreSQL 1.0 |
| 容器数量 | 2 | 应用 + 数据库 sidecar |
| 总 CPU | 3.5 vCPU | 应用 2.0 + PostgreSQL 0.5 + MinIO 0.5 + NPM 0.5 |
| 总内存 | 5.5 GiB | 应用 2.5 + PostgreSQL 1.5 + MinIO 1.0 + NPM 0.5 |
| 容器数量 | 4 | 应用 + 数据库 + 对象存储 + 反向代理 |
| 重启策略 | Always | 崩溃自动重启 |
| 外网模式 | 绑定已有 EIP | IP 固定不变 |
| 镜像仓库 | 私有 Registry 内网拉取 | <1 |
| 镜像仓库 | ACR VPC 或私有 Registry 内网拉取 | <1 根据 REGISTRY_MODE 选择 |
---

View File

@@ -108,23 +108,14 @@
## 铁律 6: 凭证不上传,配置不提交
**原则**:敏感凭证绝不提交到代码仓库
**原则**:敏感凭证绝不提交到**公开**代码仓库。私有仓库可根据实际情况决定
**执行保障**
1. `eci.conf` 包含 `ALI_ACCESS_KEY_SECRET` → 必须加入 `.gitignore`
1. `eci.conf` 包含 `ALI_ACCESS_KEY_SECRET`公开仓库必须加入 `.gitignore`,私有仓库可提交
2. `.env` 包含数据库密码、API Key → 必须加入 `.gitignore`
3. CI 工作流中不硬编码任何密钥
4. 所有凭证通过环境变量或本地配置文件注入
**Git 安全检查**(每次提交前执行):
```bash
# 检查是否意外提交了敏感文件
git diff --cached --name-only | grep -E "(eci\.conf|\.env)"
# 检查是否硬编码了 AK/SK
git diff --cached | grep -E "ALI_ACCESS_KEY|sk-"
```
---
## 铁律 7: 端口最小开放原则
@@ -158,19 +149,72 @@ git diff --cached | grep -E "ALI_ACCESS_KEY|sk-"
---
## 铁律 9: 部署后必须验证应用可用
## 铁律 9: DNS 域名与 EIP 绑定
**原则**容器 Running ≠ 应用可用。部署流程必须验证完整检查链通过
**原则**DNS A 记录创建后,域名与固定 EIP 永久绑定域名不变、EIP 不变
**执行保障**
1. 容器组状态 Running → 仅代表容器进程启动
2. 检查每个容器 `Ready=True` & `RestartCount=0` → 容器级就绪
3. 等待 PostgreSQL 接受连接 → 数据库就绪
4. 执行 `prisma db push` → Schema 同步
5. 轮询 `/api/health` 返回 HTTP 200 → 应用就绪
6. 健康检查超时 → 输出容器 Events 和日志供诊断,不静默成功
1. DNS 记录按域名创建,指向 `ECI_EIP_ADDRESS`
2. 已存在的 A 记录使用 `UpdateDomainRecord` 更新 Value不重复创建
3. EIP 固定不变(铁律 1因此 DNS 记录无需更新
**检查顺序**:容器组 Running → 容器 Ready=True & RestartCount=0 → PostgreSQL 就绪 → Schema 迁移 → HTTP /api/health 200 → 部署成功
**用户价值**
- HTTPS 证书自动续期
- 客户端域名访问稳定
- SSL 指纹与域名一致
---
## 铁律 10: NPM 代理配置持久化
**原则**NPM 代理主机配置存储在 NAS容器重建不丢失。
**执行保障**
1. NPM 数据目录 `/data` 挂载 NAS
2. Let's Encrypt 证书目录 `/etc/letsencrypt` 挂载 NAS
3. 代理规则、SSL 证书、访问列表全部持久化
**数据清单**
| 数据类型 | 存储位置 | 持久化保障 |
|----------|----------|------------|
| 代理主机规则 | NAS /data | ✅ 容器重建保留 |
| SSL 证书 | NAS /etc/letsencrypt | ✅ 容器重建保留 |
| 访问列表 | NAS /data | ✅ 容器重建保留 |
---
## 铁律 11: RAM 资源组感知
**原则**:当 RAM 子帐号绑定资源组时,所有资源创建和查询 API 必须传 `--ResourceGroupId`,否则会权限不足或查询不到已有资源。
**执行保障**
1. `eci.conf``ECI_RESOURCE_GROUP_ID` 非空时,自动构建 `RG_PARAM="--ResourceGroupId $ECI_RESOURCE_GROUP_ID"`
2. 所有 `aliyun` 资源创建命令VPC、VSwitch、SecurityGroup、NAS、EIP附加 `$RG_PARAM`
3. 所有 `aliyun` 资源查询命令DescribeVpcs、DescribeContainerGroups 等)附加 `$RG_PARAM`
4. ECI 容器组操作Create/Delete/Describe附加 `$RG_PARAM`
5. 为空时不传,兼容无资源组绑定的 RAM 帐号
**例外**
- `CreateVSwitch` 不支持 `--ResourceGroupId`(继承 VPC 的资源组)
- 安全组规则操作Authorize/RevokeSecurityGroup无需传
**Why**`eci-d8d-next` RAM 子帐号绑定资源组 `rg-aek6bbbehek5tnq`,不带此参数的 API 调用返回空结果或权限错误,导致误创建重复资源。
**How to apply**:在 phase-01 和部署脚本执行前初始化 `RG_PARAM`,后续所有 aliyun 命令统一附加。
### 铁律 12: NAS 子目录挂载前必须预创建
ECI NFS 挂载指定子路径时,该路径必须在 NAS 上存在,否则 `MountVolume.SetUp` 失败,容器组卡在 Pending。
**操作**:部署前用 `aliyun nas CreateDir` 预创建所有挂载目录:
```bash
aliyun nas CreateDir --FileSystemId $NAS_FS_ID --RootDirectory /d8d-next/pgdata \
--OwnerUserId 0 --OwnerGroupId 0 --Permission 0755 --RegionId $ECI_REGION
```
**Why**ECI 容器组创建时如果 NAS 子目录不存在4 个卷全部 `FailedMount: file does not exist`,容器无法启动。`CreateDir` 幂等操作,目录已存在不会报错。
**How to apply**:在 `eci-deploy.sh` 创建容器组之前,自动调用 `CreateDir` 确保目录存在。phase-01 创建 NAS 后也需执行此步骤。
---

View File

@@ -6,7 +6,7 @@
| 优势 | 说明 |
|------|------|
| ✅ 完全自托管 | 不依赖 ACR/Docker Hub,所有镜像私有存储 |
| ✅ 完全自托管 | 支持 ACR 或自建 Registry,所有镜像私有存储 |
| ✅ Spot 实例成本 | Registry 使用抢占式实例,相比按量节省 90% |
| ✅ NAS 持久化 | 数据 + 镜像都存 NAS容器重建不丢失 |
| ✅ 双固定 EIP | 应用 (3001) + Registry (5000)IP 永久不变 |
@@ -67,7 +67,19 @@
| 02 | `phases/phase-02-config.md` | 配置生成eci.conf 填充
| 03 | `phases/phase-03-ci-setup.md` | CI 工作流 + 脚本部署
| 04 | `phases/phase-04-deploy.md` | 触发发布 + ECI 容器组部署
| 05 | `phases/phase-05-verify.md` | 部署验证 + 健康检查
| 05 | `phases/phase-05-verify.md` | 部署验证 + 健康检查 |
| 06 | `phases/phase-06-dns-npm-ssl.md` | DNS + NPM 代理 + SSL 证书配置 |
## 核心工具
| 工具 | 路径 | 用途 |
|------|------|------|
| `ci.py` | `scripts/ci.py` | CI 状态查看、构建日志、触发构建、tag 管理 |
| `release_tag.sh` | `scripts/release_tag.sh` | 创建 git tag 触发 CI |
| `eci-deploy.sh` | `scripts/eci-deploy.sh` | ECI 容器组部署脚本 |
| `eci-registry.sh` | `scripts/eci-registry.sh` | 私有 Registry 部署脚本 |
> **CI 管理首选 `ci.py`**`python3 scripts/ci.py status` 查看构建状态、`python3 scripts/ci.py log <run_id>` 查看日志、`python3 scripts/ci.py build <version>` 触发构建。自动安装依赖,从 `eci.conf` 读配置。
## 铁律与优化