Ctrl K
ComunidadGuías…

docker-compose.yml

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
services:
  # ─── next-app (Next.js 16 RSC) ───────────────────────────────────────
  # Standalone Bun-built Next.js process. After the legacy retirement
  # nginx routes EVERY user-facing route here (SEO pages, auth, /library,
  # /player, /admin, and the catch-all). RSC server-side fetches go
  # straight to go-api:8080 via the Docker network (GO_API_INTERNAL_URL),
  # NOT through nginx. At the nginx layer /api/* → go-api and
  # /socket.io/ → ws-server.
  next-app:
    build:
      context: .
      dockerfile: next-app/Dockerfile
      # Build args read from the env file (deploy.sh + docker compose
      # both pull --env-file=.env.production). NEXT_PUBLIC_SENTRY_DSN
      # MUST land at build because Next inlines NEXT_PUBLIC_* into the
      # client bundle. Empty default keeps a build-without-env working.
      args:
        NEXT_PUBLIC_SENTRY_DSN: ${NEXT_PUBLIC_SENTRY_DSN:-}
        # The token says who may upload; these two say where to. All three
        # have to reach the build or the source-map step cannot run.
        SENTRY_AUTH_TOKEN: ${SENTRY_AUTH_TOKEN:-}
        SENTRY_ORG: ${SENTRY_ORG:-}
        SENTRY_PROJECT: ${SENTRY_PROJECT:-}
        # Set by deploy.sh from `git rev-parse HEAD`; empty elsewhere.
        GIT_SHA: ${GIT_SHA:-}
        # Where pages point AniList images: our own copy of the originals
        # (nginx /img/anilist/, filled by go-api's image warm job) instead of
        # AniList's CDN. Inlined at build time like every NEXT_PUBLIC_*. The
        # `-` (not `:-`) is the off switch: an empty value in .env.production
        # survives and puts every image back on AniList at the next deploy.
        NEXT_PUBLIC_IMAGE_MIRROR_BASE: ${NEXT_PUBLIC_IMAGE_MIRROR_BASE-https://animegoclub.com/img/anilist/}
    # Named so next-image below can run this same build without building it
    # a second time. It is the name compose would give it anyway.
    image: animego-next-app:latest
    restart: unless-stopped
    env_file: .env.production
    environment:
      # RSC server-side fetch base (next-app server → go-api directly via
      # the Docker network, NOT through nginx). .env.production also sets
      # this; the default here keeps a bare `docker compose up` coherent
      # now that the legacy Express `app` service is retired.
      GO_API_INTERNAL_URL: ${GO_API_INTERNAL_URL:-http://go-api:8080}
      NODE_ENV: production
      NEXT_TELEMETRY_DISABLED: "1"
      PORT: "3000"
      HOSTNAME: "0.0.0.0"
      # Server-side timezone. Without this the container defaults to UTC
      # and any toLocaleTimeString / toLocaleDateString call SSR-renders
      # a different string than the client (browser local TZ) →
      # React error #418 hydration text mismatch. Asia/Shanghai matches
      # the primary user base (CN/HK/TW/海外华人). Browser-side TZ
      # outliers (US/EU users) will still mismatch — those callsites
      # need suppressHydrationWarning as a separate fix.
      TZ: Asia/Shanghai
    depends_on:
      - go-api
    networks:
      - animego
    healthcheck:
      # Use 127.0.0.1 not localhost: alpine resolves localhost to ::1
      # first, but next 16 standalone server only binds the IPv4
      # wildcard (HOSTNAME=0.0.0.0). v6 connect fails, container shows
      # unhealthy even though every external request works.
      test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/api/healthz"]
      interval: 15s
      timeout: 3s
      retries: 3
      start_period: 30s
    # Headroom for SSR while caches are still cold after a deploy.
    mem_limit: 1g
    logging:
      driver: json-file
      options:
        max-size: "10m"
        max-file: "3"

  # ─── next-image (the image optimizer, on its own) ────────────────────
  # The same build as next-app, but nginx sends it /_next/image and nothing
  # else. An image nobody has asked for yet costs a download and an AVIF
  # encode, and anyone can ask for any image the site shows; when a scraper
  # did exactly that, the encodes shared next-app's CPU and page renders timed
  # out behind them. Here they get a ceiling of their own: a flood of new
  # images makes images slow (past nginx's max_conns, some come back 503 and
  # are tried again on the next view), and pages keep the rest of the machine.
  #
  # No env_file: this container fetches and decodes images from outside and
  # needs none of the application's secrets to do it.
  next-image:
    image: animego-next-app:latest
    # Built by next-app's `build:` above, never pulled from a registry.
    pull_policy: never
    restart: unless-stopped
    environment:
      NODE_ENV: production
      NEXT_TELEMETRY_DISABLED: "1"
      PORT: "3000"
      HOSTNAME: "0.0.0.0"
      TZ: Asia/Shanghai
      # Server-side error reporting, the one runtime variable the server reads
      # at boot (sentry.server.config.ts). Empty disables it.
      SENTRY_DSN: ${SENTRY_DSN:-}
    networks:
      - animego
    healthcheck:
      test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/api/healthz"]
      interval: 15s
      timeout: 3s
      retries: 3
      start_period: 30s
    cpus: 1.5
    # Every request in flight holds its source image, and the ones sharp is
    # working on hold decoded frames too. nginx caps how many are in flight
    # (max_conns in default.conf, set so that bursts queue here rather than
    # fail).
    mem_limit: 1g
    volumes:
      # Optimized images survive container recreation (every deploy recreates
      # this container). Only images/: the rest of .next/cache is the fetch
      # data cache, which must not outlive the build that wrote it.
      - next-image-cache:/app/.next/cache/images
    logging:
      driver: json-file
      options:
        max-size: "10m"
        max-file: "3"

  # ─── ws-server (socket.io danmaku) ───────────────────────────────────
  # Standalone Node 20 process speaking socket.io v4. Reads JWT_SECRET
  # shared with go-api so one login token works across surfaces. Writes
  # danmakus + episode_windows directly to Postgres. nginx routes
  # /socket.io/ here (the legacy Express `app` it used to share is retired).
  ws-server:
    build:
      context: ./ws-server
      dockerfile: Dockerfile
    restart: unless-stopped
    env_file: .env.production
    environment:
      PORT_WS: 3001
    depends_on:
      postgres:
        condition: service_healthy
    networks:
      - animego
    healthcheck:
      test: ["CMD", "wget", "-qO-", "http://localhost:3001/health"]
      interval: 15s
      timeout: 3s
      retries: 3
      start_period: 5s
    mem_limit: 128m
    logging:
      driver: json-file
      options:
        max-size: "10m"
        max-file: "3"

  # ─── P8.5: postgres (Postgres 16 + pg_cron) ───────────────────────────
  # Custom image bundles pg_cron for danmaku TTL jobs and River queue
  # extension support.  pg_cron is loaded via shared_preload_libraries
  # baked into the image's postgresql.conf.sample — no runtime flag
  # needed beyond the command below (which is belt-and-suspenders for
  # fresh volumes that haven't run initdb yet).
  # go-api + ws-server both connect via DATABASE_URL constructed in their
  # respective environment blocks; neither reads POSTGRES_PASSWORD directly.
  postgres:
    build:
      context: ./go-api/docker/postgres
    restart: unless-stopped
    environment:
      POSTGRES_DB: animego
      POSTGRES_USER: animego
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    volumes:
      - postgres-data:/var/lib/postgresql/data
    command: ["postgres", "-c", "shared_preload_libraries=pg_cron", "-c", "cron.database_name=animego"]
    networks:
      - animego
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U animego -d animego"]
      interval: 10s
      timeout: 3s
      retries: 5
      start_period: 10s
    # Headroom above shared_buffers for per-connection work under load.
    mem_limit: 1g
    logging:
      driver: json-file
      options:
        max-size: "10m"
        max-file: "3"

  # ─── P9: schema migrate (one-shot) ────────────────────────────────────
  # Applies go-api/migrations/*.sql (golang-migrate) to the postgres volume.
  # NOT in the default `up` set — guarded behind the `migrate` profile so it
  # only runs on an explicit `docker compose --profile migrate run --rm
  # migrate`. The cutover MUST run this before go-api starts: go-api's River
  # queue crash-loops on an empty DB (no river_job table). There is no other
  # schema-apply path — go-api/cmd/migrate does not exist, migrations are not
  # embedded in the server binary, and the VPS has no host golang-migrate.
  # Connects over the internal animego network (postgres:5432), so it dodges
  # the host-port IPv6/first-init race that p9-dry-run.sh works around.
  # Pinned to v4.19.1 to match the golang-migrate used locally.
  migrate:
    image: migrate/migrate:v4.19.1
    profiles: ["migrate"]
    depends_on:
      postgres:
        condition: service_healthy
    volumes:
      - ./go-api/migrations:/migrations:ro
    command:
      [
        "-path", "/migrations",
        "-database", "postgres://animego:${POSTGRES_PASSWORD}@postgres:5432/animego?sslmode=disable",
        "up",
      ]
    networks:
      - animego
    restart: "no"

  # ─── P8.5: go-api (Go HTTP API) ───────────────────────────────────────
  # Chi-based Go server.  Waits for postgres healthcheck before starting
  # so pgxpool.Connect at boot never races a cold DB.
  # DATABASE_URL is constructed here from POSTGRES_PASSWORD so the
  # literal password never lands in .env.production (only the bare var does).
  # PORT_GO matches go-api/internal/config/config.go envInt("PORT_GO", 8080).
  go-api:
    build:
      context: ./go-api
    restart: unless-stopped
    # Docker's default stop grace is 10s, and main.go has always given
    # srv.Shutdown a 15s budget — so the graceful path was never actually
    # reachable: the kernel SIGKILLed the process five seconds into it.
    # Migration 0025's activity recorder made that visible rather than
    # merely wasteful, because it drains its in-memory buffer after
    # Shutdown returns and a SIGKILL there throws away the last minute of
    # counters. 30s covers 15s of shutdown, the recorder's 3s drain and the
    # job queue's 10s stop (queue.Shutdown: five to finish, five to cancel
    # what is still running), 28s in all.
    stop_grace_period: 30s
    env_file: .env.production
    environment:
      DATABASE_URL: postgres://animego:${POSTGRES_PASSWORD}@postgres:5432/animego?sslmode=disable
      PORT_GO: "8080"
      AVATAR_DIR: /data/avatars
      # Where the hant_backfill river worker finds the vendored zh-Hant
      # datasets. The Dockerfile bakes go-api/data/hant here rather than
      # go:embed-ing it, because cgroup-hk.json is CC BY-SA and embedding
      # would pull it into an AGPL package. The Go default is the
      # checkout-relative data/hant, which does not exist in the image, so
      # this line is what makes the sweep able to run at all in production.
      HANT_DATA_DIR: /usr/local/share/animego/hant
      # The image warm job asks nginx's internal warm location to store each
      # AniList original the database references. Port 8090 exists only on the
      # compose network (nginx publishes 80/443 and nothing else). As with the
      # mirror switch above, an empty value in .env.production turns the job
      # off.
      IMAGE_WARM_BASE_URL: ${IMAGE_WARM_BASE_URL-http://nginx:8090/warm/}
      # The variant warm job asks nginx's internal image location, overnight,
      # for every variant of those images the pages show, so nginx encodes and
      # keeps each before a reader needs it. The same internal-only port. An
      # empty value in .env.production turns the job off.
      IMAGE_VARIANT_WARM_BASE_URL: ${IMAGE_VARIANT_WARM_BASE_URL-http://nginx:8090/_next/image}
    volumes:
      # Member-pass photos live here (served at /api/avatars/*). A named
      # volume keeps them across deploys — back this up alongside postgres.
      - avatars:/data/avatars
    depends_on:
      postgres:
        condition: service_healthy
    networks:
      - animego
    healthcheck:
      test: ["CMD", "wget", "-qO-", "http://localhost:8080/health"]
      interval: 15s
      timeout: 3s
      retries: 3
      start_period: 10s
    # Headroom for the in-process caches while they refill after a restart.
    mem_limit: 512m
    # More log than the other services keep: every API request writes a
    # line, and at 3 x 10m the night's job summaries (the image variant
    # warm passes, 01:00–07:00 Asia/Shanghai) had rotated away by the
    # morning. 5 x 50m holds about a day.
    logging:
      driver: json-file
      options:
        max-size: "50m"
        max-file: "5"

  # ─── Enrichment backfill (one-shot ops tool) ──────────────────────────
  # Re-validates every existing bgm binding against the authoritative id-map
  # + dandanplay, then fixes the legacy backlog (wrong bindings → reset for
  # re-enrich; missing CN → healed from dandanplay). NOT in the default `up`
  # set — guarded behind the `tools` profile. Reuses the go-api image (which
  # now bundles the bgmbackfill binary) + its env, overriding the entrypoint.
  # Runs as root so it can write report.json / backup-*.json into
  # ./backfill-out on the host — the operator reviews the report and KEEPS
  # the backup as the --apply rollback.
  #
  # Run on the VPS AFTER deploy + migrate, reviewing the report first:
  #   mkdir -p backfill-out
  #   docker compose --env-file .env.production --profile tools run --rm backfill --report
  #   docker compose --env-file .env.production --profile tools run --rm backfill --apply
  #   docker compose --env-file .env.production restart go-api   # orphan scan re-enriches the reset rows
  #   docker compose --env-file .env.production --profile tools run --rm backfill --heal
  backfill:
    build:
      context: ./go-api
    profiles: ["tools"]
    user: "0:0"
    working_dir: /out
    env_file: .env.production
    environment:
      DATABASE_URL: postgres://animego:${POSTGRES_PASSWORD}@postgres:5432/animego?sslmode=disable
    volumes:
      - ./backfill-out:/out
    depends_on:
      postgres:
        condition: service_healthy
    networks:
      - animego
    entrypoint: ["bgmbackfill"]
    restart: "no"
    logging:
      driver: json-file
      options:
        max-size: "10m"
        max-file: "3"

  # ─── zh-Hant title backfill (one-shot ops tool) ───────────────────────
  # Fills anime_cache.title_hant / description_hant from the vendored
  # datasets in go-api/data/hant (baked into the image at
  # /usr/local/share/animego/hant). Same shape as `backfill` above: tools
  # profile, root so it can write the report and backup into ./backfill-out,
  # and the operator reads the report before applying anything.
  #
  # Nothing here touches the network, so unlike the enrichment workers there
  # is no rate-limit budget to blow by running it alongside a live go-api.
  #
  #   mkdir -p backfill-out
  #   docker compose --env-file .env.production --profile tools run --rm hantbackfill --report
  #   docker compose --env-file .env.production --profile tools run --rm hantbackfill --apply
  #   # later, to find rows whose source hash no longer matches its input:
  #   docker compose --env-file .env.production --profile tools run --rm hantbackfill --restale
  hantbackfill:
    build:
      context: ./go-api
    profiles: ["tools"]
    user: "0:0"
    working_dir: /out
    env_file: .env.production
    environment:
      DATABASE_URL: postgres://animego:${POSTGRES_PASSWORD}@postgres:5432/animego?sslmode=disable
    volumes:
      - ./backfill-out:/out
    depends_on:
      postgres:
        condition: service_healthy
    networks:
      - animego
    entrypoint: ["hantbackfill", "--data", "/usr/local/share/animego/hant"]
    restart: "no"
    logging:
      driver: json-file
      options:
        max-size: "10m"
        max-file: "3"

  nginx:
    image: nginx:alpine
    restart: unless-stopped
    # 80 and 443 only. The warm server on 8090 stores whatever it is asked
    # for, so it must stay reachable from the compose network alone: never
    # add it here.
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
      - ./nginx/selfsigned.crt:/etc/nginx/ssl/selfsigned.crt:ro
      - ./nginx/selfsigned.key:/etc/nginx/ssl/selfsigned.key:ro
      # P8.1 Day-1 Fix: static maintenance page served as the `error_page`
      # target whenever next-app is unreachable (default.conf 502/503/504
      # `@next_app_down`) or whenever a P6/P7/P9 surface is requested while
      # nginx is on default.legacy.conf rollback (Express has no handler).
      # Bind-mount, not COPY, so editing the file + `docker compose restart
      # nginx` is enough — no image rebuild.
      - ./nginx/maintenance.html:/usr/share/nginx/html/maintenance.html:ro
      # Both image caches in default.conf live on this volume: next_image
      # (encoded variants, so a recreated nginx does not send them all to
      # next-image again) and anilist_orig (the AniList originals, which
      # take hours to warm and must outlive every deploy).
      - nginx-cache:/var/cache/nginx
      # Rules that must not be public, kept only on the server (git ignores
      # the directory). default.conf includes what is there; an empty or
      # missing directory adds nothing.
      - ./nginx/private:/etc/nginx/private:ro
    depends_on:
      - next-app
    networks:
      - animego
    # Headroom for open connections and the image cache's working set. The
    # cache's key index is shared memory (keys_zone in default.conf) and
    # counts against this limit.
    mem_limit: 512m
    logging:
      driver: json-file
      options:
        max-size: "10m"
        max-file: "3"

volumes:
  postgres-data:
  avatars:
  next-image-cache:
  nginx-cache:

networks:
  animego: