12k
All articles

Как Dockerize-ировать приложение на Bun

Соберите Bun-приложение в Docker: production-Dockerfile, .dockerignore, привязка 0.0.0.0, healthchecks, Compose и graceful SIGTERM.

OpenReplay Team
OpenReplay Team
Как Dockerize-ировать приложение на Bun

Production-ready Dockerfile для сервиса на Bun умещается в несколько строк, однако разница между Dockerfile, который работает локально, и тем, который выдержит деплой, определяется четырьмя вещами: зафиксированным базовым образом, запуском от непривилегированного пользователя, сервисом, привязанным к 0.0.0.0, и обработчиком SIGTERM, корректно завершающим обработку текущих запросов. В этой статье приводится рабочий Dockerfile, а затем последовательно устраняется каждый из этих недостатков с готовым кодом для копирования.

У вас есть приложение на Bun — API, воркер или backend-for-frontend — которое работает на вашей машине. Вам нужно контейнеризировать его, не набивая шишки на специфике Bun в Docker. Далее: минимальный Dockerfile, кэширование слоёв, мультистейдж-сборка в standalone-бинарник, production-харденинг, Docker Compose с Postgres, обработчик graceful shutdown для встроенного сервера Bun и таблица четырёх наиболее распространённых скрытых ошибок с их исправлениями.

Ключевые выводы

  • Сервис на Bun, слушающий только на 127.0.0.1 внутри контейнера, недостижим через опубликованный порт; привяжите его к 0.0.0.0 (все IPv4-интерфейсы), чтобы механизм публикации портов Docker мог перенаправлять трафик.
  • Bun 1.2, выпущенный 22 января 2025 года, изменил формат lockfile по умолчанию на текстовый bun.lock; мигрируйте с bun.lockb командой bun install --save-text-lockfile --frozen-lockfile --lockfile-only.
  • Ссылки на Docker-образы по умолчанию указывают на latest при отсутствии тега, что может неявно изменить вашу версию Bun между деплоями; фиксируйте явный тег, например oven/bun:1.
  • Копируйте package.json и bun.lock до исходного кода приложения, чтобы кэш слоёв Docker пропускал bun install при пересборках, где изменился только исходный код.
  • Без обработчика SIGTERM Docker отправляет SIGTERM, ждёт до истечения таймаута остановки по умолчанию (10 секунд для Linux-контейнеров), а затем отправляет SIGKILL, если процесс не завершился, что может привести к потере обрабатываемых запросов.

Минимальный Dockerfile

Четыре строки помещают приложение на Bun в контейнер. Это самый быстрый путь от «работает локально» до собранного образа.

FROM oven/bun
COPY . .
RUN bun install
CMD ["bun", "run", "src/index.ts"]

Сборка:

docker build -t bun-app .
docker run -p 3000:3000 bun-app

Bun выполняет TypeScript напрямую, поэтому этапа сборки через tsc нет — точка входа указывает прямо на ваш .ts-файл. Замените src/index.ts на реальную точку входа вашего приложения. Базовый образ — официальный oven/bun на Docker Hub.

Используйте это для черновиков, но не для production. Строка FROM oven/bun неявно подтягивает latest, копирует весь каталог (включая node_modules и .env), запускается от root и пересобирает зависимости при каждом изменении кода. Остальная часть статьи устраняет каждый из этих недостатков.

Добавьте .dockerignore

Файл .dockerignore уменьшает контекст сборки и предотвращает попадание секретов и локальных артефактов в образ. Создайте его в первую очередь.

node_modules
.git
.gitignore
.env
.env.*
dist
*.log
.vscode
README.md

Две записи особенно важны. node_modules исключается, потому что bun install заново генерирует его внутри образа из вашего lockfile — копирование node_modules с хоста добавит платформозависимые бинарники, которые могут не подойти для контейнера. .env и .env.* исключаются, чтобы локальные секреты никогда не попадали в слой образа, где они сохраняются даже если последующий слой удалит файл.

Что использовать в Dockerfile: bun.lock или bun.lockb?

По умолчанию используйте bun.lock. Bun 1.2, выпущенный 22 января 2025 года, изменил формат lockfile по умолчанию на текстовый bun.lock, заменив устаревший бинарный bun.lockb. В старых руководствах по-прежнему упоминается bun.lockb, и копирование неправильного имени файла в Dockerfile приводит к запутанной ошибке --frozen-lockfile.

