From 7c0b9b3aafedd4e90739110dd70484a1a8645c45 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Tue, 18 Aug 2026 14:50:35 +0800 Subject: [PATCH 1/6] =?UTF-8?q?perf:=20=E5=88=A0=E9=99=A4=20psycopg2-binar?= =?UTF-8?q?y=20=E4=BE=9D=E8=B5=96=20+=20=E9=A2=84=E6=9E=84=E5=BB=BA=20API?= =?UTF-8?q?=20=E5=9F=BA=E7=A1=80=E9=95=9C=E5=83=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 子任务 2.1 - 删除重复 PostgreSQL 驱动: - health.py: psycopg2 → psycopg (psycopg3 迁移,3处修改) - requirements-base.txt: 删除 psycopg2-binary==2.9.9 - 项目已全面使用 psycopg3,仅 health.py 遗留 psycopg2 子任务 2.2 - 预构建 API 基础镜像: - 新增 infra/docker/api-base.Dockerfile (系统依赖+Python依赖预装) - 简化 infra/docker/api.Dockerfile (从基础镜像开始,仅叠加业务代码) - 新增 .gitea/workflows/api-base-image.yml (自动构建基础镜像) - 新增 scripts/ci/rebuild-api-base-image.sh (手动重建脚本) 预期效果: - API Image 构建时间从 15-20 分钟降至 3-5 分钟 - 消除 psycopg2 和 psycopg3 重复安装(减少包体积) --- .gitea/workflows/api-base-image.yml | 95 ++++++++++++++++++++++++++++ apps/api/app/api/routes/health.py | 6 +- infra/docker/api-base.Dockerfile | 43 +++++++++++++ infra/docker/api.Dockerfile | 76 ++++------------------ requirements-base.txt | 1 - scripts/ci/rebuild-api-base-image.sh | 40 ++++++++++++ 6 files changed, 192 insertions(+), 69 deletions(-) create mode 100644 .gitea/workflows/api-base-image.yml create mode 100644 infra/docker/api-base.Dockerfile create mode 100755 scripts/ci/rebuild-api-base-image.sh diff --git a/.gitea/workflows/api-base-image.yml b/.gitea/workflows/api-base-image.yml new file mode 100644 index 000000000..d52c8b612 --- /dev/null +++ b/.gitea/workflows/api-base-image.yml @@ -0,0 +1,95 @@ +name: API Base Image Build + +on: + push: + branches: + - develop + - main + paths: + - 'requirements-base.txt' + - 'requirements.txt' + - 'infra/docker/api-base.Dockerfile' + workflow_dispatch: # 支持手动触发 + +jobs: + build-api-base: + name: Build API Base Image + runs-on: runtime-builder + timeout-minutes: 30 + steps: + - name: Checkout code + shell: sh + env: + GITHUB_TOKEN: ${{ github.token }} + run: | + curl -sH "Authorization: token $GITHUB_TOKEN" \ + "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" \ + | bash + + - name: Docker login to Registry + shell: sh + env: + ACR_USERNAME: ${{ secrets.ACR_USERNAME }} + ACR_PASSWORD: ${{ secrets.ACR_PASSWORD }} + GITEA_REGISTRY_USER: xiaoxia + GITEA_REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} + run: | + set -eu + for i in 1 2 3; do + echo "=== Docker login 尝试 $i/3 ===" + if printf '%s' "${ACR_PASSWORD}" | docker login xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com -u "${ACR_USERNAME}" --password-stdin \ + && docker login git.xiaoxiajianji.com -u "${GITEA_REGISTRY_USER}" -p "${GITEA_REGISTRY_TOKEN}"; then + echo "✅ Docker login successful" + break + fi + echo "❌ Docker login 失败(尝试 $i/3),5s 后重试..." + sleep 5 + done + + - name: Setup buildx builder + shell: sh + run: | + set -eu + BUILDER_NAME="ci-builder-${GITHUB_RUN_ID}-api-base" + if ! docker buildx inspect "$BUILDER_NAME" > /dev/null 2>&1; then + docker buildx create --use --name "$BUILDER_NAME" --driver docker-container + echo "Created $BUILDER_NAME" + else + docker buildx use "$BUILDER_NAME" + echo "Using existing $BUILDER_NAME" + fi + docker buildx inspect --bootstrap + + - name: Build and push API base image + shell: sh + run: | + set -eu + REGISTRY="xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji" + IMAGE_TAG="${REGISTRY}/saas-api-base:latest" + SAFE_REF_NAME=$(echo "${GITHUB_REF_NAME}" | tr '/' '-') + CACHE_REF="${REGISTRY}/saas-api-base-cache:${SAFE_REF_NAME}" + + echo "=== Building API base image ===" + echo "Image: ${IMAGE_TAG}" + echo "Cache: ${CACHE_REF}" + + bash scripts/ci/docker_build_push.sh \ + infra/docker/api-base.Dockerfile \ + "${IMAGE_TAG}" \ + "${CACHE_REF}" + + # 同时推送到 Gitea Packages 作为备份 + GITEA_IMAGE="git.xiaoxiajianji.com/xiaoxia-saas/saas-api-base:latest" + docker tag "${IMAGE_TAG}" "${GITEA_IMAGE}" + docker push "${GITEA_IMAGE}" || echo "Gitea Packages push failed (non-fatal)" + + echo "" + echo "✅ API base image built and pushed" + + - name: Cleanup buildx builder + if: always() + shell: sh + run: | + docker buildx rm "ci-builder-${GITHUB_RUN_ID}-api-base" 2>/dev/null || true + docker buildx prune -f 2>/dev/null || true + echo "Builder cleanup done" diff --git a/apps/api/app/api/routes/health.py b/apps/api/app/api/routes/health.py index 644196b32..f16465695 100644 --- a/apps/api/app/api/routes/health.py +++ b/apps/api/app/api/routes/health.py @@ -1,6 +1,6 @@ from datetime import datetime, timezone -import psycopg2 +import psycopg import redis from app.config import settings from fastapi import APIRouter, status @@ -49,7 +49,7 @@ async def _check_database() -> dict: "message": "Using in-memory database", } try: - conn = psycopg2.connect(settings.DATABASE_URL, connect_timeout=3) + conn = psycopg.connect(settings.DATABASE_URL, connect_timeout=3) with conn.cursor() as cur: cur.execute("SELECT 1") cur.fetchone() @@ -124,7 +124,7 @@ async def _check_migrations() -> dict: "message": "Using in-memory database, no migrations needed", } try: - conn = psycopg2.connect(settings.DATABASE_URL, connect_timeout=3) + conn = psycopg.connect(settings.DATABASE_URL, connect_timeout=3) with conn.cursor() as cur: cur.execute(""" SELECT COUNT(*) FROM information_schema.tables diff --git a/infra/docker/api-base.Dockerfile b/infra/docker/api-base.Dockerfile new file mode 100644 index 000000000..89216dc6c --- /dev/null +++ b/infra/docker/api-base.Dockerfile @@ -0,0 +1,43 @@ +# ============================================================ +# API 基础镜像(预构建) +# 预装系统依赖 + Python 依赖,业务构建从此镜像开始 +# 当 requirements-base.txt 或 requirements.txt 变更时重新构建 +# 目标:将 API Image 构建时间从 15-20 分钟降至 3-5 分钟 +# ============================================================ + +FROM python:3.12-slim + +# 使用阿里云镜像加速 +RUN sed -i 's|deb.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list.d/debian.sources 2>/dev/null || \ + sed -i 's|deb.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list 2>/dev/null || true + +# 预装系统依赖(gcc 编译 psycopg/pg 扩展,libpq-dev 编译期,libpq5 运行期,ffmpeg 封面取帧) +RUN apt-get update && apt-get install -y --no-install-recommends \ + gcc \ + libpq-dev \ + libpq5 \ + ffmpeg \ + && rm -rf /var/lib/apt/lists/* + +# 创建虚拟环境 +RUN python -m venv /opt/venv +ENV PATH="/opt/venv/bin:$PATH" + +WORKDIR /tmp + +# 预装 Python 基础依赖 +COPY requirements-base.txt requirements.txt ./ +RUN pip install --no-cache-dir \ + -i https://mirrors.aliyun.com/pypi/simple/ \ + --trusted-host mirrors.aliyun.com \ + -r requirements-base.txt -r requirements.txt + +# 虚拟环境瘦身 +RUN find /opt/venv -name "*.so" -type f -exec strip --strip-all {} \; 2>/dev/null || true +RUN find /opt/venv -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null; \ + find /opt/venv -name "*.pyc" -delete 2>/dev/null || true + +# 清理临时文件 +RUN rm -f /tmp/requirements-base.txt /tmp/requirements.txt + +ENV PYTHONPATH=/app diff --git a/infra/docker/api.Dockerfile b/infra/docker/api.Dockerfile index d38681050..3192f725c 100755 --- a/infra/docker/api.Dockerfile +++ b/infra/docker/api.Dockerfile @@ -1,77 +1,24 @@ # ============================================================ # API Dockerfile - FastAPI 应用 -# 优化:多阶段构建 + pip cache mount + 依赖分层缓存 +# 优化:从预构建基础镜像开始,仅叠加业务代码 +# 基础镜像包含所有系统依赖和 Python 依赖,构建时间 < 5 分钟 # ============================================================ -# ==================== Builder 阶段 ==================== -FROM git.xiaoxiajianji.com/xiaoxia/base/python:3.12-slim AS builder - -# 使用阿里云镜像加速 -RUN sed -i 's|deb.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list.d/debian.sources 2>/dev/null || \ - sed -i 's|deb.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list 2>/dev/null || true - -# 安装编译依赖(仅 builder 需要) -RUN apt-get update && apt-get install -y --no-install-recommends \ - gcc \ - libpq-dev \ - && rm -rf /var/lib/apt/lists/* - -# 创建虚拟环境 -RUN python -m venv /opt/venv -ENV PATH="/opt/venv/bin:$PATH" - -WORKDIR /tmp - -# ---- 依赖分层:基础依赖(变化少,缓存命中率高)---- -COPY requirements-base.txt /tmp/requirements-base.txt - -RUN --mount=type=cache,target=/root/.cache/pip,sharing=locked \ - pip install --no-cache-dir -i https://mirrors.aliyun.com/pypi/simple/ --trusted-host mirrors.aliyun.com \ - -r /tmp/requirements-base.txt \ - && rm /tmp/requirements-base.txt - -# ---- 依赖分层:业务依赖(变化频繁)---- -COPY requirements.txt /tmp/requirements.txt - -RUN --mount=type=cache,target=/root/.cache/pip,sharing=locked \ - pip install --no-cache-dir -i https://mirrors.aliyun.com/pypi/simple/ --trusted-host mirrors.aliyun.com \ - -r /tmp/requirements.txt \ - && rm /tmp/requirements.txt - -# ---- Python 依赖瘦身 ---- -RUN find /opt/venv -name "*.so" -type f -exec strip --strip-all {} \; 2>/dev/null || true -RUN find /opt/venv -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null; \ - find /opt/venv -name "*.pyc" -delete 2>/dev/null || true - -# ==================== Runtime 阶段 ==================== -FROM git.xiaoxiajianji.com/xiaoxia/base/python:3.12-slim AS runtime +FROM xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji/saas-api-base:latest # 构建参数:版本号(CI 传入 commit hash) ARG APP_VERSION=dev -# 使用阿里云镜像加速 -RUN sed -i 's|deb.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list.d/debian.sources 2>/dev/null || \ - sed -i 's|deb.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list 2>/dev/null || true - -# 只装运行时需要的库(libpq5 是 psycopg2 运行时依赖,ffmpeg 用于封面兜底取帧) -RUN apt-get update && apt-get install -y --no-install-recommends \ - libpq5 \ - ffmpeg \ - && rm -rf /var/lib/apt/lists/* - -# 从 builder 复制虚拟环境 -COPY --from=builder /opt/venv /opt/venv - # 设置工作目录 WORKDIR /app -# 复制应用代码 -COPY apps/api/ /app/apps/api/ -COPY packages/ /app/packages/ -COPY alembic.ini /app/alembic.ini -COPY migrations/ /app/migrations/ -COPY alembic/ /app/alembic/ -COPY scripts/ /app/scripts/ +# 复制应用代码(按变化频率从低到高排序,最大化层缓存命中) +COPY alembic.ini ./alembic.ini +COPY migrations/ ./migrations/ +COPY alembic/ ./alembic/ +COPY scripts/ ./scripts/ +COPY packages/ ./packages/ +COPY apps/api/ ./apps/api/ # 设置环境变量 ENV PATH="/opt/venv/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" @@ -84,5 +31,4 @@ HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health', timeout=5)" # API 入口点 -WORKDIR /app/apps/api -CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] +CMD ["uvicorn", "apps.api.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/requirements-base.txt b/requirements-base.txt index 435b17f8f..637ae9f2e 100644 --- a/requirements-base.txt +++ b/requirements-base.txt @@ -2,7 +2,6 @@ # 修改此文件会触发完整重新构建,请谨慎修改 # 数据库(基础层) -psycopg2-binary==2.9.9 psycopg[binary]==3.2.2 sqlalchemy==2.0.35 alembic==1.13.3 diff --git a/scripts/ci/rebuild-api-base-image.sh b/scripts/ci/rebuild-api-base-image.sh new file mode 100755 index 000000000..34994d3de --- /dev/null +++ b/scripts/ci/rebuild-api-base-image.sh @@ -0,0 +1,40 @@ +#!/bin/bash +# ============================================================ +# 重建 API 基础镜像脚本 +# 用途:当 requirements-base.txt 或 requirements.txt 变更时手动触发 +# 前提:需要在已登录 ACR 的构建服务器上执行 +# ============================================================ + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" + +REGISTRY="xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji" +IMAGE_NAME="saas-api-base" +TAG="latest" +FULL_TAG="${REGISTRY}/${IMAGE_NAME}:${TAG}" + +echo "=========================================" +echo "🔨 Rebuilding API base image" +echo " Registry: ${REGISTRY}" +echo " Image: ${FULL_TAG}" +echo " Context: ${REPO_ROOT}" +echo "=========================================" + +cd "$REPO_ROOT" + +# 构建并推送 +docker buildx build \ + --platform linux/amd64 \ + --tag "${FULL_TAG}" \ + --push \ + -f infra/docker/api-base.Dockerfile \ + . + +echo "" +echo "✅ API base image pushed: ${FULL_TAG}" + +# 显示镜像大小 +docker pull "${FULL_TAG}" > /dev/null 2>&1 +docker images "${FULL_TAG}" --format "table {{.Repository}}:{{.Tag}}\t{{.Size}}" -- 2.54.0 From bbdb53e22668c330bbe2399d15a85da640db4d93 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Tue, 18 Aug 2026 15:14:27 +0800 Subject: [PATCH 2/6] =?UTF-8?q?test:=20=E6=B7=BB=E5=8A=A0=20health.py=20ps?= =?UTF-8?q?ycopg3=20=E8=BF=9E=E6=8E=A5=E6=A3=80=E6=9F=A5=E5=8D=95=E5=85=83?= =?UTF-8?q?=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 覆盖 _check_database() 和 _check_migrations() 中的 psycopg.connect 调用: - 成功连接路径(覆盖 lines 52, 127) - 连接失败路径 - 内存数据库跳过路径 - startup_check 端点综合测试 预期增量覆盖率从 33% 提升至 100% --- tests/unit/test_health_routes.py | 184 +++++++++++++++++++++++++++++++ 1 file changed, 184 insertions(+) create mode 100644 tests/unit/test_health_routes.py diff --git a/tests/unit/test_health_routes.py b/tests/unit/test_health_routes.py new file mode 100644 index 000000000..c74cd2de6 --- /dev/null +++ b/tests/unit/test_health_routes.py @@ -0,0 +1,184 @@ +"""Unit tests for apps/api/app/api/routes/health.py + +覆盖 _check_database() 和 _check_migrations() 中 psycopg3 连接逻辑。 +确保增量覆盖率 ≥ 60%(目标覆盖 lines 52, 127)。 +""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + + +@pytest.mark.asyncio +class TestCheckDatabase: + """Tests for _check_database() health check function.""" + + @patch("apps.api.app.api.routes.health.settings") + @patch("apps.api.app.api.routes.health.psycopg.connect") + async def test_check_database_success(self, mock_connect, mock_settings): + """PostgreSQL 连接成功时返回 healthy。""" + mock_settings.USE_IN_MEMORY_DB = False + mock_settings.DATABASE_URL = "postgresql+psycopg://test:test@localhost/test" + + # Mock connection and cursor + mock_conn = MagicMock() + mock_cursor = MagicMock() + mock_cursor.__enter__ = MagicMock(return_value=mock_cursor) + mock_cursor.__exit__ = MagicMock(return_value=False) + mock_cursor.fetchone.return_value = (1,) + mock_conn.cursor.return_value = mock_cursor + mock_connect.return_value = mock_conn + + from apps.api.app.api.routes.health import _check_database + + result = await _check_database() + + assert result["status"] == "healthy" + assert result["type"] == "postgresql" + assert result["message"] == "Database connection successful" + mock_connect.assert_called_once_with( + "postgresql+psycopg://test:test@localhost/test", connect_timeout=3 + ) + mock_cursor.execute.assert_called_once_with("SELECT 1") + mock_conn.close.assert_called_once() + + @patch("apps.api.app.api.routes.health.settings") + @patch("apps.api.app.api.routes.health.psycopg.connect") + async def test_check_database_connection_failure(self, mock_connect, mock_settings): + """PostgreSQL 连接失败时返回 unhealthy。""" + mock_settings.USE_IN_MEMORY_DB = False + mock_settings.DATABASE_URL = "postgresql+psycopg://test:test@localhost/test" + mock_connect.side_effect = Exception("connection refused") + + from apps.api.app.api.routes.health import _check_database + + result = await _check_database() + + assert result["status"] == "unhealthy" + assert result["type"] == "postgresql" + assert "connection refused" in result["message"] + + @patch("apps.api.app.api.routes.health.settings") + async def test_check_database_in_memory(self, mock_settings): + """使用内存数据库时跳过 PostgreSQL 检查。""" + mock_settings.USE_IN_MEMORY_DB = True + + from apps.api.app.api.routes.health import _check_database + + result = await _check_database() + + assert result["status"] == "healthy" + assert result["type"] == "in_memory" + + +@pytest.mark.asyncio +class TestCheckMigrations: + """Tests for _check_migrations() health check function.""" + + @patch("apps.api.app.api.routes.health.settings") + @patch("apps.api.app.api.routes.health.psycopg.connect") + async def test_check_migrations_success(self, mock_connect, mock_settings): + """所有迁移表存在时返回 healthy。""" + mock_settings.USE_IN_MEMORY_DB = False + mock_settings.DATABASE_URL = "postgresql+psycopg://test:test@localhost/test" + + mock_conn = MagicMock() + mock_cursor = MagicMock() + mock_cursor.__enter__ = MagicMock(return_value=mock_cursor) + mock_cursor.__exit__ = MagicMock(return_value=False) + mock_cursor.fetchone.return_value = (5,) # 5 tables found + mock_conn.cursor.return_value = mock_cursor + mock_connect.return_value = mock_conn + + from apps.api.app.api.routes.health import _check_migrations + + result = await _check_migrations() + + assert result["status"] == "healthy" + assert result["message"] == "Database migrations applied" + mock_connect.assert_called_once_with( + "postgresql+psycopg://test:test@localhost/test", connect_timeout=3 + ) + mock_conn.close.assert_called_once() + + @patch("apps.api.app.api.routes.health.settings") + @patch("apps.api.app.api.routes.health.psycopg.connect") + async def test_check_migrations_missing_tables(self, mock_connect, mock_settings): + """迁移表不完整时返回 unhealthy。""" + mock_settings.USE_IN_MEMORY_DB = False + mock_settings.DATABASE_URL = "postgresql+psycopg://test:test@localhost/test" + + mock_conn = MagicMock() + mock_cursor = MagicMock() + mock_cursor.__enter__ = MagicMock(return_value=mock_cursor) + mock_cursor.__exit__ = MagicMock(return_value=False) + mock_cursor.fetchone.return_value = (2,) # Only 2 of 5 tables + mock_conn.cursor.return_value = mock_cursor + mock_connect.return_value = mock_conn + + from apps.api.app.api.routes.health import _check_migrations + + result = await _check_migrations() + + assert result["status"] == "unhealthy" + assert "Missing tables" in result["message"] + assert "2/5" in result["message"] + + @patch("apps.api.app.api.routes.health.settings") + @patch("apps.api.app.api.routes.health.psycopg.connect") + async def test_check_migrations_connection_failure(self, mock_connect, mock_settings): + """数据库连接失败时返回 unhealthy。""" + mock_settings.USE_IN_MEMORY_DB = False + mock_settings.DATABASE_URL = "postgresql+psycopg://test:test@localhost/test" + mock_connect.side_effect = Exception("connection refused") + + from apps.api.app.api.routes.health import _check_migrations + + result = await _check_migrations() + + assert result["status"] == "unhealthy" + assert "Migration check failed" in result["message"] + + @patch("apps.api.app.api.routes.health.settings") + async def test_check_migrations_in_memory(self, mock_settings): + """使用内存数据库时跳过迁移检查。""" + mock_settings.USE_IN_MEMORY_DB = True + + from apps.api.app.api.routes.health import _check_migrations + + result = await _check_migrations() + + assert result["status"] == "healthy" + assert "no migrations needed" in result["message"] + + +@pytest.mark.asyncio +class TestStartupCheck: + """Tests for startup_check() endpoint.""" + + @patch("apps.api.app.api.routes.health._check_migrations") + @patch("apps.api.app.api.routes.health._check_database") + async def test_startup_all_healthy(self, mock_db, mock_mig): + """所有检查通过时返回 started。""" + mock_db.return_value = {"status": "healthy"} + mock_mig.return_value = {"status": "healthy"} + + from apps.api.app.api.routes.health import startup_check + + result = await startup_check() + + assert result["status"] == "started" + + @patch("apps.api.app.api.routes.health._check_migrations") + @patch("apps.api.app.api.routes.health._check_database") + async def test_startup_db_unhealthy(self, mock_db, mock_mig): + """数据库不健康时返回 starting + 503。""" + mock_db.return_value = {"status": "unhealthy", "message": "fail"} + mock_mig.return_value = {"status": "healthy"} + + from apps.api.app.api.routes.health import startup_check + + result = await startup_check() + + assert result["status"] == "starting" + assert result.status_code == 503 -- 2.54.0 From f6ebc3645b696ecb1cea88dde304310f4900e3ec Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Tue, 18 Aug 2026 15:20:26 +0800 Subject: [PATCH 3/6] =?UTF-8?q?fix:=20api-base.Dockerfile=20=E4=BD=BF?= =?UTF-8?q?=E7=94=A8=E8=87=AA=E6=89=98=E7=AE=A1=E5=9F=BA=E7=A1=80=E9=95=9C?= =?UTF-8?q?=E5=83=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FROM python:3.12-slim → FROM git.xiaoxiajianji.com/xiaoxia/base/python:3.12-slim 构建服务器无法访问 Docker Hub(registry-1.docker.io 超时), 改用与 worker-base 一致的自托管镜像。 --- infra/docker/api-base.Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/infra/docker/api-base.Dockerfile b/infra/docker/api-base.Dockerfile index 89216dc6c..9108a2df9 100644 --- a/infra/docker/api-base.Dockerfile +++ b/infra/docker/api-base.Dockerfile @@ -5,7 +5,7 @@ # 目标:将 API Image 构建时间从 15-20 分钟降至 3-5 分钟 # ============================================================ -FROM python:3.12-slim +FROM git.xiaoxiajianji.com/xiaoxia/base/python:3.12-slim # 使用阿里云镜像加速 RUN sed -i 's|deb.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list.d/debian.sources 2>/dev/null || \ -- 2.54.0 From 45a9e4f2db0543e2d24811c2bbe1b67c3e036c3c Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Tue, 18 Aug 2026 15:33:52 +0800 Subject: [PATCH 4/6] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20test=5Fstartup?= =?UTF-8?q?=5Fdb=5Funhealthy=20JSONResponse=20=E8=A7=A3=E6=9E=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit startup_check() 不健康时返回 JSONResponse 对象, 不能直接用下标访问,需要先 json.loads(result.body)。 --- tests/unit/test_health_routes.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/unit/test_health_routes.py b/tests/unit/test_health_routes.py index c74cd2de6..beb6543a7 100644 --- a/tests/unit/test_health_routes.py +++ b/tests/unit/test_health_routes.py @@ -180,5 +180,7 @@ class TestStartupCheck: result = await startup_check() - assert result["status"] == "starting" assert result.status_code == 503 + import json + body = json.loads(result.body) + assert body["status"] == "starting" -- 2.54.0 From 394d8656b5ac0a583f5ad32689c9397d9161c590 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Tue, 18 Aug 2026 17:28:50 +0800 Subject: [PATCH 5/6] =?UTF-8?q?fix:=20api-base-image=20workflow=20?= =?UTF-8?q?=E5=8E=BB=E6=8E=89=20registry=20cache=20=E9=81=BF=E5=85=8D=20bu?= =?UTF-8?q?ildx=20=E5=B4=A9=E6=BA=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit buildx 在导出 registry cache 时反复出现 RPC 连接断开: rpc error: code = Unavailable desc = closing transport due to: connection error 改为仅使用本地缓存(--cache-to type=local),避免 registry cache 导出。 --- .gitea/workflows/api-base-image.yml | 53 ++++++++++++++--------------- 1 file changed, 26 insertions(+), 27 deletions(-) diff --git a/.gitea/workflows/api-base-image.yml b/.gitea/workflows/api-base-image.yml index d52c8b612..e8b676884 100644 --- a/.gitea/workflows/api-base-image.yml +++ b/.gitea/workflows/api-base-image.yml @@ -9,7 +9,7 @@ on: - 'requirements-base.txt' - 'requirements.txt' - 'infra/docker/api-base.Dockerfile' - workflow_dispatch: # 支持手动触发 + workflow_dispatch: jobs: build-api-base: @@ -46,37 +46,37 @@ jobs: sleep 5 done - - name: Setup buildx builder - shell: sh - run: | - set -eu - BUILDER_NAME="ci-builder-${GITHUB_RUN_ID}-api-base" - if ! docker buildx inspect "$BUILDER_NAME" > /dev/null 2>&1; then - docker buildx create --use --name "$BUILDER_NAME" --driver docker-container - echo "Created $BUILDER_NAME" - else - docker buildx use "$BUILDER_NAME" - echo "Using existing $BUILDER_NAME" - fi - docker buildx inspect --bootstrap - - name: Build and push API base image shell: sh run: | set -eu REGISTRY="xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji" IMAGE_TAG="${REGISTRY}/saas-api-base:latest" - SAFE_REF_NAME=$(echo "${GITHUB_REF_NAME}" | tr '/' '-') - CACHE_REF="${REGISTRY}/saas-api-base-cache:${SAFE_REF_NAME}" - echo "=== Building API base image ===" + echo "=== Building API base image (no registry cache, local cache only) ===" echo "Image: ${IMAGE_TAG}" - echo "Cache: ${CACHE_REF}" - bash scripts/ci/docker_build_push.sh \ - infra/docker/api-base.Dockerfile \ - "${IMAGE_TAG}" \ - "${CACHE_REF}" + # 使用本地缓存,避免 registry cache 导出导致 buildx 崩溃 + LOCAL_CACHE="/tmp/buildx-cache/api-base-local" + mkdir -p "$LOCAL_CACHE" + + # 创建或使用已有 builder + if ! docker buildx inspect api-base-builder > /dev/null 2>&1; then + docker buildx create --use --name api-base-builder --driver docker-container + else + docker buildx use api-base-builder + fi + docker buildx inspect --bootstrap + + # 构建并推送(仅使用本地缓存,不使用 registry cache) + docker buildx build \ + --platform linux/amd64 \ + --cache-from "type=local,src=${LOCAL_CACHE}" \ + --cache-to "type=local,dest=${LOCAL_CACHE},mode=max" \ + -f infra/docker/api-base.Dockerfile \ + -t "${IMAGE_TAG}" \ + --push \ + . # 同时推送到 Gitea Packages 作为备份 GITEA_IMAGE="git.xiaoxiajianji.com/xiaoxia-saas/saas-api-base:latest" @@ -84,12 +84,11 @@ jobs: docker push "${GITEA_IMAGE}" || echo "Gitea Packages push failed (non-fatal)" echo "" - echo "✅ API base image built and pushed" + echo "✅ API base image built and pushed: ${IMAGE_TAG}" - - name: Cleanup buildx builder + - name: Cleanup builder if: always() shell: sh run: | - docker buildx rm "ci-builder-${GITHUB_RUN_ID}-api-base" 2>/dev/null || true - docker buildx prune -f 2>/dev/null || true + docker buildx rm api-base-builder 2>/dev/null || true echo "Builder cleanup done" -- 2.54.0 From c57be3e47dbc54defc121e87c12a0452340dd386 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Tue, 18 Aug 2026 17:43:37 +0800 Subject: [PATCH 6/6] =?UTF-8?q?fix:=20api-base-image=20workflow=20?= =?UTF-8?q?=E6=94=B9=E7=94=A8=E6=99=AE=E9=80=9A=20docker=20build=20?= =?UTF-8?q?=E9=81=BF=E5=85=8D=20buildx=20push=20=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit buildx --push 后镜像不在本地 daemon,导致后续 tag 失败。 改用普通 docker build + docker push 分步执行,更稳定可靠。 --- .gitea/workflows/api-base-image.yml | 56 ++++++++++++----------------- 1 file changed, 23 insertions(+), 33 deletions(-) diff --git a/.gitea/workflows/api-base-image.yml b/.gitea/workflows/api-base-image.yml index e8b676884..41770e936 100644 --- a/.gitea/workflows/api-base-image.yml +++ b/.gitea/workflows/api-base-image.yml @@ -15,7 +15,7 @@ jobs: build-api-base: name: Build API Base Image runs-on: runtime-builder - timeout-minutes: 30 + timeout-minutes: 45 steps: - name: Checkout code shell: sh @@ -50,45 +50,35 @@ jobs: shell: sh run: | set -eu - REGISTRY="xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji" - IMAGE_TAG="${REGISTRY}/saas-api-base:latest" + ACR_IMAGE="xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji/saas-api-base:latest" + GITEA_IMAGE="git.xiaoxiajianji.com/xiaoxia-saas/saas-api-base:latest" - echo "=== Building API base image (no registry cache, local cache only) ===" - echo "Image: ${IMAGE_TAG}" + echo "=== Building API base image ===" - # 使用本地缓存,避免 registry cache 导出导致 buildx 崩溃 - LOCAL_CACHE="/tmp/buildx-cache/api-base-local" - mkdir -p "$LOCAL_CACHE" - - # 创建或使用已有 builder - if ! docker buildx inspect api-base-builder > /dev/null 2>&1; then - docker buildx create --use --name api-base-builder --driver docker-container - else - docker buildx use api-base-builder - fi - docker buildx inspect --bootstrap - - # 构建并推送(仅使用本地缓存,不使用 registry cache) - docker buildx build \ - --platform linux/amd64 \ - --cache-from "type=local,src=${LOCAL_CACHE}" \ - --cache-to "type=local,dest=${LOCAL_CACHE},mode=max" \ + # 使用普通 docker build(单平台不需要 buildx) + docker build \ -f infra/docker/api-base.Dockerfile \ - -t "${IMAGE_TAG}" \ - --push \ + -t "${ACR_IMAGE}" \ . - # 同时推送到 Gitea Packages 作为备份 - GITEA_IMAGE="git.xiaoxiajianji.com/xiaoxia-saas/saas-api-base:latest" - docker tag "${IMAGE_TAG}" "${GITEA_IMAGE}" - docker push "${GITEA_IMAGE}" || echo "Gitea Packages push failed (non-fatal)" - echo "" - echo "✅ API base image built and pushed: ${IMAGE_TAG}" + echo "✅ Image built successfully" - - name: Cleanup builder + # 推送到 ACR + echo "=== Pushing to ACR ===" + docker push "${ACR_IMAGE}" + echo "✅ Pushed to ACR" + + # 打标签并推送到 Gitea Packages 作为备份 + echo "=== Pushing to Gitea Packages ===" + docker tag "${ACR_IMAGE}" "${GITEA_IMAGE}" + docker push "${GITEA_IMAGE}" || echo "⚠️ Gitea Packages push failed (non-fatal)" + echo "✅ Gitea backup push completed" + + - name: Cleanup if: always() shell: sh run: | - docker buildx rm api-base-builder 2>/dev/null || true - echo "Builder cleanup done" + ACR_IMAGE="xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji/saas-api-base:latest" + docker rmi "${ACR_IMAGE}" 2>/dev/null || true + echo "Cleanup done" -- 2.54.0