Как Dockerize-ировать приложение на Bun
Соберите Bun-приложение в Docker: production-Dockerfile, .dockerignore, привязка 0.0.0.0, healthchecks, Compose и graceful SIGTERM.
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
Discover how at OpenReplay.com.
Файл .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 | На основе Debian | 81,93 МБ |
oven/bun:1.3.14-slim | Debian slim | 63,32 МБ |
oven/bun:1.3.14-alpine | Alpine | 40,87 МБ |
oven/bun:1.3.14-distroless | Distroless | 40,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 install | bun.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 сделал текстовый формат форматом по умолчанию.