Document Docker Compose deployment workflow

This commit is contained in:
wangbo 2026-05-23 21:13:23 +08:00
parent 8ad5b06c18
commit 6f730be0cd
6 changed files with 580 additions and 0 deletions

20
.dockerignore Normal file
View File

@ -0,0 +1,20 @@
.git
.idea
.nx
.turbo
.devenv*
.direnv
.DS_Store
.env
*.log
node_modules
**/node_modules
dist
apps/web/dist
apps/api/bin
apps/api/tmp
apps/api/data
coverage

80
Dockerfile Normal file
View File

@ -0,0 +1,80 @@
# syntax=docker/dockerfile:1.7
ARG GO_VERSION=1.26.3
ARG NODE_VERSION=22
FROM --platform=$BUILDPLATFORM golang:${GO_VERSION}-alpine AS api-builder
ARG TARGETOS=linux
ARG TARGETARCH=amd64
ARG GOPROXY=https://goproxy.cn,direct
ENV GOPROXY=$GOPROXY
RUN apk add --no-cache ca-certificates git
WORKDIR /src
COPY go.work go.work.sum ./
COPY apps/api/go.mod apps/api/go.sum apps/api/
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
cd apps/api && \
for attempt in 1 2 3; do \
go mod download && exit 0; \
echo "go mod download failed, retrying (${attempt}/3)" >&2; \
sleep $((attempt * 3)); \
done; \
go mod download
COPY apps/api apps/api
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
cd apps/api && \
CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -trimpath -ldflags="-s -w" -o /out/easyai-ai-gateway ./cmd/gateway && \
CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -trimpath -ldflags="-s -w" -o /out/easyai-ai-gateway-migrate ./cmd/migrate
FROM alpine:3.22 AS api
RUN apk add --no-cache ca-certificates tzdata wget && \
adduser -D -H -u 10001 appuser
WORKDIR /app
COPY --from=api-builder /out/easyai-ai-gateway /app/easyai-ai-gateway
COPY --from=api-builder /out/easyai-ai-gateway-migrate /app/easyai-ai-gateway-migrate
COPY apps/api/migrations /app/migrations
RUN mkdir -p /app/data/static/generated /app/data/static/uploaded && \
chown -R appuser:appuser /app
USER appuser
EXPOSE 8088
ENV APP_ENV=production \
HTTP_ADDR=:8088 \
AI_GATEWAY_GENERATED_STORAGE_DIR=/app/data/static/generated \
AI_GATEWAY_UPLOADED_STORAGE_DIR=/app/data/static/uploaded
CMD ["/app/easyai-ai-gateway"]
FROM --platform=$BUILDPLATFORM node:${NODE_VERSION}-alpine AS web-builder
WORKDIR /src
RUN npm install -g pnpm@10.18.1
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml nx.json ./
COPY apps/web/package.json apps/web/package.json
COPY packages/contracts/package.json packages/contracts/package.json
RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store \
pnpm install --frozen-lockfile
COPY packages packages
COPY apps/web apps/web
ARG VITE_GATEWAY_API_BASE_URL=/gateway-api
ENV VITE_GATEWAY_API_BASE_URL=$VITE_GATEWAY_API_BASE_URL
RUN pnpm --filter @easyai-ai-gateway/web build
FROM nginx:1.27-alpine AS web
COPY docker/nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=web-builder /src/apps/web/dist /usr/share/nginx/html
EXPOSE 80

View File

