Files
fquiz/.github/workflows/main.yml
T

420 lines
16 KiB
YAML

name: fquiz 镜像构建与部署
on:
push:
branches:
- dev
workflow_dispatch:
concurrency:
group: fquiz-build-and-deploy
cancel-in-progress: true
permissions:
contents: read
packages: write
env:
REGISTRY: ghcr.io
jobs:
build-and-push:
runs-on: ubuntu-latest
outputs:
api_image: ${{ steps.vars.outputs.api_image }}
web_image: ${{ steps.vars.outputs.web_image }}
image_tag: ${{ steps.vars.outputs.image_tag }}
steps:
- name: 拉取代码
uses: actions/checkout@v4
- name: 生成镜像变量
id: vars
shell: bash
run: |
OWNER_LC="${GITHUB_REPOSITORY_OWNER,,}"
echo "api_image=${{ env.REGISTRY }}/${OWNER_LC}/fquiz-api" >> "$GITHUB_OUTPUT"
echo "web_image=${{ env.REGISTRY }}/${OWNER_LC}/fquiz-web" >> "$GITHUB_OUTPUT"
echo "image_tag=${GITHUB_SHA}" >> "$GITHUB_OUTPUT"
- name: 安装 Buildx
uses: docker/setup-buildx-action@v3
- name: 登录 GHCR
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: 构建并推送 API 镜像
uses: docker/build-push-action@v6
with:
context: ./api
file: ./api/Dockerfile
push: true
build-args: |
PIP_INDEX_URL=${{ secrets.PIP_INDEX_URL || vars.PIP_INDEX_URL || 'https://pypi.org/simple' }}
PIP_DEFAULT_TIMEOUT=${{ vars.PIP_DEFAULT_TIMEOUT || '120' }}
tags: |
${{ steps.vars.outputs.api_image }}:${{ steps.vars.outputs.image_tag }}
${{ steps.vars.outputs.api_image }}:latest
cache-from: type=gha,scope=fquiz-api
cache-to: type=gha,mode=max,scope=fquiz-api
- name: 构建并推送 Web 镜像
uses: docker/build-push-action@v6
with:
context: ./web
file: ./web/Dockerfile
push: true
build-args: |
NEXT_PUBLIC_API_BASE_URL=${{ vars.NEXT_PUBLIC_API_BASE_URL || 'http://127.0.0.1:8000' }}
tags: |
${{ steps.vars.outputs.web_image }}:${{ steps.vars.outputs.image_tag }}
${{ steps.vars.outputs.web_image }}:latest
cache-from: type=gha,scope=fquiz-web
cache-to: type=gha,mode=max,scope=fquiz-web
deploy:
runs-on: ubuntu-latest
needs: build-and-push
if: github.ref == 'refs/heads/dev'
steps:
- name: 校验部署参数
env:
SERVER_HOST: ${{ secrets.SERVER_HOST || vars.SERVER_HOST }}
SERVER_USER: ${{ secrets.SERVER_USER || vars.SERVER_USER }}
SERVER_SSH_KEY: ${{ secrets.SERVER_SSH_KEY }}
SERVER_PASSWORD: ${{ secrets.SERVER_PASSWORD }}
run: |
set -euo pipefail
[ -n "${SERVER_HOST}" ] || { echo "::error::缺少 SERVER_HOST(请在 Secrets 或 Variables 中配置)"; exit 1; }
[ -n "${SERVER_USER}" ] || { echo "::error::缺少 SERVER_USER(请在 Secrets 或 Variables 中配置)"; exit 1; }
if [ -z "${SERVER_SSH_KEY}" ] && [ -z "${SERVER_PASSWORD}" ]; then
echo "::error::缺少登录凭据:请至少配置 SERVER_SSH_KEY 或 SERVER_PASSWORD"
exit 1
fi
- name: 通过 SSH 拉取并更新容器
uses: appleboy/ssh-action@v1.2.0
env:
DEPLOY_PATH: ${{ secrets.DEPLOY_PATH || vars.DEPLOY_PATH }}
API_IMAGE: ${{ needs.build-and-push.outputs.api_image }}
WEB_IMAGE: ${{ needs.build-and-push.outputs.web_image }}
IMAGE_TAG: ${{ needs.build-and-push.outputs.image_tag }}
NEXT_PUBLIC_API_BASE_URL: ${{ vars.NEXT_PUBLIC_API_BASE_URL || 'http://127.0.0.1:8000' }}
FLOWER_BASIC_AUTH: ${{ secrets.FLOWER_BASIC_AUTH || vars.FLOWER_BASIC_AUTH || 'admin:admin' }}
GHCR_USERNAME: ${{ github.actor }}
GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
host: ${{ secrets.SERVER_HOST || vars.SERVER_HOST }}
username: ${{ secrets.SERVER_USER || vars.SERVER_USER }}
port: ${{ secrets.SERVER_PORT || vars.SERVER_PORT || 22 }}
timeout: 120s
command_timeout: 45m
key: ${{ secrets.SERVER_SSH_KEY }}
password: ${{ secrets.SERVER_PASSWORD }}
envs: DEPLOY_PATH,API_IMAGE,WEB_IMAGE,IMAGE_TAG,NEXT_PUBLIC_API_BASE_URL,FLOWER_BASIC_AUTH,GHCR_USERNAME,GHCR_TOKEN
script: |
set -euo pipefail
export DOCKER_CLIENT_TIMEOUT="${DOCKER_CLIENT_TIMEOUT:-600}"
export COMPOSE_HTTP_TIMEOUT="${COMPOSE_HTTP_TIMEOUT:-600}"
DEPLOY_DIR="${DEPLOY_PATH:-/opt/fquiz}"
cd "${DEPLOY_DIR}"
cat > deploy/pro-deploy/compose.yml <<'YAML'
services:
db:
image: ${POSTGRES_IMAGE}
container_name: ${COMPOSE_PROJECT_NAME}-db
environment:
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
ports:
- "${POSTGRES_PORT}:5432"
volumes:
- prod_db_data:/var/lib/postgresql/data
restart: unless-stopped
redis:
image: ${REDIS_IMAGE}
container_name: ${COMPOSE_PROJECT_NAME}-redis
command: redis-server --appendonly yes
ports:
- "${REDIS_PORT}:6379"
volumes:
- prod_redis_data:/data
restart: unless-stopped
minio:
image: ${MINIO_IMAGE}
container_name: ${COMPOSE_PROJECT_NAME}-minio
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: ${MINIO_ACCESS_KEY}
MINIO_ROOT_PASSWORD: ${MINIO_SECRET_KEY}
ports:
- "${MINIO_API_PORT}:9000"
- "${MINIO_CONSOLE_PORT}:9001"
volumes:
- prod_minio_data:/data
restart: unless-stopped
minio-init:
image: ${MINIO_MC_IMAGE}
container_name: ${COMPOSE_PROJECT_NAME}-minio-init
depends_on:
- minio
entrypoint: /bin/sh
command:
- -c
- >
until mc alias set local "$${MINIO_ENDPOINT}" "$${MINIO_ACCESS_KEY}" "$${MINIO_SECRET_KEY}"; do
sleep 1;
done;
mc mb -p "local/$${MINIO_BUCKET}" || true;
environment:
MINIO_ENDPOINT: ${MINIO_ENDPOINT}
MINIO_ACCESS_KEY: ${MINIO_ACCESS_KEY}
MINIO_SECRET_KEY: ${MINIO_SECRET_KEY}
MINIO_BUCKET: ${MINIO_BUCKET}
restart: "no"
api:
image: ${API_IMAGE}
container_name: ${COMPOSE_PROJECT_NAME}-api
depends_on:
- db
- redis
- minio
- minio-init
env_file:
- ./.env.prod
environment:
API_HOST: 0.0.0.0
API_PORT: 8000
DB_HOST: db
DB_PORT: 5432
MINIO_ENDPOINT: http://minio:9000
CELERY_BROKER_URL: redis://redis:6379/0
CELERY_RESULT_BACKEND: redis://redis:6379/1
expose:
- "8000"
restart: unless-stopped
celery-worker:
image: ${API_IMAGE}
container_name: ${COMPOSE_PROJECT_NAME}-celery-worker
command:
- celery
- -A
- app.core.celery_app.celery_app
- worker
- --loglevel=${CELERY_LOG_LEVEL}
- --concurrency=${CELERY_WORKER_CONCURRENCY}
- --queues=${CELERY_WORKER_QUEUES}
depends_on:
- api
- redis
env_file:
- ./.env.prod
environment:
DB_HOST: db
DB_PORT: 5432
CELERY_BROKER_URL: redis://redis:6379/0
CELERY_RESULT_BACKEND: redis://redis:6379/1
FLOWER_API_BASE_URL: http://flower:5555
restart: unless-stopped
celery-beat:
image: ${API_IMAGE}
container_name: ${COMPOSE_PROJECT_NAME}-celery-beat
command:
- celery
- -A
- app.core.celery_app.celery_app
- beat
- --loglevel=${CELERY_LOG_LEVEL}
- --schedule=/tmp/celerybeat-schedule
depends_on:
- api
- redis
env_file:
- ./.env.prod
environment:
DB_HOST: db
DB_PORT: 5432
CELERY_BROKER_URL: redis://redis:6379/0
CELERY_RESULT_BACKEND: redis://redis:6379/1
restart: unless-stopped
flower:
image: ${API_IMAGE}
container_name: ${COMPOSE_PROJECT_NAME}-flower
command:
- celery
- -A
- app.core.celery_app.celery_app
- flower
- --address=0.0.0.0
- --port=5555
- --persistent=False
- --basic-auth=${FLOWER_BASIC_AUTH}
depends_on:
- redis
environment:
CELERY_BROKER_URL: redis://redis:6379/0
CELERY_RESULT_BACKEND: redis://redis:6379/1
CELERY_TIMEZONE: ${CELERY_TIMEZONE}
FLOWER_BASIC_AUTH: ${FLOWER_BASIC_AUTH}
expose:
- "5555"
restart: unless-stopped
web:
image: ${WEB_IMAGE}
container_name: ${COMPOSE_PROJECT_NAME}-web
depends_on:
- api
environment:
NEXT_PUBLIC_API_BASE_URL: ${NEXT_PUBLIC_API_BASE_URL}
NODE_ENV: production
expose:
- "3000"
restart: unless-stopped
volumes:
prod_db_data:
prod_redis_data:
prod_minio_data:
YAML
if [ ! -f deploy/pro-deploy/.env ]; then
cat > deploy/pro-deploy/.env <<'ENV'
COMPOSE_PROJECT_NAME=fquiz-prod
API_IMAGE=ghcr.io/chengkml/fquiz-api:latest
WEB_IMAGE=ghcr.io/chengkml/fquiz-web:latest
POSTGRES_IMAGE=docker.m.daocloud.io/pgvector/pgvector:pg16
REDIS_IMAGE=docker.m.daocloud.io/library/redis:7-alpine
MINIO_IMAGE=minio/minio:latest
MINIO_MC_IMAGE=minio/mc:latest
POSTGRES_PORT=5434
REDIS_PORT=6379
MINIO_API_PORT=9000
MINIO_CONSOLE_PORT=9001
NEXT_PUBLIC_API_BASE_URL=https://quiz.example.com/api
CELERY_LOG_LEVEL=INFO
CELERY_WORKER_CONCURRENCY=4
CELERY_WORKER_QUEUES=default,celery
CELERY_TIMEZONE=Asia/Shanghai
FLOWER_BASIC_AUTH=admin:change_me
POSTGRES_DB=fquiz
POSTGRES_USER=fquiz
MINIO_ENDPOINT=http://minio:9000
MINIO_BUCKET=fquiz-files
ENV
echo "[warn] deploy/pro-deploy/.env 不存在,已写入默认模板,请尽快改成生产配置。"
fi
if [ ! -f deploy/pro-deploy/.env.prod ]; then
cat > deploy/pro-deploy/.env.prod <<'ENV'
DATABASE_URL=postgresql+psycopg://fquiz:replace_strong_password@db:5432/fquiz
DB_HOST=db
DB_PORT=5432
DB_NAME=postgres
DB_SCHEMA=public
DB_USERNAME=fquiz
DB_PASSWORD=replace_strong_password
USER_USERNAME_COLUMN=username
USER_PASSWORD_COLUMN=password_hash
USER_STATUS_COLUMN=status
FILE_VFS_ROOT=./data/vfs
MINIO_ENABLED=true
MINIO_ENDPOINT=http://minio:9000
MINIO_ACCESS_KEY=replace_strong_access_key
MINIO_SECRET_KEY=replace_strong_secret_key
MINIO_BUCKET=fquiz-files
MINIO_REGION=us-east-1
JWT_SECRET_KEY=replace_strong_jwt_secret
ACCESS_TOKEN_EXPIRE_MINUTES=480
REFRESH_TOKEN_EXPIRE_DAYS=30
REFRESH_COOKIE_SECURE=true
REFRESH_COOKIE_SAMESITE=lax
LLM_PROVIDER_API_KEYS=
LLM_REQUEST_TIMEOUT_SECONDS=60
CHAT_CONTEXT_MESSAGE_LIMIT=12
CHAT_DEFAULT_SYSTEM_PROMPT=You are a helpful assistant.
CELERY_BROKER_URL=redis://redis:6379/0
CELERY_RESULT_BACKEND=redis://redis:6379/1
CELERY_TIMEZONE=Asia/Shanghai
SCHEDULER_EXPIRE_INTERVAL_SECONDS=60
FLOWER_API_BASE_URL=http://flower:5555
FLOWER_API_TIMEOUT_SECONDS=10
FLOWER_BASIC_AUTH=admin:change_me
WORKER_REGISTRY_TTL_SECONDS=90
CELERY_WORKER_QUEUES=default,celery
WINE_BINARY_PATH=wine
WINE_ALLOWED_ROOT=./data/wine
WINE_DEFAULT_TIMEOUT_SECONDS=300
WINE_MAX_TIMEOUT_SECONDS=1800
INITIAL_ADMIN_EMAIL=admin@example.com
INITIAL_ADMIN_USERNAME=admin
INITIAL_ADMIN_PASSWORD=replace_strong_admin_password
POSTGRES_DB=fquiz
POSTGRES_USER=fquiz
POSTGRES_PASSWORD=replace_strong_password
API_CORS_ORIGINS=https://quiz.example.com
API_CORS_ORIGIN_REGEX=
ENV
echo "[warn] deploy/pro-deploy/.env.prod 不存在,已写入默认模板,请尽快改成生产配置。"
fi
cat > .images.env <<ENV
API_IMAGE=${API_IMAGE}:${IMAGE_TAG}
WEB_IMAGE=${WEB_IMAGE}:${IMAGE_TAG}
NEXT_PUBLIC_API_BASE_URL=${NEXT_PUBLIC_API_BASE_URL}
FLOWER_BASIC_AUTH=${FLOWER_BASIC_AUTH}
ENV
echo "${GHCR_TOKEN}" | docker login ghcr.io -u "${GHCR_USERNAME}" --password-stdin
COMPOSE_CMD="docker compose"
if ! docker compose version >/dev/null 2>&1; then
COMPOSE_CMD="docker-compose"
fi
pull_with_retry() {
local max_retries=3
local attempt=1
while true; do
if ${COMPOSE_CMD} --env-file deploy/pro-deploy/.env --env-file deploy/pro-deploy/.env.prod --env-file .images.env -f deploy/pro-deploy/compose.yml pull; then
break
fi
if [ "${attempt}" -ge "${max_retries}" ]; then
echo "[error] docker compose pull failed after ${max_retries} attempts."
return 1
fi
local sleep_seconds=$((attempt * 20))
echo "[warn] docker compose pull failed (attempt ${attempt}/${max_retries}), retrying in ${sleep_seconds}s..."
sleep "${sleep_seconds}"
attempt=$((attempt + 1))
done
}
pull_with_retry
if ! ${COMPOSE_CMD} --env-file deploy/pro-deploy/.env --env-file deploy/pro-deploy/.env.prod --env-file .images.env -f deploy/pro-deploy/compose.yml up -d --remove-orphans; then
echo "[error] docker compose up failed, collecting container diagnostics..."
${COMPOSE_CMD} --env-file deploy/pro-deploy/.env --env-file deploy/pro-deploy/.env.prod --env-file .images.env -f deploy/pro-deploy/compose.yml ps || true
docker logs --tail 300 fquiz-prod-api || true
docker logs --tail 200 fquiz-prod-db || true
docker logs --tail 200 fquiz-prod-redis || true
docker logs --tail 200 fquiz-prod-celery-worker || true
docker logs --tail 200 fquiz-prod-celery-beat || true
docker logs --tail 200 fquiz-prod-flower || true
exit 1
fi
${COMPOSE_CMD} --env-file deploy/pro-deploy/.env --env-file deploy/pro-deploy/.env.prod --env-file .images.env -f deploy/pro-deploy/compose.yml ps