Деплой и наблюдаемость TypeScript-сервиса
Код написан и протестирован. Теперь его нужно превратить в артефакт, доставить в прод и сделать так, чтобы в проде было видно, что происходит. Наблюдаемость — не роскошь: без логов, метрик и трейсинга инцидент превращается в гадание. Разберём путь от сборки до трёх столпов наблюдаемости.
Сборка артефакта
Напомним ключевое: типы стираются, в рантайм едет JavaScript. Продакшн-сборка = проверка типов (гейт) + быстрый эмит.
// package.json (фрагмент)
{
"type": "module",
"scripts": {
"typecheck": "tsc --noEmit",
"build": "tsup src/index.ts --format esm --sourcemap --clean",
"start": "node dist/index.js"
}
}
Для приложения (не библиотеки) часто бандлят весь код в один-два файла — это
уменьшает размер, ускоряет старт и упрощает образ. Для библиотеки — эмитят с
.d.ts и обоими форматами модулей (см. раздел о публикации). Всегда включайте
source maps (--sourcemap): без них стектрейсы в проде будут указывать на
номера строк в собранном/минифицированном JS, а не в вашем .ts, — отладка
превращается в мучение.
Source maps и отладка
Source map — файл .js.map, сопоставляющий позиции в собранном JS с исходным
.ts. С ним стектрейсы, дебаггер и профайлер показывают ваш настоящий код.
// включите поддержку source maps в рантайме, чтобы стектрейсы указывали на .ts
// начиная с Node 20 это встроено: node --enable-source-maps dist/index.js
Отлаживать TypeScript можно прямо в исходниках: node --inspect открывает порт
для Chrome DevTools / VS Code, а source maps связывают точки останова с .ts.
Для локальной разработки удобен tsx (или node --watch --experimental-strip-types) —
он исполняет .ts напрямую без отдельной сборки. Важно: решение о том, куда
выкладывать source maps в проде — вопрос безопасности: публичные maps раскрывают
исходники. Для бэкенда держите их приватными (или загружайте в систему трекинга
ошибок вроде Sentry, а из образа удаляйте).
Контейнеризация: многоступенчатый Docker
Docker упаковывает приложение вместе с рантаймом в неизменяемый образ. Ключевой приём для TypeScript — многоступенчатая сборка (multi-stage build): собираем в одном слое с полными devDependencies, а в финальный образ кладём только рантайм и production-зависимости. Это кратно уменьшает образ и поверхность атаки.
# ---------- Стадия 1: сборка ----------
FROM node:22-slim AS build
WORKDIR /app
RUN corepack enable # активируем pnpm
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile # все зависимости, включая dev
COPY . .
RUN pnpm typecheck && pnpm build # гейт типов + сборка в dist/
# ---------- Стадия 2: только прод-зависимости ----------
FROM node:22-slim AS deps
WORKDIR /app
RUN corepack enable
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile --prod # БЕЗ devDependencies
# ---------- Стадия 3: финальный образ ----------
FROM node:22-slim AS runtime
WORKDIR /app
ENV NODE_ENV=production
USER node # не root — принцип наименьших привилегий
COPY --from=deps /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
COPY package.json ./
EXPOSE 3000
CMD ["node", "--enable-source-maps", "dist/index.js"]
Практики продакшн-образов:
- Base image.
node:22-slim— разумный баланс.distroless(без shell и пакетного менеджера) ещё безопаснее.alpineменьше, но musl libc иногда даёт сюрпризы с нативными модулями. .dockerignore. Исключитеnode_modules,.git,dist, тесты — не тащите мусор в контекст сборки.- Не root. Запускайте под непривилегированным пользователем (
USER node). - Пиньте версии. И базовый образ, и зависимости (через lock-файл) — ради воспроизводимости.
- Кеш слоёв. Копируйте
package.jsonи ставьте зависимости до копирования исходников — тогда слой сnode_modulesпереиспользуется, пока не изменились зависимости.
Три столпа наблюдаемости
- Логи отвечают «что произошло» — дискретные события.
- Метрики — «сколько и как быстро» — агрегированные числа во времени (RPS, латентность, ошибки, использование памяти).
- Трейсы — «где именно потратилось время» — путь одного запроса через все сервисы и слои.
Логи: структурированные, через pino
Никогда не логируйте в прод через console.log со строками. В проде нужны
структурированные логи (JSON): их можно фильтровать, искать и агрегировать.
Стандарт для Node — pino: он очень быстрый (пишет JSON
эффективно) и типизирован.
import { pino } from "pino";
export const logger = pino({
level: config.LOG_LEVEL,
// редактируем секреты, чтобы не утекли в логи
redact: ["req.headers.authorization", "*.password", "*.token"],
});
// логируйте объектом (структурно), а не конкатенацией строк
logger.info({ userId: user.id, action: "login" }, "пользователь вошёл");
logger.error({ err, orderId }, "не удалось создать заказ"); // err сериализуется целиком
Ключевые практики логирования:
- Уровни осмысленно.
error— требует внимания человека;warn— подозрительно, но обработано;info— важные бизнес-события;debug— детали для разбора, выключены в проде. - Correlation ID. Присваивайте каждому запросу идентификатор и протаскивайте
его во все логи запроса (pino +
AsyncLocalStorageили интеграция с трейсингом). Без него нельзя собрать историю одного запроса из потока логов. - Не логируйте PII и секреты. Используйте
redact. - JSON в проде, красивый вывод локально.
pino-pretty— только для разработки.
Метрики: Prometheus
Метрики собирают через prom-client и
отдают на эндпоинте /metrics, который скрейпит Prometheus, а визуализирует
Grafana. Минимальный набор для сервиса — «золотые сигналы» (латентность, трафик,
ошибки, насыщение):
import { Counter, Histogram, register } from "prom-client";
const httpDuration = new Histogram({
name: "http_request_duration_seconds",
help: "Длительность HTTP-запросов",
labelNames: ["method", "route", "status"] as const,
});
// на каждый запрос замеряем длительность и метим по маршруту/статусу
app.addHook("onResponse", (req, reply, done) => {
httpDuration.observe(
{ method: req.method, route: req.routeOptions.url ?? "unknown", status: reply.statusCode },
reply.elapsedTime / 1000,
);
done();
});
app.get("/metrics", async (_req, reply) => {
reply.header("Content-Type", register.contentType);
return register.metrics();
});
Трейсинг: OpenTelemetry
OpenTelemetry (OTel) — вендор-нейтральный стандарт телеметрии. Его главная суперсила — автоматическая инструментация: подключив SDK, вы получаете трейсы HTTP, БД, gRPC без ручной разметки. Трейс показывает путь запроса как дерево спанов (span — отрезок работы) со временем каждого шага — это незаменимо для распределённых систем и поиска «где тормозит».
// tracing.ts — подключается ПЕРВЫМ, до импорта приложения
import { NodeSDK } from "@opentelemetry/sdk-node";
import { getNodeAutoInstrumentations } from "@opentelemetry/auto-instrumentations-node";
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
const sdk = new NodeSDK({
serviceName: "orders-api",
traceExporter: new OTLPTraceExporter(), // шлёт в OTel Collector -> Jaeger/Tempo
instrumentations: [getNodeAutoInstrumentations()], // авто-трейсинг http, pg, fetch...
});
sdk.start();
// корректно завершаем экспорт при остановке
process.on("SIGTERM", () => void sdk.shutdown());
# запуск с предзагрузкой инструментации до кода приложения
node --enable-source-maps --import ./dist/tracing.js dist/index.js
Свяжите три столпа: прокидывайте trace_id в структурные логи (pino) — тогда из
метрики-аномалии можно перейти к трейсу медленного запроса, а из спана — к его
логам. Это и есть настоящая наблюдаемость.
Производительность
- Профилируйте, не гадайте.
node --prof,--cpu-prof, инспектор в Chrome DevTools, clinic.js. Оптимизируйте по данным. - Следите за event loop lag. Главный признак проблем в Node — задержка event
loop (см. файл про конкурентность).
perf_hooks.monitorEventLoopDelayили метрика из prom-client. Растёт lag — где-то блокирующий синхронный код. - Кешируйте разумно. In-memory (LRU) для горячих данных, Redis для общего кеша. Всегда думайте про инвалидацию и TTL.
- Пулы соединений. К БД и внешним сервисам — через пул, а не соединение на запрос.
- Не оптимизируйте вслепую. Сначала корректность и наблюдаемость, потом — измеренные узкие места.
Публикация пакета с типами (для библиотек)
Если вы публикуете библиотеку, потребители должны получить типы. Настройте
package.json так, чтобы отдать и код, и .d.ts, и оба формата модулей:
{
"name": "@acme/utils",
"version": "1.2.0",
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
}
},
"files": ["dist"],
"sideEffects": false
}
Проверьте корректность экспортов и типов инструментом publint и @arethetypeswrong/cli перед публикацией — они ловят типичные ошибки конфигурации ESM/CJS/типов. Версионируйте по semver, автоматизируйте релиз через changesets.
Источники
- pino — логирование.
- OpenTelemetry для JS — трейсинг и метрики.
- prom-client — метрики Prometheus.
- Docker: multi-stage builds и Node.js Docker best practices.
- publint и Are the types wrong?.
Что дальше
Соберём всё в целостный взгляд: как TypeScript живёт в SDLC — от требований до эксплуатации, — плюс кураторский список лучших ресурсов для дальнейшего роста.