golang

От Firebase к Self-Hosted

  • пятница, 5 июля 2024 г. в 00:00:07
https://habr.com/ru/articles/826508/

Приложению cara.app пришёл счет от Vercel на 96280$. Многие стартапы начинают с Vercel и Firebase, затем из нежелания платить гуглу уходят на свои сервера

Поговорим с нюансами про стэк технологий, в частности выбор языка, и оценим усилия на миграцию на свои сервера. Разберём на примере моего пет-проекта без Firebase (Github).

КлиентСерверМониторингK8S

Демо с инфраструктурой:


Про клиент

Благодаря Firebase rules, с базой не страшно работать на клиенте. На своём сервере мы такого себе не позволяем, клиент опираться на сервер касательно аутентификации и базы

Но и с Firebase не всё так радужно, при неправильной настройке rules база вытаскивается скриптом ниже, запускаемым из консоли браузера под авторизованным юзером. Конфиг легко ищется в коде сайта
const script = document.createElement('script');
script.type = 'module';
script.textContent = `
  import { initializeApp } from "<https://www.gstatic.com/firebasejs/10.3.1/firebase-app.js>";
  import { getAuth }       from '<https://www.gstatic.com/firebasejs/10.3.1/firebase-auth.js>'
  import { getAnalytics }  from "<https://www.gstatic.com/firebasejs/10.3.1/firebase-analytics.js>";
  import { getFirestore, collection, getDocs, addDoc }  from '<https://www.gstatic.com/firebasejs/10.3.1/firebase-firestore.js>'
// TODO: search for it in source code
const firebaseConfig = {
apiKey: "<>",
authDomain: "<>.firebaseapp.com",
projectId: "<>",
storageBucket: "<>.appspot.com",
messagingSenderId: "<>",
appId: "<>",
measurementId: "G-<>"
};
const app = initializeApp(firebaseConfig);
const analytics = getAnalytics(app);
window.app = app
window.analytics = analytics
window.db = getFirestore(app)
window.collection = collection
window.getDocs = getDocs
window.addDoc = addDoc
window.auth = getAuth(app)
alert("Houston, we have a problem!")
`;
document.body.appendChild(script);

Приятная практика — вынести работу с Firebase в отдельный файл, функции заменяются на работу с API и больше ничего не меняется. В примере используется тандем Axios и Tanstack

Развёртываем с Docker

Собираем Vite через команду в package.json, собранное приложение выставляем через nginx. Так приложение проще всего развернуть на сервере

# Build stage
FROM node:21.6.2-alpine as build
WORKDIR /client
COPY package.json yarn.lock ./
RUN yarn config set registry <https://registry.yarnpkg.com> && \\
    yarn install
COPY . .
RUN yarn build

# Serve stage
FROM nginx:alpine
COPY --from=build /client/build /usr/share/nginx/html
COPY --from=build /client/nginx/nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 3000
CMD ["nginx", "-g", "daemon off;"]

Про сервер

Для практики я выбрал Golang и давайте посмотрим, куда меня это завело. На всех популярных языках есть клиенты для баз данных и обработчики запросов, различия языков проявят себя позже

Аутентификация

Всё как у людей, в примере даётся выбор регистрации через провайдеры или по email. Я использовал JWT токены и Google — под это уже написаны библиотеки

Для логина и регистрации через Google определяются 2 ручки:

  • /api/v1/google/login — сюда ведёт кнопка “Войти через Google”

  • /api/v1/google/callback — при успешном входе сюда придёт редирект от Google, тут пользователь сохраняется в БД и для него генерируется JWT токен. Этот URL регистрируется в google cloud (localhost подходит, локальные домены нет)

В БД у пользователя держится массив Providers - от него идёт понимание, зарегистрировался пользователь через Google, email или всё вместе

Что характерно для JWT токенов, их нельзя отменить. Кнопка “выйти из аккаунта” вносит токены в чёрный список, для этого подключают Redis и указывают срок жизни ключа до конца жизни токена

