name: Docker Build and Deploy on: push: branches: - dev/add-agent paths: - 'api/**' - '.github/workflows/**' workflow_dispatch: inputs: branch: description: '部署分支' required: false type: string default: '' env: IMAGE_NAME: lifecho-api REGISTRY: crpi-u2903xccyzd6nqnc.cn-shanghai.personal.cr.aliyuncs.com REGISTRY_NAMESPACE: huaga jobs: build-and-push: name: Build and Push Docker Image runs-on: ubuntu-latest permissions: contents: read steps: - name: Checkout code uses: actions/checkout@v4 with: ref: ${{ github.event.inputs.branch || github.ref }} - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Log in to Alibaba Cloud Container Registry env: REGISTRY: ${{ env.REGISTRY }} USERNAME: ${{ secrets.ALIYUN_CR_USERNAME }} PASSWORD: ${{ secrets.ALIYUN_CR_PASSWORD }} run: | echo "正在登录到阿里云容器镜像服务..." echo "Registry: $REGISTRY" echo "Username: $USERNAME" echo "Password length: ${#PASSWORD}" # 使用 printf 确保密码正确传递(包括特殊字符) printf '%s\n' "$PASSWORD" | docker login "$REGISTRY" --username="$USERNAME" --password-stdin echo "✅ 登录成功!" - name: Extract metadata (tags, labels) for Docker id: meta uses: docker/metadata-action@v5 with: images: ${{ env.REGISTRY }}/${{ env.REGISTRY_NAMESPACE }}/${{ env.IMAGE_NAME }} tags: | type=ref,event=branch type=ref,event=pr type=semver,pattern={{version}} type=semver,pattern={{major}}.{{minor}} type=sha,prefix={{branch}}- type=raw,value=latest,enable={{is_default_branch}} - name: Build and push Docker image uses: docker/build-push-action@v5 with: context: ./api file: ./api/Dockerfile push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} cache-from: type=gha cache-to: type=gha,mode=max deploy: name: Deploy to Remote Server runs-on: ubuntu-latest needs: build-and-push if: github.event_name != 'pull_request' steps: - name: Checkout code uses: actions/checkout@v4 with: ref: ${{ github.event.inputs.branch || github.ref }} - name: Set up SSH uses: webfactory/ssh-agent@v0.9.0 with: ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }} - name: Add server to known hosts run: | mkdir -p ~/.ssh ssh-keyscan -H -p "${{ secrets.SSH_PORT || 22 }}" "${{ secrets.SSH_HOST }}" >> ~/.ssh/known_hosts - name: Determine image tag id: image_tag run: | DEPLOY_BRANCH="${{ github.event.inputs.branch || github.ref_name }}" echo "deploy_branch=$DEPLOY_BRANCH" >> "$GITHUB_OUTPUT" if [ "$DEPLOY_BRANCH" == "main" ] || [ "$DEPLOY_BRANCH" == "master" ]; then echo "tag=latest" >> "$GITHUB_OUTPUT" else BRANCH_TAG=$(echo "$DEPLOY_BRANCH" | sed 's/\//-/g') echo "tag=$BRANCH_TAG" >> "$GITHUB_OUTPUT" fi - name: Prepare remote candidate release env: SSH_USER: ${{ secrets.SSH_USER }} SSH_HOST: ${{ secrets.SSH_HOST }} SSH_PORT: ${{ secrets.SSH_PORT || 22 }} IMAGE_TAG: ${{ env.REGISTRY }}/${{ env.REGISTRY_NAMESPACE }}/${{ env.IMAGE_NAME }}:${{ steps.image_tag.outputs.tag }} COMPOSE_DIR: ${{ secrets.DEPLOY_PATH || '/opt/life-echo' }} REGISTRY: ${{ env.REGISTRY }} ALIYUN_CR_USERNAME: ${{ secrets.ALIYUN_CR_USERNAME }} ALIYUN_CR_PASSWORD: ${{ secrets.ALIYUN_CR_PASSWORD }} run: | set -euo pipefail echo "准备候选版本..." echo "镜像标签: $IMAGE_TAG" echo "部署目录: $COMPOSE_DIR/api" echo "$ALIYUN_CR_PASSWORD" | ssh -p "$SSH_PORT" "$SSH_USER@$SSH_HOST" \ "docker login $REGISTRY --username=$ALIYUN_CR_USERNAME --password-stdin" ssh -p "$SSH_PORT" "$SSH_USER@$SSH_HOST" " set -euo pipefail mkdir -p '$COMPOSE_DIR/api' mkdir -p '$COMPOSE_DIR/api/backups' docker network inspect api_life-echo-network >/dev/null 2>&1 || docker network create api_life-echo-network " echo "上传候选 compose、环境变量与迁移文件..." scp -P "$SSH_PORT" ./api/docker-compose.yml "$SSH_USER@$SSH_HOST:$COMPOSE_DIR/api/docker-compose.candidate.yml" scp -P "$SSH_PORT" ./api/.env.production "$SSH_USER@$SSH_HOST:$COMPOSE_DIR/api/.env.production.candidate" ssh -p "$SSH_PORT" "$SSH_USER@$SSH_HOST" " set -euo pipefail rm -rf '$COMPOSE_DIR/api/migrations.candidate' mkdir -p '$COMPOSE_DIR/api/migrations.candidate' " scp -P "$SSH_PORT" ./api/migrations/*.sql "$SSH_USER@$SSH_HOST:$COMPOSE_DIR/api/migrations.candidate/" ssh -p "$SSH_PORT" "$SSH_USER@$SSH_HOST" " set -euo pipefail cd '$COMPOSE_DIR/api' echo '拉取候选镜像: $IMAGE_TAG' docker pull '$IMAGE_TAG' sed -i.tmp 's|image:.*lifecho-api.*|image: $IMAGE_TAG|g' docker-compose.candidate.yml sed -i.tmp 's|image:.*life-echo-api.*|image: $IMAGE_TAG|g' docker-compose.candidate.yml rm -f docker-compose.candidate.yml.tmp 2>/dev/null || true " - name: Backup and run database migrations safely env: SSH_USER: ${{ secrets.SSH_USER }} SSH_HOST: ${{ secrets.SSH_HOST }} SSH_PORT: ${{ secrets.SSH_PORT || 22 }} IMAGE_TAG: ${{ env.REGISTRY }}/${{ env.REGISTRY_NAMESPACE }}/${{ env.IMAGE_NAME }}:${{ steps.image_tag.outputs.tag }} COMPOSE_DIR: ${{ secrets.DEPLOY_PATH || '/opt/life-echo' }} COMPOSE_FILE: docker-compose.yml DB_USER: ${{ secrets.MIGRATION_DB_USER || '' }} DB_PASSWORD: ${{ secrets.MIGRATION_DB_PASSWORD || '' }} DB_NAME: ${{ secrets.MIGRATION_DB_NAME || '' }} run: | set -euo pipefail ssh -p "$SSH_PORT" "$SSH_USER@$SSH_HOST" \ COMPOSE_DIR="$COMPOSE_DIR" \ COMPOSE_FILE="$COMPOSE_FILE" \ IMAGE_TAG="$IMAGE_TAG" \ DB_USER="$DB_USER" \ DB_PASSWORD="$DB_PASSWORD" \ DB_NAME="$DB_NAME" \ "bash -s" <<'REMOTE' set -euo pipefail CURRENT_COMPOSE="$COMPOSE_DIR/api/$COMPOSE_FILE" CANDIDATE_ENV="$COMPOSE_DIR/api/.env.production.candidate" CANDIDATE_MIGRATIONS="$COMPOSE_DIR/api/migrations.candidate" BACKUP_DIR="$COMPOSE_DIR/api/backups" DB_CONTAINER="life-echo-postgres" API_CONTAINER="life-echo-api-prod" WORKER_CONTAINER="life-echo-celery-worker" NETWORK_NAME="api_life-echo-network" BACKUP_FILE="$BACKUP_DIR/life_echo_$(date +%Y%m%d_%H%M%S).dump" ROLLBACK_REQUIRED=0 CURRENT_API_RUNNING=0 CURRENT_WORKER_RUNNING=0 EFFECTIVE_DB_USER="" EFFECTIVE_DB_PASSWORD="" EFFECTIVE_DB_NAME="" EFFECTIVE_MIGRATION_DATABASE_URL="" resolve_db_config() { local database_url="" database_url="$(sed -n 's/^DATABASE_URL=//p' "$CANDIDATE_ENV" | head -n 1)" if [ -z "$database_url" ]; then echo "candidate env 中未找到 DATABASE_URL" exit 1 fi case "$database_url" in \"*\") database_url="${database_url:1:${#database_url}-2}" ;; \'*\') database_url="${database_url:1:${#database_url}-2}" ;; esac mapfile -t parsed_db_parts < <( python3 -c 'import sys; from urllib.parse import unquote, urlsplit; parts = urlsplit(sys.argv[1]); print(unquote(parts.username or "")); print(unquote(parts.password or "")); print((parts.path or "/").lstrip("/"))' "$database_url" ) EFFECTIVE_DB_USER="${DB_USER:-${parsed_db_parts[0]}}" EFFECTIVE_DB_PASSWORD="${DB_PASSWORD:-${parsed_db_parts[1]}}" EFFECTIVE_DB_NAME="${DB_NAME:-${parsed_db_parts[2]}}" if [ -z "$EFFECTIVE_DB_USER" ] || [ -z "$EFFECTIVE_DB_NAME" ]; then echo "无法解析有效的数据库用户名或数据库名" exit 1 fi EFFECTIVE_MIGRATION_DATABASE_URL="$( EFFECTIVE_DB_USER="$EFFECTIVE_DB_USER" \ EFFECTIVE_DB_PASSWORD="$EFFECTIVE_DB_PASSWORD" \ EFFECTIVE_DB_NAME="$EFFECTIVE_DB_NAME" \ DB_CONTAINER="$DB_CONTAINER" \ python3 -c 'import os; from urllib.parse import quote; user = quote(os.environ["EFFECTIVE_DB_USER"], safe=""); password = os.environ.get("EFFECTIVE_DB_PASSWORD", ""); database = quote(os.environ["EFFECTIVE_DB_NAME"], safe=""); host = os.environ["DB_CONTAINER"]; auth = (user + ":" + quote(password, safe="") + "@") if password else (user + "@"); print("postgresql://%s%s:5432/%s" % (auth, host, database))' )" } wait_for_db() { until docker exec "$DB_CONTAINER" pg_isready -U "$EFFECTIVE_DB_USER" -d "$EFFECTIVE_DB_NAME" >/dev/null 2>&1; do echo "等待数据库就绪..." sleep 2 done } start_current_data_services() { if [ ! -f "$CURRENT_COMPOSE" ]; then echo "未找到当前线上 compose 文件:$CURRENT_COMPOSE" exit 1 fi cd "$COMPOSE_DIR/api" docker-compose -f "$CURRENT_COMPOSE" up -d postgres redis } restore_backup() { if [ ! -f "$BACKUP_FILE" ]; then echo "未找到数据库备份文件,无法自动恢复" return 1 fi if ! docker ps --format '{{.Names}}' | grep -qx "$DB_CONTAINER"; then start_current_data_services fi wait_for_db docker exec "$DB_CONTAINER" psql -U "$EFFECTIVE_DB_USER" -d postgres -c \ "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '$EFFECTIVE_DB_NAME' AND pid <> pg_backend_pid();" \ >/dev/null 2>&1 || true docker exec "$DB_CONTAINER" dropdb -U "$EFFECTIVE_DB_USER" --if-exists "$EFFECTIVE_DB_NAME" || true docker exec "$DB_CONTAINER" createdb -U "$EFFECTIVE_DB_USER" "$EFFECTIVE_DB_NAME" docker exec -i "$DB_CONTAINER" \ pg_restore -U "$EFFECTIVE_DB_USER" -d "$EFFECTIVE_DB_NAME" --clean --if-exists --no-owner --no-privileges < "$BACKUP_FILE" } rollback() { exit_code=$? if [ "$ROLLBACK_REQUIRED" -eq 1 ]; then echo "迁移失败,开始恢复数据库并重新拉起旧线上服务..." restore_backup if [ -f "$CURRENT_COMPOSE" ]; then cd "$COMPOSE_DIR/api" docker-compose -f "$CURRENT_COMPOSE" up -d postgres redis docker-compose -f "$CURRENT_COMPOSE" up -d api celery-worker || true fi fi exit "$exit_code" } trap rollback ERR mkdir -p "$BACKUP_DIR" resolve_db_config if docker ps --format '{{.Names}}' | grep -qx "$API_CONTAINER"; then CURRENT_API_RUNNING=1 fi if docker ps --format '{{.Names}}' | grep -qx "$WORKER_CONTAINER"; then CURRENT_WORKER_RUNNING=1 fi if ! docker ps --format '{{.Names}}' | grep -qx "$DB_CONTAINER"; then echo "当前数据库容器未运行,先拉起线上 postgres/redis..." start_current_data_services fi wait_for_db echo "备份生产数据库到 $BACKUP_FILE" docker exec "$DB_CONTAINER" pg_dump -U "$EFFECTIVE_DB_USER" -d "$EFFECTIVE_DB_NAME" -F c > "$BACKUP_FILE" echo "停止线上 API 写入,准备执行迁移..." ROLLBACK_REQUIRED=1 cd "$COMPOSE_DIR/api" if [ "$CURRENT_API_RUNNING" -eq 1 ] || [ "$CURRENT_WORKER_RUNNING" -eq 1 ]; then docker-compose -f "$CURRENT_COMPOSE" stop api celery-worker || true fi docker rm -f "$API_CONTAINER" "$WORKER_CONTAINER" 2>/dev/null || true wait_for_db echo "执行幂等 SQL 迁移..." docker exec -i "$DB_CONTAINER" psql -v ON_ERROR_STOP=1 -U "$EFFECTIVE_DB_USER" -d "$EFFECTIVE_DB_NAME" < "$CANDIDATE_MIGRATIONS/sync_schema_to_models.sql" docker exec -i "$DB_CONTAINER" psql -v ON_ERROR_STOP=1 -U "$EFFECTIVE_DB_USER" -d "$EFFECTIVE_DB_NAME" < "$CANDIDATE_MIGRATIONS/fix_chapter_order_index.sql" docker exec -i "$DB_CONTAINER" psql -v ON_ERROR_STOP=1 -U "$EFFECTIVE_DB_USER" -d "$EFFECTIVE_DB_NAME" < "$CANDIDATE_MIGRATIONS/add_chapter_is_active.sql" docker exec -i "$DB_CONTAINER" psql -v ON_ERROR_STOP=1 -U "$EFFECTIVE_DB_USER" -d "$EFFECTIVE_DB_NAME" < "$CANDIDATE_MIGRATIONS/add_user_profile_fields.sql" docker exec -i "$DB_CONTAINER" psql -v ON_ERROR_STOP=1 -U "$EFFECTIVE_DB_USER" -d "$EFFECTIVE_DB_NAME" < "$CANDIDATE_MIGRATIONS/fix_chapter_order_index_v2.sql" echo "执行 chapter_sections 数据迁移..." docker run --rm \ --network "$NETWORK_NAME" \ --env-file "$CANDIDATE_ENV" \ -e MIGRATION_DATABASE_URL="$EFFECTIVE_MIGRATION_DATABASE_URL" \ --entrypoint python \ "$IMAGE_TAG" -m scripts.run_chapter_sections_migration echo "执行 memoir_images 数据迁移..." docker run --rm \ --network "$NETWORK_NAME" \ --env-file "$CANDIDATE_ENV" \ -e MIGRATION_DATABASE_URL="$EFFECTIVE_MIGRATION_DATABASE_URL" \ --entrypoint python \ "$IMAGE_TAG" -m scripts.run_memoir_images_migration echo "执行 chapter_sections.image_id 外键迁移..." docker exec -i "$DB_CONTAINER" psql -v ON_ERROR_STOP=1 -U "$EFFECTIVE_DB_USER" -d "$EFFECTIVE_DB_NAME" < "$CANDIDATE_MIGRATIONS/add_section_image_id_fk.sql" echo "验证关键表结构..." docker exec "$DB_CONTAINER" psql -v ON_ERROR_STOP=1 -U "$EFFECTIVE_DB_USER" -d "$EFFECTIVE_DB_NAME" -c \ "SELECT to_regclass('public.chapter_sections') AS chapter_sections, to_regclass('public.memoir_images') AS memoir_images;" trap - ERR ROLLBACK_REQUIRED=0 echo "数据库迁移全部完成" REMOTE - name: Promote candidate release env: SSH_USER: ${{ secrets.SSH_USER }} SSH_HOST: ${{ secrets.SSH_HOST }} SSH_PORT: ${{ secrets.SSH_PORT || 22 }} COMPOSE_DIR: ${{ secrets.DEPLOY_PATH || '/opt/life-echo' }} COMPOSE_FILE: docker-compose.yml run: | set -euo pipefail echo "迁移成功,切换线上版本..." ssh -p "$SSH_PORT" "$SSH_USER@$SSH_HOST" " set -euo pipefail cd '$COMPOSE_DIR/api' if [ -f '$COMPOSE_FILE' ]; then cp '$COMPOSE_FILE' '${COMPOSE_FILE}.predeploy' fi if [ -f '.env.production' ]; then cp '.env.production' '.env.production.predeploy' fi mv 'docker-compose.candidate.yml' '$COMPOSE_FILE' mv '.env.production.candidate' '.env.production' rm -rf 'migrations' mv 'migrations.candidate' 'migrations' docker-compose -f '$COMPOSE_FILE' up -d --remove-orphans echo '等待服务启动...' sleep 15 docker image prune -f || true docker-compose -f '$COMPOSE_FILE' ps " - name: Verify deployment env: SSH_USER: ${{ secrets.SSH_USER }} SSH_HOST: ${{ secrets.SSH_HOST }} SSH_PORT: ${{ secrets.SSH_PORT || 22 }} COMPOSE_DIR: ${{ secrets.DEPLOY_PATH || '/opt/life-echo' }} run: | echo "验证部署状态..." ssh -p "$SSH_PORT" "$SSH_USER@$SSH_HOST" \ "cd $COMPOSE_DIR/api && docker-compose ps && docker-compose logs --tail=50 api"