Перейти к содержимому
База данных и общий compose

База данных и общий compose

В прошлой лабораторной мы подняли Fastify-сервер с парой маршрутов, которые работали с in-memory массивом пользователей. Сейчас же мы:

  • подключим к backend настоящую базу данных — PostgreSQL через ORM Prisma;
  • перепишем in-memory ручки на работу с БД и применим первую миграцию;
  • упакуем backend в Docker (multi-stage Dockerfile);
  • соберём общий compose на основе механизма override’ов: один базовый файл и две вариации (dev / prod);
  • поставим перед всем этим NGINX как единую точку входа и Scalar как UI для просмотра OpenAPI.

К концу лабораторной у нас одной командой будет подниматься вся информационная система — frontend, backend, БД, NGINX и Scalar в одной сети.

Подключение Prisma и описание схемы

Взаимодействовать с базой будем не путём прямых SQL-запросов, как вы это делали раньше, а через ORM.

ORM (Object-Relational Mapping, объектно-реляционное отображение) — технология программирования, суть которой заключается в создании «виртуальной объектной базы данных». Фактически вы начинаете взаимодействовать с базой путём совершения операций с объектами.

В нашем проекте будем использовать Prisma ORM — один из самых стабильных и зрелых вариантов в сообществе. Есть и другие варианты, например, Drizzle. Он предлагает более высокую скорость выполнения операций и возможность низкоуровневого формирования запросов, что обеспечивает больший контроль. Но мы пока действуем по принципу наименьшего сопротивления — Prisma обладает более обширным сообществом и не требует углублённой конфигурации при первоначальном запуске.

Эта лабораторная ориентирована на Prisma 6.19.x. В версии 7 изменился механизм загрузки строки подключения к базе, поэтому, чтобы инструкции отрабатывали корректно, в package.json явно зафиксируйте мажорную версию 6 для prisma и @prisma/client. Если рядом со схемой автоматически создастся файл prisma.config.ts — его нужно удалить.

Из папки backend/ устанавливаем Prisma и инициализируем структуру:

backend/Terminal
# фиксируем мажорную версию Prisma 6 — на v7 поведение генератора и output ломает текущую инструкцию
npm i prisma@^6.19 @prisma/client@^6.19
npx prisma init --datasource-provider postgresql

Эта команда создаст:

  • prisma/schema.prisma — файл схемы базы;
  • .env рядом с ним (его можно сразу удалить — мы будем хранить переменные окружения в общем .env корня проекта).

Дополним package.json ещё парой Prisma-скриптов:

backend/package.json
{
  ...
  "scripts": {
    "dev": "tsx src/server.ts",
    "build": "tsc",
    "start": "node dist/server.js",
    "db:generate": "prisma generate",
    "db:deploy": "prisma migrate deploy"
  }
  ...
}

Теперь поправим автоматически созданный schema.prisma. Основная строчка, которая нас интересует — output внутри блока generator client. По умолчанию там появляется значение по типу src/generated/..., однако после сборки образа в production код генерируется в директорию dist, и все импорты сломаются.

Поэтому сделаем небольшие изменения:

  • в качестве генератора установим просто prisma-client вместо prisma-client-js;
  • в поле output установим значение, немного отличающееся от стандартного — ../src/generated/prisma.