Если в вашем репозитории ещё используется бинарный lockfile, выполните миграцию один раз:

bun install --save-text-lockfile --frozen-lockfile --lockfile-only
# затем удалите bun.lockb и закоммитьте bun.lock

Эта команда задокументирована в документации по lockfile Bun. После миграции везде в Dockerfile используйте bun.lock.

Кэширование слоёв: разделите зависимости и исходный код

Копируйте package.json и bun.lock до исходного кода приложения, чтобы кэш слоёв Docker пропускал bun install при каждой пересборке, где изменились только исходные файлы — это единственное изменение с наибольшим влиянием на скорость локальных итераций.

FROM oven/bun:1

WORKDIR /app

# Сначала копируем lockfile и манифест — этот слой инвалидируется
# только при изменении зависимостей
COPY package.json bun.lock ./
RUN bun install --frozen-lockfile

# Исходный код копируем последним — редактирование кода пересобирает только отсюда
COPY src ./src
COPY tsconfig.json ./

CMD ["bun", "run", "src/index.ts"]

Docker кэширует каждую инструкцию как слой и повторно использует кэшированные слои, когда их входные данные не изменились. Поскольку COPY package.json bun.lock предшествует COPY src, редактирование исходного файла не затрагивает слой зависимостей, и bun install полностью пропускается. bun install --frozen-lockfile прерывает сборку, если bun.lock не синхронизирован с package.json — именно такое поведение нужно в Dockerfile, поскольку оно обнаруживает расхождение lockfile на этапе сборки, а не во время выполнения. Поведение флага задокументировано в документации Bun CLI install.

Обратите внимание на строку COPY tsconfig.json. Bun читает tsconfig.json для алиасов путей и параметров компилятора; если ваше приложение использует маппинг путей и вы забыли скопировать этот файл, разрешение модулей завершится ошибкой при запуске внутри контейнера, даже если локально всё работает.

Мультистейдж-сборка: компиляция в standalone-бинарник

bun build --compile создаёт самодостаточный исполняемый файл, объединяющий ваш код, npm-пакеты, ассеты и среду выполнения Bun, поэтому в финальном образе Bun не нужно устанавливать отдельно. Используйте это в мультистейдж-сборке, чтобы скопировать только бинарник в минимальный базовый образ и получить меньший финальный образ.

# --- Этап сборки ---
FROM oven/bun:1 AS build
WORKDIR /app

COPY package.json bun.lock ./
RUN bun install --frozen-lockfile

COPY src ./src
COPY tsconfig.json ./

# Компилируем в единый исполняемый файл, включающий среду выполнения Bun
RUN bun build ./src/index.ts --compile --outfile server

# --- Этап выполнения ---
FROM debian:12-slim
WORKDIR /app

COPY --from=build /app/server /app/server

USER nobody
EXPOSE 3000
CMD ["/app/server"]

Этап сборки устанавливает зависимости и компилирует. Этап выполнения начинается с debian:12-slim и копирует только скомпилированный бинарник server — без образа Bun, без node_modules, без исходного кода. Бинарник включает среду выполнения, поэтому запускается без наличия Bun. Измерьте разницу в размере на своём приложении, прежде чем называть конкретные цифры — она зависит от количества зависимостей и размера ассетов.

Production-харденинг

Зафиксируйте явный тег Bun, запускайте от непривилегированного пользователя и устанавливайте только production-зависимости. Эти три изменения превращают рабочий Dockerfile в готовый к деплою.

FROM oven/bun:1-alpine

WORKDIR /app

# Создаём непривилегированного пользователя (синтаксис Alpine)
RUN addgroup -S appgroup && adduser -S appuser -G appgroup

COPY package.json bun.lock ./
RUN bun install --frozen-lockfile --production

COPY src ./src
COPY tsconfig.json ./

RUN chown -R appuser:appgroup /app
USER appuser

ENV NODE_ENV=production
EXPOSE 3000

CMD ["bun", "run", "src/index.ts"]

Фиксируйте тег. Ссылки на Docker-образы по умолчанию указывают на latest при отсутствии тега, что означает: docker pull может неявно изменить вашу версию Bun между деплоями. Фиксируйте явный тег Bun, чтобы обновления среды выполнения были намеренными и отслеживаемыми — официальное руководство Bun по Docker использует oven/bun:1. Вариант 1-alpine доступен на Docker Hub для уменьшения размера образа; убедитесь, что он подходит для ваших зависимостей, прежде чем использовать его, поскольку Alpine использует musl libc вместо glibc.

Запускайте от непривилегированного пользователя. По умолчанию пользователь контейнера — root. Создание appuser и переключение на него с помощью USER appuser ограничивает радиус поражения в случае компрометации процесса — это базовое требование из руководства Docker по безопасности.

Устанавливайте только production-зависимости. bun install --frozen-lockfile --production пропускает devDependencies, уменьшая размер образа и сокращая поверхность атаки.

Почему мой контейнер на Bun недоступен на опубликованном порту?

Сервис на Bun, слушающий только на 127.0.0.1 внутри Docker-контейнера, не будет доступен через опубликованный порт. Привяжите его к 0.0.0.0 (все IPv4-интерфейсы), чтобы механизм публикации портов Docker мог перенаправлять трафик.

Это наиболее распространённая скрытая ошибка в контейнеризированном сервисе на Bun. Контейнер запускается, логи выглядят нормально, docker ps показывает маппинг порта — и каждый запрос с хоста зависает или возвращает ошибку соединения. Причина — адрес прослушивания.

Для Bun.serve() явно задайте hostname:

// src/index.ts
const port = Number(process.env.PORT) || 3000;

Bun.serve({
  port,
  hostname: "0.0.0.0", // обязательно внутри Docker
  fetch(req) {
    return new Response(JSON.stringify({ status: "ok" }), {
      headers: { "Content-Type": "application/json" },
    });
  },
});

console.log(`Listening on http://0.0.0.0:${port}`);

Параметры Bun.serve() задокументированы в документации Bun HTTP API. Для Elysia настройте serve.hostname: "0.0.0.0". Для других фреймворков, работающих на Bun, убедитесь, что базовый сервер Bun настроен на прослушивание 0.0.0.0.

Session replay фронтендов, взаимодействующих с сервисом на Bun, может выявить эту ошибку на уровне Dockerfile. Сервис, привязанный к localhost, проявляется в браузере как каскад таймаутов fetch/XHR сразу после деплоя — симптом, который выглядит как баг фронтенда, но берёт начало в адресе прослушивания контейнера. Инструменты вроде OpenReplay фиксируют эту клиентскую картину сбоя — именно там эта неправильная конфигурация обычно обнаруживается впервые.

Docker Compose с Postgres

Файл Docker Compose запускает ваше приложение на Bun совместно с PostgreSQL, ожидает прохождения healthcheck базы данных перед стартом приложения и перезапускает приложение при сбое.

# compose.yaml
services:
  api:
    build:
      context: .
      dockerfile: Dockerfile
    ports:
      - "3000:3000"
    env_file:
      - .env.production
    depends_on:
      postgres:
        condition: service_healthy
    restart: unless-stopped
    healthcheck:
      test: ["CMD", "wget", "--spider", "-q", "http://localhost:3000/health"]
      interval: 30s
      timeout: 5s
      retries: 3

  postgres:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: app
      POSTGRES_PASSWORD: secret
      POSTGRES_DB: appdb
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app"]
      interval: 10s
      timeout: 5s
      retries: 5

volumes:
  pgdata:

depends_on с condition: service_healthy удерживает сервис api до тех пор, пока Postgres не сообщит о готовности, что требует наличия healthcheck у зависимости — без него service_healthy никогда не выполнится. restart: unless-stopped перезапускает приложение после сбоя, но учитывает ручную остановку.

Обратите внимание: ключ version: верхнего уровня отсутствует. Спецификация Compose помечает свойство version верхнего уровня как устаревшее; Compose игнорирует его при выборе схемы и выдаёт предупреждение при его наличии. Не указывайте его.

Переменные окружения: не запекайте секреты в образ

Передавайте конфигурацию через env_file во время выполнения, а не копируйте секреты в слой образа. Слои образов постоянны и доступны для инспекции, поэтому секрет, записанный в слой, остаётся восстановимым даже если последующая инструкция удалит его.

Сервис Compose выше уже ссылается на .env.production через env_file. Читайте эти значения в коде из process.env:

// src/config.ts
export const config = {
  port: Number(process.env.PORT) || 3000,
  databaseUrl: process.env.DATABASE_URL ?? "",
  jwtSecret: process.env.JWT_SECRET ?? "",
};

