Блог переехал с Ghost на собственный движок на Go

· News · Read in English

Этот блог перезапущен с нуля. До этого он жил на Ghost, и в какой-то момент я понял, что больше трачу сил на обслуживание платформы, чем на тексты. Ghost давно перестал быть «простым движком для блога»: это Node.js, MySQL, рассылки, платные подписки, членство, интеграции — целый издательский комбайн, из которого мне нужна была от силы десятая часть. А то, что действительно мешало, создатели исправлять не торопились.

Теперь здесь всё своё. Маленькое, на Go, упакованное в Docker-контейнер. Ниже — как оно устроено, что вошло, что сознательно осталось за бортом и почему.

Что было нужно

Список требований уместился в одну строку: посты в Markdown, картинки слева, справа или по центру с автоматическим уменьшением при загрузке, RSS. И всё.

Чего не нужно было с самого начала: SEO-обвеса, членства и платных подписок, рассылок, собственных комментариев. Всё это есть в Ghost, всё это тянет за собой код, базу и обновления — и ничем из этого я не пользовался.

Цифры

Блог живёт на Raspberry Pi у меня дома. Замер в день переезда:

Память
Ghost: Node.js 189 МБ
Ghost: MySQL 531 МБ
Новый блог 15 МБ

Страница собирается за 3–7 миллисекунд. После удаления Ghost вместе с его образами, бэкапами и кэшем сборки на Pi освободилось около 13 ГБ.

Один бинарник

Весь блог — это одна программа на Go. Шаблоны, стили и скрипты вшиты внутрь через go:embed, так что на сервер не нужно подкладывать ни одного файла рядом. Внешних зависимостей две: goldmark для Markdown и golang.org/x/image для работы с картинками. Остальное — стандартная библиотека Go.

Отброшено: веб-фреймворки, ORM, сборщики фронтенда. Для блога с одним автором хватает net/http из коробки, а каждая лишняя зависимость — это ещё одна вещь, которая однажды сломается при обновлении.

Никакой базы данных

Посты, страницы, настройки и сведения о картинках хранятся в одном файле blog.json. Он целиком лежит в памяти, а при каждом изменении записывается атомарно: сначала во временный файл, затем переименование. Даже если питание пропадёт посреди записи, на диске останется либо старая версия, либо новая, но не обрывок.

Отброшено: MySQL, PostgreSQL и даже SQLite. Тексты личного блога — это мегабайты, а не гигабайты. База данных — это отдельный процесс (в случае Ghost — самый прожорливый), миграции схемы и отдельная процедура бэкапа. JSON-файл можно открыть глазами, скопировать вместе с остальными данными и восстановить одной командой tar.

Markdown

Посты пишутся в Markdown: CommonMark плюс таблицы, зачёркивание, списки задач и автоссылки в духе GitHub. Сверху — несколько расширений, которых мне не хватало:

  • картинка с фрагментом #left или #right обтекается текстом, без фрагмента или с #center — встаёт по ширине колонки; на телефоне обтекание превращается в картинку на всю ширину;
  • подпись под картинкой берётся из её title;
  • выделение через двойные знаки равенства, сноски и [TOC] — оглавление по заголовкам, как в начале этой заметки;
  • ссылка на YouTube отдельной строкой превращается в плеер с режимом повышенной конфиденциальности.

В редакторе есть панель кнопок на весь этот диалект и предпросмотр, который рендерит сам сервер — то есть ровно так, как пост будет выглядеть на сайте.

Сырой HTML в постах проходит как есть. Обычно это дыра, но здесь пишет только один человек — я, — так что это осознанное решение, а не забывчивость.

Отброшено: визуальный WYSIWYG-редактор, как в Ghost. Он удобен ровно до момента, когда нужно понять, почему абзац отформатирован не так, как хотелось. Markdown — это обычный текст, который всегда можно прочитать и исправить руками.

Картинки