Я храню JWT токены в httpOnly куках, выбрал этот путь исходя из альтернатив:

  • из-за редиректа от Google я не могу указать токен в header’е ответа, react без SSR не сможет его прочитать

  • не захотел оставлять токен в URL, ведь потом с frontend его нужно доставать

CORS

Для работы с куками разрешаю Access-Control-Allow-Credentials и ставлю локальные домены, локалхост и адреса инфраструктуры в Access-Control-Allow-Origin

	corsCfg := cors.DefaultConfig()
	corsCfg.AllowOrigins = []string{
		cfg.FrontendUrl,
		"http://prometheus:9090",
		"https://chemnitz-map.local",
		"https://api.chemnitz-map.local",
		"http://localhost:8080"}
	corsCfg.AllowCredentials = true
	corsCfg.AddExposeHeaders(telemetry.TraceHeader)
	corsCfg.AddAllowHeaders(jwt.AuthorizationHeader)
	r.Use(cors.New(corsCfg))

Переменные окружения

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

Я решил это скриптом, который подтягивает переменные из Gitlab CI/CD variables и создаёт .env.production. Это привязывает меня к Gitlab, в идеале ради этого подключается Vault

Unit тесты

От них не скрыться, что с Firebase, что со своим сервером. Их задача - давать уверенность и меньше тестировать вручную

Я покрыл бизнес логику Unit тестами и ощутил разницу: на позднем этапе проекта поменял поле у сущности юзера — изменение минорное, но эта сущность встречается в коде 27 раз. Это поле шифруются для базы и база работает с DBO сущностью юзера, в запросах она парсится в JSON и обратно. Для проверки изменения ручным тестированием нужно ткнуть каждый запрос пару раз с разными параметрами

Документация запросов Swagger

Swagger документация, здесь можно потыкать запросы
Swagger документация, здесь можно потыкать запросы

Swagger в Golang неудобен — указания пишутся в комментариями к коду без валидации и подсказок:

// GetUser godoc
//
//	@Summary		Retrieves a user by its ID
//	@Description	Retrieves a user from the MongoDB database by its ID.
//	@Tags			users
//	@Produce		json
//	@Security		BearerAuth
//	@Param			id	path		string						true	"ID of the user to retrieve"
//	@Success		200	{object}	dto.GetUserResponse			"Successful response"
//	@Failure		401	{object}	dto.UnauthorizedResponse	"Unauthorized"
//	@Failure		404	{object}	lib.ErrorResponse			"User not found"
//	@Failure		500	{object}	lib.ErrorResponse			"Internal server error"
//	@Router			/api/v1/user/{id} [get]
func (s *userService) GetUser(c *gin.Context){...}

В отличие от .Net или Java, где Swagger настраивается аннотациями: [SwaggerResponse(200, сообщение, тип)]

Более того, генерация Swagger в Go не происходит автоматически, поэтому вызываем сборку swagger конфига при каждом изменении. IDE тут упрощает жизнь — перед сборкой приложения настраивается вызов скрипта генерации Swagger

#!/usr/bin/env sh
export PATH=$(go env GOPATH)/bin:$PATH

swag fmt && swag init -g ./cmd/main.go -o ./docs

Поддерживать Swagger в Golang сложнее, но альтернативы с такими же характеристиками нет: коллекции запросов в лице Postman, Insomnia или Hoppscotch проигрывают Swagger из-за ручного труда для создания запросов

До кучи по конфигурации swagger.json можно сгенерировать Typescript файл со всеми запросами с указанием желаемого генератора из списка

swagger-codegen generate -i ./docs/swagger.json -l typescript-fetch -o ./docs/swagger-codegen-ts-api

Docker

Как и клиент сервер собирается в 2 ступени. Для Go не забываем указывать операционку для билда и go mod download, чтобы не скачивать зависимости при каждой сборке

# build stage
FROM golang:1.22.3-alpine3.19 AS builder
WORKDIR /app
COPY go.mod .
COPY go.sum .
RUN go mod download
COPY . .
RUN GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o main ./cmd/main.go

