feat(ci,alembic): Hub Docker镜像构建发布,数据库迁移并发安全加固

- build-hub.yml 新增 Docker job,构建多架构镜像推送至 GHCR 和 Docker Hub
- build-hub/build-proxy Release 名称简化为 tag 名
- alembic/env.py 使用 PostgreSQL advisory lock 防止多进程并发迁移竞态
- 迁移脚本改用 ADD/DROP COLUMN IF NOT EXISTS 替代 inspector 检查
This commit is contained in:
fawney19
2026-03-02 13:24:58 +08:00
parent 68bae686da
commit 01df063cc1
4 changed files with 128 additions and 35 deletions

View File

@@ -7,6 +7,12 @@ on:
permissions: permissions:
contents: write contents: write
packages: write
env:
REGISTRY: ghcr.io
GHCR_IMAGE: fawney19/aether-hub
DOCKERHUB_IMAGE: fawney19/aether-hub
jobs: jobs:
build: build:
@@ -83,9 +89,84 @@ jobs:
- name: Create GitHub Release - name: Create GitHub Release
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@v2
with: with:
name: "aether-hub ${{ github.ref_name }}" name: "${{ github.ref_name }}"
generate_release_notes: true generate_release_notes: true
files: | files: |
artifacts/aether-hub-* artifacts/aether-hub-*
artifacts/SHA256SUMS.txt artifacts/SHA256SUMS.txt
fail_on_unmatched_files: true fail_on_unmatched_files: true
docker:
needs: build
runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags/')
steps:
- uses: actions/checkout@v4
- name: Download Linux artifacts
uses: actions/download-artifact@v4
with:
pattern: aether-hub-linux-*
merge-multiple: true
path: artifacts
- name: Prepare binaries
run: |
mkdir -p aether-hub/build/linux-amd64 aether-hub/build/linux-arm64
tar xzf artifacts/aether-hub-linux-amd64.tar.gz -C aether-hub/build/linux-amd64
tar xzf artifacts/aether-hub-linux-arm64.tar.gz -C aether-hub/build/linux-arm64
- name: Generate CI Dockerfile
run: |
cat > aether-hub/Dockerfile.ci << 'EOF'
FROM debian:bookworm-slim
ARG TARGETARCH
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates && rm -rf /var/lib/apt/lists/*
COPY build/linux-${TARGETARCH}/aether-hub /usr/local/bin/aether-hub
EXPOSE 8085
ENTRYPOINT ["/usr/local/bin/aether-hub"]
CMD ["--bind", "0.0.0.0:8085"]
EOF
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: |
${{ env.REGISTRY }}/${{ env.GHCR_IMAGE }}
docker.io/${{ env.DOCKERHUB_IMAGE }}
tags: |
type=match,pattern=hub-v(.*),group=1
type=match,pattern=hub-v(\d+\.\d+),group=1
type=sha,prefix=
flavor: |
latest=auto
- name: Build and push
uses: docker/build-push-action@v5
with:
context: ./aether-hub
file: ./aether-hub/Dockerfile.ci
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
platforms: linux/amd64,linux/arm64

View File

@@ -113,7 +113,7 @@ jobs:
- name: Create GitHub Release - name: Create GitHub Release
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@v2
with: with:
name: "aether-proxy ${{ github.ref_name }}" name: "${{ github.ref_name }}"
generate_release_notes: true generate_release_notes: true
files: | files: |
artifacts/aether-proxy-* artifacts/aether-proxy-*

View File

@@ -3,13 +3,15 @@ Alembic 环境配置
用于数据库迁移的运行时环境设置 用于数据库迁移的运行时环境设置
""" """
from logging.config import fileConfig
from sqlalchemy import engine_from_config, pool
from alembic import context
import os import os
import sys import sys
from logging.config import fileConfig
from pathlib import Path from pathlib import Path
from sqlalchemy import engine_from_config, pool, text
from alembic import context
# 添加项目根目录到 Python 路径 # 添加项目根目录到 Python 路径
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
@@ -48,6 +50,10 @@ if config.config_file_name is not None:
# 目标元数据(包含所有表定义) # 目标元数据(包含所有表定义)
target_metadata = Base.metadata target_metadata = Base.metadata
# PostgreSQL 全局迁移锁,避免多进程并发执行 Alembic 导致竞态(重复加列/索引等)
# ID 由 crc32("aether-alembic-migration") 拼接生成,仅需全局唯一即可
MIGRATION_ADVISORY_LOCK_ID = 582694137405821
def run_migrations_offline() -> None: def run_migrations_offline() -> None:
""" """
@@ -83,15 +89,31 @@ def run_migrations_online() -> None:
) )
with connectable.connect() as connection: with connectable.connect() as connection:
context.configure( lock_acquired = False
connection=connection, try:
target_metadata=target_metadata, if connection.dialect.name == "postgresql":
compare_type=True, # 比较列类型变更 connection.execute(
compare_server_default=True, # 比较默认值变更 text("SELECT pg_advisory_lock(:lock_id)"),
) {"lock_id": MIGRATION_ADVISORY_LOCK_ID},
)
lock_acquired = True
with context.begin_transaction(): context.configure(
context.run_migrations() connection=connection,
target_metadata=target_metadata,
compare_type=True, # 比较列类型变更
compare_server_default=True, # 比较默认值变更
)
with context.begin_transaction():
context.run_migrations()
finally:
if lock_acquired:
connection.rollback()
connection.execute(
text("SELECT pg_advisory_unlock(:lock_id)"),
{"lock_id": MIGRATION_ADVISORY_LOCK_ID},
)
# 根据模式选择运行方式 # 根据模式选择运行方式

View File

@@ -10,8 +10,7 @@ from __future__ import annotations
from collections.abc import Sequence from collections.abc import Sequence
import sqlalchemy as sa from sqlalchemy import text
from sqlalchemy import inspect
from alembic import op from alembic import op
@@ -24,33 +23,24 @@ depends_on: str | Sequence[str] | None = None
def upgrade() -> None: def upgrade() -> None:
conn = op.get_bind() conn = op.get_bind()
inspector = inspect(conn) # Use PostgreSQL native IF NOT EXISTS to avoid duplicate-column races
existing_columns = {col["name"] for col in inspector.get_columns("usage")} # when migrations are triggered concurrently (e.g. startup + manual run).
conn.execute(text("ALTER TABLE usage ADD COLUMN IF NOT EXISTS provider_request_body JSON"))
if "provider_request_body" not in existing_columns: conn.execute(
op.add_column("usage", sa.Column("provider_request_body", sa.JSON(), nullable=True)) text("ALTER TABLE usage ADD COLUMN IF NOT EXISTS provider_request_body_compressed BYTEA")
if "provider_request_body_compressed" not in existing_columns: )
op.add_column( conn.execute(text("ALTER TABLE usage ADD COLUMN IF NOT EXISTS client_response_body JSON"))
"usage", sa.Column("provider_request_body_compressed", sa.LargeBinary(), nullable=True) conn.execute(
) text("ALTER TABLE usage ADD COLUMN IF NOT EXISTS client_response_body_compressed BYTEA")
if "client_response_body" not in existing_columns: )
op.add_column("usage", sa.Column("client_response_body", sa.JSON(), nullable=True))
if "client_response_body_compressed" not in existing_columns:
op.add_column(
"usage", sa.Column("client_response_body_compressed", sa.LargeBinary(), nullable=True)
)
def downgrade() -> None: def downgrade() -> None:
conn = op.get_bind() conn = op.get_bind()
inspector = inspect(conn)
existing_columns = {col["name"] for col in inspector.get_columns("usage")}
for col in ( for col in (
"client_response_body_compressed", "client_response_body_compressed",
"client_response_body", "client_response_body",
"provider_request_body_compressed", "provider_request_body_compressed",
"provider_request_body", "provider_request_body",
): ):
if col in existing_columns: conn.execute(text(f"ALTER TABLE usage DROP COLUMN IF EXISTS {col}"))
op.drop_column("usage", col)