Каждая загруженная картинка проходит через один конвейер: декодируется, разворачивается по EXIF-ориентации (фото с телефона больше не лежат на боку), уменьшается до 2000 пикселей по ширине, плюс сохраняются копии на 600, 1000 и 1400 пикселей — браузер сам выберет подходящую через srcset. Перекодирование заодно вычищает метаданные: модель камеры и GPS-координаты больше не уезжают в интернет вместе с фотографией.

PNG сохраняют прозрачность, GIF остаются как есть вместе с анимацией (перекодирование потеряло бы кадры или цвета), SVG хранится без изменений, а WebP сохраняется как JPEG.

Отброшено: ImageMagick и libvips. Они быстрее и умеют больше, но тянут за собой C-библиотеки, а значит — сборку с CGO и толстый образ. На чистом Go бинарник собирается статически, и образ получается маленьким.

Два языка по одному адресу

Пост может существовать на русском и на английском. Раньше в Ghost это были два отдельных поста с суффиксом -ru в адресе. Теперь это один пост с переводом и один URL, а язык выбирается так: явный ?lang= в ссылке, затем переключатель RU/EN (запоминается в cookie), затем язык браузера, затем язык блога по умолчанию. Если перевода нет — показывается оригинал с пометкой.

Старые адреса с -ru никуда не пропали: они перенаправляют на новый пост с ?lang=ru. RSS-лент три: по умолчанию, отдельно русская и отдельно английская.

Комментарии — в другом месте

Собственных комментариев в блоге нет и не будет: модерация, спам и хранение чужих персональных данных — это не то, чем хочется заниматься ради личного блога. Вместо этого под каждым постом есть ссылки на мои профили в Bluesky, Mastodon и X — обсуждать можно там.

Новые посты блог сам анонсирует в Bluesky карточкой со ссылкой — с обложкой, заголовком и началом текста. Это включается галочкой в редакторе, работает и для отложенных постов и никогда не отправляет одно и то же дважды.

Вход в админку

Имя пользователя, пароль и одноразовый код из приложения-аутентификатора (TOTP). Каждый код принимается только один раз. Любое изменение на странице безопасности выкидывает из админки все остальные браузеры. Число попыток входа ограничено.

Пароль хранится в виде хеша PBKDF2-SHA256 из стандартной библиотеки Go, сессия — подписанная HMAC cookie, без отдельного хранилища сессий на сервере.

Docker

Образ собирается в два этапа: на первом Go компилирует статический бинарник, на втором он копируется в чистый Alpine, где из лишнего есть только часовые пояса. Контейнер работает от непривилегированного пользователя, все данные — в одном томе: blog.json, настройки входа, ключ сессий и картинки.

Собирается образ прямо из репозитория на GitHub, так что обновление на сервере — это одна команда:

docker compose build --no-cache && docker compose up -d

Каждое изменение кода перед этим проходит проверку на GitHub: форматирование, go vet, тесты с детектором гонок и пробная сборка под ARM64 — архитектуру Raspberry Pi.

Бэкапы и запасной сервер

Каждую ночь скрипт упаковывает том с данными вместе с настройками контейнера в архив и хранит десять последних. Блог при этом не останавливается: файлы пишутся атомарно, поэтому их можно безопасно читать на ходу.

Кроме того, на сервере в Амстердаме стоит копия блога, которая каждые десять минут сверяется с домашней и при расхождении получает свежие данные. Админки у неё нет вовсе — она запускается с отключённым интерфейсом администратора, чтобы никто случайно не отредактировал копию, которую следующая синхронизация всё равно перезапишет. Если с Raspberry Pi что-то случится, она должна будет подхватить блог.

Переезд с Ghost

Для переезда в репозитории есть конвертер JSON-экспорта Ghost и импорт, который прогоняет все старые картинки через тот же конвейер. Адреса постов сохранились, а старые ссылки на картинки вида /content/images/… перенаправляются на новые копии — так что внешние ссылки на этот блог не сломались.

Сам Ghost удалён полностью, вместе с базой и всеми его бэкапами. Дороги назад нет, и не нужно.

Исходный код

Код блога открыт: github.com/WhyElEss/go-blog