Skip to main content

Как я заменил менеджер закладок на 1,3 ГБ инструментом на 17 МБ

VPS с 7,6 ГБ RAM уперся в память и заставил меня уйти с Karakeep. Миграция на Readeck превратилась в небольшой проект на Go: Telegram-бот с поддержкой нескольких пользователей и MCP-сервер, который я теперь подключаю к Claude.

· 9 мин чтения

Я довольно долго закрывал на это глаза: каждый раз, когда я смотрел docker stats на своём Hetzner VPS, Karakeep с большим отрывом оказывался самым тяжёлым контейнером. Машина с 7,6 ГБ RAM, 25 сервисов — и одно Node-приложение, которое держит 1,32 ГБ резидентной памяти ради того, что в моём случае было просто навороченным списком ссылок со скриншотами.

Доступной памяти оставалось 1,4 ГБ, swap упирался в 2,0 / 2,0 ГБ, а Postgres с лимитом 768 МБ доходил до 61% использования. Ничего не горело, но запас перестал ощущаться запасом.

Это история о том, как я с этим разобрался. В итоге получилось три шага: аудит, миграция и — поскольку готовые решения не совсем подошли — небольшой проект на Go.

Аудит

Сначала нужно было понять, что именно ест память. docker stats --no-stream и for f in /proc/*/status; do awk '/^VmSwap:/{if($2>0)print …}' $f; done отсортировали всё по потреблению памяти и объёму данных в swap. В топе оказались шесть контейнеров:

КонтейнерRAM
karakeep-web1.32 ГБ
memory-embeddings (TEI для RAG)964 МБ
authentik-server728 МБ
city_dashboard622 МБ
infra-postgres469 МБ
authentik-worker281 МБ

Swap на 2 ГБ был почти полностью «холодным». vmstat показывал si=2, so=3 kB/s: swap был заполнен, но активного постоянного обмена туда-сюда не происходило. Ядро выгрузило в swap неактивные данные — TEI запускается раз в 30 минут, worker-ы Evening News тоже работают периодически — и держало горячие страницы в RAM. Такая нагрузка на память — учебный пример нормального поведения системы, а не реальная проблема.

Реальное исправление оказалось простым: я подтянул свежий образ Karakeep — их тег :release отставал на три месяца, — добавил второй swap-файл на 4 ГБ и снизил vm.swappiness с дефолтных для Ubuntu 60 до 20. После этого картина стала такой:

МетрикаДоПосле
Используемая RAM5.4 ГБ4.4 ГБ
Доступно1.4 ГБ2.7 ГБ
Swap2.0 / 2 ГБ (100%)1.9 / 6 ГБ (32%)

Обычное обновление Karakeep снизило потребление RAM с 1,32 ГБ до 818 МБ: примерно минус 500 МБ просто за счёт свежего процесса и чистого Node heap. Машина снова начала дышать.

Но когда ты срезаешь 500 МБ с процесса, который всё равно занимает около 800 МБ, неизбежно начинаешь спрашивать себя: а нужны ли мне вообще эти 800 МБ?

Выбор

У Karakeep есть реальные сильные стороны: автотегирование с помощью ИИ, снапшоты и скриншоты через Chrome, нативные мобильные приложения с share-расширениями. Но в моём случае почти все сохранения — это ссылки, пересланные из Telegram, которые я потом читаю на iOS. AI-теги — приятная штука, но не обязательная. Архивы скриншотов — тоже. А вот рабочий путь из мобильного share sheet — уже критично.

Первым кандидатом был linkding. Python/Django, около 100 МБ в памяти и необычно богатая экосистема сторонних приложений: три iOS-приложения (LinkBuddy, Linkdy, LinkThing), четыре клиента для Android (LinkBuddy, Linkdy, Linklater, Pinkt), несколько Telegram-ботов, расширения для браузеров и даже linkding-injector, который показывает ваши сохранённые ссылки внутри результатов Google или DuckDuckGo. Технически он бы подошёл.

Остановил меня интерфейс. linkding — добротный внутренний инструмент: плотный и функциональный. Но эстетика у него — «трекер задач 2019 года», а смотреть на него мне пришлось бы каждый день, в свободное время. Это не технический аргумент, но вполне реальный.

Следующим в списке был Readeck: из того же Go-семейства, что и остальная часть моего стека, около 60 МБ в памяти и поддержка OPDS, чтобы отправлять закладки на электронную читалку в виде EPUB. В прошлый раз я отказался от него, потому что у него не было нативных мобильных приложений. Но теперь я заглянул в release notes и обнаружил, что v0.22 в марте 2026 года принесла iOS-приложение с OAuth и офлайн-чтением, а на F-Droid появился активный Android-клиент. Главный блокер исчез.

Shiori был в списке для полноты. Бинарник на Go, около 30 МБ, сознательно минимальный: без мобильных приложений, без ИИ, расширение для браузера ещё в бете. Правильный инструмент для кого-то, но неправильный для меня.

Оставшийся честный аргумент против Readeck — автоматическое AI-тегирование в Karakeep. Я посидел с этим вопросом. Скучный ответ: на практике я почти никогда не искал закладки по тегам, которые генерировал Karakeep; их было приятно видеть после сохранения, но они не были точкой входа для поиска. Более интересный ответ: с MCP-сервером перед Readeck AI-тегирование превращается в BYO-LLM. Claude может вызвать readeck_get_article, кратко пересказать статью и по запросу вызвать readeck_add_labels с тегами в моём стиле. Это не функция, которую я теряю. Это функция, которую я переношу туда, где сам контролирую поведение.

Readeck победил: более чистый UX для чтения, теперь достаточно убедительная мобильная часть, бинарник на Go, OPDS для читалки, а возражение про AI-теги оказалось решаемым, а не фатальным. Единственный компромисс, который остаётся, — отсутствие полноценных Chrome-снапшотов. Readeck извлекает текст статьи и сохраняет встроенные ресурсы, но не хранит визуальный архив страницы. Для моего способа пользоваться менеджером закладок это честный обмен.

Подключение

Деплой опирался на две уже существующие части инфраструктуры: общий Postgres (infra-postgres) и authentik SSO.

Для Postgres я просто создал роль readeck_user и базу данных readeck в том же экземпляре, который уже используют Authentik и ещё несколько сервисов. Контейнер Readeck подключается через READECK_DATABASE_SOURCE=postgres://readeck_user:…@infra-postgres-1:5432/readeck?sslmode=disable. Один пайплайн бэкапов покрывает всё.

SSO оказалось сложнее, чем я ожидал.

Readeck из коробки поддерживает forwarded authentication: он читает заголовки Remote-User, Remote-Email и Remote-Groups от доверенного reverse proxy. Authentik proxy outpost отдаёт X-authentik-username, X-authentik-email и X-authentik-groups. Но встроенные middleware в Traefik не умеют переименовывать заголовки. Они могут задавать статические custom headers, но не могут собрать их значения из входящих заголовков.

Моя первая попытка — Caddy-sidecar перед Readeck:

:8080 {
  reverse_proxy readeck:8000 {
    header_up Remote-User   {http.request.header.X-Authentik-Username}
    header_up Remote-Email  {http.request.header.X-Authentik-Email}
    header_up Remote-Groups admin
  }
}

Это сработало: примерно 10 МБ RAM на такой мост, два контейнера на сервис, зато функционально всё правильно.

Потом я начал искать более чистый путь и нашёл его: scope mappings в Authentik могут возвращать специальный ключ ak_proxy.additionalHeaders, который outpost отправляет как произвольные response headers. Scope mapping для Readeck стал таким:

return {
    "ak_proxy": {
        "user_attributes": request.user.group_attributes(request),
        "is_superuser": request.user.is_superuser,
        "additionalHeaders": {
            "Remote-User":   request.user.username,
            "Remote-Email":  request.user.email,
            "Remote-Groups": "admin" if request.user.is_superuser else "user",
        },
    }
}

В итоге хватило одного специфичного для Readeck правила authResponseHeaders=…,Remote-User,Remote-Email,Remote-Groups в forwardAuth middleware в лейблах Traefik — без лишнего sidecar-контейнера. Идея с Caddy тихо умерла.

Во время тестирования всплыли ещё два ограничения:

  1. Мобильные приложения используют API-токены, а не OAuth cookies. Если бы каждый запрос к /api/* проходил через Authentik, это сломало бы iOS-приложение и любой клиент с Bearer-токеном. Решение: второй router Traefik для Host(read.dzarlax.dev) && PathPrefix(/api), который обходит SSO middleware и отправляет запросы напрямую в Readeck. Readeck сам обрабатывает Bearer-аутентификацию для этих запросов.

  2. Let’s Encrypt меня зарейтлимитил. Я запустил deploy раньше, чем DNS-запись успела распространиться. Пять быстрых ACME-ошибок вызвали блокировку «too many failed authorisations for read.dzarlax.dev, retry after …». Я дождался окончания окна, перезапустил контейнер, и сертификат выдался с первой попытки. Урок: не поднимать TLS-маршрут, пока DNS действительно не живой.

Инструментарий

Здесь миграция перестала быть просто миграцией и стала проектом.

Старую связку Telegram→Karakeep обслуживал karakeepbot — сторонний Go-бот поверх API Karakeep. В BotFather я оставил того же бота и переиспользовал существующий токен, но новый backend говорит уже с другим API. Значит, нужен был новый код для Readeck.

Ещё я хотел видеть Readeck внутри Claude. Мои MCP-серверы для личной памяти и календаря уже стали достаточно полезными, чтобы команды вроде «найди мои закладки» и «прочитай эту статью» выглядели очевидным следующим шагом. Проще всего было сделать это в той же кодовой базе, что и бот: у них общий API-клиент.

Репозиторий — Dzarlax-AI/readeck_toolkit: один Go-модуль, два бинарника (cmd/bot, cmd/mcp), один Docker-образ. После нескольких итераций он пришёл к такой форме:

  • Бот поддерживает несколько пользователей через config.toml. Каждый блок [[tenants]] связывает Telegram user id с API-токеном Readeck. Входящее сообщение → находим id отправителя → берём токен этого пользователя → вызываем Readeck → закладка попадает в правильный аккаунт. Неизвестные отправители молча игнорируются. Добавление нового человека, например для общего домашнего использования, выглядит так: он создаёт API-токен Readeck в Settings → API tokens, пишет боту /whoami, получает свой числовой id, вы добавляете новый блок [[tenants]] и запускаете docker compose restart bot.

  • MCP устроен наоборот: без состояния и без учётных данных на сервере. Сервер знает только base URL Readeck. Каждый MCP-клиент при подключении передаёт свой токен Readeck через заголовок X-API-Key; handler инструмента достаёт токен из контекста запроса и на каждый вызов создаёт Readeck-клиент. Один экземпляр MCP можно безопасно использовать нескольким людям: Readeck ограничивает доступ по токену. На сервере нет пользовательской конфигурации и секретов на диске.

Пару тупиков тоже стоит назвать:

  • Первая версия MCP использовала SSE transport. NewSSEServer из библиотеки mcp-go казался самым простым вариантом по умолчанию. Локально всё работало. Но за Traefik middleware stripprefix событие SSE endpoint анонсировало путь /message?sessionId=… без префикса /readeck, и клиент после этого уходил не на тот route. Чтобы это исправить, пришлось бы убрать stripprefix и сообщить серверу полный публичный путь. Потом я перепроверил спецификацию и увидел, что SSE теперь считается legacy-подходом. Современные MCP-серверы должны использовать Streamable HTTP: один POST endpoint с опциональным streaming-ответом. server.NewStreamableHTTPServer(s, server.WithStateLess(true), server.WithEndpointPath("/readeck/mcp")) в итоге оказался чище и понятнее для многопользовательской схемы.

  • В первой версии config.toml был отдельный раздел [mcp] с полем tenant, чтобы MCP понимал, от чьего имени действует. После перехода на X-API-Key в каждом запросе весь этот раздел испарился. Теперь MCP нужен только [readeck].base_url; личность пользователя приходит в заголовках.

Финальная поверхность инструментов — девять функций:

readeck_save           save URL, optional title + labels
readeck_search         full-text search
readeck_list_recent    last N bookmarks
readeck_get_article    extracted article, HTML→Markdown server-side
readeck_mark_read      flip is_archived
readeck_add_labels     append labels, preserve existing
readeck_remove_labels  drop specified labels
readeck_delete         permanent delete
readeck_list_labels    every label with bookmark count

readeck_get_article — та функция, ради которой MCP действительно заслуживает место внутри Claude. Естественный запрос звучит примерно так: «найди мою сохранённую статью про Postgres _shared_buffers_ и кратко перескажи её конкретные рекомендации». Claude вызывает readeck_search, выбирает самый релевантный id из списка, вызывает readeck_get_article и делает саммари. Я при этом вообще не открываю веб-интерфейс. В паре с readeck_add_labels это превращается в BYO-LLM-теггер, который закрывает пробел Karakeep из предыдущего раздела.

Что в итоге получилось

Бот и MCP вместе потребляют всего 7 МБ RAM в простое. Karakeep, который я остановил, но оставил на диске на пару недель на случай, если пожалею о переезде, потреблял 818 МБ. Разница в занимаемой памяти — в 47 раз на весь стек инструментов. Взамен я потерял визуальные скриншоты страниц, получил чуть менее отполированный iOS-опыт и потратил примерно три свободных вечера.

Удаление Karakeep запланировано в Todoist на две недели вперёд — достаточно далеко, чтобы успеть понять, чего мне будет не хватать. Пока ответ такой: «иногда автоматических AI-тегов». Это ровно тот пробел, который MCP может закрыть, как только я сяду и напишу для него Claude prompt.

Что дальше?

Очевидная v2 — save hook в боте. Сейчас бот просто делает POST /api/bookmarks и отвечает. Если расширить его так, чтобы он ещё отправлял POST {id, url, manual_labels} на save_hook_url, настроенный для каждого пользователя, то любой обработчик на той стороне — Claude routine, n8n workflow или небольшой скрипт с API-ключом модели — сможет забрать статью через MCP, сгенерировать AI-теги и саммари, а потом записать их обратно через readeck_add_labels и обновление описания. Все строительные блоки уже есть: бот аккуратно срабатывает при сохранении, MCP умеет читать и писать всё нужное. Не хватает только webhook-склейки. Это примерно один вечер работы и закрывает потребность в автоматическом тегировании без встраивания LLM прямо в кодовую базу. Вся идея была в BYO-LLM, так что пусть hook остаётся hook-ом.

Репозиторий: github.com/Dzarlax-AI/readeck_toolkit. Лицензия — MIT, multi-arch image доступен на ghcr.io/dzarlax-ai/readeck-toolkit. Бот полезен даже одному пользователю: iOS share sheet отправляет URL прямо в него в один тап, бот сохраняет ссылку с опциональными #hashtag-метками и возвращает Readeck-ссылку. Больше не нужно открывать веб-интерфейс только ради того, чтобы сохранить ссылку. Multi-tenant config пригодится, если захочется поделиться инстансом с партнёром или небольшой командой. MCP-сервер — для всех, кто хочет связать Claude или любой другой MCP-совместимый клиент со своим Readeck.