Раньше можно было просто убрать строку output в принципе, и продолжил бы работать стандартный import { PrismaClient } from '@prisma/client', но начиная с v7 в Prisma она станет обязательной, поэтому лучше подготовиться к этому заранее.
backend/prisma/schema.prisma
generator client {
  provider = "prisma-client"
  output   = "../src/generated/prisma"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

// Сущность User
model User {
  id        String   @id @default(cuid())
  email     String   @unique
  name      String?
  createdAt DateTime @default(now())
}

Ключевое слово model используется для обозначения сущностей моделей данных, про которые мы с вами разговаривали на 2 курсе в рамках дисциплины «Проектирование баз данных». Здесь специально оставлена только одна сущность User — этого достаточно, чтобы запустить миграции и убедиться в работоспособности связки. Room и Booking для итогового задания вы будете описывать самостоятельно по этому образцу.

После этого нам необходимо сделать первую миграцию, которая сгенерирует описание структуры БД и перечень всех изменений. Но делать мы это будем только после того, как поднимем сам контейнер с БД — к этому пункту мы вернёмся чуть позже.

Prisma-плагин для Fastify

Подключим Prisma в Fastify через отдельный плагин — так клиент создаётся один раз на всё приложение, аккуратно закрывается на shutdown и доступен во всех маршрутах через app.prisma:

backend/src/plugins/prisma.ts
import fp from 'fastify-plugin'
import { PrismaClient } from '../generated/prisma/client.js'

// Расширяем типизацию Fastify: после регистрации плагина у экземпляра появится свойство prisma.
declare module 'fastify' {
  interface FastifyInstance {
    prisma: PrismaClient
  }
}

// Fastify-плагин, который создаёт один экземпляр PrismaClient и подключает его ко всему приложению.
export default fp(async (app) => {
  const prisma = new PrismaClient()
  // decorate делает prisma доступным как app.prisma во всех маршрутах и хуках.
  app.decorate('prisma', prisma)

  // Хук onClose вызывает $disconnect, когда сервер останавливается, чтобы закрыть соединение с БД.
  app.addHook('onClose', async (inst) => {
    await inst.prisma.$disconnect()
  })
})

Теперь добавим регистрацию плагина в app.ts и перепишем in-memory ручки на работу с БД. Изменения точечные — основной каркас buildApp() (helmet, cors, rate-limit, swagger, error handlers, notFoundHandler) остаётся ровно тем же, что в лабе про Fastify. Меняются только три вещи:

1. Импорт и регистрация Prisma-плагина. Добавляем импорт и регистрируем плагин после инфраструктурных плагинов, но до маршрутов:

backend/src/app.ts (новый импорт + регистрация)
import prismaPlugin from './plugins/prisma.js'

// внутри buildApp(), после await app.register(swagger, ...):
await app.register(prismaPlugin)

2. Удаляем массив users из памяти. Старая константа

const users: User[] = [
  { id: 'u-1', email: 'alice@example.com' },
  { id: 'u-2', email: 'bob@example.com' },
]

больше не нужна — данные теперь живут в БД.

3. Хендлеры /api/users и /api/health. Сами схемы маршрутов не меняются (в /api/health дополнительно появляется ответ 503), а вот тела хендлеров заменяем на работу с Prisma:

backend/src/app.ts (новые хендлеры)
app.get('/api/users', { schema: /* как было в лабе 06 */ },
  async () => app.prisma.user.findMany({ select: { id: true, email: true } })
)

app.get(
  '/api/health',
  {
    schema: {
      operationId: 'health',
      tags: ['System'],
      summary: 'Health/Readiness',
      description: 'Проверяет, что процесс жив и база данных отвечает.',
      response: {
        200: { description: 'Ready', content: { 'application/json': { schema: Health } } },
        503: { description: 'Temporarily unavailable', content: { 'application/problem+json': { schema: ProblemDetails } } },
      },
    },
  },
  async (_req, reply) => {
    try {
      // Если SELECT 1 прошёл — сервис готов.
      await app.prisma.$queryRaw`SELECT 1`
      return { ok: true } as Health
    } catch {
      // Возвращаем 503, чтобы балансировщик мог вывести инстанс из ротации.
      reply.code(503).type('application/problem+json').send({
        type: 'https://example.com/problems/dependency-unavailable',
        title: 'Service Unavailable',
        status: 503,
        detail: 'Database ping failed',
        instance: '/api/health',
      } satisfies ProblemDetails)
    }
  }
)

server.ts и types.ts остаются без изменений — graceful shutdown сам корректно вызовет prisma.$disconnect() через onClose-хук плагина, а схемы User/Health/ProblemDetails уже подходят и для in-memory, и для Prisma.

Dockerfile для бэкенда

В лабораторной про Docker мы написали multi-stage Dockerfile для фронтенда. Теперь по той же схеме соберём backend — несколько стадий: установка зависимостей, сборка TS, dev-режим (для compose) и облегчённый prod-образ.

backend/Dockerfile
FROM node:24-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm \
    npm i --no-audit --no-fund

FROM node:24-alpine AS build
WORKDIR /app
ENV NODE_ENV=development
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npx prisma generate --schema=prisma/schema.prisma
RUN npm run build
# Оставляем только runtime-зависимости
RUN npm prune --omit=dev

FROM node:24-alpine AS dev
WORKDIR /app
ENV NODE_ENV=development
COPY --from=deps /app/node_modules ./node_modules
COPY . .
CMD ["sh","-lc","npx prisma generate --schema=prisma/schema.prisma && npx prisma migrate deploy || true; npx tsx src/server.ts"]

FROM node:24-alpine AS prod
WORKDIR /app
ENV NODE_ENV=production
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/dist         ./dist
COPY --from=build /app/prisma       ./prisma
COPY --from=build /app/src/generated/prisma ./dist/generated/prisma

USER node
EXPOSE 3000
CMD ["node","dist/server.js"]

Команды npx prisma generate в сочетании с migrate deploy призваны сгенерировать типы, через которые мы оперируем как объектами в коде в соответствии со схемой БД, и применить ранее созданные миграции.

Итоговая структура проекта

После всех этих манипуляций общая структура нашего проекта должна выглядеть примерно следующим образом:

      • schema.prisma
      • server.ts
    • Dockerfile
      • nginx.conf
    • Dockerfile
    • .dockerignore
    • vite.config.ts
    • nginx.conf
  • .env
  • compose.base.yaml
  • compose.dev.yaml
  • compose.prod.yaml
В frontend/ уже есть свой nginx/nginx.conf — он описывает раздачу статики SPA в prod-режиме (мы добавили его в лабе про Docker). А корневой nginx/nginx.conf отвечает за dev-режим, где он работает как единая точка входа и проксирует трафик на frontend, backend и Scalar. Это два разных файла с разными задачами — не путайте их между собой.

Общий compose: базовый файл

Начнём с того, что инициализируем базовый файл. Будем основываться на концепции override’ов, которая упоминалась в лабораторной про Docker: один базовый compose-файл с общими настройками и несколько override’ов под конкретное окружение.

compose.base.yaml
# БАЗОВЫЕ ЯКОРЯ
x-env-file: &env_file [.env]

x-db-env: &db_env
  POSTGRES_DB: ${POSTGRES_DB}
  POSTGRES_USER: ${POSTGRES_USER}
  POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}

