почему я не просто использовал wordpress, а построил свой блог

21 июня 2026 г. learningmetasveltekit

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

Что я сначала рассматривал

Перед тем как отказаться от готовых решений, я действительно посмотрел на очевидные варианты. WordPress казался мне неправильным инструментом. Слишком много расширений для функций, которые мне не нужны, и весь PHP-стек, который я вообще не хотел учить только ради блога. Ghost был ближе, действительно красиво работает из коробки, но всё равно это чужая платформа. А суть этого проекта никогда не была в том, чтобы «иметь блог». Суть была в том, чтобы «построить что-то настоящее, достаточно сложное, чтобы я не смог его как-то схитрить». Готовая платформа не спрашивает тебя, как работает вход в систему, как хранятся загрузки или что происходит, когда на одну страницу одновременно заходят двести человек. Мне был нужен инструмент, который именно эти вопросы задаёт, а не тот, что ответит на них раньше, чем я даже подумаю их спросить.

К тому времени я кое-как начал разбираться в Go и SvelteKit, в основном через небольшие скрипты и недоделанные учебные проекты, и ничего из этого не складывалось в пазл на что-то настоящее. Блог выглядел как хороший повод. Достаточно маленький, чтобы действительно закончить, но с достаточно большим количеством реальных деталей (аккаунты, база данных, публичный интерфейс), чтобы нельзя было просто схитрить. Поэтому вместо того чтобы установить готовое решение, я решил построить то, что научило бы меня больше всего, даже знал, что это займёт намного больше времени.

Как это всё в итоге сложилось

Пять сервисов, один файл Docker Compose, всё работает на одной машине в моём доме. Изначально это совсем не был план. Просто масса маленьких решений, большинство из которых принимались потому что предыдущий подход сломался, в итоге свелась к этому.

services:
  postgres:
  auth:
  blog:
  frontend:
  caddy:

Почему именно Go и SvelteKit, а не что-то более популярное

Ни один из этих выборов не был про то, чтобы выбрать «лучший» инструмент. Go потому что хотелось чего-то скомпилированного и скучного в хорошем смысле, языка, где нет пятнадцати разных одобренных способов структурировать маленький веб-сервис. И стандартной библиотеки достаточно для большей части пути до рабочего HTTP-сервера без того чтобы по умолчанию тянуться к фреймворку. SvelteKit выбрал в основном потому что это было самое новое в моём списке на тот момент, и я хотел, чтобы фронтенд этого проекта тоже был учебным проектом, а не просто местом где я приклеиваю знакомый инструмент к двум Go-сервисам. Django или Express позволили бы мне быстрее получить рабочий сайт. Оба это языки которые я неплохо знал до начала. Но скорость здесь не была целью.

Docker Compose вместо чего-то более сложного. это такое же скучное решение. Это работает на одной машине. Kubernetes решает проблемы которых у меня нет, координирует множество машин, разворачивает приложение без простоев по целому флоту, ничего из этого не применимо к одному серверу в моём доме. Файл Compose который я могу прочитать целиком за минуту, лучше чем более «правильная» система которую пришлось бы неделями учить без реальной пользы при таком масштабе.

Сервис auth простыми словами

Один маленький Go-сервис с одной работой: знать кто ты и доказывать это всему остальному без того чтобы кому-либо приходилось прямо доверять пароль. Регистрация, вход, получение короткоживущего токена доступа плюс долгоживущий файл cookie обновления, так что ты остаёшься вошедшим без того чтобы токен доступа сам жил вечно, если его вдруг утекут.

func (s *Server) Login(w http.ResponseWriter, r *http.Request) {
	var req loginRequest
	if err := decodeJSON(w, r, &req); err != nil {
		writeDecodeError(w, r, err)
		return
	}

	user, err := s.Queries.GetUserByUsername(r.Context(), req.Username)
	if err != nil {
		writeError(w, r, http.StatusUnauthorized, "invalid_credentials", "invalid username or password")
		return
	}

	ok, _ := password.Verify(req.Password, user.PasswordHash)
	if !ok {
		writeError(w, r, http.StatusUnauthorized, "invalid_credentials", "invalid username or password")
		return
	}

	s.issueTokensAndRespond(w, r, user, http.StatusOK)
}

Пароли конечно никогда не хранятся в открытом виде, только хэши. И это специально выбранный хэш спроектированный чтобы работать медленно (argon2id), так что даже если база данных утечёт, перебрать хэш обратно в реальный пароль будет слишком дорого, чтобы стоило пытаться в масштабе.

Сервис blog простыми словами

Второй Go-сервис, совершенно отдельный, собственная база данных, отвечает за посты, комментарии, теги, категории, метаданные медиа. Он вообще ничего не знает о паролях, просто доверяет токену который выдал auth-сервис, в точности как охранник в клубе доверяет браслету не перепроверяя удостоверение каждый раз когда ты проходишь мимо новой двери.

r.Route("/admin/posts", func(r chi.Router) {
	r.Use(authmw.RequireCapability(verifier, matrix, capability.PostsManage))
	r.Get("/", s.AdminListPosts)
	r.Post("/", s.AdminCreatePost)
	r.Patch("/{id}", s.AdminUpdatePost)
})

Держать это как второй отдельный сервис выглядело избыточным в начале. Зачем лишние сетевые хопы если в этом нет смысла? А потом я захотел отдельно думать над «что может пойти не так в системе аккаунтов» и отдельно над «что может пойти не так в системе постов». Два маленьких скучных проблемы вместо одной большой страшной.

Фронтенд на SvelteKit простыми словами

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

async function handle(event: RequestEvent): Promise<Response> {
	const upstreamPath = event.url.pathname.replace(/^\/api/, '') || '/';
	const target = new URL(upstreamPath + event.url.search, env.BLOG_INTERNAL_URL);
	const upstream = await forwardRequest(event, target, extraHeaders);
	return new Response(upstream.body, { status: upstream.status });
}

PostgreSQL простыми словами

Две отдельные базы данных, одна для auth, одна для blog. Не одна общая схема со всем перемешанным. Поначалу выглядело как лишняя работа, потом стало понятно что это правильно с первого раза когда я захотел сделать резервную копию или перенести одну без трогания другой.

MinIO простыми словами

Загруженные картинки и аватары живут не в Postgres, а в MinIO, который говорит тем же API что и Amazon S3 но работает как просто ещё один контейнер на моей машине. Мне это особенно нравится потому что это значит что позже, если я когда-нибудь захочу перейти на реальное облачное хранилище, это будет просто изменение конфига, а не переписывание. Понадобится ли мне это, отдельный вопрос. Иметь эту опцию стоит почти ничего с самого начала.

Caddy простыми словами

Единственное во всём стеке что имеет открытый публичный порт. Всё остальное только разговаривает друг с другом через внутреннюю Docker-сеть, ничего прямо не доступно снаружи кроме как через Caddy. Он терминирует TLS, так что мне никогда не нужно думать о продлении сертификатов. И это то что на самом деле прикрепляет каждый лимит размера и ограничение скорости которое в остальной части этого поста я планирую ругать за то что я его сначала неправильно сделал.

