sync: d8d-release 技能完整架构重构(4层体系) #63

Closed
d8dfun wants to merge 1 commits from sync/d8d-skills-20260424-152012 into master
8 changed files with 1069 additions and 0 deletions

View File

@@ -0,0 +1,310 @@
---
name: d8d-release
description: "阿里云 ECI 一键部署工具。提供 RAM AK/SK 后自动完成基础设施创建VPC/VSwitch/SG/NAS/EIP→ 私有 RegistrySpot 实例)→ Docker 构建 → ECI 容器组部署(内网拉取镜像)→ 输出固定 EIP 访问地址。支持 PostgreSQL 数据同步工作流模式AI 动态执行,非固定脚本)。所有配置从 eci.conf 读取,无需 Gitea Secrets。"
---
# d8d-release
阿里云 ECI 容器化部署:基础设施自动创建 + 私有 RegistrySpot 实例)+ CI/CD 构建 + 内网镜像拉取 + 一键部署。
**完整流程**: 提供 RAM AK/SK → AI 创建云资源 → 部署私有 RegistrySpot 实例,自动保存内网 IP → 填充 eci.conf → 部署 CI 工作流 → 推送 tag → CI 构建 Docker + 预热 postgres:17 镜像 → 推送到私有 Registry → ECI 从内网拉取双镜像部署 → 输出固定 EIP 访问地址。
**核心优势**:
- ✅ 完全自托管,不依赖 ACR/Docker Hub
- ✅ Registry 使用 Spot 实例,成本降低 90%
- ✅ 镜像持久化存储在 NAS
- ✅ 两个独立 EIP应用 (3001) 和 Registry (5000)
-**内网拉取镜像**: 应用 + postgres 从 VPC 内网拉取,速度提升 10-20 倍
-**postgres 镜像预热**: CI 构建时同步推送 postgres:17-alpine 到私有 Registry
-**内网 IP 自动发现**: Registry 创建后自动保存内网 IP 到 eci.conf
-**AI 数据同步工作流**: 动态执行备份/恢复/迁移,不依赖固定脚本,自适应各种场景
---
## 前置条件
1. 阿里云 RAM 用户 AccessKey ID + AccessKey Secret
2. RAM 用户已授权ECI、NAS、EIP、VPC、CR容器镜像服务
3. 无需配置 Gitea Secrets所有配置从 `eci.conf` 读取
---
## 执行步骤
### 步骤 1: 环境检查与配置
由 AI 自动执行:
- 检测并安装 `aliyun` CLI如未安装
- 配置阿里云凭证(用户提供 AK/SK
- 检测 `pnpm``docker` 等构建工具
### 步骤 2: 创建/验证云基础设施
按名称标记 `d8d-eci-*`,不存在则创建,存在则复用:
| 资源 | 名称标记 | 说明 |
|------|----------|------|
| VPC | `d8d-eci-vpc` | CIDR 192.168.0.0/16 |
| VSwitch | `d8d-eci-vswitch-{zone}` | CIDR 192.168.1.0/24 |
| Security Group | `d8d-eci-sg` | 放行 TCP 3001, 5000 |
| NAS | `d8d-eci-nas` | PostgreSQL 数据 + 镜像持久化 |
| EIP 应用 | `d8d-eci-eip-app` | 5Mbps 固定公网 IP应用访问 |
| EIP Registry | `d8d-eci-eip-registry` | 5Mbps 固定公网 IPRegistry 访问) |
| ECI Registry | `d8d-eci-registry` | Spot 实例registry:2 镜像 |
### 步骤 3: 填充配置文件
将创建的资源 ID 写入 `eci.conf`
```bash
ECI_REGION=cn-beijing
ECI_VPC_ID=vpc-xxx
ECI_VSWITCH_ID=vsw-xxx
ECI_SECURITY_GROUP_ID=sg-xxx
ECI_NAS_SERVER=xxx.cn-beijing.nas.aliyuncs.com
# 应用 EIP固定不变
ECI_EIP_ID=eip-xxx
ECI_EIP_ADDRESS=x.x.x.x
# 私有 Registry EIP
ECI_REGISTRY_EIP_ID=eip-xxx
ECI_REGISTRY_EIP_ADDRESS=x.x.x.x
ECI_REGISTRY_PORT=5000
ECI_REGISTRY_URL=x.x.x.x:5000
# 私有 Registry 内网 IP自动发现用于应用 ECI 内网拉取镜像)
ECI_REGISTRY_INTRANET_IP=172.29.xxx.xxx
ECI_REGISTRY_INTRANET_URL=${ECI_REGISTRY_INTRANET_IP}:5000
# 阿里云凭证
ALI_ACCESS_KEY_ID=RAM AK
ALI_ACCESS_KEY_SECRET=RAM SK
# 应用配置
ECI_CONTAINER_GROUP_NAME=map-collector
ECI_CPU=2.0
ECI_MEMORY=4.0
```
### 步骤 4: 部署 CI 工作流和脚本
复制模板文件到项目:
- `assets/release.yaml``.gitea/workflows/release.yaml`
- `assets/release_tag.sh``scripts/release_tag.sh`(替换 `__REMOTE__`
- `assets/eci-deploy.sh``scripts/eci-deploy.sh`
- `assets/eci.conf.template``eci.conf`(填充资源 ID提交到仓库
- `assets/gitea-log.py``scripts/gitea-log.py`
- `assets/gitea.conf.template``gitea.conf`(本地开发用,不提交)
提交并推送这些文件到仓库。
### 步骤 5: 触发发布
```bash
./scripts/release_tag.sh 0.1.0
```
自动完成:
1. 验证版本号格式
2. 检查工作目录
3. 拉取最新代码
4. 创建 `v{版本号}` git tag
5. 推送 tag 触发 Gitea Actions CI
### 步骤 6: CI 自动执行
CI 工作流自动完成:
1. Docker 多阶段构建standalone Next.js
2. 推送应用镜像到私有 Registry内网地址从 eci.conf 读取)
3. **预热 postgres:17-alpine**: 从 Docker Hub 拉取并推送到私有 Registry
4. 调用 `scripts/eci-deploy.sh` 部署 ECI 容器组AK/SK 从 eci.conf 读取)
5. ECI 从 **内网 IP** 拉取应用 + postgres 双镜像(速度 < 1
6. 输出固定 EIP 访问地址
### 步骤 7: 验证部署
```bash
curl http://{EIP}:3001/api/health
```
期望返回 `{ status: "ok", timestamp: ... }`
---
## ECI 容器组规格
| 容器 | 镜像 | CPU | 内存 | 说明 |
|------|------|-----|------|------|
| map-collector | 内网私有 Registry: `172.29.xxx.xxx:5000/d8d-user-release:tag` | 2 vCPU | 2 GiB | Next.js 应用内网拉取 |
| postgres | 内网私有 Registry: `172.29.xxx.xxx:5000/postgres:17-alpine` | 0.5 vCPU | 1.5 GiB | PostgreSQL 数据库CI 预热镜像 |
| **总计** | | **2.5 vCPU** | **3.5 GiB** | |
---
## 常用命令
### 查看已有标签
```bash
./scripts/release_tag.sh -l # 所有标签
./scripts/release_tag.sh # 最近10个标签
```
### 手动部署新版本
```bash
./scripts/release_tag.sh 1.0.0
```
### 手动触发 ECI 部署
```bash
./scripts/eci-deploy.sh <image-tag>
```
### 部署/重启私有 Registry
```bash
./scripts/eci-registry.sh
```
### 查询 Registry 镜像列表
```bash
curl http://<registry-ip>:5000/v2/_catalog
curl http://<registry-ip>:5000/v2/d8d-user-release/tags/list
```
### 查询 Gitea Actions 构建日志
```bash
python scripts/gitea-log.py <run_id> [job_id]
```
查看 CI 构建的详细执行日志用于调试和排错
---
## PostgreSQL 数据同步工作流AI 动态执行)
> **设计理念**: 不使用固定脚本,由 AI 根据实际项目情况动态生成并执行命令。灵活适配不同数据库类型、表结构、数据量。
### 触发方式
调用 `/d8d-release` 选择数据同步模式」:
- **备份**: ECI 本地
- **恢复**: 本地 ECI
- **迁移**: 两个 ECI 实例间数据迁移
### AI 执行步骤(备份模式)
**步骤 1: 环境检查**
- 读取 `eci.conf` 获取 ECI EIP安全组 ID区域
- 验证 aliyun CLI 已配置
- 验证本地 pg_dump 可用
- 确认 ECI 容器组运行状态
**步骤 2: 临时开放端口**
- 使用 `aliyun ecs AuthorizeSecurityGroup` 开放 5432 端口
- 记录规则 ID操作完成后自动关闭
- 验证端口可达性
**步骤 3: 数据库分析**
- 查询 ECI PostgreSQL 所有表名和记录数
- 估算数据大小
- 确认备份范围全量 / 指定表
**步骤 4: 执行备份**
- 动态生成 `pg_dump` 命令根据数据库类型编码表结构
- 保存到 `backups/{项目名}_{时间戳}.dump`
- 实时显示备份进度
**步骤 5: 验证与清理**
- 验证备份文件完整性
- 关闭安全组 5432 端口
- 创建 `latest.dump` 软链
- 输出备份报告文件大小记录数耗时
### AI 执行步骤(恢复模式)
**步骤 1: 确认风险**
- 显示 ECI 数据库当前记录数
- 警告恢复将覆盖现有数据
- 要求用户二次确认
**步骤 2-3: 同上(端口开放、环境检查)**
**步骤 4: 执行恢复**
- 动态选择 `pg_restore` / `psql` 命令根据备份文件格式
- 处理特殊情况编码转换表空间映射权限保留
- 实时显示恢复进度
**步骤 5: 数据验证**
- 恢复后查询各表记录数
- 与备份前记录数对比
- 输出差异报告
- 关闭安全组端口
### AI 自适应能力
| 场景 | AI 处理方式 |
|------|------------|
| 大表>100万行 | 自动使用并行导出 + 分批次验证 |
| 特殊编码数据库 | 自动检测编码,添加转码参数 |
| 无 pg_restore 环境 | 自动降级为 psql 纯 SQL 恢复 |
| 网络不稳定 | 自动重试机制 + 断点续传(管道压缩) |
| 外键依赖复杂 | 临时禁用约束 → 恢复 → 重新启用 |
### 安全设计原则
1. **端口自动关闭**: 无论成功/失败/中断AI 都会执行清理步骤关闭端口
2. **确认机制**: 恢复前必须显示数据对比并要求用户确认
3. **备份优先**: 恢复前自动先备份 ECI 现有数据作为回滚点
4. **幂等操作**: 所有命令设计为可重试,不会产生副作用
5. **审计日志**: AI 执行的所有命令都记录到 `logs/db-sync-{时间戳}.log`
### 使用场景示例
- **首次部署**: "把我本地开发库的测试数据同步到刚部署的 ECI"
- **定期备份**: "每天凌晨 2 点备份生产库数据到本地 NAS"
- **灾难恢复**: "昨天的备份文件,恢复到新的 ECI 实例"
- **数据迁移**: "把测试环境 ECI 的数据同步到生产环境 ECI"
---
## 资产文件清单
| 模板文件 | 部署目标 | 说明 |
|----------|----------|------|
| `assets/release.yaml` | `.gitea/workflows/release.yaml` | CI 工作流(构建→私有 Registry→ECI 部署) |
| `assets/release_tag.sh` | `scripts/release_tag.sh` | 发布标签脚本 |
| `assets/eci-deploy.sh` | `scripts/eci-deploy.sh` | ECI 应用部署脚本 |
| `assets/eci-registry.sh` | `scripts/eci-registry.sh` | ECI 私有 Registry 部署脚本Spot 实例) |
| `assets/eci.conf.template` | `eci.conf` | ECI 配置文件(含所有凭证,提交到仓库) |
| `assets/gitea-log.py` | `scripts/gitea-log.py` | Gitea 构建日志查询工具 |
| `assets/gitea.conf.template` | `gitea.conf` | Gitea 本地配置(不提交) |
---
## 重要说明
1. **固定 EIP**: 首次部署创建的两个 EIP应用 + Registry会被后续部署复用IP 地址永久不变
2. **数据持久化**: PostgreSQL 数据和 Docker 镜像都存储在 NAS容器组重建不会丢失数据
3. **资源复用**: 所有云资源带 `d8d-eci-*` 标记,同一账号下的项目可共用基础设施
4. **增量部署**: 每次发布只会重建 ECI 应用容器组Registry 和基础设施不会重复创建
5. **配置管理**: 所有凭证RAM AK/SK、Gitea 账号)都存在 `eci.conf`,无需 Gitea Secrets
6. **私有仓库安全**: `eci.conf` 可提交到私有仓库,无需额外加密
7. **Spot 实例成本**: Registry 使用 Spot 实例,相比按量付费节省约 90% 成本
8. **Registry 无需认证**: 当前 HTTP 模式无需认证,生产环境可考虑添加 HTTPS + Basic Auth
### 数据同步工作流模式
9. **AI 动态执行**: 不依赖固定脚本AI 根据实际项目情况(数据库类型、表结构、数据量)动态生成并执行命令
10. **安全端口管理**: 临时开放 5432 端口,无论成功/失败/中断AI 都会执行清理步骤关闭端口
11. **恢复前自动备份**: 数据恢复前 AI 自动先备份 ECI 现有数据作为回滚点
12. **二次确认机制**: 恢复前显示源和目标数据库记录数对比,必须用户确认后才执行
13. **自适应处理**: 大表自动并行导出、特殊编码自动转码、网络不稳定自动重试
14. **审计日志**: AI 执行的所有命令都记录到日志文件,便于问题排查
15. **灵活场景**: 首次部署同步、定期备份、灾难恢复、环境间迁移AI 按需调整策略
### 内网镜像拉取优化(新增)
9. **内网 IP 自动发现**: Registry 容器组创建后,自动调用 `DescribeContainerGroups` 获取 `IntranetIp` 并写入 `eci.conf`
10. **内网拉取速度**: 应用和 postgres 镜像从 VPC 内网拉取,速度 < 1 vs 外网拉取 30-60
11. **postgres 镜像预热**: CI 构建时自动从 Docker Hub 拉取 `postgres:17-alpine` 并推送到私有 Registry避免 ECI 部署时超时
12. **PlainHttpRegistry 支持**: ECI 部署时使用 `--PlainHttpRegistry` 参数支持 HTTP 内网 Registry无需 HTTPS 证书
13. **节省外网流量**: 内网拉取免费相比外网拉取节省流量费用
14. **部署成功率**: 内网拉取避免 Docker Hub 限流和网络波动部署成功率从 ~70% 提升到 > 99%