x-fastify-env: &fastify_env
  DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}?schema=public
  PORT: "3000"

x-db-healthy: &db_healthy
  depends_on:
    db:
      condition: service_healthy

services:
  db:
    image: postgres:17-alpine
    env_file: *env_file
    environment: *db_env
    volumes: [ "db_data:/var/lib/postgresql/data" ]
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
      interval: 5s
      timeout: 3s
      retries: 10

  backend:
    <<: *db_healthy
    env_file: *env_file
    build:
      context: ./backend
      dockerfile: Dockerfile
      # target задаётся в override-файлах (dev/prod)
    environment: *fastify_env
    expose: ["3000"]

  scalar:
    # для учебного стенда оставляем :latest — на проде лучше пинить конкретный SHA-дайджест
    image: scalarapi/api-reference:latest
    environment:
      # URL относительный, редирект идёт через тот же NGINX
      API_REFERENCE_CONFIG: >
        {"layout":"modern","theme":"purple",
         "sources":[{"name":"API","url":"/openapi.json"}]}
    <<: *db_healthy
    expose: ["8080"]

volumes:
  db_data:

С файлом .env мы уже подробно разбирались в лабораторной про запуск проекта (там его читал Vite через import.meta.env). Здесь тот же .env будет использоваться docker compose и пробрасываться сразу в несколько контейнеров — Postgres, backend и Scalar. Не забудьте оставить его в .gitignore.