@ -43,6 +43,60 @@ pnpm dev
后端热更新可通过 `GO_WATCH_SHUTDOWN_GRACE_MS``GO_WATCH_RESTART_DELAY_MS` 调整旧进程退出等待时间与重启间隔。
## Docker Compose 一键部署
仓库内提供了面向 `linux/amd64` 的 Docker Compose 构建和部署脚本,会自动构建 API/Web 镜像、启动 PostgreSQL、执行数据库迁移并验证 API 与 Web 是否可访问:
```bash
scripts/deploy-compose.sh
```
部署成功后默认访问地址:
- Web: `http://127.0.0.1:5178`
- API: `http://127.0.0.1:8088/healthz`
- Web 反代 API: `http://127.0.0.1:5178/gateway-api/healthz`
常用覆盖项:
```bash
AI_GATEWAY_IMAGE_TAG=2026.05.23-1 scripts/deploy-compose.sh
AI_GATEWAY_IMAGE_TAG=2026.05.23-1 AI_GATEWAY_PUSH=1 scripts/deploy-compose.sh
AI_GATEWAY_IMAGE_TAG=2026.05.23-1 scripts/deploy-compose.sh push
AI_GATEWAY_WEB_PORT=8080 AI_GATEWAY_API_PORT=18088 scripts/deploy-compose.sh
AI_GATEWAY_GO_PROXY='https://proxy.golang.org,direct' scripts/deploy-compose.sh
AI_GATEWAY_SKIP_BUILD=1 scripts/deploy-compose.sh
scripts/deploy-compose.sh down
scripts/deploy-compose.sh clean
```
默认镜像地址为:
- API: `registry.cn-shanghai.aliyuncs.com/easyaigc/ai-gateway:latest`
- Web: `registry.cn-shanghai.aliyuncs.com/easyaigc/ai-gateway-web:latest`
执行 `scripts/deploy-compose.sh push` 或设置 `AI_GATEWAY_PUSH=1` 时,会同时推送当前版本 tag 和 `latest`。当前版本 tag 优先使用 `AI_GATEWAY_IMAGE_TAG`;如果没有设置,则使用根 `package.json` 里的 `version`
推送前需要先登录阿里云镜像仓库:
```bash
docker login --username=<your-aliyun-account> registry.cn-shanghai.aliyuncs.com
```
Web 容器的 Nginx 配置通过 bind mount 挂载自仓库文件 [docker/nginx.conf](docker/nginx.conf),可直接修改该文件调整静态资源和 `/gateway-api` 反向代理配置。修改后执行以下命令使配置生效:
```bash
docker compose -f docker-compose.yml restart web
```
Compose 默认使用独立容器数据库 `postgres:18-alpine`,数据卷会保留在 `postgres_data``api_data`。为避免本地开发 `.env` 中的 `localhost` 数据库地址污染容器部署compose 使用 `AI_GATEWAY_COMPOSE_*` 变量作为容器部署专用覆盖,例如:
```bash
AI_GATEWAY_COMPOSE_DATABASE_URL='postgresql://easyai:easyai2025@postgres:5432/easyai_ai_gateway?sslmode=disable' scripts/deploy-compose.sh
```
数据库迁移仍通过 `migrator` 容器执行,但脚本使用 `docker compose run --rm migrator`,迁移成功后不会留下 `migrator-1 Exited` 容器。
## OpenAPI 文档
修改 `apps/api/internal/httpapi` 下的接口、请求或响应类型后,请重新执行:

114
docker-compose.yml Normal file
View File

@ -0,0 +1,114 @@
name: ${COMPOSE_PROJECT_NAME:-easyai-ai-gateway}
x-api-environment: &api-environment
APP_ENV: ${AI_GATEWAY_COMPOSE_APP_ENV:-production}
HTTP_ADDR: :8088
AI_GATEWAY_DATABASE_URL: ${AI_GATEWAY_COMPOSE_DATABASE_URL:-postgresql://easyai:easyai2025@postgres:5432/easyai_ai_gateway?sslmode=disable}
CONFIG_JWT_SECRET: ${CONFIG_JWT_SECRET:-this is a very secret secret}
IDENTITY_MODE: ${AI_GATEWAY_COMPOSE_IDENTITY_MODE:-hybrid}
SERVER_MAIN_BASE_URL: ${AI_GATEWAY_COMPOSE_SERVER_MAIN_BASE_URL:-http://host.docker.internal:3000}
SERVER_MAIN_INTERNAL_TOKEN: ${SERVER_MAIN_INTERNAL_TOKEN:-change-me}
TASK_PROGRESS_CALLBACK_ENABLED: ${AI_GATEWAY_COMPOSE_TASK_PROGRESS_CALLBACK_ENABLED:-false}
TASK_PROGRESS_CALLBACK_URL: ${AI_GATEWAY_COMPOSE_TASK_PROGRESS_CALLBACK_URL:-http://host.docker.internal:3000/internal/platform/task-progress-callbacks}
TASK_PROGRESS_CALLBACK_TIMEOUT_MS: ${TASK_PROGRESS_CALLBACK_TIMEOUT_MS:-5000}
TASK_PROGRESS_CALLBACK_MAX_ATTEMPTS: ${TASK_PROGRESS_CALLBACK_MAX_ATTEMPTS:-10}
CORS_ALLOWED_ORIGIN: ${AI_GATEWAY_COMPOSE_CORS_ALLOWED_ORIGIN:-http://localhost:5178,http://127.0.0.1:5178}
AI_GATEWAY_PUBLIC_BASE_URL: ${AI_GATEWAY_COMPOSE_PUBLIC_BASE_URL:-http://localhost:8088}
AI_GATEWAY_GENERATED_STORAGE_DIR: /app/data/static/generated
AI_GATEWAY_UPLOADED_STORAGE_DIR: /app/data/static/uploaded
services:
postgres:
image: ${AI_GATEWAY_POSTGRES_IMAGE:-postgres:18-alpine}
platform: ${AI_GATEWAY_PLATFORM:-linux/amd64}
environment:
POSTGRES_DB: ${AI_GATEWAY_COMPOSE_DATABASE_NAME:-easyai_ai_gateway}
POSTGRES_USER: ${AI_GATEWAY_COMPOSE_PG_USER:-easyai}
POSTGRES_PASSWORD: ${AI_GATEWAY_COMPOSE_PG_PASSWORD:-easyai2025}
ports:
- "${AI_GATEWAY_DB_PORT:-54329}:5432"
volumes:
- postgres_data:/var/lib/postgresql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U \"$${POSTGRES_USER}\" -d \"$${POSTGRES_DB}\""]
interval: 5s
timeout: 5s
retries: 20
start_period: 10s
restart: unless-stopped
migrator:
image: ${AI_GATEWAY_API_IMAGE:-${AI_GATEWAY_IMAGE_REGISTRY:-registry.cn-shanghai.aliyuncs.com/easyaigc}/ai-gateway:${AI_GATEWAY_IMAGE_TAG:-latest}}
platform: ${AI_GATEWAY_PLATFORM:-linux/amd64}
build:
context: .
dockerfile: Dockerfile
target: api
args:
GOPROXY: ${AI_GATEWAY_GO_PROXY:-https://goproxy.cn,direct}
command: ["/app/easyai-ai-gateway-migrate"]
environment: *api-environment
extra_hosts:
- "host.docker.internal:host-gateway"
depends_on:
postgres:
condition: service_healthy
restart: "no"
api:
image: ${AI_GATEWAY_API_IMAGE:-${AI_GATEWAY_IMAGE_REGISTRY:-registry.cn-shanghai.aliyuncs.com/easyaigc}/ai-gateway:${AI_GATEWAY_IMAGE_TAG:-latest}}
platform: ${AI_GATEWAY_PLATFORM:-linux/amd64}
build:
context: .
dockerfile: Dockerfile
target: api
args:
GOPROXY: ${AI_GATEWAY_GO_PROXY:-https://goproxy.cn,direct}
environment: *api-environment
ports:
- "${AI_GATEWAY_API_PORT:-8088}:8088"
volumes:
- api_data:/app/data
extra_hosts:
- "host.docker.internal:host-gateway"
depends_on:
postgres:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8088/readyz | grep -q '\"ok\":true'"]
interval: 10s
timeout: 5s
retries: 20
start_period: 10s
restart: unless-stopped
web:
image: ${AI_GATEWAY_WEB_IMAGE:-${AI_GATEWAY_IMAGE_REGISTRY:-registry.cn-shanghai.aliyuncs.com/easyaigc}/ai-gateway-web:${AI_GATEWAY_IMAGE_TAG:-latest}}
platform: ${AI_GATEWAY_PLATFORM:-linux/amd64}
build:
context: .
dockerfile: Dockerfile
target: web
args:
VITE_GATEWAY_API_BASE_URL: ${AI_GATEWAY_WEB_API_BASE_URL:-/gateway-api}
ports:
- "${AI_GATEWAY_WEB_PORT:-5178}:80"
volumes:
- type: bind
source: ./docker/nginx.conf
target: /etc/nginx/conf.d/default.conf
read_only: true
depends_on:
api:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1/gateway-api/healthz | grep -q 'easyai-ai-gateway'"]
interval: 10s
timeout: 5s
retries: 20
start_period: 10s
restart: unless-stopped
volumes:
postgres_data:
api_data:

29
docker/nginx.conf Normal file
View File

@ -0,0 +1,29 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
client_max_body_size 200m;
location = /gateway-api {
return 308 /gateway-api/;
}
location /gateway-api/ {
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Connection "";
proxy_buffering off;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_pass http://api:8088/;
}
location / {
try_files $uri $uri/ /index.html;
}
}

283
scripts/deploy-compose.sh Executable file
View File

@ -0,0 +1,283 @@
#!/usr/bin/env bash
set -euo pipefail
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
COMPOSE_FILE="${COMPOSE_FILE:-docker-compose.yml}"
COMPOSE_PROJECT_NAME="${COMPOSE_PROJECT_NAME:-easyai-ai-gateway}"
AI_GATEWAY_PLATFORM="${AI_GATEWAY_PLATFORM:-linux/amd64}"
AI_GATEWAY_IMAGE_REGISTRY="${AI_GATEWAY_IMAGE_REGISTRY:-registry.cn-shanghai.aliyuncs.com/easyaigc}"
AI_GATEWAY_IMAGE_TAG="${AI_GATEWAY_IMAGE_TAG:-latest}"
AI_GATEWAY_API_IMAGE="${AI_GATEWAY_API_IMAGE:-${AI_GATEWAY_IMAGE_REGISTRY}/ai-gateway:${AI_GATEWAY_IMAGE_TAG}}"
AI_GATEWAY_WEB_IMAGE="${AI_GATEWAY_WEB_IMAGE:-${AI_GATEWAY_IMAGE_REGISTRY}/ai-gateway-web:${AI_GATEWAY_IMAGE_TAG}}"
ACTION="${1:-deploy}"
export COMPOSE_PROJECT_NAME
export AI_GATEWAY_PLATFORM
export AI_GATEWAY_API_IMAGE
export AI_GATEWAY_WEB_IMAGE
export DOCKER_DEFAULT_PLATFORM="${DOCKER_DEFAULT_PLATFORM:-$AI_GATEWAY_PLATFORM}"
compose=(docker compose -f "$COMPOSE_FILE")
usage() {
cat <<'EOF'
Usage:
scripts/deploy-compose.sh Build, migrate, start, and verify
scripts/deploy-compose.sh deploy Same as default
scripts/deploy-compose.sh push Build and push API/Web images
scripts/deploy-compose.sh down Stop containers, keep volumes
scripts/deploy-compose.sh clean Stop containers and remove volumes
Useful environment overrides:
AI_GATEWAY_PLATFORM=linux/amd64
AI_GATEWAY_IMAGE_TAG=2026.05.23-1
AI_GATEWAY_API_IMAGE=registry.cn-shanghai.aliyuncs.com/easyaigc/ai-gateway:2026.05.23-1
AI_GATEWAY_WEB_IMAGE=registry.cn-shanghai.aliyuncs.com/easyaigc/ai-gateway-web:2026.05.23-1
AI_GATEWAY_WEB_PORT=5178
AI_GATEWAY_API_PORT=8088
AI_GATEWAY_DB_PORT=54329
AI_GATEWAY_PUSH=1
AI_GATEWAY_SKIP_BUILD=1
EOF
}
fail_with_logs() {
echo "[ai-gateway] deployment failed; recent container state follows" >&2
"${compose[@]}" ps >&2 || true
"${compose[@]}" logs --tail=160 postgres migrator api web >&2 || true
exit 1
}
remove_stale_migrator() {
"${compose[@]}" rm -sf migrator >/dev/null 2>&1 || true
}
wait_for_service_healthy() {
local service="$1"
local container_id=""
local status=""
for _ in $(seq 1 60); do
container_id="$("${compose[@]}" ps -q "$service" 2>/dev/null || true)"
if [[ -n "$container_id" ]]; then
status="$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' "$container_id" 2>/dev/null || true)"
if [[ "$status" == "healthy" || "$status" == "running" ]]; then
echo "[ai-gateway] ${service} is ${status}"
return 0
fi
if [[ "$status" == "exited" || "$status" == "dead" ]]; then
echo "[ai-gateway] ${service} stopped while waiting for health" >&2
fail_with_logs
fi
fi
sleep 2
done
echo "[ai-gateway] timed out waiting for ${service} to become healthy" >&2
fail_with_logs
}
wait_for_http() {
local label="$1"
local url="$2"
local expected="${3:-}"
local body=""
for _ in $(seq 1 60); do
if body="$(curl -fsS --max-time 5 "$url" 2>/dev/null)"; then
if [[ -z "$expected" || "$body" == *"$expected"* ]]; then
echo "[ai-gateway] verified ${label}: ${url}"
return 0
fi
fi
sleep 2
done
echo "[ai-gateway] timed out waiting for ${label}: ${url}" >&2
[[ -n "$body" ]] && echo "$body" >&2
fail_with_logs
}
published_port() {
local service="$1"
local private_port="$2"
local fallback="$3"
local endpoint=""
endpoint="$("${compose[@]}" port "$service" "$private_port" 2>/dev/null | tail -n 1 || true)"
if [[ -n "$endpoint" ]]; then
echo "${endpoint##*:}"
else
echo "$fallback"
fi
}
build_images() {
if [[ "${AI_GATEWAY_SKIP_BUILD:-0}" != "1" ]]; then
echo "[ai-gateway] building images"
echo "[ai-gateway] api image: ${AI_GATEWAY_API_IMAGE}"
echo "[ai-gateway] web image: ${AI_GATEWAY_WEB_IMAGE}"
"${compose[@]}" build --pull
else
echo "[ai-gateway] skipping image build"
fi
}
package_version() {
local version=""
version="$(
sed -nE 's/^[[:space:]]*"version"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/p' "$PROJECT_ROOT/package.json" \
| head -n 1
)"
if [[ -z "$version" ]]; then
echo "[ai-gateway] cannot infer package version from package.json; set AI_GATEWAY_IMAGE_TAG" >&2
exit 1
fi
echo "$version"
}
push_version_tag() {
if [[ "$AI_GATEWAY_IMAGE_TAG" == "latest" ]]; then
package_version
else
echo "$AI_GATEWAY_IMAGE_TAG"
fi
}
image_repository() {
local image="$1"
echo "${image%:*}"
}
tag_release_images() {
local version_tag="$1"
local api_repo web_repo
local api_version_image api_latest_image web_version_image web_latest_image
api_repo="$(image_repository "$AI_GATEWAY_API_IMAGE")"
web_repo="$(image_repository "$AI_GATEWAY_WEB_IMAGE")"
api_version_image="${api_repo}:${version_tag}"
api_latest_image="${api_repo}:latest"
web_version_image="${web_repo}:${version_tag}"
web_latest_image="${web_repo}:latest"
docker image inspect "$AI_GATEWAY_API_IMAGE" >/dev/null 2>&1 || {
echo "[ai-gateway] missing local API image: ${AI_GATEWAY_API_IMAGE}; build it first" >&2
exit 1
}
docker image inspect "$AI_GATEWAY_WEB_IMAGE" >/dev/null 2>&1 || {
echo "[ai-gateway] missing local Web image: ${AI_GATEWAY_WEB_IMAGE}; build it first" >&2
exit 1
}
docker tag "$AI_GATEWAY_API_IMAGE" "$api_version_image"
docker tag "$AI_GATEWAY_API_IMAGE" "$api_latest_image"
docker tag "$AI_GATEWAY_WEB_IMAGE" "$web_version_image"
docker tag "$AI_GATEWAY_WEB_IMAGE" "$web_latest_image"
RELEASE_IMAGES=(
"$api_version_image"
"$api_latest_image"
"$web_version_image"
"$web_latest_image"
)
}
push_images() {
local version_tag
local image
version_tag="$(push_version_tag)"
echo "[ai-gateway] release image tag: ${version_tag}"
echo "[ai-gateway] latest tag will also be pushed"
tag_release_images "$version_tag"
for image in "${RELEASE_IMAGES[@]}"; do
echo "[ai-gateway] pushing image: ${image}"
if ! docker push "$image"; then
echo "[ai-gateway] failed to push image; login may be required:" >&2
echo "[ai-gateway] docker login --username=<your-aliyun-account> registry.cn-shanghai.aliyuncs.com" >&2
exit 1
fi
done
}
deploy() {
cd "$PROJECT_ROOT"
command -v docker >/dev/null 2>&1 || {
echo "[ai-gateway] docker is required" >&2
exit 1
}
docker compose version >/dev/null 2>&1 || {
echo "[ai-gateway] docker compose v2 is required" >&2
exit 1
}
command -v curl >/dev/null 2>&1 || {
echo "[ai-gateway] curl is required for deployment verification" >&2
exit 1
}
echo "[ai-gateway] compose project: ${COMPOSE_PROJECT_NAME}"
echo "[ai-gateway] target platform: ${AI_GATEWAY_PLATFORM}"
echo "[ai-gateway] image tag: ${AI_GATEWAY_IMAGE_TAG}"
build_images
if [[ "${AI_GATEWAY_PUSH:-0}" == "1" ]]; then
push_images
fi
echo "[ai-gateway] starting postgres"
"${compose[@]}" up -d postgres
wait_for_service_healthy postgres
echo "[ai-gateway] running database migrations"
remove_stale_migrator
"${compose[@]}" run --rm migrator
remove_stale_migrator
echo "[ai-gateway] starting api and web"
"${compose[@]}" up -d api web
local api_port
local web_port
api_port="$(published_port api 8088 "${AI_GATEWAY_API_PORT:-8088}")"
web_port="$(published_port web 80 "${AI_GATEWAY_WEB_PORT:-5178}")"
wait_for_http "api health" "http://127.0.0.1:${api_port}/healthz" "easyai-ai-gateway"
wait_for_http "api readiness" "http://127.0.0.1:${api_port}/readyz" '"ok":true'
wait_for_http "web reverse proxy" "http://127.0.0.1:${web_port}/gateway-api/healthz" "easyai-ai-gateway"
wait_for_http "web app" "http://127.0.0.1:${web_port}/" "EasyAI AI Gateway"
echo "[ai-gateway] deployment succeeded"
echo "[ai-gateway] Web: http://127.0.0.1:${web_port}"
echo "[ai-gateway] API: http://127.0.0.1:${api_port}/healthz"
}
case "$ACTION" in
deploy|up)
deploy
;;
push)
cd "$PROJECT_ROOT"
build_images
push_images
;;
down)
cd "$PROJECT_ROOT"
"${compose[@]}" down --remove-orphans
;;
clean)
cd "$PROJECT_ROOT"
"${compose[@]}" down -v --remove-orphans
;;
-h|--help|help)
usage
;;
*)
usage >&2
exit 2
;;
esac