From 755dd3409cc5dd50d9eeb913615e86f4bb4a4027 Mon Sep 17 00:00:00 2001 From: zfc Date: Mon, 2 Mar 2026 21:38:43 +0800 Subject: [PATCH] chore: Add 3 dev rules to CLAUDE.md template and archive CICD reference docs Co-Authored-By: Claude Opus 4.6 --- .claude/skills/CLAUDE.md.template | 3 + cicd_integration_updated (2).md | 641 +++++++++++++++++++ drone_cicd_deployment_guide (2).md | 947 +++++++++++++++++++++++++++++ 3 files changed, 1591 insertions(+) create mode 100644 cicd_integration_updated (2).md create mode 100644 drone_cicd_deployment_guide (2).md diff --git a/.claude/skills/CLAUDE.md.template b/.claude/skills/CLAUDE.md.template index 50bce9c..83286e5 100644 --- a/.claude/skills/CLAUDE.md.template +++ b/.claude/skills/CLAUDE.md.template @@ -78,6 +78,9 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co - **包管理器**: 使用 {{包管理器}}(不是 {{其他包管理器}}) - **TDD 流程**: 先写测试再实现,核心业务逻辑覆盖率 100% - **日志规范**: 使用日志管理器,避免 console.log +- **入口统一校验**: 所有外部输入(API 参数、用户输入、配置项)在入口处统一校验,内部代码信任已校验的数据,不重复检查 +- **空值显式报错**: 禁止静默吞掉 null/undefined/空字符串,遇到非预期空值必须抛出明确错误信息(含变量名和上下文),让问题在第一现场暴露 +- **关键节点结构化日志**: 在业务关键节点(请求进出、状态变更、外部调用、异常捕获)输出结构化日志,包含 traceId、操作、耗时、结果,便于排查和监控 - **知识沉淀**: 将有价值的对话迭代沉淀到文档中,包括: - 重要技术决策和架构演进 → 更新 CLAUDE.md 相关章节 - 新功能实现方案 → 更新组件职责、数据流等章节 diff --git a/cicd_integration_updated (2).md b/cicd_integration_updated (2).md new file mode 100644 index 0000000..c02ff6f --- /dev/null +++ b/cicd_integration_updated (2).md @@ -0,0 +1,641 @@ +# CI/CD 集成方案(最终落地版) + +## 概述 + +本文档记录抖音评论管理系统的 CI/CD 方案:**Drone CI + 私有 Docker Registry + Docker Compose 自动部署**。 + +目标: + +- 每天凌晨 0 点(北京时间)自动拉取 `main` 最新代码并部署 +- 创建 Tag(如 `v1.9.0210.7`)时自动构建并部署 +- 不在每次 `push` 时触发 +- 生产环境继续沿用 `docker-compose.prod.yml` + +--- + +## 基础设施 + +| 角色 | 位置 | 说明 | +|------|------|------| +| Gitea 服务器 | 腾讯云 `git.internal.intelligrow.cn` | 代码仓库 | +| Drone CI 服务器 | Ubuntu x64 `192.168.31.107` | 执行构建任务 + 运行私有 Docker Registry | +| 生产服务器 | Ubuntu x64 `192.168.31.48` | 运行 Docker Compose 生产服务,SSH 端口 3141 | + +--- + +## 架构流程 + +```text +定时触发(cron)或 创建 tag + | + v + Drone 拉取代码 + | + +-- 构建 backend 镜像并推送 Registry + +-- 构建 frontend 镜像并推送 Registry + v + SSH 到生产服务器执行 deploy-remote.sh + | + +-- pull 新镜像 + +-- 停止 celery_beat + +-- 更新 backend + celery_worker + +-- 健康检查(容器内 /health) + +-- alembic upgrade head(失败即中断) + +-- 启动 celery_beat + frontend + +-- 最终健康检查 + v + 发送企业微信通知(成功/失败) +``` + +--- + +## 文件结构 + +```text +.drone.yml # Drone CI 流水线 +scripts/ ++-- deploy.sh # 原有手动部署脚本 ++-- deploy-remote.sh # Drone 远程调用的自动部署脚本 ++-- server-setup.sh # 原有服务器初始化脚本 +docker-compose.prod.yml # 生产环境 compose 配置 +docs/ ++-- cicd_integration_updated.md # 本文档 +``` + +--- + +## Phase 1:Drone CI 服务器准备(一次性) + +### 1.1 Drone CI 服务 (docker-compose.yml) + +在 Drone CI 服务器 `~/drone/docker-compose.yml`: + +```yaml +services: + drone-server: + image: drone/drone:2 + container_name: drone-server + restart: always + ports: + - "3080:80" + environment: + - DRONE_GITEA_SERVER=https://git.internal.intelligrow.cn + - DRONE_GITEA_CLIENT_ID= + - DRONE_GITEA_CLIENT_SECRET= + - DRONE_SERVER_HOST=drone.internal.intelligrow.cn + - DRONE_SERVER_PROTO=https + - DRONE_RPC_SECRET= + - DRONE_USER_CREATE=username:,admin:true + volumes: + - ./data:/data + + drone-runner: + image: drone/drone-runner-docker:1 + container_name: drone-runner + restart: always + depends_on: + - drone-server + environment: + - DRONE_RPC_PROTO=http + - DRONE_RPC_HOST=drone-server + - DRONE_RPC_SECRET= + - DRONE_RUNNER_CAPACITY=2 + - DRONE_RUNNER_NAME=drone-runner-1 + - DRONE_RUNNER_PRIVILEGED_IMAGES=plugins/docker + volumes: + - /var/run/docker.sock:/var/run/docker.sock +``` + +关键配置说明: + +- **`DRONE_RPC_PROTO=http`**:Runner 在 Docker 内网直连 drone-server 容器的 80 端口,不走 HTTPS。外部通过反向代理 `drone.internal.intelligrow.cn` 访问时才用 HTTPS。 +- **`DRONE_USER_CREATE`**:`username` 必须与 Gitea 登录用户名完全一致(不是邮箱),否则管理员权限不生效。 +- **`DRONE_RUNNER_PRIVILEGED_IMAGES=plugins/docker`**:允许 plugins/docker 以特权模式运行(本方案最终未使用 plugins/docker,但保留配置以备后用)。 + +### 1.2 启动私有 Registry + +在 Drone CI 服务器执行: + +```bash +docker run -d --name registry \ + -p 5000:5000 \ + -v /opt/registry-data:/var/lib/registry \ + --restart always \ + registry:2 +``` + +验证: + +```bash +curl http://localhost:5000/v2/_catalog +# 预期输出: {"repositories":[]} +``` + +### 1.3 配置 insecure registry + +**Drone CI 服务器** `/etc/docker/daemon.json`: + +```json +{ + "registry-mirrors": [ + "https://docker.1panel.live", + "https://docker.1panel.dev", + "https://docker.1ms.run" + ], + "insecure-registries": [ + "docker.internal.intelligrow.cn:5000", + "192.168.31.107:5000" + ] +} +``` + +**生产服务器** `/etc/docker/daemon.json`: + +```json +{ + "registry-mirrors": [ + "https://docker.1panel.live", + "https://docker.1panel.dev", + "https://docker.1ms.run" + ], + "insecure-registries": ["docker.internal.intelligrow.cn:5000"] +} +``` + +修改后重启 Docker: + +```bash +sudo systemctl restart docker +``` + +> 注意:`insecure-registries` 中不要带 `http://` 前缀,直接写 `host:port` 格式。 + +### 1.4 配置 SSH 免密 + +在 Drone CI 服务器生成密钥并添加到生产服务器: + +```bash +ssh-keygen -t ed25519 -C "drone-ci" -f ~/.ssh/drone_deploy -N "" +ssh-copy-id -i ~/.ssh/drone_deploy.pub -p 3141 miaosi@192.168.31.48 +``` + +验证: + +```bash +ssh -i ~/.ssh/drone_deploy -p 3141 miaosi@192.168.31.48 "echo ok" +``` + +--- + +## Phase 2:Drone 仓库设置 + +### 2.1 开启 Trusted 模式 + +在 Drone 面板 → 仓库 Settings → General → Project Settings → 勾选 **Trusted**。 + +> 需要管理员权限。如果看不到 Trusted 选项,检查 `DRONE_USER_CREATE` 的 username 是否与 Gitea 用户名一致。 + +### 2.2 配置 Secrets + +在 Drone 面板(仓库设置 → Secrets)添加: + +| Secret 名称 | 用途 | 实际值示例 | +|-------------|------|-----------| +| `backend_repo` | 后端镜像完整仓库地址 | `docker.internal.intelligrow.cn:5000/douyin-backend` | +| `frontend_repo` | 前端镜像完整仓库地址 | `docker.internal.intelligrow.cn:5000/douyin-frontend` | +| `deploy_host` | 生产服务器 IP | `192.168.31.48` | +| `deploy_user` | SSH 用户 | `miaosi` | +| `deploy_ssh_key` | SSH 私钥内容 | `-----BEGIN OPENSSH PRIVATE KEY-----...-----END OPENSSH PRIVATE KEY-----` | +| `deploy_path` | 生产服务器部署目录 | `/opt/docker/douyin_comments_management` | +| `wecom_webhook` | 企业微信 Webhook(可选) | `https://qyapi.weixin.qq.com/...` | + +> 注意:`backend_repo` 和 `frontend_repo` 的 Registry 地址必须与生产服务器 `.env` 中的 `DOCKER_REGISTRY` 使用相同的主机名(都用域名或都用 IP),否则 Docker 会认为是不同的镜像。 + +### 2.3 配置 Cron Job + +在 Drone 面板(仓库设置 → Cron Jobs)添加: + +| 字段 | 值 | +|------|------| +| Name | `nightly-build` | +| Branch | `main` | +| Schedule | `0 16 * * *` | + +说明: + +- Drone 默认按 UTC 解释 Cron +- `0 16 * * *` = UTC 16:00 = 北京时间次日 00:00 + +--- + +## Phase 3:配置文件(最终落地版本) + +### 3.1 `.drone.yml` + +```yaml +kind: pipeline +type: docker +name: build-and-deploy + +trigger: + event: + - tag + - cron + +volumes: + - name: dockersock + host: + path: /var/run/docker.sock + +steps: + - name: build-backend + image: docker:27-cli + volumes: + - name: dockersock + path: /var/run/docker.sock + environment: + BACKEND_REPO: + from_secret: backend_repo + commands: + - '[ -n "$BACKEND_REPO" ] || (echo "backend_repo secret is empty" && exit 1)' + - echo "Building backend image tag:${DRONE_TAG:-latest}" + - docker build -t "$BACKEND_REPO:${DRONE_TAG:-latest}" -t "$BACKEND_REPO:latest" ./backend + - docker push "$BACKEND_REPO:${DRONE_TAG:-latest}" + - docker push "$BACKEND_REPO:latest" + + - name: build-frontend + image: docker:27-cli + volumes: + - name: dockersock + path: /var/run/docker.sock + environment: + FRONTEND_REPO: + from_secret: frontend_repo + commands: + - '[ -n "$FRONTEND_REPO" ] || (echo "frontend_repo secret is empty" && exit 1)' + - echo "Building frontend image tag:${DRONE_TAG:-latest}" + - docker build -t "$FRONTEND_REPO:${DRONE_TAG:-latest}" -t "$FRONTEND_REPO:latest" ./frontend + - docker push "$FRONTEND_REPO:${DRONE_TAG:-latest}" + - docker push "$FRONTEND_REPO:latest" + + - name: deploy + image: appleboy/drone-ssh + environment: + DEPLOY_PATH: + from_secret: deploy_path + settings: + host: + from_secret: deploy_host + username: + from_secret: deploy_user + key: + from_secret: deploy_ssh_key + port: 3141 + command_timeout: 1800s + script_stop: true + envs: + - DRONE_TAG + - DEPLOY_PATH + script: + - IMAGE_TAG="$DRONE_TAG"; [ -n "$IMAGE_TAG" ] || IMAGE_TAG="latest" + - cd "$DEPLOY_PATH" + - bash scripts/deploy-remote.sh "$IMAGE_TAG" + + - name: notify-success + image: curlimages/curl + environment: + WECOM_WEBHOOK: + from_secret: wecom_webhook + commands: + - | + if [ -n "${WECOM_WEBHOOK:-}" ]; then + VERSION="$DRONE_TAG" + [ -n "$VERSION" ] || VERSION="nightly-$(date +%Y%m%d)" + curl -sS -X POST "$WECOM_WEBHOOK" \ + -H "Content-Type: application/json" \ + -d "{\"msgtype\":\"text\",\"text\":{\"content\":\"✅ 部署成功\\n版本: ${VERSION}\\n仓库: ${DRONE_REPO}\\n时间: $(date '+%Y-%m-%d %H:%M:%S')\"}}" + fi + when: + status: + - success + + - name: notify-failure + image: curlimages/curl + environment: + WECOM_WEBHOOK: + from_secret: wecom_webhook + commands: + - | + if [ -n "${WECOM_WEBHOOK:-}" ]; then + VERSION="$DRONE_TAG" + [ -n "$VERSION" ] || VERSION="nightly-$(date +%Y%m%d)" + curl -sS -X POST "$WECOM_WEBHOOK" \ + -H "Content-Type: application/json" \ + -d "{\"msgtype\":\"text\",\"text\":{\"content\":\"❌ 部署失败\\n版本: ${VERSION}\\n仓库: ${DRONE_REPO}\\n构建: ${DRONE_BUILD_LINK}\"}}" + fi + when: + status: + - failure +``` + +### 3.2 `scripts/deploy-remote.sh` + +```bash +#!/usr/bin/env bash +# ======================================== +# 远程部署脚本 - 被 Drone CI SSH 调用 +# ======================================== +# 用法: bash scripts/deploy-remote.sh [image_tag] + +set -euo pipefail + +GREEN='\033[0;32m' +RED='\033[0;31m' +YELLOW='\033[1;33m' +NC='\033[0m' + +log_info() { echo -e "${GREEN}[INFO]${NC} $(date '+%Y-%m-%d %H:%M:%S') $1"; } +log_warn() { echo -e "${YELLOW}[WARN]${NC} $(date '+%Y-%m-%d %H:%M:%S') $1"; } +log_error() { echo -e "${RED}[ERROR]${NC} $(date '+%Y-%m-%d %H:%M:%S') $1"; } + +DEPLOY_PATH="${DEPLOY_PATH:-/opt/docker/douyin_comments_management}" +COMPOSE_FILE="${COMPOSE_FILE:-docker-compose.prod.yml}" +IMAGE_TAG="${1:-latest}" +LOCK_FILE="/tmp/douyin-deploy.lock" +MAX_ATTEMPTS="${MAX_ATTEMPTS:-30}" + +cleanup_lock() { + rm -f "$LOCK_FILE" +} + +acquire_lock() { + if [ -f "$LOCK_FILE" ]; then + local old_pid + old_pid=$(cat "$LOCK_FILE" 2>/dev/null || true) + if [ -n "$old_pid" ] && kill -0 "$old_pid" 2>/dev/null; then + log_error "已有部署进程运行中 (PID: $old_pid)" + exit 1 + fi + rm -f "$LOCK_FILE" + fi + + echo "$$" > "$LOCK_FILE" + trap cleanup_lock EXIT +} + +compose() { + docker compose -f "$COMPOSE_FILE" "$@" +} + +wait_backend_healthy() { + local attempt=1 + + while [ "$attempt" -le "$MAX_ATTEMPTS" ]; do + if compose exec -T backend python -c "import sys,urllib.request;urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=3);sys.exit(0)" >/dev/null 2>&1; then + log_info "后端健康检查通过" + return 0 + fi + + log_info "等待后端就绪... (${attempt}/${MAX_ATTEMPTS})" + sleep 3 + attempt=$((attempt + 1)) + done + + return 1 +} + +main() { + acquire_lock + + [ -d "$DEPLOY_PATH" ] || { log_error "部署目录不存在: $DEPLOY_PATH"; exit 1; } + cd "$DEPLOY_PATH" + + [ -f "$COMPOSE_FILE" ] || { log_error "Compose 文件不存在: $COMPOSE_FILE"; exit 1; } + + export IMAGE_TAG + export VERSION="$IMAGE_TAG" + + log_info "开始部署,镜像版本: $IMAGE_TAG" + + log_info "拉取最新镜像..." + compose pull backend celery_worker celery_beat frontend + + log_info "停止 celery_beat..." + compose stop celery_beat || true + + log_info "更新 backend 与 celery_worker..." + compose up -d --no-deps backend celery_worker + + if ! wait_backend_healthy; then + log_error "后端未在预期时间内就绪" + compose logs --tail=200 backend || true + exit 1 + fi + + log_info "执行数据库迁移..." + compose exec -T backend alembic upgrade head + + log_info "启动 celery_beat..." + compose up -d --no-deps celery_beat + + log_info "更新 frontend..." + compose up -d --no-deps frontend + + log_info "清理孤儿容器..." + compose up -d --remove-orphans + + if ! wait_backend_healthy; then + log_error "部署完成后健康检查失败" + compose logs --tail=200 backend || true + exit 1 + fi + + docker image prune -f >/dev/null 2>&1 || log_warn "镜像清理失败,已跳过" + + log_info "部署完成!版本: $IMAGE_TAG" + compose ps +} + +main "$@" +``` + +> 注意:脚本同时 export `IMAGE_TAG` 和 `VERSION`,因为生产服务器的 `docker-compose.prod.yml` 使用 `${VERSION:-latest}` 作为镜像 tag 变量。 + +--- + +## 生产服务器 `.env` 配置 + +确保 `/opt/docker/douyin_comments_management/.env` 至少包含: + +```bash +DOCKER_REGISTRY=docker.internal.intelligrow.cn:5000 + +POSTGRES_PASSWORD=xxx +REDIS_PASSWORD=xxx +RABBITMQ_PASSWORD=xxx +# 其余生产配置保持现有值 +``` + +> `DOCKER_REGISTRY` 的值必须与 Drone Secret 中 `backend_repo` / `frontend_repo` 的 Registry 主机名一致。 + +--- + +## 回滚方案 + +### 方式 1:推送旧版本 tag 触发重新部署 + +```bash +git tag v1.8.1-rollback v1.8.1 +git push origin v1.8.1-rollback +``` + +### 方式 2:生产服务器手动回滚 + +```bash +ssh -p 3141 miaosi@192.168.31.48 +cd /opt/docker/douyin_comments_management +bash scripts/deploy-remote.sh v1.8.1 +``` + +### 方式 3:手动 compose 回滚 + +```bash +cd /opt/docker/douyin_comments_management +export VERSION=v1.8.1 +docker compose -f docker-compose.prod.yml pull backend celery_worker celery_beat frontend +docker compose -f docker-compose.prod.yml up -d --no-deps backend celery_worker celery_beat frontend +``` + +--- + +## 验证方式 + +### 验证 Registry + +```bash +# Drone CI 服务器 +curl http://localhost:5000/v2/_catalog +curl http://localhost:5000/v2/douyin-backend/tags/list +curl http://localhost:5000/v2/douyin-frontend/tags/list +``` + +### 验证构建与部署触发 + +```bash +# 方式1:Tag 触发 +git tag v1.9.0210.8 +git push origin v1.9.0210.8 + +# 方式2:Drone 面板手动触发 cron 或点击 NEW BUILD +``` + +### 验证生产服务 + +```bash +# 检查容器状态 +ssh -p 3141 miaosi@192.168.31.48 "cd /opt/docker/douyin_comments_management && docker compose -f docker-compose.prod.yml ps" + +# 后端健康检查(容器内) +ssh -p 3141 miaosi@192.168.31.48 "cd /opt/docker/douyin_comments_management && docker compose -f docker-compose.prod.yml exec -T backend python -c \"import urllib.request;urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=3);print('ok')\"" + +# 数据库迁移状态 +ssh -p 3141 miaosi@192.168.31.48 "cd /opt/docker/douyin_comments_management && docker compose -f docker-compose.prod.yml exec -T backend alembic current" +``` + +--- + +## 故障排查 + +| 问题 | 排查方式 | +|------|---------| +| YAML 解析错误 | 检查 `.drone.yml` 语法,Drone 对 `volumes`/`environment` 格式敏感 | +| 构建失败 | Drone 面板查看 pipeline 日志 | +| 镜像推送失败 (HTTPS) | 确认两台服务器 `insecure-registries` 配置正确(不带 `http://` 前缀) | +| Secret 为空 | 使用 `environment: { VAR: { from_secret: name } }` 而非 `secrets` 字段 | +| Drone 变量替换冲突 | `${DRONE_TAG}` 是 Drone 变量可直接使用;自定义 shell 变量用 `$$VAR` 转义 | +| Tag 不触发构建 | 检查 Gitea Webhook 是否勾选"创建"事件;`.drone.yml` trigger 不要加 `cron: [name]` | +| Step is pending | 检查 Runner 是否连通 Server;仓库是否开启 Trusted | +| DinD 启动失败 | 改用 `docker:27-cli` + 挂载宿主机 Docker socket(本方案采用的方式) | +| deploy 找不到脚本 | 确认 `deploy-remote.sh` 已复制到生产服务器部署目录的 `scripts/` 下 | +| Docker 权限不足 | 生产服务器执行 `sudo usermod -aG docker ` 后重新登录 | +| 镜像名 invalid reference | 检查生产服务器 `.env` 中 `DOCKER_REGISTRY` 变量是否正确设置 | +| 数据库迁移失败 | `docker compose -f docker-compose.prod.yml logs -f backend` | +| Cron 未触发 | 核对 Drone Cron 名称、分支是否 `main`、Schedule 是否正确 | + +--- + +## 踩坑记录 + +以下是实际部署过程中遇到的问题及解决方案,供后续参考: + +### 1. Drone YAML 解析错误 (`cannot unmarshal !!map into string`) + +**原因**:Drone Docker pipeline 对 `environment` 中 `from_secret` 语法和 `volumes` 格式有特定要求。早期版本同时使用 `volumes` + `environment: from_secret` 会触发解析错误。 + +**解决**:确保仓库开启 Trusted 模式后,`volumes` 和 `environment: from_secret` 可以正常共存。 + +### 2. Tag 推送不触发构建 + +**原因**:`.drone.yml` 中同时配置了 `event: [tag, cron]` 和 `cron: [nightly-build]`,Drone 将触发条件做 AND 运算。Tag 事件无法满足 cron 条件,导致永远不触发。 + +**解决**:移除 `cron: [nightly-build]` 过滤,只保留 `event: [tag, cron]`。 + +### 3. plugins/docker DinD 启动失败 + +**原因**:`plugins/docker` 插件内部启动 Docker 守护进程(Docker-in-Docker),可能因 cgroup/存储驱动兼容性问题无法启动。 + +**解决**:放弃 `plugins/docker`,改用 `docker:27-cli` 镜像 + 挂载宿主机 Docker socket 的方式构建镜像。 + +### 4. plugins/docker 要求 semver 格式 tag + +**原因**:`auto_tag: true` 配置要求 Git tag 符合语义化版本(如 `v1.0.0`),非标准格式(如 `v1.9.0210.1`)会解析失败。 + +**解决**:移除 `auto_tag`,改用 `${DRONE_TAG:-latest}` 手动指定镜像 tag。 + +### 5. Drone 变量替换与 Shell 变量冲突 + +**原因**:Drone 会在执行前对 `${VAR}` 语法做自身的变量替换。自定义 shell 变量(如 `TAG="xxx"; echo ${TAG}`)中的 `${TAG}` 会被 Drone 替换为空。 + +**解决**:直接使用 Drone 内置变量 `${DRONE_TAG:-latest}`,避免中间 shell 变量。 + +### 6. `secrets` 字段注入环境变量不生效 + +**原因**:Drone 步骤级 `secrets:` 字段在某些场景下不会将 secret 注入为环境变量。 + +**解决**:改用 `environment: { VAR: { from_secret: name } }` 显式声明。 + +### 7. Runner 连接 Server 失败 + +**原因**:Runner 配置 `DRONE_RPC_PROTO=https` 但 drone-server 容器内部只监听 80 端口(HTTPS 由外部反向代理终止)。 + +**解决**:Runner 通过外部域名 `drone.internal.intelligrow.cn` 连接(走反向代理的 HTTPS),而非直连容器内网。 + +### 8. 管理员权限不生效(看不到 Trusted 选项) + +**原因**:`DRONE_USER_CREATE=username:zhanghuayu@intelligrow.ai,admin:true` 中的 username 使用了邮箱而非 Gitea 登录用户名。 + +**解决**:改为 `username:zhanghuayu,admin:true`(与 Gitea 用户名完全一致)。 + +### 9. 生产服务器镜像名 invalid reference + +**原因**:`docker-compose.prod.yml` 使用 `${DOCKER_REGISTRY}` 变量,但 `.env` 中未设置或变量名不匹配(曾误设为 `REGISTRY_HOST`)。 + +**解决**:在 `.env` 中添加 `DOCKER_REGISTRY=docker.internal.intelligrow.cn:5000`,确保变量名与 compose 文件一致。 + +### 10. insecure-registries 格式错误 + +**原因**:`/etc/docker/daemon.json` 中 `insecure-registries` 配置了 `http://docker.internal.intelligrow.cn:5000`(带协议前缀),Docker 不识别。 + +**解决**:去掉 `http://` 前缀,直接写 `docker.internal.intelligrow.cn:5000`。 + +--- + +## 注意事项 + +1. **Registry 无认证**:仅建议内网使用;公网请加 TLS + 认证(或使用 Harbor)。 +2. **Docker Socket 挂载**:构建步骤挂载宿主机 Docker socket,需仓库开启 Trusted 模式。 +3. **并发部署保护**:`deploy-remote.sh` 使用 `/tmp/douyin-deploy.lock` 防止并发部署。 +4. **迁移失败即中断**:`alembic upgrade head` 失败会使整个部署失败,防止"假成功"。 +5. **健康检查不依赖 curl**:采用容器内 Python 请求 `/health`,与当前镜像一致。 +6. **deploy-remote.sh 需手动同步**:该脚本存放在生产服务器上,代码更新后需手动复制或通过部署流程同步。 +7. **VERSION 与 IMAGE_TAG**:`deploy-remote.sh` 同时 export 两个变量,兼容不同 compose 文件的命名。 diff --git a/drone_cicd_deployment_guide (2).md b/drone_cicd_deployment_guide (2).md new file mode 100644 index 0000000..a77249c --- /dev/null +++ b/drone_cicd_deployment_guide (2).md @@ -0,0 +1,947 @@ +# Drone CI/CD 通用部署指南 + +基于 **Drone CI + 私有 Docker Registry + Docker Compose** 的自动化部署方案。适用于任何基于 Docker 镜像部署的项目。 + +本指南基于抖音评论管理系统的实际落地经验编写,涵盖从零搭建到生产可用的全流程。 + +--- + +## 目录 + +- [前提条件](#前提条件) +- [架构概览](#架构概览) +- [第一步:基础设施准备(一次性)](#第一步基础设施准备一次性) +- [第二步:项目接入 Drone CI](#第二步项目接入-drone-ci) +- [第三步:编写 .drone.yml](#第三步编写-droneyml) +- [第四步:编写部署脚本](#第四步编写部署脚本) +- [第五步:生产服务器配置](#第五步生产服务器配置) +- [第六步:配置 Drone 仓库设置](#第六步配置-drone-仓库设置) +- [第七步:验证](#第七步验证) +- [回滚方案](#回滚方案) +- [多项目管理](#多项目管理) +- [踩坑清单](#踩坑清单) +- [故障排查速查表](#故障排查速查表) +- [附录:完整模板文件](#附录完整模板文件) + +--- + +## 前提条件 + +| 组件 | 要求 | +|------|------| +| Gitea 服务器 | 已有,可通过 HTTPS 访问 | +| Drone CI 服务器 | Ubuntu x64,已安装 Docker,已部署 Drone Server + Runner | +| 生产服务器 | Ubuntu x64,已安装 Docker + Docker Compose | +| 项目 | 包含 Dockerfile,可通过 `docker compose` 部署 | + +如果 Drone CI 尚未部署,参见 [附录 A:Drone CI 部署](#附录-a-drone-ci-部署)。 + +--- + +## 架构概览 + +```text +触发方式: + A) 创建 Git Tag(如 v1.0.0)→ 自动构建部署指定版本 + B) Drone Cron 定时任务 → 自动构建部署 latest + +流水线: + Drone CI 拉取代码 + | + +-- 构建镜像 1 (如 backend) → 推送到 Registry + +-- 构建镜像 2 (如 frontend) → 推送到 Registry + +-- 构建镜像 N ... + | + v + SSH 到生产服务器 → 执行部署脚本 + | + +-- docker compose pull (拉取新镜像) + +-- docker compose up -d (滚动更新) + +-- 健康检查 + +-- 数据库迁移(如有) + | + v + 发送通知(企业微信/钉钉/飞书,可选) +``` + +三台服务器各司其职: + +| 角色 | 职责 | +|------|------| +| Gitea 服务器 | 代码仓库,通过 Webhook 触发 Drone | +| Drone CI 服务器 | 执行构建 + 运行私有 Docker Registry | +| 生产服务器 | 运行 Docker Compose 部署的生产服务 | + +--- + +## 第一步:基础设施准备(一次性) + +以下操作只需做一次,所有项目共享。 + +### 1.1 启动私有 Docker Registry + +在 **Drone CI 服务器**上执行: + +```bash +docker run -d --name registry \ + -p 5000:5000 \ + -v /opt/registry-data:/var/lib/registry \ + --restart always \ + registry:2 +``` + +验证: + +```bash +curl http://localhost:5000/v2/_catalog +# 预期输出: {"repositories":[]} +``` + +### 1.2 配置 insecure-registries + +由于 Registry 未配置 TLS,需要在 **所有需要访问 Registry 的服务器** 上配置 insecure-registries。 + +编辑 `/etc/docker/daemon.json`: + +```json +{ + "insecure-registries": [":5000"] +} +``` + +然后重启 Docker: + +```bash +sudo systemctl restart docker +``` + +> **重要**: +> - `insecure-registries` 的值 **不要** 带 `http://` 前缀,直接写 `host:port` +> - 如果已有 `registry-mirrors` 等其他配置,合并到同一个 JSON 文件中,不要覆盖 +> - Drone CI 服务器和生产服务器 **都需要** 配置 + +**示例**(假设 Drone CI 服务器 IP 为 `192.168.1.100`,域名为 `ci.example.com`): + +```json +{ + "registry-mirrors": [ + "https://docker.1panel.live" + ], + "insecure-registries": [ + "ci.example.com:5000", + "192.168.1.100:5000" + ] +} +``` + +### 1.3 配置 SSH 免密登录 + +在 **Drone CI 服务器**上生成部署专用密钥: + +```bash +ssh-keygen -t ed25519 -C "drone-ci-deploy" -f ~/.ssh/drone_deploy -N "" +``` + +将公钥添加到 **生产服务器**: + +```bash +# 如果 SSH 端口是 22 +ssh-copy-id -i ~/.ssh/drone_deploy.pub @ + +# 如果 SSH 端口不是 22(如 3141) +ssh-copy-id -i ~/.ssh/drone_deploy.pub -p @ +``` + +验证: + +```bash +ssh -i ~/.ssh/drone_deploy -p @ "echo ok" +``` + +### 1.4 确保生产服务器用户有 Docker 权限 + +```bash +# 在生产服务器上 +sudo usermod -aG docker +# 需要重新登录生效 +``` + +--- + +## 第二步:项目接入 Drone CI + +### 2.1 在 Drone 面板激活仓库 + +1. 打开 Drone CI 面板(如 `https://drone.example.com`) +2. 点击 **SYNC** 同步 Gitea 仓库列表 +3. 找到目标仓库,点击 **ACTIVATE** + +### 2.2 开启 Trusted 模式 + +仓库 Settings → General → Project Settings → 勾选 **Trusted**。 + +> 这是必须的,因为构建步骤需要挂载宿主机 Docker socket。如果看不到 Trusted 选项,说明当前用户不是 Drone 管理员。检查 Drone Server 的 `DRONE_USER_CREATE` 环境变量,`username` 必须与 Gitea 登录用户名完全一致(不是邮箱)。 + +### 2.3 配置 Secrets + +在仓库 Settings → Secrets 添加以下密钥: + +| Secret 名称 | 说明 | 填写示例 | +|-------------|------|---------| +| `image_repo_` | 每个需构建的服务的完整镜像地址 | `ci.example.com:5000/myproject-backend` | +| `deploy_host` | 生产服务器 IP 或域名 | `192.168.1.200` | +| `deploy_user` | SSH 登录用户名 | `ubuntu` | +| `deploy_ssh_key` | SSH **私钥**完整内容 | `-----BEGIN OPENSSH PRIVATE KEY-----...` | +| `deploy_path` | 生产服务器上的项目部署目录 | `/opt/myproject` | +| `wecom_webhook` | 通知 Webhook URL(可选) | `https://qyapi.weixin.qq.com/...` | + +> **关于 `image_repo_`**:每个需要构建的服务单独一个 secret。例如,项目有 backend 和 frontend,就创建 `backend_repo` 和 `frontend_repo`。Registry 地址必须与生产服务器 `.env` 中的镜像地址一致。 + +### 2.4 配置 Cron 定时构建(可选) + +仓库 Settings → Cron Jobs 添加: + +| 字段 | 值 | 说明 | +|------|------|------| +| Name | `nightly-build` | 任务名称 | +| Branch | `main` | 构建分支 | +| Schedule | `0 16 * * *` | UTC 16:00 = 北京时间 00:00 | + +> Drone 使用 UTC 时区解释 Cron 表达式。北京时间 = UTC + 8。 + +--- + +## 第三步:编写 .drone.yml + +在项目根目录创建 `.drone.yml`。 + +### 核心原则 + +1. **使用 `docker:27-cli` + 宿主机 Docker socket**,不用 `plugins/docker` DinD(避免 DinD 启动失败问题) +2. **使用 `environment: from_secret`** 注入密钥,不用 `secrets:` 字段 +3. **使用 `${DRONE_TAG:-latest}`** 作为镜像 tag,不自定义中间变量(避免 Drone 变量替换冲突) +4. **触发条件只用 `event`**,不叠加 `cron: [name]`(Drone 触发条件是 AND 运算) + +### 模板 + +```yaml +kind: pipeline +type: docker +name: build-and-deploy + +trigger: + event: + - tag # Git Tag 触发 + - cron # 定时触发 + +volumes: + - name: dockersock + host: + path: /var/run/docker.sock + +steps: + # ========================================== + # 构建步骤 - 每个服务一个 step + # ========================================== + - name: build- + image: docker:27-cli + volumes: + - name: dockersock + path: /var/run/docker.sock + environment: + IMAGE_REPO: + from_secret: _repo + commands: + - '[ -n "$IMAGE_REPO" ] || (echo "_repo secret is empty" && exit 1)' + - echo "Building , tag=${DRONE_TAG:-latest}" + - docker build -t "$IMAGE_REPO:${DRONE_TAG:-latest}" -t "$IMAGE_REPO:latest" ./ + - docker push "$IMAGE_REPO:${DRONE_TAG:-latest}" + - docker push "$IMAGE_REPO:latest" + + - name: build- + image: docker:27-cli + volumes: + - name: dockersock + path: /var/run/docker.sock + environment: + IMAGE_REPO: + from_secret: _repo + commands: + - '[ -n "$IMAGE_REPO" ] || (echo "_repo secret is empty" && exit 1)' + - echo "Building , tag=${DRONE_TAG:-latest}" + - docker build -t "$IMAGE_REPO:${DRONE_TAG:-latest}" -t "$IMAGE_REPO:latest" ./ + - docker push "$IMAGE_REPO:${DRONE_TAG:-latest}" + - docker push "$IMAGE_REPO:latest" + + # ========================================== + # 部署步骤 + # ========================================== + - name: deploy + image: appleboy/drone-ssh + environment: + DEPLOY_PATH: + from_secret: deploy_path + settings: + host: + from_secret: deploy_host + username: + from_secret: deploy_user + key: + from_secret: deploy_ssh_key + port: 22 # ← 改成实际 SSH 端口 + command_timeout: 1800s + script_stop: true + envs: + - DRONE_TAG + - DEPLOY_PATH + script: + - IMAGE_TAG="$DRONE_TAG"; [ -n "$IMAGE_TAG" ] || IMAGE_TAG="latest" + - cd "$DEPLOY_PATH" + - bash scripts/deploy-remote.sh "$IMAGE_TAG" + + # ========================================== + # 通知步骤(可选) + # ========================================== + - name: notify-success + image: curlimages/curl + environment: + WEBHOOK_URL: + from_secret: wecom_webhook + commands: + - | + if [ -n "${WEBHOOK_URL:-}" ]; then + VERSION="${DRONE_TAG:-nightly-$(date +%Y%m%d)}" + curl -sS -X POST "$WEBHOOK_URL" \ + -H "Content-Type: application/json" \ + -d "{\"msgtype\":\"text\",\"text\":{\"content\":\"✅ 部署成功\nProject: ${DRONE_REPO}\n版本: ${VERSION}\n时间: $(date '+%Y-%m-%d %H:%M:%S')\"}}" + fi + when: + status: [success] + + - name: notify-failure + image: curlimages/curl + environment: + WEBHOOK_URL: + from_secret: wecom_webhook + commands: + - | + if [ -n "${WEBHOOK_URL:-}" ]; then + VERSION="${DRONE_TAG:-nightly-$(date +%Y%m%d)}" + curl -sS -X POST "$WEBHOOK_URL" \ + -H "Content-Type: application/json" \ + -d "{\"msgtype\":\"text\",\"text\":{\"content\":\"❌ 部署失败\nProject: ${DRONE_REPO}\n版本: ${VERSION}\n构建: ${DRONE_BUILD_LINK}\"}}" + fi + when: + status: [failure] +``` + +**替换占位符**: + +| 占位符 | 替换为 | 示例 | +|--------|--------|------| +| ``, `` | 你的服务名 | `backend`, `frontend`, `api`, `web` | +| `` | Docker build context 路径 | `./backend`, `./frontend`, `.` | +| `port: 22` | 生产服务器 SSH 端口 | `22`, `3141` | + +--- + +## 第四步:编写部署脚本 + +在项目中创建 `scripts/deploy-remote.sh`,这个脚本由 Drone SSH 步骤在生产服务器上调用。 + +### 通用模板 + +```bash +#!/usr/bin/env bash +# ======================================== +# 远程部署脚本 - 被 Drone CI SSH 调用 +# ======================================== +# 用法: bash scripts/deploy-remote.sh [image_tag] + +set -euo pipefail + +# ---------- 日志 ---------- +GREEN='\033[0;32m'; RED='\033[0;31m'; YELLOW='\033[1;33m'; NC='\033[0m' +log_info() { echo -e "${GREEN}[INFO]${NC} $(date '+%Y-%m-%d %H:%M:%S') $1"; } +log_warn() { echo -e "${YELLOW}[WARN]${NC} $(date '+%Y-%m-%d %H:%M:%S') $1"; } +log_error() { echo -e "${RED}[ERROR]${NC} $(date '+%Y-%m-%d %H:%M:%S') $1"; } + +# ---------- 配置(可通过环境变量覆盖) ---------- +DEPLOY_PATH="${DEPLOY_PATH:-/opt/myproject}" +COMPOSE_FILE="${COMPOSE_FILE:-docker-compose.prod.yml}" +IMAGE_TAG="${1:-latest}" +LOCK_FILE="/tmp/$(basename "$DEPLOY_PATH")-deploy.lock" +MAX_ATTEMPTS="${MAX_ATTEMPTS:-30}" +HEALTH_CHECK_INTERVAL="${HEALTH_CHECK_INTERVAL:-3}" + +# ---------- 部署锁 ---------- +cleanup_lock() { rm -f "$LOCK_FILE"; } + +acquire_lock() { + if [ -f "$LOCK_FILE" ]; then + local old_pid + old_pid=$(cat "$LOCK_FILE" 2>/dev/null || true) + if [ -n "$old_pid" ] && kill -0 "$old_pid" 2>/dev/null; then + log_error "已有部署进程运行中 (PID: $old_pid)" + exit 1 + fi + rm -f "$LOCK_FILE" + fi + echo "$$" > "$LOCK_FILE" + trap cleanup_lock EXIT +} + +# ---------- 工具函数 ---------- +compose() { + docker compose -f "$COMPOSE_FILE" "$@" +} + +# 健康检查 - 根据项目实际情况修改 +# 方式 1:容器内用 python 请求(适用于 Python 后端镜像) +wait_healthy_python() { + local service="$1" + local url="${2:-http://127.0.0.1:8000/health}" + local attempt=1 + + while [ "$attempt" -le "$MAX_ATTEMPTS" ]; do + if compose exec -T "$service" python -c \ + "import sys,urllib.request;urllib.request.urlopen('${url}', timeout=3);sys.exit(0)" \ + >/dev/null 2>&1; then + log_info "${service} 健康检查通过" + return 0 + fi + log_info "等待 ${service} 就绪... (${attempt}/${MAX_ATTEMPTS})" + sleep "$HEALTH_CHECK_INTERVAL" + attempt=$((attempt + 1)) + done + return 1 +} + +# 方式 2:容器内用 curl 请求(适用于内置 curl 的镜像) +wait_healthy_curl() { + local service="$1" + local url="${2:-http://127.0.0.1:8000/health}" + local attempt=1 + + while [ "$attempt" -le "$MAX_ATTEMPTS" ]; do + if compose exec -T "$service" curl -sf "$url" >/dev/null 2>&1; then + log_info "${service} 健康检查通过" + return 0 + fi + log_info "等待 ${service} 就绪... (${attempt}/${MAX_ATTEMPTS})" + sleep "$HEALTH_CHECK_INTERVAL" + attempt=$((attempt + 1)) + done + return 1 +} + +# 方式 3:从宿主机请求(适用于有端口映射的服务) +wait_healthy_host() { + local url="${1:-http://127.0.0.1:8000/health}" + local attempt=1 + + while [ "$attempt" -le "$MAX_ATTEMPTS" ]; do + if curl -sf "$url" >/dev/null 2>&1; then + log_info "健康检查通过: $url" + return 0 + fi + log_info "等待服务就绪... (${attempt}/${MAX_ATTEMPTS})" + sleep "$HEALTH_CHECK_INTERVAL" + attempt=$((attempt + 1)) + done + return 1 +} + +# ---------- 主流程 ---------- +main() { + acquire_lock + + [ -d "$DEPLOY_PATH" ] || { log_error "部署目录不存在: $DEPLOY_PATH"; exit 1; } + cd "$DEPLOY_PATH" + [ -f "$COMPOSE_FILE" ] || { log_error "Compose 文件不存在: $COMPOSE_FILE"; exit 1; } + + # 导出镜像 tag 变量 - 根据 compose 文件中使用的变量名按需调整 + export IMAGE_TAG + export VERSION="$IMAGE_TAG" + # export TAG="$IMAGE_TAG" # 如果你的 compose 用 ${TAG} + + log_info "========== 开始部署 ==========" + log_info "镜像版本: $IMAGE_TAG" + log_info "部署目录: $DEPLOY_PATH" + log_info "Compose: $COMPOSE_FILE" + + # --- 1. 拉取新镜像 --- + log_info "[1/5] 拉取新镜像..." + compose pull + # 或者只拉取特定服务: compose pull backend frontend + + # --- 2. 滚动更新服务 --- + log_info "[2/5] 更新服务..." + compose up -d --remove-orphans + # 如果需要控制更新顺序,拆分为多步: + # compose up -d --no-deps backend + # compose up -d --no-deps frontend + + # --- 3. 健康检查 --- + log_info "[3/5] 健康检查..." + # 根据你的项目选择合适的健康检查方式,以下是示例: + # wait_healthy_python "backend" "http://127.0.0.1:8000/health" + # wait_healthy_curl "api" "http://127.0.0.1:3000/health" + # wait_healthy_host "http://127.0.0.1:8080/health" + # + # 如果健康检查失败,取消注释下面的代码: + # if ! wait_healthy_python "backend"; then + # log_error "健康检查失败" + # compose logs --tail=200 backend || true + # exit 1 + # fi + + # --- 4. 数据库迁移(如有) --- + log_info "[4/5] 数据库迁移..." + # 根据项目使用的迁移工具选择: + # compose exec -T backend alembic upgrade head # Python Alembic + # compose exec -T api npx prisma migrate deploy # Node Prisma + # compose exec -T api npm run migrate # 自定义脚本 + # compose exec -T backend python manage.py migrate # Django + # 如果不需要迁移,注释掉即可 + + # --- 5. 清理 --- + log_info "[5/5] 清理旧镜像..." + docker image prune -f >/dev/null 2>&1 || log_warn "镜像清理失败,已跳过" + + log_info "========== 部署完成 ==========" + log_info "版本: $IMAGE_TAG" + compose ps +} + +main "$@" +``` + +### 脚本要点 + +1. **部署锁**:通过 PID 文件防止并发部署 +2. **`set -euo pipefail`**:任何命令失败立即退出,防止"假成功" +3. **变量导出**:同时 export `IMAGE_TAG` 和 `VERSION`,兼容不同 compose 文件的命名习惯 +4. **健康检查**:提供三种方式,根据项目镜像内置的工具选择 +5. **数据库迁移**:在健康检查通过后执行,迁移失败会中断部署 + +--- + +## 第五步:生产服务器配置 + +### 5.1 目录结构 + +确保生产服务器上部署目录结构如下: + +```text +/opt/myproject/ # DEPLOY_PATH +├── docker-compose.prod.yml # 生产 compose 配置 +├── .env # 环境变量(镜像地址、数据库密码等) +└── scripts/ + └── deploy-remote.sh # 部署脚本(从代码仓库复制) +``` + +### 5.2 docker-compose.prod.yml 中的镜像引用 + +compose 文件中使用变量引用 Registry 中的镜像: + +```yaml +services: + backend: + image: ${DOCKER_REGISTRY}/myproject-backend:${VERSION:-latest} + # ... + + frontend: + image: ${DOCKER_REGISTRY}/myproject-frontend:${VERSION:-latest} + # ... +``` + +### 5.3 .env 文件 + +```bash +# 镜像仓库地址 - 必须与 Drone Secret 中的 image_repo 前缀一致 +DOCKER_REGISTRY=ci.example.com:5000 + +# 其他生产环境配置 +POSTGRES_PASSWORD=xxx +REDIS_PASSWORD=xxx +# ... +``` + +> **变量一致性**:假设 Drone Secret `backend_repo` = `ci.example.com:5000/myproject-backend`,那么 `.env` 中的 `DOCKER_REGISTRY` 必须是 `ci.example.com:5000`,compose 中的镜像名必须是 `${DOCKER_REGISTRY}/myproject-backend:${VERSION:-latest}`。三者拼起来的镜像全名必须完全一致。 + +### 5.4 首次部署 + +首次部署需要手动初始化: + +```bash +cd /opt/myproject + +# 确认 .env 和 compose 文件就位 +ls -la .env docker-compose.prod.yml + +# 手动拉取并启动 +export VERSION=latest +docker compose -f docker-compose.prod.yml pull +docker compose -f docker-compose.prod.yml up -d + +# 运行数据库迁移(如有) +docker compose -f docker-compose.prod.yml exec -T backend alembic upgrade head +``` + +--- + +## 第六步:配置 Drone 仓库设置 + +### Secrets 快速清单 + +针对你的新项目,在 Drone 面板创建以下 Secrets: + +```text +backend_repo = :5000/-backend +frontend_repo = :5000/-frontend # 如果有 +deploy_host = +deploy_user = +deploy_ssh_key = +deploy_path = /opt/ +wecom_webhook = <通知 webhook url> # 可选 +``` + +> `deploy_ssh_key` 是第一步中生成的 SSH 私钥内容,所有项目可以共用同一个密钥(只要目标生产服务器相同)。 + +### Cron 配置 + +如果需要定时构建: + +| 字段 | 值 | +|------|------| +| Name | `nightly-build` | +| Branch | `main` | +| Schedule | `0 16 * * *`(北京时间 00:00) | + +--- + +## 第七步:验证 + +### 7.1 Registry 验证 + +```bash +# 在 Drone CI 服务器上 +curl http://localhost:5000/v2/_catalog +``` + +### 7.2 Tag 触发构建 + +```bash +git tag v1.0.0 +git push origin v1.0.0 +``` + +在 Drone 面板观察 pipeline 执行状态。 + +### 7.3 生产服务验证 + +```bash +# 检查容器状态 +ssh -p @ "cd /opt/myproject && docker compose -f docker-compose.prod.yml ps" + +# 检查镜像版本 +ssh -p @ "docker images | grep myproject" +``` + +--- + +## 回滚方案 + +### 方式 1:手动指定版本回滚 + +```bash +ssh -p @ +cd /opt/myproject +bash scripts/deploy-remote.sh v1.0.0 # 指定要回滚到的版本 +``` + +### 方式 2:直接 compose 回滚 + +```bash +ssh -p @ +cd /opt/myproject +export VERSION=v1.0.0 +docker compose -f docker-compose.prod.yml pull +docker compose -f docker-compose.prod.yml up -d +``` + +### 方式 3:通过 Git Tag 触发 + +```bash +git tag v1.0.0-rollback v1.0.0 +git push origin v1.0.0-rollback +``` + +--- + +## 多项目管理 + +当多个项目共用同一套 Drone CI + Registry 基础设施时: + +### 共享部分(不需要重复) + +- Drone CI Server + Runner(已部署) +- Docker Registry(已运行在 :5000) +- SSH 密钥(可共用同一个 `drone_deploy` 密钥) +- `insecure-registries` 配置(已在两台服务器上配置) + +### 每个新项目需要做的 + +1. 在 Drone 面板激活仓库 + 开启 Trusted +2. 在 Drone 面板添加该仓库的 Secrets(镜像地址、部署路径) +3. 项目代码中添加 `.drone.yml` 和 `scripts/deploy-remote.sh` +4. 生产服务器创建部署目录 + 放置 compose 文件和 `.env` +5. (可选)配置 Cron 定时构建 + +### Registry 镜像命名规范 + +建议统一命名格式: + +```text +:5000/-: +``` + +示例: + +```text +ci.example.com:5000/douyin-backend:v1.0.0 +ci.example.com:5000/douyin-frontend:v1.0.0 +ci.example.com:5000/crm-api:v2.1.0 +ci.example.com:5000/crm-web:v2.1.0 +ci.example.com:5000/blog-app:latest +``` + +查看所有仓库: + +```bash +curl http://localhost:5000/v2/_catalog +``` + +查看某个镜像的所有 tag: + +```bash +curl http://localhost:5000/v2/-/tags/list +``` + +--- + +## 踩坑清单 + +以下是实际部署中遇到的问题,按出现频率排序: + +### 1. `insecure-registries` 格式错误 + +**错误**:Docker push 报 `server gave HTTP response to HTTPS client` + +**原因**:`/etc/docker/daemon.json` 中写了 `http://host:5000` + +**正确写法**:直接写 `host:5000`,不带协议前缀 + +### 2. Drone 变量替换冲突 + +**错误**:构建命令中的 shell 变量值为空 + +**原因**:Drone 在执行命令前会对 `${VAR}` 做自身的变量替换,自定义 shell 变量 `${TAG}` 被 Drone 替换为空 + +**解决**:直接使用 Drone 内置变量 `${DRONE_TAG:-latest}`,不要赋值给中间变量 + +### 3. Secret 注入不生效 + +**错误**:环境变量为空,步骤因 secret 缺失而失败 + +**原因**:使用了步骤级 `secrets:` 字段 + +**解决**:改用 `environment: { VAR: { from_secret: name } }` + +### 4. plugins/docker DinD 启动失败 + +**错误**:`Unable to reach Docker Daemon after 15 attempts` + +**原因**:`plugins/docker` 内部启动 Docker 守护进程失败(cgroup / 存储驱动兼容性) + +**解决**:使用 `docker:27-cli` + 挂载宿主机 `/var/run/docker.sock` + +### 5. Trusted 选项不可见 + +**错误**:仓库 Settings 中找不到 Trusted 选项 + +**原因**:`DRONE_USER_CREATE` 的 `username` 填了邮箱,不是 Gitea 用户名 + +**解决**:`username` 必须与 Gitea 登录用户名完全一致 + +### 6. Tag 触发与 Cron 触发互相排斥 + +**错误**:推送 Tag 后 Drone 不触发构建 + +**原因**:`.drone.yml` 同时配了 `event: [tag, cron]` 和 `cron: [nightly-build]`,Drone 触发条件是 **AND** 运算 + +**解决**:只保留 `event: [tag, cron]`,不加 `cron:` 过滤 + +### 7. 生产服务器镜像名不匹配 + +**错误**:`docker compose pull` 报 invalid reference 或拉取到错误镜像 + +**原因**:Drone Secret 中的 Registry 地址(如 IP)与生产服务器 `.env` 中的(如域名)不一致 + +**解决**:统一使用同一个地址格式(建议统一用域名或统一用 IP) + +### 8. SSH 端口不对 + +**错误**:deploy 步骤超时或连接拒绝 + +**原因**:`appleboy/drone-ssh` 默认使用 22 端口 + +**解决**:在 `.drone.yml` 的 deploy settings 中显式指定 `port: ` + +### 9. Docker 权限不足 + +**错误**:生产服务器 `permission denied while trying to connect to the Docker daemon socket` + +**解决**:`sudo usermod -aG docker ` 后重新登录 + +### 10. daemon.json 被覆盖 + +**错误**:修改 `insecure-registries` 时丢失了已有的 `registry-mirrors` 配置 + +**预防**:修改前先 `cat /etc/docker/daemon.json` 查看现有内容,合并修改 + +--- + +## 故障排查速查表 + +| 现象 | 检查方向 | +|------|---------| +| Pipeline 不触发 | Gitea Webhook 是否勾选"创建"事件;`.drone.yml` trigger 配置 | +| Step 一直 pending | Runner 是否连通 Server;仓库是否 Trusted | +| 构建报 secret 为空 | 使用 `environment: from_secret` 而非 `secrets:` | +| Docker build 失败 | Dockerfile 是否正确;build context 路径是否对 | +| Docker push 失败 (HTTPS) | 两台服务器 `insecure-registries` 配置 | +| Docker push 失败 (连接拒绝) | Registry 容器是否运行:`docker ps \| grep registry` | +| SSH 部署失败 | 密钥是否正确;端口是否匹配;用户是否有 Docker 权限 | +| 找不到部署脚本 | `deploy-remote.sh` 是否已复制到生产服务器的对应目录 | +| 镜像名 invalid reference | 生产服务器 `.env` 的 `DOCKER_REGISTRY` 变量是否正确 | +| compose pull 拉到旧镜像 | 检查 Registry 地址和镜像 tag 是否一致 | +| 数据库迁移失败 | `docker compose logs -f ` 查看报错 | +| 健康检查超时 | 增大 `MAX_ATTEMPTS`;检查服务是否正常启动 | + +--- + +## 附录 A:Drone CI 部署 + +如果 Drone CI 尚未部署,在 Drone CI 服务器上创建 `~/drone/docker-compose.yml`: + +```yaml +services: + drone-server: + image: drone/drone:2 + container_name: drone-server + restart: always + ports: + - "3080:80" # Drone Web UI 端口,通过反向代理暴露 HTTPS + environment: + - DRONE_GITEA_SERVER=https:// + - DRONE_GITEA_CLIENT_ID= + - DRONE_GITEA_CLIENT_SECRET= + - DRONE_SERVER_HOST= + - DRONE_SERVER_PROTO=https + - DRONE_RPC_SECRET=<随机生成的长字符串> + - DRONE_USER_CREATE=username:,admin:true + volumes: + - ./data:/data + + drone-runner: + image: drone/drone-runner-docker:1 + container_name: drone-runner + restart: always + depends_on: + - drone-server + environment: + # Runner 通过 Docker 内网直连 server 的 80 端口 + - DRONE_RPC_PROTO=http + - DRONE_RPC_HOST=drone-server + - DRONE_RPC_SECRET=<与 server 相同的 secret> + - DRONE_RUNNER_CAPACITY=2 + - DRONE_RUNNER_NAME=drone-runner-1 + volumes: + - /var/run/docker.sock:/var/run/docker.sock +``` + +启动: + +```bash +cd ~/drone +docker compose up -d +``` + +### Gitea OAuth App 配置 + +1. Gitea → 站点管理 → 应用 → 创建 OAuth2 应用 +2. 重定向 URI:`https:///login` +3. 将 Client ID 和 Client Secret 填入上面的 compose 配置 + +### 生成 RPC Secret + +```bash +openssl rand -hex 16 +``` + +### 反向代理配置(Nginx 示例) + +```nginx +server { + listen 443 ssl; + server_name drone.example.com; + + ssl_certificate /path/to/cert.pem; + ssl_certificate_key /path/to/key.pem; + + location / { + proxy_pass http://127.0.0.1:3080; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} +``` + +--- + +## 附录 B:新项目接入 Checklist + +为新项目接入 CI/CD 时,按此清单逐项完成: + +```text +□ 基础设施(一次性,已完成则跳过) + □ Registry 运行中 + □ insecure-registries 已配置(Drone CI 服务器 + 生产服务器) + □ SSH 密钥已配置 + +□ Drone 面板 + □ 仓库已激活(ACTIVATE) + □ Trusted 已勾选 + □ Secrets 已添加(image_repo, deploy_host, deploy_user, deploy_ssh_key, deploy_path) + □ Cron Job 已配置(如需定时构建) + +□ 项目代码 + □ .drone.yml 已创建 + □ scripts/deploy-remote.sh 已创建 + +□ 生产服务器 + □ 部署目录已创建 + □ docker-compose.prod.yml 已放置 + □ .env 已配置(DOCKER_REGISTRY 等) + □ scripts/deploy-remote.sh 已复制到部署目录 + □ 首次手动部署成功 + +□ 验证 + □ 推送 Tag 后 Drone 自动构建并部署成功 + □ 生产服务正常运行 + □ 回滚流程测试通过 +```