В проекте обычно оставляют файл .env.example, в котором содержатся описание и пример всех конфигурационных переменных, поддерживаемых проектом. А в системах автоматической сборки и тестирования (CI) они задаются при помощи специальных инструментов платформы, например, GitHub Secrets.

Для нашего проекта стандартный .env файл может выглядеть примерно следующим образом:

.env
POSTGRES_DB=example
POSTGRES_USER=exampleuser
POSTGRES_PASSWORD=examplepassword
Файлов с переменными среды (как и с compose-конфигурациями) может быть несколько. Например .env.dev, .env.prod и др. Их также можно переназначать в зависимости от того, с каким файлом мы работаем — compose.dev.yml, compose.prod.yml, …

Dev-вариация и общий NGINX

Теперь, когда переменные среды сконфигурированы, добавим нашу dev-вариацию:

compose.dev.yaml
services:
  frontend:
    build:
      context: ./frontend
      dockerfile: Dockerfile
      target: dev
    expose: ["5173"]
    volumes:
      - ./frontend:/app
      - /app/node_modules
    environment:
      - CHOKIDAR_USEPOLLING=true

  nginx:
    image: nginx:1.29-alpine
    depends_on:
      - frontend
      - backend
      - scalar
    ports: ["80:80"]
    volumes:
      - ./nginx/nginx.conf:/etc/nginx/conf.d/default.conf:ro

  backend:
    build:
      context: ./backend
      dockerfile: Dockerfile
      target: dev
    volumes:
      - ./backend/prisma:/app/prisma
      - /app/node_modules

Здесь монтируется (т.е. папка / файл, который находится на хосте, и ссылка на него переносится внутрь самого контейнера) файл nginx.conf, который мы ещё не создавали. Это файл конфигурации веб-сервера NGINX — его задача переадресовывать запросы фронтенда и бэкенда из их внутренней сети на стандартный порт 80 (и 443), которые по умолчанию используются для обработки сетевых запросов. Также с его помощью можно балансировать нагрузку, ограничивать спам запросами и проводить другие настройки сетевой конфигурации.

Сам файл размещаем в директории nginx, заранее созданной в корне:

nginx/nginx.conf
# --- Auto-Upgrade для WebSocket
map $http_upgrade $connection_upgrade {
  default upgrade;
  ''      close;
}

# --- Upstreams
upstream backend_upstream { server backend:3000; keepalive 32; }
upstream vite_upstream    { server frontend:5173; keepalive 32; }
upstream scalar_upstream  { server scalar:8080;  keepalive 8;  }

