# 服务器部署用 Docker Compose(no-push 本地构建模式) # 镜像来源:CI 容器内本地 docker build(不推送到 registry) # 基础设施:MySQL + Redis 已在服务器 Docker 中运行(不在此文件管理) # # 部署目录:/opt/edu/ # 部署命令(CI 自动执行): # docker compose up -d --build --remove-orphans # # 首次部署手动步骤: # 1. sudo mkdir -p /opt/edu && sudo chown -R $USER:$USER /opt/edu # 2. cp infra/docker-compose.deploy.yml /opt/edu/docker-compose.yml # 3. cp infra/deploy.env.example /opt/edu/.env && 编辑填入生产密钥 # 4. docker compose up -d --build # # 注意:compose 文件中的 build.context 路径相对于 /opt/edu/ 目录 # CI 在 deploy 步骤会先把仓库 checkout 到 /opt/edu/repo/,再 cp compose 文件到 /opt/edu/ name: edu services: # ============================================================ # 应用服务(10 个:api-gateway + 3 Go/Python + 6 NestJS) # ============================================================ api-gateway: build: context: ./repo dockerfile: services/api-gateway/Dockerfile container_name: edu-api-gateway restart: unless-stopped environment: API_GATEWAY_PORT: ${API_GATEWAY_PORT:-8080} ENV: production JWT_SECRET: ${JWT_SECRET} JWT_ISSUER: ${JWT_ISSUER:-next-edu-cloud} JWT_AUDIENCE: ${JWT_AUDIENCE:-next-edu-cloud} # RS256 公钥端点(非 DevMode 下必填,config.go W7 防护) IAM_JWKS_URL: http://iam:3002/v1/iam/.well-known/jwks.json # 生产环境强制关闭 dev-token 旁路 DEV_MODE: "false" CLASSES_SERVICE_URL: http://classes:3001 IAM_SERVICE_URL: http://iam:3002 TEACHER_BFF_URL: http://teacher-bff:3003 STUDENT_BFF_URL: http://student-bff:3009 PARENT_BFF_URL: http://parent-bff:3010 CORE_EDU_SERVICE_URL: http://core-edu:3004 CONTENT_SERVICE_URL: http://content:3005 DATA_ANA_SERVICE_URL: http://data-ana:3006 MSG_SERVICE_URL: http://msg:3007 AI_SERVICE_URL: http://ai:3008 CORS_ORIGINS: ${CORS_ORIGINS:-http://localhost:3000,http://localhost:4000,http://localhost:4001,http://localhost:4002,http://localhost:4003} OTEL_EXPORTER_OTLP_ENDPOINT: ${OTEL_EXPORTER_OTLP_ENDPOINT:-http://otel-collector:4318} LOG_LEVEL: ${LOG_LEVEL:-info} ports: - "${API_GATEWAY_PORT:-8080}:8080" depends_on: classes: condition: service_healthy healthcheck: test: ["CMD", "wget", "--quiet", "--spider", "http://localhost:8080/healthz"] interval: 30s timeout: 5s start_period: 10s retries: 3 networks: - edu-net - edu-shared classes: build: context: ./repo dockerfile: services/classes/Dockerfile container_name: edu-classes restart: unless-stopped environment: PORT: 3001 DATABASE_URL: ${DATABASE_URL} REDIS_URL: ${REDIS_URL} KAFKA_BROKERS: ${KAFKA_BROKERS:-} OTEL_EXPORTER_OTLP_ENDPOINT: ${OTEL_EXPORTER_OTLP_ENDPOINT:-http://otel-collector:4318} LOG_LEVEL: ${LOG_LEVEL:-info} healthcheck: test: ["CMD", "wget", "--quiet", "--spider", "http://localhost:3001/healthz"] interval: 30s timeout: 5s start_period: 30s retries: 5 networks: - edu-net - edu-shared iam: build: context: ./repo dockerfile: services/iam/Dockerfile container_name: edu-iam restart: unless-stopped environment: PORT: 3002 DATABASE_URL: ${DATABASE_URL} REDIS_URL: ${REDIS_URL} JWT_SECRET: ${JWT_SECRET} JWT_ISSUER: ${JWT_ISSUER:-next-edu-cloud} JWT_AUDIENCE: ${JWT_AUDIENCE:-next-edu-cloud} OTEL_EXPORTER_OTLP_ENDPOINT: ${OTEL_EXPORTER_OTLP_ENDPOINT:-http://otel-collector:4318} LOG_LEVEL: ${LOG_LEVEL:-info} NODE_ENV: production healthcheck: test: ["CMD", "wget", "--quiet", "--spider", "http://localhost:3002/healthz"] interval: 30s timeout: 5s start_period: 30s retries: 5 networks: - edu-net - edu-shared teacher-bff: build: context: ./repo dockerfile: services/teacher-bff/Dockerfile container_name: edu-teacher-bff restart: unless-stopped environment: PORT: 3003 TEACHER_BFF_DEV_MODE: "false" REDIS_URL: ${REDIS_URL} IAM_GRPC_TARGET: iam:50052 IAM_SERVICE_URL: http://iam:3002 CLASSES_SERVICE_URL: http://classes:3001 CORE_EDU_GRPC_TARGET: core-edu:50053 CORE_EDU_SERVICE_URL: http://core-edu:3004 CONTENT_GRPC_TARGET: content:50054 DATA_ANA_GRPC_TARGET: data-ana:50055 MSG_GRPC_TARGET: msg:50056 MSG_SERVICE_URL: http://msg:3007 AI_GRPC_TARGET: ai:50058 OTEL_EXPORTER_OTLP_ENDPOINT: ${OTEL_EXPORTER_OTLP_ENDPOINT:-http://otel-collector:4318} LOG_LEVEL: ${LOG_LEVEL:-info} NODE_ENV: production depends_on: iam: condition: service_healthy classes: condition: service_healthy healthcheck: test: ["CMD", "wget", "--quiet", "--spider", "http://localhost:3003/healthz"] interval: 30s timeout: 5s start_period: 20s retries: 3 networks: - edu-net - edu-shared core-edu: build: context: ./repo dockerfile: services/core-edu/Dockerfile container_name: edu-core-edu restart: unless-stopped environment: PORT: 3004 DATABASE_URL: ${DATABASE_URL} REDIS_URL: ${REDIS_URL} KAFKA_BROKERS: ${KAFKA_BROKERS:-} OTEL_EXPORTER_OTLP_ENDPOINT: ${OTEL_EXPORTER_OTLP_ENDPOINT:-http://otel-collector:4318} LOG_LEVEL: ${LOG_LEVEL:-info} NODE_ENV: production DEV_MODE: "false" healthcheck: test: ["CMD", "wget", "--quiet", "--spider", "http://localhost:3004/healthz"] interval: 30s timeout: 5s start_period: 30s retries: 5 networks: - edu-net - edu-shared content: build: context: ./repo dockerfile: services/content/Dockerfile container_name: edu-content restart: unless-stopped environment: PORT: 3005 DATABASE_URL: ${DATABASE_URL} REDIS_URL: ${REDIS_URL} NEO4J_URL: ${NEO4J_URL:-} NEO4J_PASSWORD: ${NEO4J_PASSWORD:-} ES_URL: ${ES_URL:-} OTEL_EXPORTER_OTLP_ENDPOINT: ${OTEL_EXPORTER_OTLP_ENDPOINT:-http://otel-collector:4318} LOG_LEVEL: ${LOG_LEVEL:-info} NODE_ENV: production DEV_MODE: "false" healthcheck: test: ["CMD", "wget", "--quiet", "--spider", "http://localhost:3005/healthz"] interval: 30s timeout: 5s start_period: 30s retries: 5 networks: - edu-net - edu-shared msg: build: context: ./repo dockerfile: services/msg/Dockerfile container_name: edu-msg restart: unless-stopped environment: PORT: 3007 DATABASE_URL: ${DATABASE_URL} REDIS_URL: ${REDIS_URL} KAFKA_BROKERS: ${KAFKA_BROKERS:-} ES_URL: ${ES_URL:-} PUSH_GATEWAY_URL: http://push-gateway:8081 OTEL_EXPORTER_OTLP_ENDPOINT: ${OTEL_EXPORTER_OTLP_ENDPOINT:-http://otel-collector:4318} LOG_LEVEL: ${LOG_LEVEL:-info} NODE_ENV: production DEV_MODE: "false" depends_on: push-gateway: condition: service_healthy healthcheck: test: ["CMD", "wget", "--quiet", "--spider", "http://localhost:3007/healthz"] interval: 30s timeout: 5s start_period: 30s retries: 5 networks: - edu-net - edu-shared ai: build: context: ./repo/services/ai dockerfile: Dockerfile container_name: edu-ai restart: unless-stopped environment: PORT: 3008 OPENAI_API_KEY: ${OPENAI_API_KEY:-} OPENAI_BASE_URL: ${OPENAI_BASE_URL:-https://api.openai.com/v1} ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-} OTEL_ENDPOINT: ${OTEL_EXPORTER_OTLP_ENDPOINT:-http://otel-collector:4318} LOG_LEVEL: ${LOG_LEVEL:-info} DEV_MODE: "false" healthcheck: test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:3008/healthz')"] interval: 30s timeout: 5s start_period: 20s retries: 3 networks: - edu-net - edu-shared data-ana: build: context: ./repo/services/data-ana dockerfile: Dockerfile container_name: edu-data-ana restart: unless-stopped environment: PORT: 3006 CLICKHOUSE_HOST: ${CLICKHOUSE_HOST:-} CLICKHOUSE_PORT: ${CLICKHOUSE_PORT:-8123} CLICKHOUSE_DATABASE: ${CLICKHOUSE_DATABASE:-edu_analytics} CLICKHOUSE_USER: ${CLICKHOUSE_USER:-} CLICKHOUSE_PASSWORD: ${CLICKHOUSE_PASSWORD:-} OTEL_ENDPOINT: ${OTEL_EXPORTER_OTLP_ENDPOINT:-http://otel-collector:4318} LOG_LEVEL: ${LOG_LEVEL:-info} DEV_MODE: "false" KAFKA_BROKERS: ${KAFKA_BROKERS:-} healthcheck: test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:3006/healthz')"] interval: 30s timeout: 5s start_period: 20s retries: 3 networks: - edu-net - edu-shared push-gateway: build: context: ./repo/services/push-gateway dockerfile: Dockerfile container_name: edu-push-gateway restart: unless-stopped environment: PUSH_GATEWAY_PORT: 8081 JWT_SECRET: ${JWT_SECRET} REDIS_URL: ${REDIS_URL} KAFKA_BROKERS: ${KAFKA_BROKERS:-kafka:29092} # ARB-013 canonical topic name (v2 alignment with msg Outbox publisher). KAFKA_NOTIFICATION_TOPIC: edu.notify.notification.sent KAFKA_CONSUMER_GROUP: push-gateway # PUSH_INTERNAL_TOKEN is the canonical env var (ARB-013 alignment with # msg). INTERNAL_API_TOKEN kept as backward-compat alias. PUSH_INTERNAL_TOKEN: ${PUSH_INTERNAL_TOKEN:-${INTERNAL_API_TOKEN:-edu-internal-token}} JWKS_URL: http://iam:3002/v1/iam/.well-known/jwks.json WS_ALLOWED_ORIGINS: ${WS_ALLOWED_ORIGINS:-http://localhost:3000,http://localhost:4000,http://localhost:4001,http://localhost:4002,http://localhost:4003} MAX_CONNS_PER_USER: "5" HEARTBEAT_INTERVAL_SECONDS: "30" OTEL_EXPORTER_OTLP_ENDPOINT: ${OTEL_EXPORTER_OTLP_ENDPOINT:-http://otel-collector:4318} DEV_MODE: "false" ports: - "${PUSH_GATEWAY_PORT:-8081}:8081" healthcheck: test: ["CMD", "wget", "--quiet", "--spider", "http://localhost:8081/healthz"] interval: 30s timeout: 5s start_period: 10s retries: 3 networks: - edu-net - edu-shared teacher-portal: build: context: ./repo dockerfile: apps/teacher-portal/Dockerfile container_name: edu-teacher-portal restart: unless-stopped environment: NODE_ENV: production PORT: 3000 API_GATEWAY_URL: http://api-gateway:8080 ports: - "${TEACHER_PORTAL_PORT:-3000}:3000" depends_on: api-gateway: condition: service_healthy healthcheck: test: ["CMD", "wget", "--quiet", "--spider", "http://localhost:3000/"] interval: 30s timeout: 5s start_period: 20s retries: 3 networks: - edu-net - edu-shared admin-portal: build: context: ./repo dockerfile: apps/admin-portal/Dockerfile container_name: edu-admin-portal restart: unless-stopped environment: NODE_ENV: production PORT: 4003 API_GATEWAY_URL: http://api-gateway:8080 # 生产环境强制关闭 mock,使用真实后端 NEXT_PUBLIC_API_MOCKING: "disabled" NEXT_PUBLIC_MF_ENABLED: "false" NEXT_PUBLIC_SHELL_URL: http://admin-portal:4003 NEXT_PUBLIC_WS_URL: ws://push-gateway:8081/ws NEXT_PUBLIC_OTEL_ENDPOINT: ${OTEL_EXPORTER_OTLP_ENDPOINT:-http://otel-collector:4318} NEXT_PUBLIC_WEB_VITALS_ENDPOINT: /api/admin/web-vitals ports: - "${ADMIN_PORTAL_PORT:-4003}:4003" depends_on: api-gateway: condition: service_healthy teacher-bff: condition: service_healthy healthcheck: test: ["CMD", "wget", "--quiet", "--spider", "http://localhost:4003/api/ready"] interval: 30s timeout: 5s start_period: 20s retries: 3 networks: - edu-net - edu-shared networks: # 应用服务内部网络 edu-net: driver: bridge # 与已有 MySQL/Redis 共享的网络 # 需确保 MySQL/Redis 容器已加入名为 edu-shared 的网络: # docker network create edu-shared (若不存在) # docker network connect edu-shared edu-mysql # docker network connect edu-shared edu-redis edu-shared: external: true