# serve stage
FROM alpine:3.19
WORKDIR /app
COPY --from=builder /app/main .
COPY --from=builder /app/app.yml .
COPY --from=builder /app/resources/datasets/ ./resources/datasets/
EXPOSE 8080
CMD ["/app/main"]

Про мониторинг

Мы хотим повторить опыт с Firebase, соответственно, мы должны понимать, что происходит с нашими данными и запросами. Ради этого настраиваем настраиваем стороннюю инфраструктуру

Метрики Prometheus & Grafana

Благодаря метрикам, мы понимаем нагрузку сервера. Для Go есть библиотека penglongli/gin-metrics, которая собирает метрики по запросам. По этим метрикам можно сразу отобразить графики по готовому Grafana конфигу в репозитории

Архитектура метрик
Архитектура метрик
Grafana
Grafana

Логи в Loki

Хорошей практикой считается брать логи прямо из docker контейнеров, а не http логером, но в примере я на это не пошёл

Так или иначе логи пишем в структурированном формате JSON, чтобы сторонняя система Loki смогла их прожевать и дать инструменты фильтрации. Для структурированных логов используется кастомный логгер, я использовал Zap

Архитектура логов
Архитектура логов
Loki
Loki

openTelemetry и трассировка через Jaeger

К каждом запросу прикрепляется заголовок x-trace-id, по нему можно посмотреть весь путь запроса в системе. Это актуально для микросервисов

Архитектура трассировки
Архитектура трассировки
Путь 1 запроса в Jaeger
Путь 1 запроса в Jaeger

