Firebase VS self-hosted

от автора

Многие стартапы начинают с Firebase, затем из нежелания платить гуглу уходят на свои сервера — об этом и пойдёт речь

С нюансами про стэк технологий, в частности выбор языка программирования, и оценим усилия на побег от Firebase и vercel. Разберём на примере моего пет-проекта — Github. Видео демо снизу:


Про клиент

Благодаря 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 и от него идёт любая логика для обработки этих провайдеров

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

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

  • из-за редиректа от гугла я не могу указать токен в 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, но это привязало меня к Gitlab, но в идеале здесь подключается Vault

Unit тесты

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

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

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

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

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

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

// GetUser godoc // //@SummaryRetrieves a user by its ID //@DescriptionRetrieves a user from the MongoDB database by its ID. //@Tagsusers //@Producejson //@SecurityBearerAuth //@Paramidpathstringtrue"ID of the user to retrieve" //@Success200{object}dto.GetUserResponse"Successful response" //@Failure401{object}dto.UnauthorizedResponse"Unauthorized" //@Failure404{object}lib.ErrorResponse"User not found" //@Failure500{object}lib.ErrorResponse"Internal server error" //@Router/api/v1/user/{id} [get] func (s *userService) GetUser(c *gin.Context){...}

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

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

#!/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 (swagger.json) можно сгенерировать Typescript файл со всеми запросами через команду с указанием желаемого генератора из списка

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

Docker

Как и клиент сервер собирается в 2 ступени:

# 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  # run 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"]

Для Go не забываем указывать операционку для билда и go mod download для кэша


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

Мы хотим повторить опыт с Firebase, соответственно, настраиваем системы для логов и метрики

Метрики Prometheus & Grafana

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

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

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

Grafana

Логи в Loki

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

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

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

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

Loki

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

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

Архитектура трассировки

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

Путь 1 запроса в Jaeger

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

Pyroscope

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

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

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

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

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 запросов / секунду, что спокойно обработает 1 сервер, то Firebase в месяц возьмёт только за функции 100$ + плата за БД и хранилище + vercel хостинг

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

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

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

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

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


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

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

  • Во сколько обойдётся вся эта работа?

  • Сколько стоят сервера?

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

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

Однако как ни крути для Highload ни Firebase, ни Vercel не предназначены — это подтверждают истории со счетами на сотни тысяч долларов для внезапно взлетевших приложения и остаётся большой вопрос, решается ли это ценовой политикой


ссылка на оригинал статьи https://habr.com/ru/articles/826508/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *