name: xianyu-assistant x-service-defaults: &service-defaults restart: unless-stopped init: true security_opt: - no-new-privileges:true logging: driver: json-file options: max-size: "10m" max-file: "5" compress: "true" services: mysql: <<: *service-defaults image: mysql:8.0@sha256:7dcddc01f13bab2f15cde676d44d01f61fc9f99fe7785e86196dfc07d358ae2b user: mysql read_only: true cap_drop: - ALL environment: MYSQL_ROOT_PASSWORD_FILE: /run/secrets/mysql_root_password MYSQL_DATABASE: ${MYSQL_DATABASE:?MYSQL_DATABASE must be set} MYSQL_APP_USER: ${MYSQL_APP_USER:?MYSQL_APP_USER must be set} MYSQL_APP_PASSWORD_FILE: /run/secrets/mysql_app_password MYSQL_MIGRATION_USER: ${MYSQL_MIGRATION_USER:?MYSQL_MIGRATION_USER must be set} MYSQL_MIGRATION_PASSWORD_FILE: /run/secrets/mysql_migration_password command: - --character-set-server=utf8mb4 - --collation-server=utf8mb4_unicode_ci - --skip-name-resolve - --local-infile=0 - --max-connections=200 volumes: - mysql_data:/var/lib/mysql - ./deploy/mysql/init/010-least-privilege-users.sh:/docker-entrypoint-initdb.d/010-least-privilege-users.sh:ro secrets: - mysql_root_password - mysql_app_password - mysql_migration_password tmpfs: - /tmp:rw,noexec,nosuid,size=64m,mode=1777 - /var/run/mysqld:rw,noexec,nosuid,size=16m,mode=1777 networks: - data healthcheck: test: ["CMD-SHELL", "MYSQL_PWD=\"$$(cat \"$${MYSQL_ROOT_PASSWORD_FILE}\")\" mysqladmin ping --protocol=tcp -h 127.0.0.1 -uroot --silent"] interval: 10s timeout: 5s retries: 12 start_period: 40s stop_grace_period: 60s mem_limit: ${MYSQL_MEMORY_LIMIT:-1g} mem_reservation: 512m cpus: ${MYSQL_CPU_LIMIT:-1.5} pids_limit: 256 redis: <<: *service-defaults image: redis:7.4-alpine@sha256:6ab0b6e7381779332f97b8ca76193e45b0756f38d4c0dcda72dbb3c32061ab99 user: redis read_only: true cap_drop: - ALL environment: REDIS_PASSWORD_FILE: /run/secrets/redis_password command: - /bin/sh - -ec - | umask 077 password="$$(cat "$${REDIS_PASSWORD_FILE}")" case "$${password}" in ''|*[!A-Za-z0-9_-]*) echo 'Redis secret must be non-empty Base64URL text.' >&2; exit 1 ;; esac printf 'bind 0.0.0.0\nprotected-mode yes\nport 6379\ndir /data\nappendonly yes\nappendfsync everysec\nrequirepass %s\nmaxmemory 384mb\nmaxmemory-policy noeviction\n' "$${password}" > /tmp/redis.conf unset password exec redis-server /tmp/redis.conf volumes: - redis_data:/data secrets: - redis_password tmpfs: - /tmp:rw,noexec,nosuid,size=16m,mode=1777 networks: - data healthcheck: test: ["CMD-SHELL", "REDISCLI_AUTH=\"$$(cat \"$${REDIS_PASSWORD_FILE}\")\" redis-cli --no-auth-warning ping | grep -q PONG"] interval: 10s timeout: 5s retries: 10 start_period: 10s stop_grace_period: 30s mem_limit: ${REDIS_MEMORY_LIMIT:-512m} mem_reservation: 128m cpus: ${REDIS_CPU_LIMIT:-0.75} pids_limit: 128 crawler: <<: *service-defaults image: ${CRAWLER_IMAGE:-registry.cn-hangzhou.aliyuncs.com/xianyu-assistant/xianyu-assistant-crawler:latest} build: context: ./apps/crawler user: pwuser read_only: true cap_drop: - ALL environment: NODE_ENV: production HOST: 0.0.0.0 PORT: 3001 INTERNAL_API_TOKEN_FILE: /run/secrets/internal_api_token CRAWLER_JSON_LIMIT: ${CRAWLER_JSON_LIMIT:-256kb} CRAWLER_RATE_LIMIT_WINDOW_MS: ${CRAWLER_RATE_LIMIT_WINDOW_MS:-60000} CRAWLER_RATE_LIMIT_MAX: ${CRAWLER_RATE_LIMIT_MAX:-60} CRAWLER_MAX_CONCURRENCY: ${CRAWLER_MAX_CONCURRENCY:-2} CRAWLER_OPERATION_TIMEOUT_MS: ${CRAWLER_OPERATION_TIMEOUT_MS:-180000} CRAWLER_MAX_COOKIE_BYTES: ${CRAWLER_MAX_COOKIE_BYTES:-65536} CRAWLER_ALLOWED_TARGET_HOSTS: ${CRAWLER_ALLOWED_TARGET_HOSTS:-goofish.com,taobao.com} CRAWLER_ALLOWED_ORIGINS: ${CRAWLER_ALLOWED_ORIGINS:-} CRAWLER_FORCE_HEADLESS: ${CRAWLER_FORCE_HEADLESS:-true} CRAWLER_SAVE_FAILURE_SCREENSHOTS: "false" CRAWLER_SCREENSHOT_DIR: /tmp/crawler-screenshots # Chromium's process sandbox is not reliable under Docker's dropped # capabilities/no-new-privileges profile. Container isolation remains on. PLAYWRIGHT_DISABLE_SANDBOX: ${PLAYWRIGHT_DISABLE_SANDBOX:-true} expose: - "3001" secrets: - internal_api_token tmpfs: - /tmp:rw,nosuid,size=512m,mode=1777 # Chromium 在容器内运行时使用 /dev/shm 作为共享内存。 # 默认 64m 会导致页面渲染崩溃("Target closed" / "Session deleted"), # 至少需要 1GB;2GB 可稳定支撑滑块求解 + 标签页交互。 shm_size: ${CRAWLER_SHM_SIZE:-2g} networks: application: ipv4_address: 172.30.240.30 egress: healthcheck: test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3001/ready').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"] interval: 15s timeout: 5s retries: 10 start_period: 30s stop_grace_period: 30s mem_limit: ${CRAWLER_MEMORY_LIMIT:-2g} mem_reservation: 512m cpus: ${CRAWLER_CPU_LIMIT:-2.0} pids_limit: 512 migrate: <<: *service-defaults build: context: ./apps/api image: ${API_IMAGE:-registry.cn-hangzhou.aliyuncs.com/xianyu-assistant/xianyu-assistant-api:latest} command: ["python", "-m", "app.migrations", "upgrade"] restart: "no" user: "10001:10001" read_only: true cap_drop: - ALL environment: APP_ENV: production ADMIN_PASSWORD_HASH_FILE: /run/secrets/admin_password_hash MYSQL_HOST: mysql MYSQL_PORT: 3306 MYSQL_DATABASE: ${MYSQL_DATABASE:?MYSQL_DATABASE must be set} MYSQL_USER: ${MYSQL_MIGRATION_USER:?MYSQL_MIGRATION_USER must be set} MYSQL_PASSWORD_FILE: /run/secrets/mysql_password REDIS_PASSWORD_FILE: /run/secrets/redis_password JWT_SECRET_FILE: /run/secrets/jwt_secret COOKIE_CRYPTO_SECRET_FILE: /run/secrets/cookie_crypto_secret INTERNAL_API_TOKEN_FILE: /run/secrets/internal_api_token AUDIT_MUTATION_INTENT_REQUIRED: ${AUDIT_MUTATION_INTENT_REQUIRED:-true} TRUSTED_HOSTS: ${TRUSTED_HOSTS:-localhost,127.0.0.1,api} DOCS_ENABLED: "false" tmpfs: - /tmp:rw,noexec,nosuid,size=64m,mode=1777 secrets: - source: admin_password_hash target: admin_password_hash uid: "10001" gid: "10001" mode: 0400 - source: mysql_migration_password target: mysql_password uid: "10001" gid: "10001" mode: 0400 - source: redis_password target: redis_password uid: "10001" gid: "10001" mode: 0400 - source: jwt_secret target: jwt_secret uid: "10001" gid: "10001" mode: 0400 - source: cookie_crypto_secret target: cookie_crypto_secret uid: "10001" gid: "10001" mode: 0400 - source: internal_api_token target: internal_api_token uid: "10001" gid: "10001" mode: 0400 networks: - data depends_on: mysql: condition: service_healthy stop_grace_period: 30s mem_limit: ${MIGRATION_MEMORY_LIMIT:-512m} mem_reservation: 128m cpus: ${MIGRATION_CPU_LIMIT:-1.0} pids_limit: 128 api: <<: *service-defaults build: context: ./apps/api image: ${API_IMAGE:-registry.cn-hangzhou.aliyuncs.com/xianyu-assistant/xianyu-assistant-api:latest} user: "10001:10001" read_only: true cap_drop: - ALL environment: APP_ENV: production SERVER_HOST: 0.0.0.0 SERVER_PORT: 12401 ADMIN_USERNAME: ${ADMIN_USERNAME:-admin} ADMIN_PASSWORD_HASH_FILE: /run/secrets/admin_password_hash MYSQL_HOST: mysql MYSQL_PORT: 3306 MYSQL_DATABASE: ${MYSQL_DATABASE:?MYSQL_DATABASE must be set} MYSQL_USER: ${MYSQL_APP_USER:?MYSQL_APP_USER must be set} MYSQL_PASSWORD_FILE: /run/secrets/mysql_password REDIS_HOST: redis REDIS_PORT: 6379 REDIS_PASSWORD_FILE: /run/secrets/redis_password JWT_SECRET_FILE: /run/secrets/jwt_secret JWT_EXPIRATION_MS: ${JWT_EXPIRATION_MS:-43200000} JWT_ALGORITHM: HS256 JWT_ISSUER: ${JWT_ISSUER:-xianyu-assistant-api} JWT_AUDIENCE: ${JWT_AUDIENCE:-xianyu-assistant-web} COOKIE_CRYPTO_SECRET_FILE: /run/secrets/cookie_crypto_secret INTERNAL_API_TOKEN_FILE: /run/secrets/internal_api_token CRAWLER_BASE_URL: http://crawler:3001 CRAWLER_SERVICE_URL: http://crawler:3001 CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-} TRUSTED_PROXY_IPS: ${TRUSTED_PROXY_IPS:-172.30.240.20/32} TRUSTED_HOSTS: ${TRUSTED_HOSTS:-localhost,127.0.0.1,api} DOCS_ENABLED: "false" LOGIN_MAX_ATTEMPTS: ${LOGIN_MAX_ATTEMPTS:-5} LOGIN_LOCK_MINUTES: ${LOGIN_LOCK_MINUTES:-15} API_RATE_LIMIT_PER_MINUTE: ${API_RATE_LIMIT_PER_MINUTE:-600} AUDIT_LOG_RETENTION_DAYS: ${AUDIT_LOG_RETENTION_DAYS:-90} AUDIT_MUTATION_INTENT_REQUIRED: ${AUDIT_MUTATION_INTENT_REQUIRED:-true} MAX_REQUEST_BODY_BYTES: ${MAX_REQUEST_BODY_BYTES:-12582912} MAX_UPLOAD_BYTES: ${MAX_UPLOAD_BYTES:-10485760} SCHEDULER_POLL_INTERVAL_SECONDS: ${SCHEDULER_POLL_INTERVAL_SECONDS:-15} SCHEDULER_TASK_TIMEOUT_SECONDS: ${SCHEDULER_TASK_TIMEOUT_SECONDS:-300} SCHEDULER_LEASE_SECONDS: ${SCHEDULER_LEASE_SECONDS:-360} SCHEDULER_BATCH_SIZE: ${SCHEDULER_BATCH_SIZE:-20} DELIVERY_RECOVERY_ENABLED: ${DELIVERY_RECOVERY_ENABLED:-true} DELIVERY_RECOVERY_INTERVAL_SECONDS: ${DELIVERY_RECOVERY_INTERVAL_SECONDS:-600} DELIVERY_RECOVERY_BATCH_SIZE: ${DELIVERY_RECOVERY_BATCH_SIZE:-50} DELIVERY_RECOVERY_MIN_AGE_SECONDS: ${DELIVERY_RECOVERY_MIN_AGE_SECONDS:-300} STORAGE_DIR: /app/data COMMERCIAL_FRONTEND_URL: ${COMMERCIAL_FRONTEND_URL:-} COMMERCIAL_ADMIN_URL: ${COMMERCIAL_ADMIN_URL:-} COMMERCIAL_BACKEND_BASE_URL: ${COMMERCIAL_BACKEND_BASE_URL:-} COMMERCIAL_BACKEND_HEALTH_PATH: ${COMMERCIAL_BACKEND_HEALTH_PATH:-/admin-api/health} COMMERCIAL_BACKEND_ADMIN_HEALTH_PATH: ${COMMERCIAL_BACKEND_ADMIN_HEALTH_PATH:-/admin-api/health} COMMERCIAL_BACKEND_USER_HEALTH_PATH: ${COMMERCIAL_BACKEND_USER_HEALTH_PATH:-/api/health} COMMERCIAL_BACKEND_BRIDGE_PREFIX: ${COMMERCIAL_BACKEND_BRIDGE_PREFIX:-/admin-api/open-source-bridge} COMMERCIAL_BACKEND_ACCESS_TOKEN_FILE: /run/secrets/commercial_backend_access_token COMMERCIAL_BACKEND_SITE_CODE: ${COMMERCIAL_BACKEND_SITE_CODE:-open-source} COMMERCIAL_BACKEND_SITE_NAME: ${COMMERCIAL_BACKEND_SITE_NAME:-open-source} COMMERCIAL_BACKEND_TIMEOUT_SECONDS: ${COMMERCIAL_BACKEND_TIMEOUT_SECONDS:-15} COMMERCIAL_BACKEND_MUTATION_IDEMPOTENCY_ENABLED: ${COMMERCIAL_BACKEND_MUTATION_IDEMPOTENCY_ENABLED:-true} COMMERCIAL_BACKEND_PAYMENT_IDEMPOTENCY_ENABLED: ${COMMERCIAL_BACKEND_PAYMENT_IDEMPOTENCY_ENABLED:-true} COMMERCIAL_BACKEND_PAID_AD_PLACEMENT_ENFORCED: ${COMMERCIAL_BACKEND_PAID_AD_PLACEMENT_ENFORCED:-true} EMBEDDING_API_KEY_FILE: /run/secrets/embedding_api_key EMBEDDING_BASE_URL: ${EMBEDDING_BASE_URL:-https://api.openai.com/v1} EMBEDDING_MODEL: ${EMBEDDING_MODEL:-text-embedding-3-small} AI_PROVIDER_ENABLED: ${AI_PROVIDER_ENABLED:-false} AI_PROVIDER_BASE_URL: ${AI_PROVIDER_BASE_URL:-} AI_PROVIDER_API_KEY_FILE: /run/secrets/ai_provider_api_key AI_PROVIDER_MODEL: ${AI_PROVIDER_MODEL:-gpt-4o-mini} AI_PROVIDER_TIMEOUT_SECONDS: ${AI_PROVIDER_TIMEOUT_SECONDS:-30} XIANYU_MTOP_APP_KEY: ${XIANYU_MTOP_APP_KEY:-34839810} expose: - "12401" volumes: - api_uploads:/app/uploads - api_data:/app/data # 将仓库根目录的 CHANGELOG.md 只读挂载到容器,供"关于我们"更新日志板块解析展示 - ./CHANGELOG.md:/app/CHANGELOG.md:ro secrets: - source: admin_password_hash target: admin_password_hash uid: "10001" gid: "10001" mode: 0400 - source: mysql_app_password target: mysql_password uid: "10001" gid: "10001" mode: 0400 - source: redis_password target: redis_password uid: "10001" gid: "10001" mode: 0400 - source: jwt_secret target: jwt_secret uid: "10001" gid: "10001" mode: 0400 - source: cookie_crypto_secret target: cookie_crypto_secret uid: "10001" gid: "10001" mode: 0400 - source: internal_api_token target: internal_api_token uid: "10001" gid: "10001" mode: 0400 - source: commercial_backend_access_token target: commercial_backend_access_token uid: "10001" gid: "10001" mode: 0400 - source: embedding_api_key target: embedding_api_key uid: "10001" gid: "10001" mode: 0400 - source: ai_provider_api_key target: ai_provider_api_key uid: "10001" gid: "10001" mode: 0400 tmpfs: - /tmp:rw,noexec,nosuid,size=128m,mode=1777 networks: application: ipv4_address: 172.30.240.10 data: egress: depends_on: mysql: condition: service_healthy redis: condition: service_healthy migrate: condition: service_completed_successfully healthcheck: test: ["CMD", "python", "-c", "import urllib.request; r=urllib.request.urlopen('http://127.0.0.1:12401/health/ready', timeout=3); raise SystemExit(0 if r.status == 200 else 1)"] interval: 15s timeout: 5s retries: 10 start_period: 45s stop_grace_period: 45s mem_limit: ${API_MEMORY_LIMIT:-1g} mem_reservation: 256m cpus: ${API_CPU_LIMIT:-2.0} pids_limit: 256 worker: extends: service: api command: ["python", "-m", "app.worker"] environment: SCHEDULER_HEARTBEAT_PATH: /tmp/xianyu-scheduler-heartbeat SCHEDULER_HEARTBEAT_MAX_AGE_SECONDS: ${SCHEDULER_HEARTBEAT_MAX_AGE_SECONDS:-60} healthcheck: test: ["CMD", "python", "-m", "app.worker", "--check"] interval: 15s timeout: 5s retries: 4 start_period: 30s networks: application: ipv4_address: 172.30.240.11 data: egress: stop_grace_period: 370s mem_limit: ${SCHEDULER_MEMORY_LIMIT:-1g} mem_reservation: 256m cpus: ${SCHEDULER_CPU_LIMIT:-1.0} pids_limit: 256 web: <<: *service-defaults image: ${WEB_IMAGE:-registry.cn-hangzhou.aliyuncs.com/xianyu-assistant/xianyu-assistant-web:latest} build: context: ./apps/web user: nginx read_only: true cap_drop: - ALL ports: - "${WEB_BIND_ADDRESS:-127.0.0.1}:${WEB_PORT:-8080}:8080" tmpfs: - /tmp:rw,noexec,nosuid,size=128m,mode=1777 networks: application: ipv4_address: 172.30.240.20 edge: ipv4_address: 172.30.243.10 depends_on: api: condition: service_healthy healthcheck: test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8080/readyz | grep -q '\"status\":\"ready\"'"] interval: 15s timeout: 5s retries: 10 start_period: 10s stop_grace_period: 20s mem_limit: ${WEB_MEMORY_LIMIT:-256m} mem_reservation: 64m cpus: ${WEB_CPU_LIMIT:-0.5} pids_limit: 128 networks: application: internal: true ipam: config: - subnet: 172.30.240.0/24 data: internal: true egress: edge: ipam: config: - subnet: 172.30.243.0/24 gateway: 172.30.243.1 volumes: mysql_data: redis_data: api_uploads: api_data: secrets: # 所有密钥均从 ./secrets/ 文件读取,由 start.sh / start.bat 首次启动时 # 自动生成。容器内通过 environment 中的 *_FILE=/run/secrets/ 读取。 # 主机文件路径与 .env.example 中的 *_FILE 变量保持一致作为文档说明。 admin_password_hash: file: ./secrets/admin-password-hash mysql_root_password: file: ./secrets/mysql-root-password mysql_app_password: file: ./secrets/mysql-app-password mysql_migration_password: file: ./secrets/mysql-migration-password redis_password: file: ./secrets/redis-password jwt_secret: file: ./secrets/jwt-secret cookie_crypto_secret: file: ./secrets/cookie-crypto-secret internal_api_token: file: ./secrets/internal-api-token commercial_backend_access_token: file: ./secrets/commercial-backend-access-token embedding_api_key: file: ./secrets/embedding-api-key ai_provider_api_key: file: ./secrets/ai-provider-api-key