Перейти к основному содержанию

linkding на Go: сутки на порт и неделя на то, что всплыло после

2 October 2026

Закладки я давно храню в linkding . Привык к нему настолько, что на прошлой неделе написал для него клиент под Android1. К самому серверу претензия у меня была одна, зато постоянная.

linkding написан на Python и Django. Настройки запуска я не менял, а по умолчанию образ поднимает uWSGI в два процесса по два потока2. Одновременно сервер обрабатывает четыре запроса, остальные ждут в очереди. При этом мой linkding смотрит в интернет, и кроме меня к нему ходят сканеры и боты, которые перебирают адреса. Время от времени он просто переставал отвечать, и приходилось ждать, пока отпустит. Число процессов можно поднять, но каждый занимает память, а пользуется сервисом один человек.

В какой-то момент я задумался, нельзя ли перенести linkding на Go. Писать руками порт чужого проекта с админкой, OIDC, импортом, снимками страниц и двумя базами данных я бы не стал. Поэтому поручил анализ и работу Codex.

Сутки

Сначала появился документ с условиями приёмки3. В нём зафиксирована версия оригинала, v1.47.0, и перечислено, что должно совпасть: маршруты, поля JSON, коды ответов, тексты ошибок, внешний вид страниц, настройки LD_*. Отдельной строкой записано главное правило всей работы: если документация и код оригинала расходятся, образцом считается ответ запущенного linkding этой версии.

Так и шла работа. Каждый шаг сверялся с Python-версией, и при любом расхождении порт дорабатывался. По истории репозитория это видно лучше, чем по моему пересказу: десятки коммитов подряд начинаются со слова match. Совпадение формы ошибки при битом заголовке с токеном. Совпадение ответов на HEAD и OPTIONS. Порядок вариантов сортировки в выпадающем списке. Счётчики в фильтрах админки. Переводы админки, взятые из каталогов Django.

Первый коммит датирован вечером 25 сентября, релиз v1.47.0 вышел днём 26-го4. Между ними 76 коммитов. Codex работал чуть больше суток почти без остановок, и на выходе получился сервер, который собирается без CGO в один исполняемый файл и умеет SQLite и PostgreSQL.

Существующую установку порт принимает через отдельную команду миграции5. Она переносит пользователей, хеши паролей, закладки, теги, токены API и файлы в новую базу, а исходную не трогает, так что откатиться можно в любой момент. Пароли и токены остаются прежними, заново нужно только войти.

Первый запуск: OIDC

Я перенёс данные, запустил порт на месте оригинала и не смог войти.

Перед linkding у меня стоит Caddy: он принимает HTTPS, а до приложения запрос идёт по обычному HTTP. Порт видел у себя HTTP и именно с этой схемой собирал адрес возврата для OIDC. Провайдер такой адрес не принимал.

В оригинале эту задачу решает промежуточный слой Django, который читает заголовки прокси. В порте пришлось чинить код: с настройкой LD_USE_X_FORWARDED_HOST сервер берёт схему и имя хоста из заголовков X-Forwarded-Proto и X-Forwarded-Host6. Без неё он эти заголовки игнорирует, иначе адрес возврата мог бы подставить любой клиент; на оба случая есть тесты. Исправление вышло в v1.47.1, через два часа после первого релиза.

Go оказался медленнее

Ради чего всё затевалось, нужно было проверить. Я попросил нагрузочное сравнение: один и тот же набор из 10 000 закладок, оба сервера в контейнерах с одинаковыми ограничениями, запускаются по очереди7.

Результат на SQLite меня удивил.

Запрос, 8 клиентовPython, запросов/сGo, запросов/сGo к Python
Список1772351,33
Поиск66550,84
Поиск с тегом59410,70
Страница интерфейса90710,79
Создание закладки3473070,89

Порт проигрывал оригиналу почти везде, где работали несколько клиентов сразу. А в том же прогоне нашлась вещь похуже скорости: при восьми параллельных клиентах 664 из 1000 запросов на создание закладки вернули HTTP 500. SQLite не пускал вторую пишущую транзакцию, пока шла первая, а порт не ждал. Это закрыл тот же релиз v1.47.1: транзакции теперь открываются в режиме IMMEDIATE.

Дальше началась обычная работа с базой. Разбор запросов показал, что язык тут ни при чём:

  • Страницу из ста закладок порт собирал сотней отдельных запросов, по одному на закладку с её тегами. Python забирал теги для всей страницы разом.
  • При создании закладки проверка на дубликат не попадала в индекс и просматривала все закладки владельца.
  • Поиск по тегу повторял соединение таблиц для каждой закладки-кандидата.
  • На каждое слово в поиске функция сравнения без учёта регистра вызывалась четыре раза.

Каждая правка мерилась отдельной парой прогонов «до и после»8:

