Как я заменил менеджер закладок на 1,3 ГБ инструментом на 17 МБ
VPS с 7,6 ГБ RAM уперся в память и заставил меня уйти с Karakeep. Миграция на Readeck превратилась в небольшой проект на Go: Telegram-бот с поддержкой нескольких пользователей и MCP-сервер, который я теперь подключаю к Claude.
Я довольно долго закрывал на это глаза: каждый раз, когда я смотрел 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-web | 1.32 ГБ |
memory-embeddings (TEI для RAG) | 964 МБ |
authentik-server | 728 МБ |
city_dashboard | 622 МБ |
infra-postgres | 469 МБ |
authentik-worker | 281 МБ |
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. После этого картина стала такой:
| Метрика | До | После |
| Используемая RAM | 5.4 ГБ | 4.4 ГБ |
| Доступно | 1.4 ГБ | 2.7 ГБ |
| Swap | 2.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 тихо умерла.
Во время тестирования всплыли ещё два ограничения:
Мобильные приложения используют API-токены, а не OAuth cookies. Если бы каждый запрос к
/api/*проходил через Authentik, это сломало бы iOS-приложение и любой клиент с Bearer-токеном. Решение: второй router Traefik дляHost(read.dzarlax.dev) && PathPrefix(/api), который обходит SSO middleware и отправляет запросы напрямую в Readeck. Readeck сам обрабатывает Bearer-аутентификацию для этих запросов.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 middlewarestripprefixсобытие SSEendpointанонсировало путь/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.