sync: d8d-alipay-preview 新增 + d8d-release 大幅演进 + uni-app-compatibility #69
69
.claude/skills/d8d-alipay-preview/SKILL.md
Normal file
69
.claude/skills/d8d-alipay-preview/SKILL.md
Normal 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 按流程执行。
|
||||
114
.claude/skills/d8d-alipay-preview/workflow.md
Normal file
114
.claude/skills/d8d-alipay-preview/workflow.md
Normal 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
|
||||
327
.claude/skills/d8d-release/assets/ci.py
Normal file
327
.claude/skills/d8d-release/assets/ci.py
Normal 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()
|
||||
@@ -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 ""
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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=
|
||||
|
||||
# 资源组 ID(RAM 子帐号绑定资源组时必填,为空则忽略)
|
||||
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=
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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` 选择「数据同步模式」 |
|
||||
|
||||
|
||||
@@ -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 创建
|
||||
|
||||
@@ -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 05;failure → 查看日志报告错误;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)
|
||||
|
||||
@@ -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 健康
|
||||
|
||||
251
.claude/skills/d8d-release/phases/phase-06-dns-npm-ssl.md
Normal file
251
.claude/skills/d8d-release/phases/phase-06-dns-npm-ssl.md
Normal 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)
|
||||
@@ -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
|
||||
|
||||
@@ -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 选择) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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 后也需执行此步骤。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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` 读配置。
|
||||
|
||||
## 铁律与优化
|
||||
|
||||
|
||||
Reference in New Issue
Block a user