Приложению 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 в 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 конфигу в репозитории


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


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


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

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

Развёртывание мониторинга через 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?
