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 "";
import { getAuth } from ''
import { getAnalytics } from "";
import { getFirestore, collection, getDocs, addDoc } from ''
// 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 && \\
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 в Golang неудобен — указания swagger«у пишутся в комментариями к коду:
// 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, сообщение, тип)]
Более того, генерация в 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
Логи в Loki
Хорошей практикой считается брать логи прямо из docker контейнеров, а не http логером, но я на это не пошёл
Так или иначе логи пишем в структурированном JSON формате, чтобы сторонняя система могла его прожевать и фильтровать. Обычно здесь мы используем кастомный логгер, я использовал Zap
Архитектура логов
Loki
openTelemetry и трассировка через Jaeger
К каждом запросу прикрепляется заголовок x-trace-id, по которому можно посмотреть весь путь запроса в системе (актуально для микросервисов)
Архитектура трассировки
Путь 1 запроса в Jaeger
Выбор языка программирования играет не последнюю роль, популярные enterprise языки (Java, C#) хорошо поддерживают стандарт openTelemetry: Language APIs & SDKs. Golang моложе и сейчас полноценно не поддерживается сбор логов (Beta). Трассировка выходит менее удобной, сложнее посмотреть путь запроса в системе
Pyroscope
Можно провесит нагрузочное или стресс тесты, а можно подключить Pyroscope и смотреть нагрузку, память и потоки реальном времени. Хотя, конечно, сам Pyroscope отъедает процент производительности
Pyroscope и выделение памяти в приложении
В контексте оптимизации, при выборе языка программирования мы выбираем его потенциал, ведь нет смысла сравнивать потолок скоростей Go, Rust, Java, C#, JS без оптимизации. Но на оптимизацию нужно вложить человекочасы и с точки зрения бизнеса может быть более релевантно смотреть производительность из коробки, доступность спецов и развитие языка
Sentry
Ошибки сервера часто ведут убытки, поэтому есть система, которая собирает полный путь и контекст ошибки как с frontend, позволяя увидеть, что накликал юзер, так и с backend
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", ""]
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 не предназначены — это подтверждают истории со счетами на сотни тысяч долларов для внезапно взлетевших приложения и остаётся большой вопрос, решается ли это ценовой политикой