server {
  listen 80;
  server_name _;

  # --- gzip: сжатие файлов при передаче
  gzip on;
  gzip_min_length 256;
  gzip_types text/plain text/css text/javascript application/javascript application/json application/xml application/xml+rss image/svg+xml;
  gzip_vary on;

  proxy_intercept_errors off;

  # ===== API -> backend =====
  location ^~ /api/ {
    proxy_pass         http://backend_upstream;
    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_read_timeout 300s;
    proxy_buffering    off;
  }

  # openapi
  location = /openapi.json { proxy_pass http://backend_upstream/openapi.json; }

  # ===== Scalar UI (документация) =====
  location ^~ /docs/ {
    proxy_pass         http://scalar_upstream/;
    proxy_http_version 1.1;
    proxy_set_header   Host $host;
    proxy_read_timeout 120s;
  }

  # ===== Vite dev + HMR =====
  location / {
    proxy_pass         http://vite_upstream;
    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;

    # WebSocket для HMR
    proxy_set_header   Upgrade           $http_upgrade;
    proxy_set_header   Connection        $connection_upgrade;

    proxy_read_timeout 300s; # длинные WS-сессии
    proxy_buffering    off;  # не буферим dev-потоки
  }
}
В прошлой лабораторной мы добавляли server.proxy в vite.config.ts, чтобы во время разработки /api/* уходили на локально запущенный backend. Когда вы поднимаете проект через compose, NGINX берёт эту функцию на себя — vite-proxy тогда не используется и не мешает. В обоих режимах фронтенд продолжает обращаться к одному и тому же относительному пути /api/....

Запуск и первая миграция

Шаг 1 — Поднять стенд

Чтобы запустить проект, достаточно ввести:

Terminal
docker compose -f compose.base.yaml -f compose.dev.yaml --env-file .env up -d --build
  • docker compose — запуск самого compose;
  • -f compose.base.yaml — наш файл с базовой конфигурацией;
  • -f compose.dev.yaml — файл, который перезаписывает / расширяет базовую конфигурацию;
  • --env-file .env — путь и имя нашего файла с переменными среды;
  • up -d --build — сборка контейнеров и тихий запуск контейнера.
Если при сборке контейнер с backend будет постоянно падать с ошибками TLS..., включите VPN и повторите попытку сборки (--build) заново. В последнее время достаточно часто наблюдаются сбои при попытке обратиться к серверам Prisma с российского IP.

Шаг 2 — Зайти внутрь контейнера backend

После первого запуска не забываем применить первую миграцию, иначе backend нормально не запустится. Для этого заходим во вкладку Exec внутри контейнера backend:

Docker Desktop — Exec

Или вводим в командной строке на хост-машине команду, чтобы зайти в shell внутри конкретного контейнера: docker compose exec backend sh (запускать необходимо из той директории, где у нас находятся compose-файлы. sh вместо bash — потому что node:24-alpine в образе bash не содержит).

Шаг 3 — Применить миграцию

container — backend
npx prisma migrate dev --name init --schema=prisma/schema.prisma
В compose.dev.yaml в секции volumes у backend есть строчка - ./backend/prisma:/app/prisma. Благодаря этому маунту папка prisma пробрасывается внутрь контейнера, и все миграции, созданные там, появляются и в локальной файловой системе хоста. Не забудьте закоммитить полученный каталог prisma/migrations/ в git — он понадобится при деплое на Render, иначе там просто нечего будет применять.

Настройка prod-конфигурации

Теперь добавим prod-файл для финальной выгрузки на сервер. В prod-варианте наш фронтенд уже собирается в статику и раздаётся при помощи веб-сервера NGINX внутри своего контейнера, поэтому файл nginx.conf необходимо также добавить и в директорию frontend/nginx/, при этом настройка чуть-чуть изменится — у frontend-NGINX появятся proxy_pass на backend и Scalar.

compose.prod.yaml

compose.prod.yaml
services:
# Фронтенд сам отдаёт статику через свой NGINX и проксирует /api и /docs
frontend:
  build:
    context: ./frontend
    dockerfile: Dockerfile
    target: prod
  depends_on:
    - backend
    - scalar
  ports: ["80:80"]

backend:
  build:
    context: ./backend
    dockerfile: Dockerfile
    target: prod

Запуск prod-сборки производится той же командой, но с другим override-файлом:

Terminal
docker compose -f compose.base.yaml -f compose.prod.yaml --env-file .env up -d --build

Архитектура итогового стенда

В dev-режиме у нас получается такая картина:

    flowchart LR
  user([Браузер]):::ext --> nginx
  subgraph compose[Docker Compose-сеть]
    direction LR
    nginx["NGINX :80<br/>(единая точка входа)"] -->|"/ + /HMR"| frontend["frontend<br/>Vite dev :5173"]
    nginx -->|"/api/*"| backend["backend<br/>Fastify :3000"]
    nginx -->|"/docs/*"| scalar["scalar<br/>UI :8080"]
    backend --> db[("db<br/>Postgres :5432")]
  end
  classDef ext fill:#f3f4f6,stroke:#9ca3af,color:#1f2937;
  

Браузер всегда обращается на localhost:80, NGINX по location-правилам разводит трафик на нужный сервис, а backend ходит в Postgres внутри той же compose-сети по DNS-имени db.

Проверка полученного результата

После того как dev-конфигурация запустилась и миграция применилась, должны открываться:

Если все пять пунктов зелёные, стенд работает.

В следующей лабораторной этот же стенд мы выгрузим в облако: фронтенд — на GitHub Pages, backend — на Render, и наладим CI/CD через GitHub Actions.