handle /api/admin/* {
	request_body {
		max_size 5MB
	}
	reverse_proxy frontend:3000
}

Вот примерно такова форма. Теперь части что на самом деле сломались, каждая заслуживает свою честную секцию вместо одного расплывчатого «потом я починил несколько ошибок» абзаца.

Сага о лимите размера загрузки

Загрузки были первой реальной стеной. Маленькое тестовое изображение загружалось нормально, потом я попробовал настоящую фотографию с моего телефона и получил криптичную ошибку без полезного сообщения вообще, просто неудавшийся запрос и ничего помогающего в консоли браузера. Оказалось что есть лимит размера запроса стоящий в Caddy перед собственным лимитом приложения, и я установил его слишком маленьким не понимая что два отдельных слоя оба должны согласиться что значит «слишком большой».

# before: media uploads fell under the same small default as everything else
handle /api/admin/* {
	request_body {
		max_size 2MB
	}
	reverse_proxy frontend:3000
}

По умолчанию Caddy совсем не достаточен для современной фотографии с телефона, и пока я не пошёл искать, я вообще не знал что это отдельный лимит от того что самый Go-сервис налагает.

Путь загрузки от админ-формы через Caddy до хранилища

Целый вечер я смотрел на логи в полном замешательстве перед тем как вообще нашёл где живёт этот лимит, не говоря уж о почему он там. Половина решения пришла из пятилетнего форум-треда, не из документов, где-то кто-то описывал точно такой же симптом в совершенно другом проекте, и это как я понял что надо искать в конфиге Caddy вместо того чтобы предполагать что ошибка в моём коде Go всё это время.

handle /api/admin/media* {
	request_body {
		max_size 1100MB
	}
	reverse_proxy frontend:3000
}

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

Комментарии и вход заняли намного больше времени чем я ожидал

Я думал что «пользователь вводит пароль, сервер проверяет» это в основном вся фишка. На самом деле под этим столько всего: правильно хэшировать пароль, выдать короткоживущий токен плюс более долгоживущий для сохранения входа, потом то что меня по настоящему достало, это ограничение скорости за обратным прокси. С комментариями вышло похоже, свой маленький вариант одного и того же урока. Форма для комментариев выглядит просто пока ты не наперляешься с обработкой спама, лимитами скорости на пользователя и модерацией. Ничего из этого не показывается в учебнике типа «добавьте поле для комментариев».

Изучение что обратный прокси на самом деле делает

Каждая попытка входа выглядела как пришедшая с одного и того же IP потому что Caddy был единственным что прямо говорит моему приложению, и я не сказал ему доверять заголовку который говорит кто на самом деле визитёр. Это долго занимало просто чтобы понять как ошибку потому что симптом выглядел как «ограничение скорости сломано» вместо «я совсем не понимаю что обратный прокси механически делает с запросом». Обратный прокси это из тех терминов что я много лет повторял не особенно понимая что он делает механически, а когда я это строил, мне пришлось правда сесть и разобраться вместо того чтобы кивать в следующий раз когда кто-то его упомянул.

Примерно как вход работает на самом деле: короткоживущий токен плюс долгоживущий

Ограничение скорости кусало меня конкретно

Как только я понял проблему обратного прокси, настоящее исправление было маленькое, просто доверяй ровно одному хопу заголовка forwarded-for, что совпадает с одним прокси что я имею перед приложением. Но чтобы туда попасть мне сначала надо было заметить что десять неудачных попыток входа от десяти разных реальных людей как-то все падали в один бакет, стать подозрительным что что-то фундаментально неправильно в том как я определяю откуда вообще пришёл запрос, и только потом понять что кажущийся исходный IP запроса это каждый раз адрес контейнера Caddy, для каждого визитёра, потому что Caddy на самом деле единственное что когда-либо открывает сокет на фронтенд. Каждый визитёр одной личностью с точки зрения приложения, это примерно настолько сломано как может быть ограничение скорости не выкидывая реальную ошибку чтобы подсказать что-то.

Редизайн, сначала стандартный потом с настоящим Lovund

Первая реальная версия дизайна этого сайта была, оглядываясь назад, шаблон блога с отпиленными серийными номерами. Голубоватый акцент, системный шрифт по умолчанию, пробелы которые выглядят как каждый другой быстрый блог что кто-то строит за выходной. Работало. Это тоже могло быть чьим угодно блогом, и это начало меня раздражать чем дольше я смотрел, как только реальная функциональность стала достаточно твёрдой что дизайн был что оставалось неделанного.

Пару недель назад я переделал всю визуальную часть. Зелёный акцент, серифный шрифт для основного текста, уже поля чем мой изначальный дефолт. Не потому что старая версия была сломана. Она выглядела как каждый другой быстрый шаблон блога, я хотел что-то что чувствовалось бы как настоящий выбор вместо того что просто стартовый набор отправил. (Я тоже сказал что не буду больше трогать CSS как только редизайн отправлен. Это продлилось дней четыре.)

Зелень в частности была не случайна. Жить где-то столь зелёном как Lovund становится летом, мох и холмы и вода делающая этот особый глубокий цвет что она делает под облачным небом, чувствовалось как акцент что этот сайт должен был иметь с самого начала вместо какого-то стандартного голубого что я просто выбрал не думая. Как только я это выбрал, остаток редизайна в основном последовал из того чтобы всё остальное было достаточно тихо что зелень действительно стояла выделяясь вместо конкурентства с шестью другими цветами за внимание.

Одна маленькая фишка которую я добавил потом

Одно что блог-сервис делает что я не планировал: планирование публикации. Пост может оставаться черновиком с будущим временем публикации, и фоновая работа проверяет каждые тридцать секунд есть ли что-то у чего время пришло, переводит это в жизнь, и ставит реальное время публикации в этот момент вместо когда я случайно нажал сохранить. Маленький механизм, одна работающая петля и два SQL-выражения под ней, ничего что требует очереди работ или чего-то фанцезамудрённого. Я это в основном делал чтобы мог написать несколько постов на одном вечере и не иметь их все выходить одновременно, разносить их вместо этого без того чтобы помнить что вернуться и отдельно нажать опубликовать каждый.

Реальности самостоятельного хостинга, потому что эта часть тоже не бесплатна

Самостоятельный запуск этого вместо платы платформе значит каждый аутэйдж это мне надо заметить и мне надо чинить, по моему расписанию, обычно обнаруживается мною когда я пробую что-то проверить. Резервные копии здесь более важны чем они были бы на управляемой платформе, потому что никакой поставщик молча не хранит копию моей базы данных где я не должен думать. Я имею обе Postgres-базы слитыми каждую ночь и посланы куда-то вне этой реальной машины, потому что одномашинная установка это тоже единая точка отказа и находить это во время реального отказа диска то как вы реально учитесь этому уроку.

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

Перезагрузка сервиса это одно когда машина сидит несколько метров от меня. Это совершенно другое чувство когда вся машина может умереть в любой момент, реальный план восстановления это свежая машина, восстановленный дамп базы, и столько сколько надо времени чтобы с нуля запустить Docker Compose, вероятно большую часть вечера если всё что я записал о конфигурации реально точно и ничего не дрейфовало молча со последней проверки. Я ещё не тестировал этот полный сценарий, что ровно то что я знаю что должен делать на спокойной выходной вместо того чтобы узнать трудный путь во время реального аутэйджа.

Что дальше

Реальные инструменты модерации комментариев, потому что сейчас я это делаю вручную прямо в базе, что хорошо при нынешнем маленьком масштабе и не останется хорошо. Реально больше писать, теперь что оправдание «строю платформу» за то что ничего не пишу в основном кончилось. И вероятно, в конце концов, реально ходить и подняться на одну из гор что я всё смотрю с парома вместо просто написания о желании это сделать.

0 комментариев

Войдите , чтобы оставить комментарий.

Войти

Забыли пароль?

Нет аккаунта?