В мире 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 контейнера или дешевого VPS.

База данных: прагматичный подход (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-чарт разворачивает DeploymentPVC под 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). Остальные таргеты планируются позже.

В ближайших планах:

  1. Android: Полноценная поддержка с загрузкой ProGuard/R8 маппингов (sourcemaps) для деобфускации стектрейсов.

  2. 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-среде:

  1. Потоки (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