diff --git a/.gitea/workflows/deploy-backend.yaml b/.gitea/workflows/deploy-backend.yaml new file mode 100644 index 0000000..c9b7326 --- /dev/null +++ b/.gitea/workflows/deploy-backend.yaml @@ -0,0 +1,53 @@ +name: Deploy backend + +on: + push: + branches: [main] + paths: + - backend/** + - .gitea/workflows/deploy-backend.yaml + workflow_dispatch: + +permissions: + contents: read + +jobs: + deploy: + runs-on: tencent-prod + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + + - name: Build and deploy on the Docker host + shell: bash + env: + DATABASE_URL: ${{ secrets.DATABASE_URL }} + WECHAT_APP_ID: ${{ secrets.WECHAT_APP_ID }} + WECHAT_APP_SECRET: ${{ secrets.WECHAT_APP_SECRET }} + run: | + set -euo pipefail + cd backend + exec 9>/tmp/ballet-island-deploy.lock + flock -w 600 9 + + latest_main="$(git ls-remote origin refs/heads/main | cut -f1)" + test -n "$latest_main" + if [ "$(git rev-parse HEAD)" != "$latest_main" ]; then + echo "Skipping deployment: a newer main commit exists." + exit 0 + fi + + export IMAGE_TAG="$(git rev-parse --short=12 HEAD)" + compose=(docker compose --env-file /dev/null -f compose.prod.yaml) + "${compose[@]}" config --quiet + "${compose[@]}" build api + "${compose[@]}" up -d --wait --wait-timeout 180 + curl --fail --silent --show-error http://127.0.0.1:8080/readyz + + - name: Show container status + if: always() + shell: bash + run: | + docker ps --all \ + --filter label=com.docker.compose.project=ballet-island \ + --format 'table {{.Names}}\t{{.Image}}\t{{.Status}}' diff --git a/README.md b/README.md index 110f20d..635f875 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,8 @@ backend/ Go 1.26 + PostgreSQL 18 go.mod / go.sum Go 模块与依赖 Dockerfile 后端镜像构建 compose.yaml API 与数据库编排 + compose.prod.yaml Gitea 生产部署编排 + DEPLOYMENT.md 生产部署与回退说明 .env.example 后端环境变量示例 scripts/dev.sh 本机 Go 开发启动脚本 ``` @@ -81,7 +83,7 @@ docker compose up -d --wait postgres ## Docker 运行与部署 -以下命令在 `backend/` 中执行: +以下本地默认 Compose 命令在 `backend/` 中执行: ```sh docker compose up -d --build --wait @@ -97,7 +99,10 @@ Compose 等待数据库健康后启动 API,并等待两个服务就绪。API `API_PORT` 控制 Docker API 的宿主机端口,`HTTP_ADDR` 控制本机 Go 服务的监听地址;修改后应同步小程序的 API 地址。本机 Go 服务与 Docker API 不应同时占用同一端口。 -正式上线需配置 HTTPS、微信 request 合法域名、环境密码与数据库备份。小程序由微信开发者工具发布,Docker 部署后端和数据库。 +正式上线需配置 HTTPS、微信 request 合法域名、数据库连接密钥与备份。小程序由微信开发者工具发布;生产 Docker Compose 只部署后端 API,连接已有 PostgreSQL 服务器。 + +使用 Gitea Actions 在目标 Docker 主机部署时,按 [生产部署说明](backend/DEPLOYMENT.md) 配置 Runner、仓库密钥与反向代理;工作流位于 `.gitea/workflows/deploy-backend.yaml`。 +生产数据库连接使用单个 `DATABASE_URL` 密钥;本地开发仍使用 `backend/.env` 中的 `PG*` 变量。 PostgreSQL 18 数据卷按[官方镜像说明](https://hub.docker.com/_/postgres)挂载到 `/var/lib/postgresql`。已有数据库卷的密码不会因修改 `.env` 自动更新;需按数据库流程修改,不要为更新密码删除数据卷。 diff --git a/backend/DEPLOYMENT.md b/backend/DEPLOYMENT.md new file mode 100644 index 0000000..b06c4cc --- /dev/null +++ b/backend/DEPLOYMENT.md @@ -0,0 +1,31 @@ +# Gitea + Docker 生产部署 + +生产 Compose 只部署 Go API,连接已由其他服务管理的 PostgreSQL;数据库可以在同一主机,也可以在另一台服务器。本地开发继续使用包含 PostgreSQL 的 `compose.yaml`。小程序仍由微信开发者工具发布,构建时的 `TARO_APP_API_BASE_URL` 应指向此 API 的 HTTPS 域名。 + +## 首次准备 + +1. 在目标 Linux 主机安装 Docker Engine、Docker Compose、Git、Bash、`flock` 和 `curl`。按照 [Gitea Runner 文档](https://docs.gitea.com/usage/actions/act-runner/)在这台主机上注册仓库专用的 `act_runner`,为它配置 `tencent-prod:host` 标签,并确保 Runner 用户可以执行 Docker 命令和 `actions/checkout@v4`。此工作流在宿主机执行代码且拥有 Docker 权限,只应给可信仓库和维护者使用。 +2. 在 Gitea 仓库启用 Actions,并按[密钥文档](https://docs.gitea.com/usage/actions/secrets/)设置 `DATABASE_URL`、`WECHAT_APP_ID`、`WECHAT_APP_SECRET`。数据库连接只使用一个 `DATABASE_URL` 密钥;`WECHAT_APP_ID` 必须与小程序 AppID 一致。缺少密钥会在 Compose 校验时失败;校验使用 `config --quiet`,不会打印展开后的密钥。 +3. `DATABASE_URL` 使用 `postgresql://用户名:已编码密码@数据库主机:端口/数据库名?sslmode=模式`,例如 `postgresql://ballet_island:@db.example.com:5432/ballet_island?sslmode=verify-full`。数据库主机必须从 **API 容器内部**可解析、可访问,不能把容器内的 `127.0.0.1` 当作另一台主机。密码中的 `@`、`:`、`/`、`?`、`#`、`%` 等 URL 特殊字符须按 [PostgreSQL 连接 URI 规则](https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNSTRING)做百分号编码。`sslmode` 按数据库实际 TLS 配置填写:服务器未启用 TLS 时用 `disable`;有可验证证书时优先用 `verify-full`,使用私有 CA 时还需让 API 容器信任该 CA。跨服务器还需放通应用主机到数据库的连接,并确认数据库允许该用户和来源地址访问。 +4. 确保宿主机 `127.0.0.1:8080` 空闲。HTTPS 反向代理将 API 域名转发到该地址,并在微信小程序后台配置 request 合法域名。生产 Compose 仅将 API 绑定到回环地址,不发布数据库端口。 +5. 首次上线前在统一数据库服务器上创建数据库和账号,确定备份位置及恢复步骤。本地 `compose.yaml` 的 `ballet-island_postgres_data` 卷不会被生产 Compose 使用;若数据仍在该卷中,须先迁移到统一数据库服务器。 + +## 发布流程 + +推送 `main` 中的 `backend/**` 或工作流变更会触发部署,也可在 Gitea Actions 中手动运行。Runner 从 Gitea 检出代码,在目标主机取得部署锁,并确认检出的提交仍是远端 `main` 最新提交。然后校验生产 Compose、以提交短 SHA 构建 API 镜像、更新 `ballet-island` Compose 项目,等待 API 的数据库就绪检查通过,再访问宿主机 `/readyz`。工作流结束时输出该项目的容器状态。 + +生产配置通过 `--env-file /dev/null` 忽略开发 `.env`,数据库连接完全由 Gitea 的 `DATABASE_URL` 指定。后端在未设置 `DATABASE_URL` 时仍使用本地开发的 `PG*` 环境变量。生产 Compose 不创建 PostgreSQL 容器,也不要求与数据库容器共享 Docker 网络;如果数据库仅通过另一 Compose 项目的内部服务名开放,须先提供 API 容器可达的网络和地址。工作流未使用 `--remove-orphans`,切换前若已有同名项目中的旧 PostgreSQL 容器,不会自动停止或清理它。 + +API 启动时会在事务和数据库锁保护下应用尚未执行的版本迁移,当前没有独立迁移命令。发布前应在统一数据库服务器上备份,未来涉及不兼容 schema 的改动须先制定迁移与回退顺序。工作流不会自动回滚数据库;失败后先检查容器状态和日志,再决定恢复备份或重新部署。 + +## 验收与回退 + +发布成功后,从服务器确认 `curl -f http://127.0.0.1:8080/readyz`,并从外部确认 HTTPS 域名的 `/readyz`。真实微信登录、合法域名及小程序构建产物仍需单独验收;`/readyz` 只证明 API 可连接数据库。 + +每个发布镜像保留 `ballet-island-api:<提交短 SHA>` 标签。要回到某个已构建版本,应先确认其镜像仍在目标主机上,并评估数据库迁移是否与旧程序兼容;随后用同一组生产环境变量设置 `IMAGE_TAG=<旧提交短 SHA>`,在 `backend/` 执行: + +```sh +docker compose --env-file /dev/null -f compose.prod.yaml up -d --wait --wait-timeout 180 --no-build +``` + +回退镜像不会回退数据库迁移。修改 Gitea 中 `DATABASE_URL` 的密码也不会修改统一数据库服务器里的账号密码,两处需要按数据库运维流程同步更新。 diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index c844c1d..1591bd1 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -29,15 +29,22 @@ func run() error { ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() - for _, key := range []string{"PGHOST", "PGDATABASE", "PGUSER", "PGPASSWORD"} { - if os.Getenv(key) == "" { - return fmt.Errorf("%s is required", key) + databaseURL := os.Getenv("DATABASE_URL") + if databaseURL == "" { + for _, key := range []string{"PGHOST", "PGDATABASE", "PGUSER", "PGPASSWORD"} { + if os.Getenv(key) == "" { + return fmt.Errorf("%s is required when DATABASE_URL is unset", key) + } } } - // Use libpq environment variables so passwords need no URL escaping. - pool, err := pgxpool.New(ctx, "") + // Production uses a URL; local development retains libpq environment variables. + pool, err := pgxpool.New(ctx, databaseURL) if err != nil { + if databaseURL != "" { + // Parser errors can include the URL, so do not log the credential-bearing input. + return errors.New("configure database: invalid DATABASE_URL") + } return fmt.Errorf("configure database: %w", err) } defer pool.Close() diff --git a/backend/compose.prod.yaml b/backend/compose.prod.yaml new file mode 100644 index 0000000..96c307d --- /dev/null +++ b/backend/compose.prod.yaml @@ -0,0 +1,22 @@ +name: ballet-island + +services: + api: + image: ballet-island-api:${IMAGE_TAG:?Set IMAGE_TAG to the Git commit} + build: + context: . + restart: unless-stopped + environment: + HTTP_ADDR: :8080 + DATABASE_URL: ${DATABASE_URL:?Set DATABASE_URL to the production PostgreSQL URL} + WECHAT_APP_ID: ${WECHAT_APP_ID:?Set WECHAT_APP_ID} + WECHAT_APP_SECRET: ${WECHAT_APP_SECRET:?Set WECHAT_APP_SECRET} + ports: + - "127.0.0.1:8080:8080" + healthcheck: + test: ["CMD", "wget", "-q", "-T", "3", "-O", "/dev/null", "http://127.0.0.1:8080/readyz"] + interval: 10s + timeout: 5s + retries: 3 + start_period: 10s + stop_grace_period: 15s