Здесь выбор языка программирования играет не последнюю роль, популярные enterprise языки (Java, C#) хорошо поддерживают стандарт openTelemetry: Language APIs & SDKs. Golang моложе и сейчас полноценно не поддерживается сбор логов (Beta). Трассировка выходит менее удобной, поэтому сложнее посмотреть путь запроса в системе

Оптимизации с Pyroscope

Можно провесит нагрузочное или стресс тесты, а можно подключить Pyroscope и смотреть нагрузку CPU, память и потоки реальном времени. Хотя, конечно, сам Pyroscope отъедает процент производительности

Pyroscope и выделение памяти в приложении
Pyroscope и выделение памяти в приложении

В контексте оптимизации мы выбираем язык программирования за его потенциал, ведь нет смысла сравнивать потолок скоростей Go, Rust, Java, C#, JS без неё. Но на оптимизацию вкладываются человекочасы и для бизнеса может быть релевантнее смотреть производительность из коробки, доступность спецов и развитие языка

Sentry

Ошибки сервера ведут к убыткам, поэтому есть система Sentry, которая собирает путь ошибки с frontend на backend, позволяя увидеть клики юзера и полный контекст происходящего

Sentry с ошибками
Sentry с ошибками

Развёртывание мониторинга через Docker Compose

Это самый простой способ поднять всё воедино. Не забываем настраивать healthcheck, volume и безопасность всех подключаемых сервисов

server/docker-compose.yml
services:
  # ----------------------------------- APPS
  chemnitz-map-server:
    build: .
    develop:
      watch:
        - action: rebuild
          path: .
    env_file:
      - .env.production
    healthcheck:
      test: ["CMD", "wget", "-q", "--spider", "<http://localhost:80/api/v1/healthcheck>"]
      interval: 15s
      timeout: 3s
      start_period: 1s
      retries: 3
    ports:
      - "8080:8080"
    networks:
      - dwt_network
    depends_on:
      mongo:
        condition: service_healthy
      loki:
        condition: service_started
----------------------------------- DATABASES
mongo:
image: mongo
healthcheck:
test: mongosh --eval 'db.runCommand("ping").ok' --quiet
interval: 15s
retries: 3
start_period: 15s
ports:
- 27017:27017
volumes:
- mongodb-data:/data/db
- ./resources/mongo-init.js:/docker-entrypoint-initdb.d/mongo-init.js
networks:
- dwt_network
env_file: .env.production
command: ["--auth"]
----------------------------------- INFRA
[MONITORING] Prometheus
prometheus:
image: prom/prometheus
ports:
- "9090:9090"
volumes:
- ./resources/prometheus.yml:/etc/prometheus/prometheus.yml
networks:
- dwt_network
[MONITORING] Grafana
grafana:
image: grafana/grafana
ports:
- "3030:3000"
networks:
- dwt_network
env_file: .env.production
environment:
- GF_FEATURE_TOGGLES_ENABLE=flameGraph
volumes:
- ./resources/grafana.yml:/etc/grafana/provisioning/datasources/datasources.yaml
- ./resources/grafana-provisioning:/etc/grafana/provisioning
- grafana:/var/lib/grafana
- ./resources/grafana-dashboards:/var/lib/grafana/dashboards
[profiling] - Pyroscope
pyroscope:
image: pyroscope/pyroscope:latest
deploy:
restart_policy:
condition: on-failure
ports:
- "4040:4040"
networks:
- dwt_network
environment:
- PYROSCOPE_STORAGE_PATH=/var/lib/pyroscope
command:
- "server"
[TRACING] Jaeger
jaeger:
image: jaegertracing/all-in-one:latest
networks:
- dwt_network
env_file: .env.production
ports:
- "16686:16686"
- "14269:14269"
- "${JAEGER_PORT:-14268}:14268"
[LOGGING] loki
loki:
image: grafana/loki:latest
ports:
- "3100:3100"
command: -config.file=/etc/loki/local-config.yaml
volumes:
- ./resources/loki-config.yaml:/etc/loki/local-config.yaml
networks:
- dwt_network
----------------------------------- OTHER
networks:
dwt_network:
driver: bridge
Persistent data stores
volumes:
mongodb-data:
chemnitz-map-server:
grafana:

Это будет работать, но только в рамках одной машины


Про развёртывание на K8S

Если 1 машина справляется с вашими нагрузками, предполагаю, вы и не сильно выйдете за бесплатный план в Firebase, не настолько сильно для появления экономического стимула заплатить за перенос всей системы и масштабироваться самому

Если взять средний RPS 100 запросов / секунду, что спокойно обработает сервер за 40$, Firebase в месяц возьмёт 100$ только за функции + плата за БД и хранилище + vercel хостинг, зато масштабируется из коробки

Для масштабирования на своих серверах уже не хватит Docker Compose + вся инфраструктура мониторинга усложняет переезд на несколько машин. Здесь мы подключаем k8s

Благо, k8s независим от серверной программы, он берёт контейнеры из registry и работает с ними. Обычно создаётся свой приватный registry, но я использовал публичный docker hub

Для всех сервисов создаём свои deployment и service манифесты, подключаем конфигурации и секреты, даём базе место с помощью PersistentVolume и PersistentVolumeClaim, пишем настройки маршрутизации в ingress, для разработки подключаем локальные домены из /etc/hosts, создавая свои сертификаты для браузера, а для прода подключаем сертификаты от Let’s Encrypt на свой домен и ву-аля!

127.0.0.1 grafana.local pyroscope.local jaeger.local prometheus.local loki.local mongo.local chemnitz-map.local api.chemnitz-map.local

Дальше при необходимости администрировать несколько машин подключается Terraform или Ansible

Ещё нам открываются опции настроить blue/green деплой, stage/prod через helm, подключить nginx mesh, что уже сложнее сделать с Firebase, если в принципе возможно, зато в Firebase проще направлять пользователя к территориально ближайшему серверу и защищаться от DDOS атак


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

  • В туториалах редко поднимаются темы деплоя, инфраструктуры, оптимизации и масштабирования, справятся ли с этим Junior разработчики, которые спокойно работают с Firebase?

  • Во сколько денег и времени обойдётся вся эта работа?

  • Какова цена ошибки?

  • Можно ли только пилить фичи и не резать косты?

  • Какая ценовая политика позволит использовать Firebase и Vercel на Highload?