В мире Kotlin-бэкенда стандартом считается JVM. Это надежно, привычно, но иногда избыточно. Когда мне понадобился простой инструмент для сбора логов ошибок с моих проектов, я не хотел разворачивать тяжелый стек с Elasticsearch или платить за Sentry.
Мне хотелось получить компактное, быстрое решение, которое можно запустить одной командой в Docker, не выделяя под него гигабайты оперативной памяти.
Так появился Katcher. Это self-hosted краш-трекер, построенный на Kotlin Multiplatform (Native). В этой статье я расскажу, как собрать современный веб-сервис без JVM, без React и без сложной сборки фронтенда, используя Ktor, SQLite и HTMX.

Архитектура: ничего лишнего
Проект состоит из одного исполняемого файла (Linux binary).
-
Backend: Ktor (CIO engine), скомпилированный в Native.
-
Database: SQLite.
-
Frontend: Server-Side Rendering (Kotlin HTML DSL) + HTMX для динамики.
Это дает моментальный старт приложения и потребление памяти в районе 30–50 МБ, что идеально для side-car контейнера или дешевого .
База данных: прагматичный подход (sqlx4k)
Для работы с SQLite в Kotlin/Native часто используют SQLDelight. Однако мне хотелось попробовать что-то, что дало бы больше контроля и асинхронности “из коробки”.
Я остановился на библиотеке sqlx4k. Это Kotlin-обертка над Rust-драйвером sqlx. Через cinterop Kotlin напрямую обращается к экспортируемому API скомпилированного Rust-кода. Это позволяет получить производительность и надежность Rust, оставаясь в удобн��м синтаксисе Kotlin.
Логика (пагинация, сортировка, соединения таблиц) реализуется на SQL. Пример функции, которая выбирает страницы групп ошибок, используя динамическую сортировку и ограничение LIMIT/OFFSET:
override suspend fun findByAppId(
appId: Int,
userId: Int,
page: Int,
pageSize: Int,
sortBy: ErrorGroupSort,
sortOrder: ErrorGroupSortOrder,
): ErrorGroupsPaginated =
db.transaction {
// внутри корутины-транзакции
val safePageSize = pageSize.coerceIn(1, 100)
val safePage = page.coerceAtLeast(1)
val offset = (safePage - 1) * safePageSize
val sortField =
when (sortBy) {
ErrorGroupSort.id -> "id"
ErrorGroupSort.title -> "title"
ErrorGroupSort.occurrences -> "occurrences"
ErrorGroupSort.lastSeen -> "last_seen"
}
val order =
when (sortOrder) {
ErrorGroupSortOrder.asc -> "ASC"
ErrorGroupSortOrder.desc -> "DESC"
}
val selectSql =
"""
SELECT
g.*,
CASE WHEN v.viewed_at IS NOT NULL THEN 1 ELSE 0 END AS viewed
FROM error_groups g
LEFT JOIN user_error_group_viewed v
ON v.group_id = g.id AND v.user_id = :userId
WHERE g.app_id = :appId
ORDER BY $sortField $order
LIMIT $pageSize OFFSET $offset
""".trimIndent()
val items =
fetchAll(
Statement.create(selectSql).apply {
bind("appId", appId)
bind("userId", userId)
},
ErrorGroupWithViewedRowMapper,
).getOrThrow()
// для count можно воспользоваться функционалом CrudRepository,
// сгенерированным sqlx4k
val total = crudRepository.countByAppId(this, appId).getOrThrow()
ErrorGroupsPaginated(
items = items,
page = safePage,
totalPages = ((total + safePageSize - 1) / safePageSize).toInt(),
sortBy = sortBy,
sortOrder = sortOrder,
)
}
Frontend: Типобезопасный HTML и HTMX
Делать SPA (Single Page Application) для внутренней админки с тремя таблицами — это усложнение ради усложнения. Но и перезагружать страницу при каждом клике в 2025 году не хочется.
Связка Ktor HTML DSL + HTMX позволяет писать UI на чистом Kotlin, получая динамику SPA, но без JavaScript-сборки.
1. Компоненты в стиле Shadcn (DSL Wrapper)
Чтобы не писать “лапшу” из Tailwind-классов в каждом div, я написал небольшую обертку над HTML DSL. Это позволяет использовать семантические компоненты, похожие на те, что мы видим в React (например, Shadcn UI), но полностью типобезопасные.
Вот как выглядит реализация кнопки
// ui/Button.kt
enum class ButtonVariant {
Default, Outline, Ghost, Destructive
}
fun FlowContent.uiButton(
variant: ButtonVariant = ButtonVariant.Default,
type: ButtonType = ButtonType.button,
block: BUTTON.() -> Unit,
) {
// Определяем через tailwind
val classes = when (variant) {
ButtonVariant.Default -> "bg-primary text-primary-foreground hover:bg-primary/90"
ButtonVariant.Outline -> "border border-input bg-background hover:bg-accent hover:text-accent-foreground"
// ... другие варианты
}
button(classes = "inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors $classes") {
this.type = type
block()
}
}
Теперь в коде страницы мы просто вызываем uiButton, не думая о CSS:
uiButton(variant = ButtonVariant.Outline) {
+"Next Page →"
}
2. Типобезопасный роутинг (Ktor Resources)
Одна из удобных фич Ktor при работе с SSR — это плагин Resources. Он позволяет описывать маршруты как иерархию классов. Это избавляет от хардкода строк в URL и гарантирует, что ссылка всегда валидна.
Избежать сложности создания ресурсов (через AppsResource.AppId.Errors.GroupId(parent = ..., groupId = ...) конструкции) можно, сделав функцию типа:
@Resource("{groupId}")
class GroupId(val parent: Errors, val groupId: Long) {
companion object {
operator fun invoke(
appId: Int,
groupId: Long,
) = GroupId(Errors(AppId(appId)), groupId)
}
}
Это позволяет создавать глубоко вложенные пути одной строкой, скрывая иерархию родителей внутри. Так генерация ссылки для HTMX выглядит лаконично и читаемо:
attributes.hx {
// Генерируем POST запрос на изменение статуса ошибки
post =
call.application.href(
AppsResource.AppId.Errors.GroupId(
appId = appId, // Просто передаем ID
groupId = group.id, // Ресурсы сами построят иерархию
),
)
target = "#resolved-container" // Обновляем только контейнер статуса
swap = HxSwap.outerHtml
}
3. Реальный пример: Таблица с пагинацией
Соберем всё вместе.
Описываем route получения страницы ошибок приложения
get<AppsResource.AppId.Errors.Paginated> { resource ->
withUserId { userId ->
val data =
errorGroupRepository.findByAppId(
appId = resource.parent.parent.appId,
page = resource.page,
pageSize = resource.pageSize,
sortBy = resource.sortBy,
sortOrder = resource.sortOrder,
userId = userId,
)
call.respondHtml {
context(call) {
errorsTableFragment(resource.parent.parent.appId, data)
}
}
}
}
Отдаём рендер html c на kotlin+htmx без JavaScript. Вся логика переходов, сортировок и обновлений описана декларативно через атрибуты hx-*.
context(call: ApplicationCall)
fun HTML.errorsTableFragment(
appId: Int,
data: ErrorGroupsPaginated,
) {
...
table(classes = "w-full min-w-max") {
thead(classes = "bg-muted text-muted-foreground border-b border-border") {
tr {
th(classes = "p-2 w-8") { }
// Хелперы для заголовков с сортировкой
headerCell(appId, ErrorGroupSort.title, "Message", data)
headerCell(appId, ErrorGroupSort.occurrences, "Count", data)
headerCell(appId, ErrorGroupSort.lastSeen, "Last seen", data)
}
}
tbody {
// Итерируемся по данным полученным из таблицы БД
data.items.forEach { group ->
// Строка таблицы - это ссылка. HTMX перехватит клик.
tr(classes = "border-b border-border transition hover:bg-muted/50") {
attributes.hx {
// Используем type-safe генерацию ссылок
get = call.application.href(
AppsResource.AppId.Errors.GroupId(
parent = AppsResource.AppId.Errors(appId = appId),
groupId = group.errorGroup.id,
),
)
pushUrl = "true"
target = "body"
swap = HxSwap.outerHtml
}
td(classes = "p-2") {
if (group.errorGroup.resolved) iconCheck()
}
td(classes = "p-2 font-medium") { +group.errorGroup.title }
td(classes = "p-2") { +group.errorGroup.occurrences.toString() }
td(classes = "p-2 text-muted-foreground") {
+group.errorGroup.lastSeen.humanReadable()
}
}
}
}
}
Когда пользователь нажимает на строку, браузер делает AJAX-запрос, а сервер отдает HTML новой страницы. Благодаря pushUrl = "true", URL в браузере обновляется, и кнопки "Назад/Вперед" работают как обычно.
Внизу рендерим кнопки переключения страниц, клики на которые будут вызывать AppsResource.AppId.Errors.Paginated описанный выше. HxSwap.innerHtml будет заставлять браузер вставить полученный контент внутрь “errors-table”.
div(classes = "flex gap-2 mt-4") {
if (data.page > 1) {
uiButton(variant = ButtonVariant.Outline) {
attributes.hx {
get =
call.application.href(
AppsResource.AppId.Errors.Paginated(
parent = AppsResource.AppId.Errors(appId = appId),
sortBy = data.sortBy,
sortOrder = data.sortOrder,
page = data.page - 1,
),
)
target = "#errors-table"
swap = HxSwap.innerHtml
}
+"← Prev"
}
}
if (data.page < data.totalPages) {
uiButton(variant = ButtonVariant.Outline) {
attributes.hx {
get =
call.application.href(
AppsResource.AppId.Errors.Paginated(
parent = AppsResource.AppId.Errors(appId = appId),
sortBy = data.sortBy,
sortOrder = data.sortOrder,
page = data.page + 1,
),
)
target = "#errors-table"
swap = HxSwap.innerHtml
}
+"Next →"
}
}
}
Сборка: один нативный бинарник и минимальный Docker-образ
Одна из целей Katcher — не только небольшой runtime footprint, но и компактный контейнер. Вместо классического варианта openjdk + fat JAR или jib build используется двухстадийная сборка нативного бинарника и минимальный runtime-образ.
Stage 1: сборка Kotlin/Native бинарника
FROM --platform=linux/amd64 gradle:9.2.0-jdk21-jammy AS build
WORKDIR /app
COPY . .
RUN gradle :server:linkReleaseExecutableNative --no-daemon --stacktrace
Используем официальный образ Gradle + JDK 21 как сборочную среду. Внутри уже есть всё нужное для Kotlin/Native toolchain.
Кладём весь проект в /app.
Запускаем Gradle-задачу :server:linkReleaseExecutableNative — это стандартный таск Kotlin/Native, который:
-
компилирует модуль
server; -
линкует всё в один исполняемый файл
server.kexe; -
подтягивает необходимые системные библиотеки.
-
После этого в
server/build/bin/native/releaseExecutable/лежит готовый бинарник, который умеет сам поднимать Ktor-сервер.
Stage 2: минимальный runtime-образ
FROM gcr.io/distroless/cc-debian12
WORKDIR /app
COPY --from=build /app/server/build/bin/native/releaseExecutable/server.kexe /app/server
#копируем libcrypt.so необходимый для org.kotlincrypto.hash:sha2
COPY --from=build /usr/lib/x86_64-linux-gnu/libcrypt.so.1 /usr/lib/x86_64-linux-gnu/
EXPOSE 8080
ENTRYPOINT ["/app/server"]
distroless/cc-debian12 — это образ без шелла, пакетного менеджера и прочего «мусора».
Внутри только минимальный набор библиотек, нужных для запуска C/C++/Native-бинарников.
Мы копируем в него:
-
сам бинарник
server.kexe→/app/server; -
зависимость
libcrypt.so.1в системный путь/usr/lib/x86_64-linux-gnu/.
Зачем нужен libcrypt.so.1?
Kotlin/Native при линковке может оставлять динамическую зависимость от этой библиотеки (она используется для kotlincrypto.hash). В build-образе она есть по умолчанию, докидываем её во второй слой, иначе при запуске получим ошибку вида:
error while loading shared libraries: libcrypt.so.1: cannot open shared object file
В результате runtime-образ:
-
не содержит JDK вообще;
-
не содержит Gradle, компиляторы и dev-пакеты;
-
запускает только один файл —
/app/server.
Снаружи это выглядит так:
docker build -t katcher-server .
docker run --rm -p 8080:8080 katcher-server
Приложение поднимается за миллисекунды
[INFO] (io.ktor.server.Application): Application started in 0.01 seconds.
и потребляет десятки мегабайт памяти

Авторизация: доверяем заголовкам, не плодим функционал пользователей
Katcher принципиально не реализует свой логин и не хранит пользователей в базе.
Он доверяет тому, что сделал слой перед ним — SSO / oauth2-proxy / Keycloak / что угодно.
Схема такая:
Браузер → Ingress / Traefik / NGINX → oauth2-proxy (или другой SSO) → Katcher
Katcher читает два заголовка:
-
X-Auth-Request-User— уникальный идентификатор пользователя; -
X-Auth-Request-Email— email.
Если хотя бы одного заголовка нет — сервер отвечает 401 Unauthorized.
Весь функционал OAuth2 / OpenID / SSO живёт в прокси, Katcher видит уже «готового» пользователя. Это даёт возможность использовать существующую инфраструктуру авторизации.
Развертывание в Kubernetes
Благодаря тому, что Katcher собран в нативный бинарник под linux x64, деплой максимально прост.
Развертывание в Kubernetes с Helm
Katcher изначально проектировался как небольшой сервис для k8s: один Pod, один PVC под SQLite, один Ingress. Для этого есть Helm-чарт.
1. Настройка авторизации через Middleware
Основная предпосылка — это «доверенная» авторизация с использованием Ingress-контроллера (Traefik/NGINX) и внешнего SSO-сервиса (например, oauth2-proxy).
Пример middleware для Traefik, который перехватывает запрос, авторизует его, и только затем добавляет нужные Katcher заголовки:
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: auth-auth-mw
namespace: auth
spec:
forwardAuth:
address: http://oauth2-proxy.auth.svc.cluster.local:4180/auth
trustForwardHeader: true
authResponseHeaders:
- X-Auth-Request-User
- X-Auth-Request-Email
Katcher, в свою очередь, просто читает эти заголовки. Если их нет, возвращается 401.
2. Конфигурация Helm (values.yaml)
Helm-чарт разворачивает Deployment, PVC под SQLite и Service с IngressRoute. Минимальный my-values.yaml для продакшена выглядит так:
# my-values.yaml
# 1. Публичный домен, на котором будет доступен Katcher
hostname: katcher.example.com
# 2. Образ сервера и минимальные ресурсы (из-за Native)
server:
image: katcher
version: 0.1.14
resources:
requests:
cpu: "30m"
memory: "32Mi"
limits:
cpu: "1"
memory: "128Mi"
# 3. Хранилище (SQLite-файл)
storage:
class: "local-path" # Ваш StorageClass
size: 512Mi
# 4. Применение Auth-Middleware для защиты UI
traefik:
middlewares:
- auth-auth-mw
3. Установка в кластер
Деплой выполняется одной командой, используя локальный чарт и файл конфигурации:
helm upgrade --install katcher ./charts/katcher
--namespace katcher --create-namespace
-f my-values.yaml
Разделение маршрутов: Чарт создает две логические входные точки:
-
UI-маршрут (
/): Защищенauth-auth-mwи доступен только авторизованным пользователям в браузере. -
API-маршрут для репортов (
/api/reports): Специально не закрывается SSO, чтобы ваши сервисы и SDK могли слать краши без интерактивного логина.
Клиент
Чтобы собирать ошибки, нужна клиентская библиотека.
Модуль клиента объявлен как Kotlin Multiplatform, но на данный момент реализована только JVM-часть. Это покрывает backend-сервисы (Ktor, Spring) и desktop-приложения (Compose for Desktop). Остальные таргеты планируются позже.
В ближайших планах:
-
Android: Полноценная поддержка с загрузкой ProGuard/R8 маппингов (sourcemaps) для деобфускации стектрейсов.
-
Native: Расширение поддержки на Linux/macOS/iOS таргеты.
Клиент реализует Offline-first подход: если сервер недоступен, краш сохраняется на диск и отправляется при следующем запуске.
Подключение максимально простое:
Katcher.start {
remoteHost = "https://katcher.example.com"
appKey = "<YOUR_APP_KEY>"
release = "1.0.0"
environment = "Production"
}
Katcher подписывается на основные механизмы возникновения необработанных исключений в JVM-среде:
-
Потоки (Threads): глобальный обработчик для всех потоков, чтобы собрать ошибки, не пойманные в
try-catch.
fun setupJvmUncaughtExceptionHandler() {
val currentHandler = Thread.getDefaultUncaughtExceptionHandler()
Thread.setDefaultUncaughtExceptionHandler { t, e ->
// 1. Пытаемся поймать и отправить краш
runCatching { Katcher.catch(e) }
// 2. Даем системе 50 мс на запись дампа
try {
Thread.sleep(50)
} catch (_: InterruptedException) {
}
// 3. Передаем управление предыдущему обработчику
currentHandler?.uncaughtException(t, e)
}
}
2. Корутины (Coroutines): CoroutineExceptionHandler, который гарантирует, что ошибки, возникающие внутри асинхронного кода, будут корректно перехвачены и отправлены в Katcher.
class KatcherCoroutineExceptionHandler :
AbstractCoroutineContextElement(CoroutineExceptionHandler.Key),
CoroutineExceptionHandler {
override fun handleException(
context: CoroutineContext,
exception: Throwable,
) {
Katcher.catch(exception)
}
}
Это обеспечивает, что любое “падение” в JVM — будет поймано, обработано и отправлено на сервер.
Есть возможность, поймать ошибку вручную, приложив дополнительный контекст
Katcher.catch(
exception,
context =
mapOf(
"key" to "value",
),
)
Чтобы не попасть в рекурсию (когда обработчик ошибки сам падает, и всё начинается сначала), используется Atomic Guard на базе kotlinx.atomicfu: только первый краш реально обрабатывается, остальные игнорируются, пока флаг поднят.
object Katcher {
private val isCrashing = atomic(false)
fun catch(
throwable: Throwable,
context: Map<String, String> = emptyMap(),
) {
val first = isCrashing.compareAndSet(expect = false, update = true)
if (!first) return
try {
val params = buildReportParams(throwable, context)
fileStore.save(params)
uploadSignal.trySend(Unit)
} finally {
isCrashing.value = false
}
}
}
Бонус: Katcher JVM (Keycloak/OAuth2)
Хотя продакшн-сервер Katcher собран в Native для минимального потребления ресурсов, для удобства локальной разработки в репозитории есть отдельный модуль: Katcher JVM (Development Server).
Этот модуль работает на традиционной Ktor/JVM и использует Exposed ORM вместо sqlx4k. Он предназначен для:
-
Быстрой локальной итерации и отладки (привычный JVM-дебаггер).
-
Тестирования интеграции с любым OAuth2/OIDC провайдером (например, Keycloak), так как он поддерживает проверку токенов “из коробки” без необходимости настройки внешнего Reverse Proxy.
Если вы хотите быстро проверить API или схему базы данных, используя привычные инструменты Exposed, этот модуль — идеальный выбор.
Заключение
Katcher — это демонстрация того, что на современном Kotlin можно писать эффективные нативные веб-сервисы.
Мы получили решение, которое:
-
Собирается в один бинарник.
-
Использует мощь Rust для работы с данными.
-
Обладает современным UI без JS-стека.
-
Легко деплоится в Docker/Kubernetes с минимальными ресурсами.
GitHub: github.com/youndie/katcher
Автор: youndie