Что изменилосьБыло, запросов/сСтало, запросов/с
Список, 32 клиента244349
Поиск, 32 клиента57107
Страница интерфейса, 32 клиента69231
Создание закладки, 8 клиентов3151462

Пары относятся к разным экспериментам, перемножать их в одно общее ускорение нельзя. И победа не полная: на поиске с тегом при 32 клиентах хвост задержек у Go в последнем сравнении остался длиннее, чем у Python. Справедливости ради, оригинал тоже выигрывает от настройки базы: после ANALYZE создание закладок на Python ускорилось с 348 до 412 запросов в секунду.

Мне важнее было другое измерение: сколько ресурсов сервер тратит при одинаковой нагрузке. Оба получали по 50 запросов в секунду9.

PythonGo
Процессор, ядер (четыре сценария API)0,43–1,630,18–0,64
Память под нагрузкой, МиБ120–15814–24
Память в простое, МиБ689

Это и есть ответ на исходную претензию. Сканеры никуда не делись, но теперь их обслуживает один процесс, которому хватает двух десятков мегабайт.

Неделя на собственном сервере

Тесты на совпадение с оригиналом проверяют то, что догадались проверить. Остальное находится, когда начинаешь пользоваться.

Фильтр, который сбрасывался. Я открыл закладки с тегом #inbox, отметил первую страницу и отправил в архив. В строке поиска по-прежнему стояло #inbox, а список под ней показывал все закладки подряд. Порт не передавал текущий запрос в адрес действия, оригинал передаёт. При проверке соседних форм нашлось то же самое в поиске и в его настройках: они теряли активные фильтры, которых не было среди видимых полей10.

Длинный заголовок. Закладка со страницы, у которой заголовок длиннее 512 символов, не сохранялась: сервер отвечал ошибкой 400. Заодно выяснилось, что некоторые пути записи это ограничение обходили и упирались уже в базу. Теперь заголовок нормализуется и обрезается до 512 символов Юникода в одном месте, через которое проходят API, формы, админка, импорт и фоновое обновление метаданных11.

Молчащий сервер. Разбираясь с заголовками, я заметил, что порт вообще не пишет в консоль, кто и зачем к нему приходил. Для сервера, на который ходят сканеры, это неудобно. Журнал запросов появился в том же релизе; токены лент из адресов в нём вырезаются.

Общий адрес у многих владельцев. Эту проблему показал разбор запросов, сам я с ней столкнуться не мог: если одну и ту же ссылку сохранили тысячи пользователей, поиск дубликата просматривал их всех. Составной индекс по адресу и владельцу поднял скорость создания в таком сценарии с 533 до 6884 запросов в секунду12. На моей установке с одним пользователем разницы нет, но оставлять это не хотелось.

За пять дней вышло восемь релизов, от v1.47.0 до v1.47.7.

Что в итоге

Сейчас у меня работает порт, и разницы с оригиналом я не замечаю: тот же интерфейс, те же расширения для браузера, тот же адрес, ShareDing ходит в тот же API.

Что стоит знать, прежде чем пробовать:

  • Порт привязан к linkding v1.47.0. Новые версии оригинала сами в него не попадут.
  • Это независимый проект. Оригинал по-прежнему ведёт его автор, Sascha Ißbrücker (sissbruecker ), и вопросы по порту ему задавать не нужно.
  • Все измерения сделаны на ноутбуке короткими прогонами. Они показывают, где порт быстрее или медленнее на одних и тех же данных, и ничего не обещают о предельной нагрузке.
  • Проверен он одной установкой — моей.

Последний пункт я могу исправить только с вашей помощью. Код лежит в репозитории juev/linkding , готовые сборки и образы — в релизах, порядок переноса описан в руководстве по миграции5. Исходная база при переносе остаётся нетронутой, так что попробовать можно без риска для закладок. Если что-то работает не так, как в оригинале, или чего-то не хватает, заведите issue.


  1. ShareDing для Android: ссылки в linkding, даже когда сервер недоступен . ↩︎

  2. uwsgi.ini в linkding v1.47.0: processes = 2, threads = 2. ↩︎

  3. linkding v1.47.0 migration contract . ↩︎

  4. Релизы juev/linkding . ↩︎

  5. Migrate from Python linkding v1.47.0 . ↩︎ ↩︎

  6. Коммит 120980f : схема из заголовка прокси для OIDC и режим транзакций SQLite. Настройка описана в руководстве по установке . ↩︎

  7. Local load comparison: Python linkding and the Go port — условия, полные таблицы для SQLite и PostgreSQL, способ повторить. ↩︎

  8. Performance optimization results . ↩︎

  9. CPU and memory at equal request rates . Память — анонимная память контейнера, медиана трёх прогонов. ↩︎

  10. Pull request #3 , вошёл в v1.47.6. ↩︎

  11. Коммит 2d80805 , вошёл в v1.47.7. ↩︎

  12. Pull request #1 , вошёл в v1.47.5. ↩︎