Держите .env.production вне образа с помощью записи .env.* в .dockerignore и вне системы контроля версий с помощью соответствующей записи в .gitignore. Образ остаётся универсальным; секреты внедряются во время выполнения.

Рабочий процесс разработки: hot reload с bind mount

Для локальной разработки используйте отдельный Dockerfile, запускающий bun --hot, и монтируйте исходный код как том, чтобы сохранения мгновенно перезагружались внутри контейнера. Держите его отдельно от production Dockerfile.

# Dockerfile.dev
FROM oven/bun:1-alpine
WORKDIR /app

COPY package.json bun.lock ./
RUN bun install --frozen-lockfile

COPY . .
EXPOSE 3000

CMD ["bun", "--hot", "src/index.ts"]
# compose.dev.yaml
services:
  api-dev:
    build:
      context: .
      dockerfile: Dockerfile.dev
    ports:
      - "3000:3000"
    volumes:
      - .:/app
      - /app/node_modules
    env_file:
      - .env.development

Флаг --hot включает hot reloading Bun: сохранение файла обновляет работающий сервер без полного перезапуска процесса. Bind mount .:/app делает правки на хосте видимыми внутри контейнера, а анонимный том /app/node_modules предотвращает затенение хостовым каталогом зависимостей, установленных во время сборки. Запускайте командой docker compose -f compose.dev.yaml up --build.

Graceful shutdown: обработка SIGTERM

Добавьте обработчик SIGTERM, чтобы текущие запросы завершились до выхода процесса. Без него Docker отправляет SIGTERM, ждёт до истечения таймаута остановки по умолчанию (10 секунд для Linux-контейнеров), а затем отправляет SIGKILL, если процесс не завершился, что может привести к потере обрабатываемых запросов.

Bun.serve() возвращает объект сервера с методом .stop(). Вызовите его с await при получении SIGTERM, чтобы текущие запросы завершились перед выходом:

// src/index.ts
const server = Bun.serve({
  port: Number(process.env.PORT) || 3000,
  hostname: "0.0.0.0",
  fetch() {
    return new Response("ok");
  },
});

const shutdown = async (signal: string) => {
  console.log(`Received ${signal}, draining connections`);
  await server.stop();
  process.exit(0);
};

process.on("SIGTERM", () => shutdown("SIGTERM"));
process.on("SIGINT", () => shutdown("SIGINT"));

Хэндл server и его метод .stop() являются частью Bun HTTP API. Основной паттерн — сохранить экземпляр сервера, возвращённый Bun.serve(), и остановить его при получении сигнала завершения.

Ожидаемые размеры образов

Базовый образ определяет большую часть итогового размера. Docker Hub публикует сжатые размеры для каждого варианта oven/bun; приведённые ниже данные взяты со страницы тегов Docker Hub для oven/bun:1.3.14 (linux/amd64).

Базовый образВариантСжатый размер
oven/bun:1.3.14На основе Debian81,93 МБ
oven/bun:1.3.14-slimDebian slim63,32 МБ
oven/bun:1.3.14-alpineAlpine40,87 МБ
oven/bun:1.3.14-distrolessDistroless40,52 МБ

Это размеры базовых образов, а не вашего финального образа приложения. Ваш образ добавляет зависимости и исходный код поверх них. Для измерения собственного образа выполните:

docker images --format "table {{.Repository}}:{{.Tag}}\t{{.Size}}"

Вариант с мультистейдж-сборкой скомпилированного бинарника на debian:12-slim может дополнительно уменьшить финальный образ, поскольку исключает полный образ Bun и node_modules, но точная разница зависит от объёма ваших зависимостей — измеряйте, а не цитируйте усреднённые проценты.

Каковы наиболее распространённые ошибки контейнера Bun в Docker?

Четыре наиболее распространённые причины скрытых сбоев контейнера Bun в Docker: неверный путь точки входа, привязка к localhost, устаревший lockfile и отсутствующий шаг копирования tsconfig.json. Каждая имеет характерный симптом и однострочное исправление.

