Node.js в Docker: HTTP-сервис, non-root и корректная остановка
Автор: Казачкин Даниил Михайлович · Обновлено
Контейнеризация Node.js-приложения включает не только COPY исходников и команду node. Нужно проверить адрес HTTP-listener, пользователя процесса, доступность порта и поведение…
Контейнеризация Node.js-приложения включает не только COPY исходников и команду node. Нужно проверить адрес HTTP-listener, пользователя процесса, доступность порта и поведение при остановке. Соберём маленький сервер на встроенном node:http: внешние зависимости не понадобятся, а каждый этап можно проверить отдельно. Пример использует Node 24; выбор major-версии явный, но тег образа остаётся обновляемым.
Один файл приложения
В новой папке node-docker-lab сохраните app.mjs. Расширение .mjs задаёт ES modules без отдельного package.json. Маршрут /healthz сообщает о готовности процесса, / возвращает JSON, а /slow намеренно задерживает ответ на секунду для проверки завершения активного запроса.
import { createServer } from 'node:http';
let stopping = false;
const server = createServer((request, response) => {
if (request.url === '/healthz') {
response.writeHead(200, { 'Content-Type': 'text/plain' });
response.end('ready');
return;
}
if (request.url !== '/' && request.url !== '/slow') {
response.writeHead(404);
response.end('not found');
return;
}
const answer = () => {
response.writeHead(200, { 'Content-Type': 'application/json' });
response.end(JSON.stringify({ message: process.env.GREETING ?? 'node-ready' }));
};
if (request.url === '/slow') setTimeout(answer, 1000);
else answer();
});
server.listen(8080, '0.0.0.0', () => console.log('ready'));
function stop() {
if (stopping) return;
stopping = true;
console.log('stopping');
const deadline = setTimeout(() => {
console.error('shutdown deadline exceeded');
server.closeAllConnections();
process.exitCode = 1;
}, 8000);
deadline.unref();
server.close(() => {
clearTimeout(deadline);
console.log('stopped');
});
}
process.once('SIGTERM', stop);
process.once('SIGINT', stop);Наличие обработчика сигнала меняет стандартное поведение Node: приложение само отвечает за завершение. server.close прекращает приём новых соединений и позволяет завершить текущие запросы; после закрытия сервера и оставшейся работы event loop процесс выходит. Таймер ограничивает ожидание HTTP-соединений. Он не заменяет отдельное закрытие базы, очереди и других ресурсов, которых в этом маленьком приложении нет. HTTP API Node 24, обработка сигналов.
Dockerfile с готовым пользователем node
Рядом создайте Dockerfile:
FROM node:24-alpine
ENV NODE_ENV=production
WORKDIR /app
COPY --chown=node:node app.mjs ./app.mjs
USER node
EXPOSE 8080
CMD ["node", "app.mjs"]Официальный образ предоставляет пользователя node. Файлу приложения достаточно прав чтения; записывать исходники при запуске серверу не требуется. JSON-форма CMD запускает node непосредственно, без промежуточного shell или npm-скрипта, поэтому сигнал Docker доходит до нужного процесса. ENV задаёт обычную переменную окружения: сам по себе NODE_ENV не оптимизирует произвольный код и не включает production-сервер.
Alpine подходит этому примеру со стандартной библиотекой. Если позже появится native addon, проверьте его совместимость с libc и архитектурой образа. Наличие пакета в локальном node_modules на другой ОС не доказывает, что бинарный модуль заработает внутри Linux-контейнера. Варианты образов описаны в официальном репозитории docker-node.
Создайте .dockerignore:
.git
node_modules
npm-debug.log
.env
coverageВ нашем Dockerfile копируется только app.mjs; исключения дополнительно ограничивают контекст сборки. Секреты не должны попадать туда «на всякий случай». Для реального проекта с зависимостями добавьте package.json и lockfile, устанавливайте зависимости внутри подходящего образа и отделяйте необходимые runtime-пакеты от инструментов разработки. Двух стадий ради одного файла здесь не нужно; их задача раскрыта в [уроке multi-stage](/lessons/without-university/docker-containerization/docker-developer-11).
Собрать и дождаться HTTP
Для Bash в этой папке выполните команды ниже. Порт 18089 и имя контейнера должны быть свободны; если учебный контейнер уже существует, сначала разберите его состояние. Не удаляйте чужой объект ради освобождения имени.
docker build -t yk-node-app:1 .
docker run -d --name yk-node-app -p 127.0.0.1:18089:8080 -e GREETING=container-node yk-node-app:1
curl --fail --retry 20 --retry-all-errors --retry-delay 1 --retry-max-time 30 --max-time 2 http://127.0.0.1:18089/healthz
curl --fail http://127.0.0.1:18089/
docker exec yk-node-app id
docker logs --tail 20 yk-node-appПовторы curl ограничены числом попыток и временем; они относятся к безопасному GET /healthz. --retry-all-errors покрывает также краткий connection reset до готовности listener. Не переносите такие повторы на запись данных без анализа повторного выполнения. Параметры curl.
Ожидайте ready, затем JSON с message равным container-node и непривилегированного пользователя node. Bind 0.0.0.0 относится к сетевому пространству контейнера; публикация 127.0.0.1:18089 ограничивает доступ на хосте. EXPOSE только документирует внутренний порт. Переменная GREETING задаётся при запуске: изменение приветствия не требует новой сборки, но уже работающий процесс не получает автоматически новое окружение хоста.
Остановка с незавершённым запросом
Откройте второй терминал и запросите /slow, а во время ожидания выполните docker stop. Либо повторите тот же опыт в одном Bash:
curl --fail http://127.0.0.1:18089/slow > slow-response.json &
request_pid=$!
sleep 0.2
docker stop --timeout 12 yk-node-app
wait "$request_pid"
cat slow-response.json
docker logs yk-node-app
docker inspect --format '{{.State.ExitCode}}' yk-node-app
docker rm yk-node-appКороткая пауза здесь помогает попасть в специально созданный секундный запрос; это не проверка readiness. В нормальном опыте ответ сохраняется, журнал содержит stopping и stopped, а exit code равен 0. На сильно загруженной машине запрос может ещё не начаться: тогда повторите эксперимент и сопоставьте временные метки. Бюджет Docker больше внутреннего ожидания, чтобы процесс успел завершить свою процедуру до принудительного KILL.
Найти ошибку на нужном уровне
Если curl получает connection refused, проверьте контейнер, логи, listener и обе стороны публикации порта. Если Node жалуется на import, проверьте имя app.mjs и реальный файл в образе. Если приложение не выходит после stopped, ищите оставшиеся таймеры, открытые соединения или фоновые ресурсы; добавление restart policy не устраняет удерживающий event loop объект.
Самостоятельно добавьте поле path в JSON и соберите новый тег. Убедитесь, что старый контейнер продолжает возвращать прежний формат до пересоздания. Затем намеренно задержите /slow дольше восьми секунд и исследуйте журнал превышения бюджета и код завершения. Поведение процесса связано с [уроком сигналов](/lessons/without-university/docker-containerization/docker-developer-26), а проверка собранного образа перед публикацией — с [уроком CI/CD](/lessons/without-university/docker-containerization/docker-developer-37).