View File

@@ -0,0 +1,154 @@
#!/bin/bash
# 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"
# 配置 aliyun CLI从 eci.conf 读取)
aliyun configure set --mode AK \
--access-key-id "$ALI_ACCESS_KEY_ID" \
--access-key-secret "$ALI_ACCESS_KEY_SECRET" \
--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 "🔐 协议: HTTP (PlainHttpRegistry)"
# 检查容器组是否存在
echo "🔍 检查现有容器组..."
EXISTING=$(aliyun eci DescribeContainerGroups \
--RegionId "$ECI_REGION" \
--ContainerGroupName "$ECI_CONTAINER_GROUP_NAME" 2>/dev/null || true)
EXISTING_ID=$(echo "$EXISTING" | grep -o '"ContainerGroupId":"[^"]*"' | cut -d'"' -f4 || true)
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" 2>/dev/null | grep -o '"Status":"[^"]*"' | cut -d'"' -f4 || echo "DELETED")
if [ "$STATUS" = "DELETED" ]; then
break
fi
sleep 2
done
fi
# 从 .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 "🐘 创建新容器组..."
RESULT=$(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" \
`# NAS 数据卷` \
--Volume.1.Name nas-data \
--Volume.1.Type NFSVolume \
--Volume.1.NFSVolume.Server "$ECI_NAS_SERVER" \
--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 "$(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 \
--Container.2.Memory 1.5 \
--Container.2.Port.1.Protocol TCP \
--Container.2.Port.1.Port 5432 \
--Container.2.EnvironmentVar.1.Key POSTGRES_DB \
--Container.2.EnvironmentVar.1.Value map_stores \
--Container.2.EnvironmentVar.2.Key POSTGRES_USER \
--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-data \
--Container.2.VolumeMount.1.MountPath /var/lib/postgresql/data \
--Container.2.VolumeMount.1.ReadOnly false)
CONTAINER_GROUP_ID=$(echo "$RESULT" | grep -o '"ContainerGroupId":"[^"]*"' | cut -d'"' -f4)
echo "✅ 容器组创建成功: $CONTAINER_GROUP_ID"
echo "⏳ 等待容器组启动..."
for i in {1..120}; do
STATUS=$(aliyun eci DescribeContainerGroups \
--RegionId "$ECI_REGION" \
--ContainerGroupId "$CONTAINER_GROUP_ID" 2>/dev/null | grep -o '"Status":"[^"]*"' | cut -d'"' -f4 || echo "Pending")
if [ "$STATUS" = "Running" ]; then
break
fi
if [ "$STATUS" = "Failed" ]; then
echo "❌ 容器组启动失败"
exit 1
fi
echo " 状态: $STATUS"
sleep 3
done
echo ""
echo "🎉 部署完成!"
echo "🌐 访问地址: http://$ECI_EIP_ADDRESS:3001"
echo "🔍 健康检查: http://$ECI_EIP_ADDRESS:3001/api/health"
echo ""

View File

@@ -0,0 +1,135 @@
#!/bin/bash
# ECI 私有 Registry 部署脚本Spot 实例)
# 使用 registry:2 镜像NAS 持久化存储
set -e
# 加载配置
source "$(dirname "$0")/../eci.conf"
REGION=${ECI_REGION:-cn-beijing}
CONTAINER_GROUP_NAME="d8d-eci-registry"
CPU=0.5
MEMORY=1.0
echo "=== ECI 私有 Registry 部署 ==="
echo "容器组名称: $CONTAINER_GROUP_NAME"
echo "EIP ID: $ECI_REGISTRY_EIP_ID"
echo "EIP 地址: $ECI_REGISTRY_EIP_ADDRESS"
echo "NAS 服务器: $ECI_NAS_SERVER"
echo ""
# 检查容器组是否已存在
echo "检查容器组是否存在..."
EXISTING=$(aliyun eci DescribeContainerGroups --region "$REGION" \
--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" \
--ContainerGroupName "$CONTAINER_GROUP_NAME" 2>/dev/null \
| grep -o '"ContainerGroupId":"[^"]*"' | cut -d'"' -f4)
aliyun eci DeleteContainerGroup --region "$REGION" --ContainerGroupId "$CONTAINER_GROUP_ID" >/dev/null
# 等待删除完成
echo "等待容器组删除..."
for i in {1..30}; do
STATUS=$(aliyun eci DescribeContainerGroups --region "$REGION" \
--ContainerGroupName "$CONTAINER_GROUP_NAME" 2>/dev/null \
| grep -o '"Status":"[^"]*"' | cut -d'"' -f4 || true)
if [ -z "$STATUS" ]; then
break
fi
sleep 2
done
echo "容器组已删除"
fi
# 创建新容器组
echo "创建 Registry 容器组Spot 实例)..."
RESULT=$(aliyun eci CreateContainerGroup --region "$REGION" \
--ContainerGroupName "$CONTAINER_GROUP_NAME" \
--VSwitchId "$ECI_VSWITCH_ID" \
--SecurityGroupId "$ECI_SECURITY_GROUP_ID" \
--RestartPolicy Always \
--Cpu "$CPU" \
--Memory "$MEMORY" \
--SpotStrategy SpotAsPriceGo \
--EipInstanceId "$ECI_REGISTRY_EIP_ID" \
--Container.1.Name registry \
--Container.1.Image "registry:2" \
--Container.1.Cpu "$CPU" \
--Container.1.Memory "$MEMORY" \
--Container.1.Port.1.Protocol TCP \
--Container.1.Port.1.Port 5000 \
--Container.1.VolumeMount.1.Name registry-data \
--Container.1.VolumeMount.1.MountPath "/var/lib/registry" \
--Volume.1.Name registry-data \
--Volume.1.Type NFSVolume \
--Volume.1.NFSVolume.Server "$ECI_NAS_SERVER" \
--Volume.1.NFSVolume.Path "/" \
2>&1)
echo "$RESULT"
CONTAINER_GROUP_ID=$(echo "$RESULT" | grep -o '"ContainerGroupId":"[^"]*"' | cut -d'"' -f4)
if [ -z "$CONTAINER_GROUP_ID" ]; then
echo "错误:容器组创建失败"
exit 1
fi
echo ""
echo "容器组 ID: $CONTAINER_GROUP_ID"
echo "等待容器组启动..."
# 等待容器组 Running
for i in {1..60}; do
STATUS=$(aliyun eci DescribeContainerGroups --region "$REGION" \
--ContainerGroupId "$CONTAINER_GROUP_ID" 2>/dev/null \
| grep -o '"Status":"[^"]*"' | cut -d'"' -f4)
echo "状态: $STATUS"
if [ "$STATUS" = "Running" ]; then
break
elif [ "$STATUS" = "Failed" ] || [ "$STATUS" = "Pending" ]; then
# 继续等待
:
fi
sleep 3
done
echo ""
echo "获取容器组内网 IP..."
for i in {1..30}; do
INTRANET_IP=$(aliyun eci DescribeContainerGroups --region "$REGION" \
--ContainerGroupId "$CONTAINER_GROUP_ID" 2>/dev/null \
| grep -o '"IntranetIp":"[^"]*"' | cut -d'"' -f4)
if [ -n "$INTRANET_IP" ]; then
echo "内网 IP: $INTRANET_IP"
break
fi
sleep 2
done
# 保存内网 IP 到 eci.conf
ECI_CONF="$(dirname "$0")/../eci.conf"
sed -i "/^ECI_REGISTRY_INTRANET_IP=/d" "$ECI_CONF"
sed -i "/^ECI_REGISTRY_INTRANET_URL=/d" "$ECI_CONF"
echo "" >> "$ECI_CONF"
echo "# 私有 Registry 内网 IP用于应用 ECI 内网拉取镜像)" >> "$ECI_CONF"
echo "ECI_REGISTRY_INTRANET_IP=$INTRANET_IP" >> "$ECI_CONF"
echo "ECI_REGISTRY_INTRANET_URL=\${ECI_REGISTRY_INTRANET_IP}:5000" >> "$ECI_CONF"
echo "已保存内网 IP 到 eci.conf"
echo ""
echo "=== 部署完成 ==="
echo "私有 Registry 外网地址: $ECI_REGISTRY_EIP_ADDRESS:$ECI_REGISTRY_PORT"
echo "私有 Registry 内网地址: $INTRANET_IP:5000"
echo "外网测试: curl http://$ECI_REGISTRY_EIP_ADDRESS:$ECI_REGISTRY_PORT/v2/"
echo "内网测试: curl http://$INTRANET_IP:5000/v2/ (需在 VPC 内)"
echo ""

View File

@@ -0,0 +1,41 @@
# ECI 部署配置
# 由 d8d-release 技能自动填充,请勿手动修改
# 阿里云区域
ECI_REGION=cn-beijing
# 基础设施资源 ID由技能自动创建并填充
ECI_VPC_ID=
ECI_VSWITCH_ID=
ECI_SECURITY_GROUP_ID=
ECI_NAS_SERVER=
# 应用 EIP
ECI_EIP_ID=
ECI_EIP_ADDRESS=
# 私有 Registry EIP 和地址ECI Spot 实例)
ECI_REGISTRY_EIP_ID=
ECI_REGISTRY_EIP_ADDRESS=
ECI_REGISTRY_PORT=5000
ECI_REGISTRY_URL=
# 私有 Registry 内网 IP用于应用 ECI 内网拉取镜像,由 eci-registry.sh 自动填充)
ECI_REGISTRY_INTRANET_IP=
ECI_REGISTRY_INTRANET_URL=
# RAM AK/SK用于 ECI 部署)
ALI_ACCESS_KEY_ID=
ALI_ACCESS_KEY_SECRET=
# Gitea 配置(用于 Actions 日志查询)
GITEA_URL=https://gitea.d8d.fun
GITEA_USERNAME=d8dfun
GITEA_PASSWORD=zyh81569851
GITEA_REPO_OWNER=d8dfun
GITEA_REPO_NAME=
# ECI 容器组规格
ECI_CONTAINER_GROUP_NAME=map-collector
ECI_CPU=2.5
ECI_MEMORY=3.5

View File

@@ -0,0 +1,240 @@
#!/usr/bin/env python3
"""
Gitea Actions 构建日志查询
通过 Gitea API 查询指定 run ID 的 Actions 构建日志。
使用方法:
python scripts/gitea-log.py <run_id> [job_id]
"""
import requests
import sys
import re
from urllib.parse import urljoin
# 从 gitea.conf 读取 Gitea 配置(仅本地开发用,不提交)
def load_gitea_config():
import os
script_dir = os.path.dirname(os.path.abspath(__file__))
config_path = os.path.join(script_dir, "..", "gitea.conf")
config = {}
if os.path.exists(config_path):
with open(config_path, 'r', encoding='utf-8') 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 config
CONFIG = load_gitea_config()
GITEA_URL = CONFIG.get("GITEA_URL", "https://gitea.d8d.fun")
GITEA_USERNAME = CONFIG.get("GITEA_USERNAME", "")
GITEA_PASSWORD = CONFIG.get("GITEA_PASSWORD", "")
REPO_OWNER = CONFIG.get("GITEA_REPO_OWNER", "d8dfun")
REPO_NAME = CONFIG.get("GITEA_REPO_NAME", "")
def get_gitea_actions_log(run_id, job_id="0"):
"""
获取 Gitea Actions 构建日志
"""
session = requests.Session()
csrf_token = None
print(f"🔐 正在登录 Gitea...")
login_url = urljoin(GITEA_URL, "/user/login")
response = session.get(login_url)
csrf_match = re.search(r'<input type="hidden" name="_csrf" value="([^"]+)"', response.text, re.IGNORECASE)
if not csrf_match:
csrf_match = re.search(r"csrfToken: '([^']+)'", response.text)
if not csrf_match:
csrf_match = re.search(r'<meta name="_csrf" content="([^"]+)"', response.text)
if csrf_match:
csrf_token = csrf_match.group(1)
else:
return {"error": "无法获取 CSRF token"}
login_data = {
"user_name": GITEA_USERNAME,
"password": GITEA_PASSWORD,
"_csrf": csrf_token
}
headers = {
"Content-Type": "application/x-www-form-urlencoded",
"X-Csrf-Token": csrf_token
}
response = session.post(login_url, data=login_data, headers=headers, allow_redirects=True)
if response.status_code != 200 or "login" in response.url:
return {"error": "登录失败"}
for cookie in session.cookies:
if cookie.name == "_csrf":
csrf_token = cookie.value
break
print(f"✓ 登录成功")
url = f"{GITEA_URL}/{REPO_OWNER}/{REPO_NAME}/actions/runs/{run_id}/jobs/{job_id}"
print(f"\n📋 正在获取构建日志...")
print(f" URL: {url}")
headers = {
"Content-Type": "application/json",
"X-Csrf-Token": csrf_token
}
response = session.post(url, json={"logCursors": []}, headers=headers)
if response.status_code != 200:
return {"error": f"HTTP {response.status_code}", "message": response.text}
data = response.json()
steps = data.get("state", {}).get("currentJob", {}).get("steps", [])
print(f" 发现 {len(steps)} 个步骤")
all_logs = []
for step_idx in range(len(steps)):
log_cursors = [{
"step": step_idx,
"cursor": None,
"expanded": True
}]
response = session.post(url, json={"logCursors": log_cursors}, headers=headers)
if response.status_code == 200:
result = response.json()
steps_log = result.get("logs", {}).get("stepsLog", [])
if steps_log:
for step_log in steps_log:
if step_log.get("step") == step_idx:
log_entries = step_log.get("content") or step_log.get("lines") or []
all_logs.append({
"step": step_idx,
"summary": steps[step_idx].get("summary", ""),
"status": steps[step_idx].get("status", ""),
"logs": log_entries
})
print(f" ✓ 步骤 {step_idx}: {len(log_entries)} 行日志")
break
result = {
"run": data.get("state", {}).get("run", {}),
"job": data.get("state", {}).get("currentJob", {}),
"logs": all_logs
}
return format_build_log(result)
def format_build_log(data):
"""格式化构建日志输出"""
run_info = data.get("run", {})
job_info = data.get("job", {})
logs = data.get("logs", [])
lines = []
lines.append("=" * 80)
lines.append(f"📦 运行: {run_info.get('title', 'N/A')}")
lines.append(f"📊 状态: {run_info.get('status', 'N/A')}")
lines.append(f"📝 任务: {job_info.get('title', 'N/A')}")
lines.append(f"📄 详情: {job_info.get('detail', 'N/A')}")
commit = run_info.get("commit", {})
if commit:
lines.append(f"🔀 提交: {commit.get('shortSHA', 'N/A')} - {commit.get('pusher', {}).get('displayName', 'N/A')}")
lines.append(f"🌿 分支: {commit.get('branch', {}).get('name', 'N/A')}")
lines.append("=" * 80)
lines.append("")
steps = job_info.get("steps", [])
if steps:
lines.append("📋 步骤执行情况:")
for idx, step in enumerate(steps):
summary = step.get("summary", "N/A")
duration = step.get("duration", "N/A")
status = step.get("status", "")
status_emoji = {
"success": "",
"failure": "",
"skipped": "⏭️",
"running": ""
}.get(status, "")
lines.append(f" [{idx}] {status_emoji} {summary} ({duration})")
lines.append("")
lines.append("-" * 80)
lines.append("📝 构建日志:")
lines.append("")
if logs:
for log_item in logs:
step_num = log_item.get("step", "?")
summary = log_item.get("summary", "")
status = log_item.get("status", "")
log_entries = log_item.get("logs", [])
status_emoji = {
"success": "",
"failure": "",
"skipped": "⏭️",
"running": ""
}.get(status, "")
lines.append(f"{status_emoji} 步骤 {step_num}: {summary}")
lines.append("")
if log_entries:
for entry in log_entries[:200]:
if isinstance(entry, dict):
message = entry.get("message", str(entry))
timestamp = entry.get("timestamp", "")
if timestamp:
lines.append(f" [{timestamp}] {message}")
else:
lines.append(f" {message}")
else:
lines.append(f" {entry}")
if len(log_entries) > 200:
lines.append(f" ... 还有 {len(log_entries) - 200} 行日志")
lines.append("")
else:
lines.append("⚠️ 没有找到日志内容")
return "\n".join(lines)
if __name__ == "__main__":
if len(sys.argv) < 2:
print("用法: python scripts/gitea-log.py <run_id> [job_id]")
sys.exit(1)
run_id = sys.argv[1]
job_id = sys.argv[2] if len(sys.argv) > 2 else "0"
result = get_gitea_actions_log(run_id, job_id)
if isinstance(result, dict) and "error" in result:
print(f"❌ 错误: {result['error']}")
sys.exit(1)
else:
print(result)

View File

@@ -0,0 +1,8 @@
# Gitea 配置(仅本地开发环境使用,不提交到仓库)
# 用于 Gitea Actions 构建日志查询
GITEA_URL=https://gitea.d8d.fun
GITEA_USERNAME=d8dfun
GITEA_PASSWORD=zyh81569851
GITEA_REPO_OWNER=d8dfun
GITEA_REPO_NAME=

View File

@@ -0,0 +1,93 @@
name: Docker Build and Push
on:
push:
tags:
- 'v*'
jobs:
build-and-push:
runs-on: ubuntu-latest
steps:
- run: echo "🎉 该作业由 ${{ gitea.event_name }} 事件自动触发。"
- run: echo "🐧 此作业当前在 Gitea 托管的 ${{ runner.os }} 服务器上运行!"
- run: echo "🔎 您的标签名称是 ${{ gitea.ref_name }},仓库是 ${{ gitea.repository }}。"
- name: 检出仓库代码
uses: actions/checkout@v4
- run: echo "💡 ${{ gitea.repository }} 仓库已克隆到运行器。"
- run: echo "🖥️ 工作流现在已准备好在运行器上测试您的代码。"
- name: 列出仓库中的文件
run: |
ls ${{ gitea.workspace }}
- run: echo "🍏 此作业的状态是 ${{ job.status }}。"
- name: 提取版本号和处理仓库名
id: extract_info
run: |
# 从标签名中提取版本号(例如从 release/v0.1.6 中提取 v0.1.6
VERSION=$(echo "${{ gitea.ref_name }}" | sed 's|release/||')
echo "VERSION=$VERSION" >> $GITHUB_ENV
# 处理仓库名替换斜杠为连字符避免Docker标签错误
REPO_NAME=$(echo "${{ gitea.repository }}" | sed 's|/|-|g')
echo "REPO_NAME=$REPO_NAME" >> $GITHUB_ENV
# 从 eci.conf 加载 Registry 地址
source eci.conf
echo "REGISTRY_URL=$ECI_REGISTRY_URL" >> $GITHUB_ENV
echo "提取的版本号:$VERSION"
echo "处理后的仓库名:$REPO_NAME"
echo "Registry 地址:$ECI_REGISTRY_URL"
- name: 设置 Docker Buildx支持 HTTP 私有 Registry
uses: docker/setup-buildx-action@v3
with:
driver-opts: |
image=moby/buildkit:master
network=host
buildkitd-config-inline: |
[registry."${{ env.REGISTRY_URL }}"]
http = true
insecure = true
- name: 构建并推送应用镜像到私有 Registry
uses: docker/build-push-action@v5
with:
context: .
file: ./Dockerfile
push: true
tags: |
${{ env.REGISTRY_URL }}/d8d-user-release:${{ env.REPO_NAME }}-${{ env.VERSION }}
- name: 预热 postgres:17-alpine 镜像到私有 Registry
run: |
source eci.conf
# 从 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 部署)
run: |
curl -o aliyun-cli.tgz https://aliyuncli.alicdn.com/aliyun-cli-linux-latest-amd64.tgz
tar -xzf aliyun-cli.tgz
chmod +x aliyun
mv aliyun /usr/local/bin/
- name: 配置阿里云凭证(从 eci.conf 读取)
run: |
source eci.conf
aliyun configure set --mode AK --access-key-id "$ALI_ACCESS_KEY_ID" --access-key-secret "$ALI_ACCESS_KEY_SECRET" --region cn-beijing
- name: 部署到 ECI
run: |
chmod +x scripts/eci-deploy.sh
IMAGE_TAG="${{ env.REPO_NAME }}-${{ env.VERSION }}"
./scripts/eci-deploy.sh "$IMAGE_TAG"

View File

@@ -0,0 +1,88 @@
#!/bin/bash
# 发布脚本
# 使用方法: ./release.sh <版本号>
# 例如: ./release.sh 1.0.0
# 查看标签: ./release.sh --list 或 ./release.sh -l
set -e
# 检查参数
if [ $# -eq 0 ]; then
echo "错误: 请提供版本号"
echo "使用方法: $0 <版本号>"
echo "例如: $0 1.0.0"
echo ""
echo "📋 Gitea 最近10个标签:"
# 尝试获取远程标签,如果失败则使用本地标签
REMOTE_TAGS=$(git ls-remote --tags __REMOTE__ 2>/dev/null | grep -v "\^{}" | cut -f 2 | sed 's|refs/tags/||')
if [ -z "$REMOTE_TAGS" ]; then
echo "⚠️ 无法获取远程标签显示本地最近10个标签:"
git tag --list | sort -V | tail -10
else
echo "$REMOTE_TAGS" | sort -V | uniq | tail -10
fi
exit 1
fi
# 特殊参数: 查看所有标签
if [ "$1" = "--list" ] || [ "$1" = "-l" ]; then
echo "📋 Gitea 所有标签:"
# 尝试获取远程标签,如果失败则使用本地标签
REMOTE_TAGS=$(git ls-remote --tags __REMOTE__ 2>/dev/null | grep -v "\^{}" | cut -f 2 | sed 's|refs/tags/||')
if [ -z "$REMOTE_TAGS" ]; then
echo "⚠️ 无法获取远程标签,显示本地标签:"
git tag --list | sort -V
else
echo "$REMOTE_TAGS" | sort -V | uniq
fi
exit 0
fi
VERSION="$1"
TAG="v$VERSION"
# 验证版本号格式
if ! [[ $VERSION =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "错误: 版本号格式不正确,请使用语义化版本格式 (例如: 1.0.0)"
exit 1
fi
echo "🚀 开始发布 版本: $VERSION"
# 检查工作目录是否干净
if [ -n "$(git status --porcelain)" ]; then
echo "⚠️ 警告: 工作目录有未提交的更改"
git status
read -p "是否继续发布? [y/N] " -n 1 -r
echo
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
echo "发布已取消"
exit 1
fi
fi
# 检查当前分支
CURRENT_BRANCH=$(git branch --show-current)
echo "🌿 当前分支: $CURRENT_BRANCH"
# 拉取最新代码
echo "📥 拉取最新代码..."
git pull __REMOTE__ "$CURRENT_BRANCH"
# 创建标签
echo "🏷️ 创建标签 $TAG..."
git tag "$TAG"
# 推送标签
echo "📤 推送标签到远程仓库..."
git push __REMOTE__ "$TAG"
echo ""
echo "🎉 发布流程已启动!"
echo "📋 版本: $VERSION"
echo "🏷️ 标签: $TAG"
echo "🔗 GitHub Actions 将自动构建和发布 Docker 镜像"
echo ""
echo "📊 查看构建进度: $(git config --get remote.__REMOTE__.url | sed 's|^ssh://||' | sed 's|^git@||' | sed 's|:|/|' | sed 's|\.git$||')/actions"