СимптомПервопричинаИсправление
Контейнер немедленно завершается с кодом 1Неверный путь точки входа в CMD или файл входа не скопированПроверьте, что путь в CMD ["bun", "run", "src/index.ts"] соответствует скопированному файлу; убедитесь, что выполнился COPY src ./src
Порт маппирован, но недоступенСервис привязан к 127.0.0.1/localhostПривяжите к 0.0.0.0 через Bun.serve({ hostname: "0.0.0.0" })
Сборка прерывается во время bun installbun.lock не синхронизирован с package.jsonЗапустите bun install локально, закоммитьте обновлённый bun.lock; после этого --frozen-lockfile пройдёт
Разрешение модулей завершается ошибкой при запускеАлиасы путей tsconfig.json не разрешены — файл не скопированДобавьте COPY tsconfig.json ./ перед CMD

Когда контейнер завершается до того, как вы успеваете его проинспектировать, откройте шелл в образе, чтобы проверить, что реально было скопировано:

docker run -it  --rm  --entrypoint  sh bun-app
# внутри: ls /app, cat package.json и т.д.

Случай с несоответствием lockfile чаще всего ошибочно воспринимается как проблема Docker. На самом деле это проблема Bun, обнаруженная Docker — bun install --frozen-lockfile делает правильное дело, отказываясь собирать с устаревшим lockfile, именно как описано в документации Bun install.

Заключение

Теперь у вас есть все необходимые компоненты: зафиксированный, запускаемый от непривилегированного пользователя production Dockerfile; вариант с мультистейдж-сборкой скомпилированного бинарника; Compose-стек с healthcheck для Postgres; инструментарий для разработки с hot reload; и обработчик SIGTERM, корректно завершающий обработку запросов. Начните с замены oven/bun на oven/bun:1 и добавления привязки к 0.0.0.0 — эти два изменения устраняют две ошибки, которые с наибольшей вероятностью настигнут вас при первом деплое, а затем постепенно добавляйте остальное по мере роста сервиса.

Часто задаваемые вопросы

Нужен ли отдельный этап сборки для запуска TypeScript в Docker-контейнере с Bun?

Нет. Bun выполняет TypeScript напрямую, поэтому в Docker-сборке нет этапа компиляции через tsc или tsx. Ваш CMD указывает прямо на .ts-файл точки входа, например CMD ['bun', 'run', 'src/index.ts']. Единственная оговорка — алиасы путей: если ваше приложение использует маппинг путей tsconfig.json, вы должны скопировать tsconfig.json в образ, иначе разрешение модулей завершится ошибкой при запуске, даже если локально всё работает.

Когда стоит использовать скомпилированный бинарник с bun build --compile вместо запуска bun run в контейнере?

Используйте мультистейдж-сборку с компилированным бинарником, когда хотите получить меньший финальный образ без установки Bun на этапе выполнения. bun build --compile создаёт самодостаточный исполняемый файл, объединяющий ваш код, npm-пакеты, ассеты и среду выполнения Bun, поэтому вы можете скопировать только этот бинарник на минимальный базовый образ вроде debian:12-slim без node_modules и образа Bun. Используйте обычный bun run для более простых сборок или когда полагаетесь на функции времени выполнения, такие как hot reload в процессе разработки.

Работает ли Alpine-вариант образа oven/bun со всеми зависимостями?

Не всегда. Вариант oven/bun:1-alpine использует musl libc вместо glibc, тогда как образ oven/bun:1 на основе Debian по умолчанию использует glibc. Нативные зависимости, скомпилированные под glibc, или пакеты, распространяющие только предсобранные бинарники под glibc, могут не работать на Alpine. Alpine-образ меньше по размеру, но убедитесь, что ваше дерево зависимостей собирается и работает там, прежде чем использовать его в production, а не считайте это равнозначной заменой.

Почему сборка контейнера завершается ошибкой во время bun install с ошибкой frozen-lockfile?

Сборка прерывается, потому что bun.lock не синхронизирован с package.json, и bun install --frozen-lockfile отказывается собирать с устаревшим lockfile. Это правильное поведение в Dockerfile: оно обнаруживает расхождение lockfile на этапе сборки, а не во время выполнения. Исправьте это, запустив bun install локально для регенерации bun.lock, а затем закоммитив обновлённый файл. Если ваш Dockerfile по-прежнему ссылается на устаревший бинарный bun.lockb, переключитесь на bun.lock, поскольку Bun 1.2 сделал текстовый формат форматом по умолчанию.

Understand every bug

Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — self-hosted, with full data ownership.

Star on GitHub

We use cookies to improve your experience. By using our site, you accept cookies.