Перейти к содержанию

Базовый образ и API каталога

Подготовленное окружение ещё не определяет, какой образ нужен приложению. В этом уроке выберем базовый Node.js image и создадим собственный API. Результат — четыре полных файла каталога, которые позже попадут в контейнер. Сервис использует стандартную библиотеку Node.js: изучение Docker не будет зависеть от установки Express или устройства стороннего фреймворка.

Проект отвечает на два адреса. /health сообщает, что HTTP-процесс способен вернуть ответ. /api/courses читает каталог учебных серий и отдаёт JSON. Эти функции имеют разные границы: доступный процесс может не прочитать повреждённый файл каталога. Такая разница понадобится при настройке healthcheck и поиске ошибок клиента.

Выберем семейство образа

В Docker Hub можно найти официальные образы Node.js. У имени node есть теги, которые описывают семейство версии и вариант операционного окружения. Для курса выберем node:24-bookworm-slim. Это явный выбор ветки Node.js и Debian-варианта, а не обещание конкретного неизменяемого содержимого.

Не будем использовать latest как замену версии. Такой тег может начать указывать на другое семейство и сделать изменение окружения незаметным. При этом и выбранный тег с номером ветки остаётся изменяемым: обновления внутри семейства могут изменить image. Для воспроизводимого выпуска позже записывают фактический digest и архитектуру; здесь их не выдумываем.

Вариант alpine отличается не только размером: в нём используется другое системное окружение, включая musl вместо glibc. Если позже появится нативная зависимость, различие может оказаться существенным. Сейчас выбираем Debian slim, чтобы сосредоточиться на контейнерной модели. Выбор вариантов и ограничения описаны в официальном репозитории Docker Node.

Выбор Что он делает явным Чего не гарантирует
node:24-bookworm-slim Ветка Node.js и вариант Debian Неизменность digest при следующем скачивании
node с latest Предпочтение текущего тега Сохранение семейства Node.js
alpine-вариант Особое минимальное окружение Совместимость любой нативной зависимости
Digest конкретного image Выбранное содержимое образа Пригодность для всех архитектур и отсутствие уязвимостей

Создадим каталог и манифест

Внутри professorweb-container-lab создайте папку api с подпапками src и data. Положите следующие полные файлы на указанные места. Начнём с api/package.json. Поле engines фиксирует учебное семейство; оно не устанавливает Node.js автоматически.

{
  "name": "professorweb-container-lab-api",
  "version": "1.0.0",
  "private": true,
  "type": "module",
  "engines": {
    "node": ">=24 <25"
  },
  "scripts": {
    "start": "node src/server.mjs",
    "dev": "node --watch src/server.mjs"
  }
}

API не имеет внешних зависимостей. Поэтому полный api/package-lock.json содержит только корневой пакет и не нуждается в выдуманных адресах скачивания или integrity-строках. Позже npm ci будет работать с этим настоящим договором отсутствия сторонних пакетов.

{
  "name": "professorweb-container-lab-api",
  "version": "1.0.0",
  "lockfileVersion": 3,
  "requires": true,
  "packages": {
    "": {
      "name": "professorweb-container-lab-api",
      "version": "1.0.0",
      "engines": {
        "node": ">=24 <25"
      }
    }
  }
}

В api/data/catalog.json разместите четыре записи. Значения lessons относятся к учебному fixture нашего контейнерного проекта, а не являются подсчётом всех материалов сайта ProfessorWeb. Идентификатор docker останется стабильным, даже когда в упражнении изменим видимый заголовок.

[
  {
    "id": "markdown",
    "title": "Статический сайт из Markdown",
    "lessons": 12
  },
  {
    "id": "http",
    "title": "HTTP для веб-разработчика",
    "lessons": 16
  },
  {
    "id": "css",
    "title": "Современный CSS",
    "lessons": 20
  },
  {
    "id": "docker",
    "title": "Docker для веб-проектов",
    "lessons": 12
  }
]

Напишем полный HTTP-сервер

Создайте api/src/server.mjs. Используем ES-модули, стандартный HTTP-сервер и чтение файла через promises. API не выполняет JavaScript из каталога и не возвращает содержимое произвольного пути по пользовательскому параметру.

import { createServer } from 'node:http';
import { readFile } from 'node:fs/promises';

const port = Number(process.env.PORT ?? 4100);
if (!Number.isInteger(port) || port < 1 || port > 65535) {
  throw new Error('PORT must be an integer between 1 and 65535');
}
const catalogFile = process.env.CATALOG_FILE
  ?? new URL('../data/catalog.json', import.meta.url);

function send(response, status, body, extraHeaders = {}) {
  response.writeHead(status, {
    'Content-Type': 'application/json; charset=utf-8',
    'Cache-Control': 'no-store',
    ...extraHeaders,
  });
  response.end(JSON.stringify(body));
}
function validateCatalog(value) {
  if (!Array.isArray(value) || value.length < 1 || value.length > 100) {
    throw new Error('Invalid catalog list');
  }
  const ids = new Set();
  return value.map(course => {
    if (course === null || typeof course !== 'object'
      || typeof course.id !== 'string'
      || !/^[a-z][a-z0-9-]{0,39}$/.test(course.id)
      || ids.has(course.id)
      || typeof course.title !== 'string'
      || course.title.trim().length < 1 || course.title.length > 100
      || !Number.isInteger(course.lessons)
      || course.lessons < 1 || course.lessons > 1000) {
      throw new Error('Invalid catalog entry');
    }
    ids.add(course.id);
    return { id: course.id, title: course.title, lessons: course.lessons };
  });
}
const server = createServer(async (request, response) => {
  let url;
  try { url = new URL(request.url, 'http://catalog.local'); }
  catch { return send(response, 400, { error: 'invalid_url' }); }
  if (request.method !== 'GET') {
    return send(response, 405, { error: 'method_not_allowed' }, { Allow: 'GET' });
  }
  if (url.search) return send(response, 400, { error: 'unexpected_query' });
  if (url.pathname === '/health') return send(response, 200, { status: 'ok' });
  if (url.pathname !== '/api/courses') {
    return send(response, 404, { error: 'not_found' });
  }
  try {
    const courses = validateCatalog(JSON.parse(await readFile(catalogFile, 'utf8')));
    return send(response, 200, { project: 'professorweb-container-lab', courses });
  } catch {
    console.error('catalog_unavailable');
    return send(response, 503, { error: 'catalog_unavailable' });
  }
});
server.listen(port, '0.0.0.0', () => console.log(`catalog_api_listening:${port}`));
process.once('SIGTERM', () => {
  console.log('catalog_api_stopping');
  server.close(() => process.exit(0));
  setTimeout(() => process.exit(1), 5000).unref();
});

Для сети контейнера важна строка listen: процесс слушает 0.0.0.0 внутри своего окружения. Позже опубликованный Docker-порт сможет направить запрос к этому адресу. Выбор 0.0.0.0 в приложении не определяет сам по себе, на каких адресах компьютера будет опубликован порт: это другая настройка, которую разберём отдельно.

PORT читается из переменной окружения, однако произвольная строка не становится корректным номером автоматически. Перед запуском проверяем целое значение и диапазон. Неподходящий PORT должен остановить процесс с понятной ошибкой, а не создать сервис на неожиданном адресе. Переменная CATALOG_FILE нужна для будущего volume; без неё сервер берёт файл рядом с собственными исходниками.

Почему каталог валидируется при чтении

Файл JSON может быть синтаксически корректным, но не соответствовать договору клиента. Например, lessons может стать строкой, идентификаторы могут повторяться, а заголовок оказаться пустым. Поэтому validateCatalog проверяет форму списка и возвращает только известные поля. Клиенту не передаём неожиданные свойства из исходного файла.

Каталог читается при каждом запросе. В таком небольшом примере это помогает увидеть изменение данных в named volume без пересборки image. Это не рекомендация читать большой файл на каждый запрос высоконагруженного сайта. Для реального каталога понадобятся размерные ограничения, подходящее хранилище, кеш и правила обновления; здесь их намеренно не объявляем реализованными.

При ошибке чтения или схемы возвращаем 503 с кодом catalog_unavailable. Подробный путь файла и содержимое каталога не включаем в публичный ответ. В журнал попадает короткий диагностический код. Оператору его недостаточно для полноценной системы наблюдаемости, но посетитель также не получает внутренние сведения окружения.

Проследим ожидаемые ответы

После самостоятельного запуска совместимым Node.js запрос GET к /api/courses должен вернуть статус 200, имя проекта и четыре записи в courses. Заголовок Content-Type должен указывать JSON и UTF-8. Сервер выставляет no-store, чтобы при упражнениях с файлом читатель не спутал старый браузерный ответ с текущим содержимым.

GET к /health должен вернуть объект со status ok. Если временно испортить каталог, /api/courses должен вернуть 503, а /health может остаться успешным. Это ожидаемое различие: проверка живого HTTP-процесса не подтверждает доступность данных. Позже Compose будет использовать именно такую узкую проверку, и в тексте не назовём её полной готовностью бизнес-функции.

Неизвестный путь возвращает 404. Метод, отличный от GET, получает 405 и Allow с допустимым методом. Query-параметры этому API не нужны; наличие строки запроса приводит к 400. Ограничение помогает сохранить простой и точный договор вместо незаметного игнорирования опечатки пользователя.

Подготовимся к контейнеризации

Обработчик SIGTERM закрывает HTTP-сервер и имеет запасной таймер завершения. В Dockerfile укажем запуск Node непосредственно, чтобы основной процесс мог получить сигнал остановки. Здесь нет очереди фоновых записей и длинной транзакции, поэтому учебная остановка не доказывает безопасное завершение любого будущего приложения. При добавлении таких задач порядок shutdown придётся расширить.

Ожидаемые ответы этого урока не выполнялись при подготовке материалов. Полное состояние находится в архиве собственного проекта, а STATE сохраняет not_run. Следующий шаг — описать упаковку этих четырёх файлов в Dockerfile и отделить время сборки от времени запуска.

Оглавление серии