Playground. Как сэкономить кучу времени на настройке окружения
Привет, Хабр! Меня зовут Никита, и я Go-разработчик. В свободное от работы время я интересуюсь платформенной разработкой, а в рабочее — практикую в команде PaaS в СберМаркете. Моя специализация — локальное окружение разработчика и тулинг.
Главная метрика, на которую работает моя команда, — Тime-Тo-Мarket, совокупное время, затраченное на разработку фичи от самого начала разработки и до релиза на пользователей.
В сложившихся процессах разработки всегда найдется место для оптимизации затрат ресурсов разработчика. Будь то написание boilerplate кода, подготовка инфраструктуры, ручной рефакторинг или перезапуск кода при внесении изменений. Список далеко не исчерпывающий.
Уже сейчас PaaS может предложить многое для сокращения времени разработки фичи. Сегодня хочу рассказать о том, как именно наша команда помогает выпускать релизы быстрее с помощью инструмента Playground.
Когда возникает мысль: «Было бы неплохо иметь единственную команду для запуска и дебага любого сервиса в компании»
Playground: локальная среда разработки
Собираем требования. Что Playground должен уметь
Выбираем технологию
Конфигурация
Межсетевое взаимодействие
Контейнеры
Приложения
Хранилища
Kafka
Как посмотреть статус запущенных сервисов?
Что делать если контейнер не запускается?
Расширения
UI/UX
Ошибки и негативные сценарии
Запуск тесов
Выводы
Единственная команда для запуска и дебага любого сервиса в компании
Перед тем как погрузимся в особенности реализации инструмента, разберем пару ситуаций, которые позволят более полно прочувствовать важность решаемой задачи.
#1 Первый рабочий день
Поздравляю, вы успешно прошли все этапы собеседования и попали в компанию своей мечты! Там, как и во многих современных компаниях, используются микросервисы. Они взаимодействуют между собой и, конечно же, используют различные базы данных.
Вас встречает ваш первый low-hanging fruit в Jira и каскад вопросов о настройке сложного рабочего окружения. Может быть, кто-то до вас уже оставил инструкции или даже конфиги на GitHub. Но не факт.
Погружаясь в исходный код, вы правите переменные окружения, разбираетесь с миграциями и ищете помощь у более опытных коллег. То, что могло быть выполнено за день, занимает у вас три или четыре. Расстроены? Скорее всего, да.
И это ещё не все. Представьте новичка в соседней команде, который проходит через те же трудности. И когда следующий новичок придёт на его место, все начнётся сначала. А если в компании уже тысяча инженеров, и компания активно нанимает новых? Выглядит довольно мрачно, не правда ли?
#2 Merge Request в чужую команду
Разработчикам иногда необходимо предложить Merge Request в чужую команду. В этот раз «повезло» именно тебе.
Склонированный проект показал, что инженерная культура соседней команды оставляет желать лучшего отличается от вашей. Например, некоторые DSN вообще прописаны прямо в исходниках.
В общем, запустить проект быстро не выходит. Свою задачу «дотолкать» необходимо, поэтому ты проходишь все шаги новичка по настройке окружения вновь.
Если хотя бы одна из ситуаций тебе знакома, знай, I feel your pain.
Предположу, что в такие моменты разработчик задумывается: «Было бы неплохо иметь единственную команду для запуска и дебага любого сервиса в компании».
Именно этим и занимается моя команда.
И это тот случай, когда интересы разработчиков и компании совпадают максимально. Во вступлении я уже говорил про Time-to-Market — скорость запуска фич. Сейчас СберМаркет #1 на рынке e-grocery в России (онлайн покупка продуктов) и, как вы можете догадаться, это очень конкурентный и динамичный рынок. Поэтому цена простоя очень дорога. Именно поэтому цель моей команды и шире — всего PaaS — выкатывать такие инструменты для наших разработчиков, чтобы команда могла работать быстрее и с меньшим количеством ошибок.
Playground
Playground предоставляет уникальную возможность легко и быстро запустить ряд сервисов прямо на вашем Mac или Linux и провести различные эксперименты. Это ваша песочница, в которой вы свободны делать всё, что захотите.
Playgroundявляется частью sbm-cli. Это утилита для разработчиков в СберМаркет, которая позволяет создать сервис, добавить в него зависимости от других сервисов, сгенерировать код из контрактов и запустить его в Docker с автоматическим развертыванием хранилищ, мок-серверов, whatever you want…
На сегодняшний день sbm-cli содержит команды из шести доменных областей:
управление шаблонами сервиса
локальная среда разработки (Playground)
работа с API
работа с БД
валидация
системные команды
Для контекста советую ознакомиться со статьей моего коллеги про sbm-cli: Как PaaS решил проблемы стандартизации разработки сервиса одной утилитой.
В этой статье речь пойдет именно о стандартизации запуска сервиса с помощью Playground.
Для экспериментов уже сейчас доступны postgres, clickhouse, kafka, elasticsearch, kibana, flipt, s3, grpc-wiremock (сервис, создающий мок-сервер для ваших контрактов в одну команду — мы выпустили его в опенсорс, подробнее об этом в статье).
Playground является частью sbm-cli и имеет следующий набор команд:
sbm-cli service up
sbm-cli service debug
sbm-cli service down
sbm-cli service status
sbm-cli service reset
sbm-cli service purge
sbm-cli service env
Перед тем как перейти к деталям реализации, давайте пофанатазируем о требованиях и возможных способах их реализовать.
Собираем требования
Ключ к упрощению — стандартизация. Если все сервисы компании написаны единообразно и валидность конфигов поддерживается через CI/CD, остается только предоставить удобный специализированный инструмент для выполнения базовых операций над сервисом.
Иными словами, построить ещё один уровень абстракции над привычными нам инструментами контейнеризации приложений. Например, над Docker.
Какой интерфейс следует предложить пользователям?
Как минимум, не слишком сложный, чтобы не изучать тонну документации. Важно поддерживать низкий порог входа, поскольку пользователями могут быть люди, которые не пишут код, например, QA специалисты.
Как максимум, такой, чтобы вывод предлагал возможные варианты развития событий после запуска той или иной команды. Пример: после запуска приложения было бы неплохо получить строчку с командой для просмотра логов контейнера с приложением. Или с
curl
запросом к ручкам сервиса. Эти мелочи важнее, чем кажется, ведь разработчику предстоит взаимодействовать с Playground на ежедневной основе.
Так или иначе, необходимый минимум — это:
запуск одного или нескольких сервисов;
остановка;
проверка статуса запущенных приложений;
и полный сброс состояния на случай непредвиденных проблем.
Что Playground должен уметь?
Отличный способ начать проектировать — идти от интерфейса пользователя.
Так мы и поступили. Вот что получилось в результате:
sbm-cli service up
— запускает сервис c хранилищами и инфраструктурными зависимостями.-storages-only
— разворачивает только хранилища и kafka, приложение разрабатывается на хосте (большинство из нас не понаслышке знает про низкую скорость Docker на Mac OS).-up-timeout
— запускает сервис c заданным таймаутом (спойлер: Playground используется в CI/CD для запуска интеграционных тестов).sbm-cli service debug
— отличается от up только тем, что позволяет подключить инструмент дебага приложения.-wait
— указывает какое из приложений проекта нужно отладить.sbm-cli service down
— останавливает сервис и хранилища.-all
— останавливает все запущенные сервисы, хранилища и kafka.sbm-cli service status
— показывает таблицу со статусами контейнеров.-all
— показывает статусы для всех запущенных сервисов.sbm-cli service reset
— alias для команд up & down.-all
— перезапускает все сервисы.-up-timeout
— для вызова up под капотом.sbm-cli service purge
— удаляет артефакты Playground из Docker.sbm-cli service env
— по аналогии c привычной командойenv
, возвращает набор переменных среды из Playground.
Выбираем технологию
Существует множество способов завернуть код в контейнер, но Docker является де-факто стандартом на рынке. Пользователи с ним скорее всего уже знакомы, поэтому было решено остановиться на нем.
Работать напрямую с Docker показалось не лучшей затеей, нужен оркестратор. Тут все сложнее. Выбирать есть из чего: minikube, podman, k0s, k3s, kind, Docker Swarm и Docker Compose.
Выбрать систему близкую к k8s казалось хорошей идеей, поскольку, чем ближе среда разработки к продовой, тем ниже вероятность «наступить на грабли» при деплое проекта. Однако, очень важно брать во внимание потребление ресурсов той или иной системы оркестрации, поскольку компьютер с Intel I7 и 16 гб RAM является самым распространенным конфигом разработчика. Обжечь инженера не входило в наши планы.
Minikube поднимает виртуальную машину на хосте пользователя, следовательно требует больше ресурсов, для задач локальной разработки он показался избыточен.
Docker Swarm не так сильно распространен, к тому же, имеет довольно высокие требования к железу, было решено отказаться от него.
Тонкая настройка поведения контейнеров (replication, scaling, self-healing) не требуется. Вряд ли кто-то захочет тестировать приложение в нескольких экземплярах на собственной машине. Для этого есть стейджи. Ну и запуск на нескольких нодах для локального тестирования — это редкий кейс.
В результате выбор пал на Docker Compose. Во-первых, он максимально прост в освоении, во-вторых, не несет с собой дополнительных расходов на поддержание полноценного кластера на локальной машине. Его функционала вполне хватает для задач разработки и тестирования.
Конфигурация
В шаблоне сервиса предусмотрен файл values.yaml aka «манифест инфраструктуры». В нем настраивается почти все: настройки service mesh, нагрузочных тестов, cron, etc. При запуске пайплайна значения манифеста сливаются с глобальными настройками инфры от команды PaaS DevOps.
Нам интересно только то, что values.yaml может содержать env переменные, разбитые по окружениям (local — для Playground, stage, prod), настройки хранилищ и deployments — исполняемые файлы.
В проекте может быть несколько точек входа. Например: api, воркеры, etc. Пример манифеста инфраструктуры приведен ниже.
deployments:
- name: main-app
addenv:
GITLAB_TIMEOUT:
local: "6s"
- name: worker
addenv:
SOME_USEFUL_ENV:
local: "very useful"
appDefaults:
addenv:
JAEGER_SAMPLER_TYPE:
local: "const"
JAEGER_SAMPLER_PARAM:
local: "1"
postgres:
enabled: true
redis:
enabled: true
kafka:
enabled: true
topics:
- name: yc.paas-service.something.updated.0
type:
- producer
Такая настройка сообщает, что пользователь планирует запустить проект, где есть основное приложение main-app и дополнительное — worker. Как видно, настраивать переменные окружения для них можно раздельно.
Из дополнительных сервисов для запуска потребуется:
Перечисленное выше послужит входными данными для развертывания приложения с помощью Playground.
Межсетевое взаимодействие
Тут все относительно просто. Контейнеры при старте имеют настроенные адреса. Доступ в интернет есть «из коробки». Для каждого контейнера создается виртуальный интерфейс, при помощи bridge он подключается к интернету.
Выходит, при запуске достаточно создать отдельную Docker сеть и подключить в нее все поднимаемые контейнеры? Давайте поэкспериментируем.
services:
nginx:
image: nginx:latest
container_name: nginx-container
ports:
- 80:80
networks:
playground-network:
app:
image: ubuntu:latest
container_name: app-container
entrypoint: sleep infinity
networks:
playground-network:
networks:
playground-network:
name: playground-network
В конфиге указаны два контейнера. nginx и app. При таких настройках Docker автоматически создает записи в DNS согласно именам контейнеров. Это легко можно проверить, выполнив HTTP запрос к nginx контейнеру из контейнера с приложением.
docker exec -it app-container \\
curl -s -o /dev/null -w "\\n%{http_code}\\n\\n" nginx-container:80
200
На превый взгляд, тут все ок. Но не совсем. Что если потребуется поднять более одного контейнера с открытым портом 80? А это точно понадобится, поскольку порты будут дублироваться от приложения к приложению. 8080 — для HTTP серверов, 9092 — для gRPC.
docker compose up
[+] Running 2/0
✔ Container app-container Created 0.1s
✔ Container nginx-container Created 0.0s
Attaching to app-container, nginx-container
Error response from daemon: Ports are not available: exposing port TCP
0.0.0.0:80 -> 0.0.0.0:0: listen tcp 0.0.0.0:80: bind: address already in use
Ошибка сообщает о том, что 80 порт уже занят. Что с этим делать? Существует как минимум два решения.
#1 Случайные порты
При создании docker compose файлов генерировать случайные порты (при этом проверять, что они свободны).
Плюсы | Минусы |
простота реализации | при перезапуске контейнеров значения портов могут меняться, что не добавит энтузиазма разработчикам (придется менять env переменные) |
#2 Алиасы для localhost
При создании Docker Compose файлов открывать порты на разных IP одной и той же Docker сети. Пример: сервис A и сервис B слушают 80 порт. Docker позволяет сделать так:
services:
nginx:
image: nginx:latest
container_name: nginx-container
ports:
- 127.7.7.29:80:80
networks:
playground-network:
app:
image: ubuntu:latest
container_name: app-container
ports:
- 127.7.7.30:80:80
entrypoint: sleep infinity
networks:
playground-network:
networks:
playground-network:
name: playground-network
Плюсы | Минусы |
удобный доступ по имени сервиса в контейнер с хоста | реализация значительно сложнее для перенаправления запросов на нужный хост потребуется изменять |
Взвесив все «за» и «против», мы решили проинвестировать больше времени в разработку. При этом сохранить более приятный интерфейс для пользователей.
Алиасы для localhost. Как это работает?
Ранее команда docker inspect nginx-container показывала, что 80 порт принадлежит хосту 0.0.0.0.
...
"NetworkSettings": {
"Bridge": "",
"Ports": {
"80/tcp": [
{
"HostIp": "0.0.0.0",
"HostPort": "80"
}
]
}
}
...
Теперь настройки сети выглядят следующим образом.
...
"NetworkSettings": {
"Bridge": "",
"Ports": {
"80/tcp": [
{
"HostIp": "127.7.7.29",
"HostPort": "80"
}
]
}
}
...
Какая проблема следует из этого? Если раньше можно было отправить запрос в контейнер используя в качестве хоста localhost, теперь требуется знать конкретный IP адрес.
curl -s -o /dev/null -w "\\n%{http_code}\\n\\n" localhost:80
000 - ошибка
curl -s -o /dev/null -w "\\n%{http_code}\\n\\n" 127.7.7.29:80
200 - успех
Подготовка алиасов
Изначально на хосте пользователя может не быть предсозданных alias для lo0. Проверить можно с помощью команды ifconfig
.
ifconfig lo0
Значит, подготовительную работу нам нужно проделать самостоятельно. Скажем, при запуске Playground (service up
) нужно создать N алиасов. При полной очистке (service purge
) — удалять все алиасы.
Для алгоритма аллокации алиасов требуется знать, занят ли конкретный. Представим, что при запуске второго сервиса в Playground, мы по ошибке взяли алиас с уже занятым портом 80. Естественно, запуск сервиса упадет с ошибкой.
Как узнать занят ли алиас? Internet address not located
означает, что свободен.
lsof -V -i@127.7.7.1
lsof: Internet address not located: @127.7.7.1
А для занятого будет вот такое сообщение:
lsof -V -i@127.7.7.29
COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME
com.docke 99 nikitych1 57u IPv4 ... 0t0 TCP 127.7.7.29:http (LISTEN)
Для добавления нового алиаса нужно выполнить команду up.
ifconfig lo0 alias 127.7.7.29/24 up
Для удаления используется флаг -alias
.
ifconfig lo0 -alias 127.7.7.29
Для добавления и удаления алиасов требуется sudo.
Доступ по доменному имени
Согласитесь, было бы неудобно каждый раз при обращении к контейнеру сервиса искать его IP адрес? Мы подумали так же. Поэтому решили при запуске и остановке Playground обновлять /etc/hosts
пользователя.
Пример /etc/hosts
после запуска сервиса Dummy.
cat /etc/hosts
...
# основное приложение
127.7.7.8 dummy-app.sbmt
# воркер
127.7.7.9 dummy-worker.sbmt
# хранилища
127.7.7.6 dummy-redis.sbmt
127.7.7.7 dummy-postgres.sbmt
# kafka поднимается один раз для всех сервисов
127.7.7.1 kafka.sbmt
127.7.7.1 zookeeper.sbmt
Это означает, что пользователь, зная паттерн (имя сервиса-имя контейнера.sbmt
) может обращаться сразу к нужному контейнеру, даже при смене сервиса или добавлении новых зависимостей.
-.`
Что делать с sudo?
На мой взгляд, запрос прав суперпользователя при каждом старте сервиса — не лучшее решение:
Страдает UX.
Предоставлять root права всей утилите sbm-cli небезопасно. Однако доступ необходим при изменений
/etc/hosts
и списка алиасов хоста.
Мы выбрали наименьшее из зол — запросить root права только один раз.
Как это реализовать? Вероятно для некоторых из нас окажется новостью, что в Unix можно конфигурировать файл /etc/sudoers
и его включения из папки /etc/sudoers.d
. По сути у нас есть возможность выбрать команды, которые не будут запрашивать root доступ у конкретного пользователя. Подробнее об этом можно прочесть здесь.
Иными словами, необязательно каждый раз запрашивать root доступ при изменении хостов или алиасов. Достаточно выбрать команды для внесения требуемых изменений и разрешить их при первом запуске Playground. Делать это нужно аккуратно, поскольку есть шанс предоставить пользователю слишком широкий доступ без запроса пароля.
Пример файла sudoers прикладывать не буду по просьбе безопасников.
Про сеть уже сказано достаточно. Можем переходить к работе с контейнерами.
Контейнеры
Итак, нам нужно запустить один или несколько сервисов, обеспечить их доступность и поддержать персистентность данных там, где это необходимо.
Запустить сервис в контейнерах значит:
Заранее подготовить набор образов для запуска хранилищ, приложений, инфры.
Сконфигурировать entrypoint каждого контейнера (может быть в виде bash скриптов или готовых бинарников)
Выполнить команду docker compose up.
На данный момент Playground уже поддерживает проекты написанные на Go и Python, для упрощения ниже буду приводить примеры только для Go-сервисов.
Для удобства дальнейшей работы с контейнерами мы придумали механизм фильтрации Playground-контейнеров по типу выполняемой задачи. Технически применение фильтров работает через механизм чтения и записи Labels Docker контейнеров.
Список labels выглядит следующим образом:
project
— контейнер с исполняемым кодомstorage
— контейнер с хранилищем (postgres, redis, elasticsearch, jaeger, grpc-wiremock)infrastructure
— контейнер с инфра-сервисом, запускается один на весь Playground (kafka)sidecar
— вспомогательный контейнер для запуска дополнительной функциональности (migrator)extension
— контейнер, ответственность за который несет сам пользователь (забегая вперед, Playground может быть расширен за счет любых сторонних образов)
Как будем реализовывать?
В качестве языка программирования выбрали Go. Для работы с Docker Compose файлами (загрузка, генерация и валидация спецификаций) выбрали compose-go, здесь доступна документация и примеры использования.
Для работы с Docker API (статусы контейнеров, загрузка образов, работа с сетью и volumes) выбрали библиотеку moby от создателей Docker.
Приложения
Для запуска контейнера с приложением у нас заранее подготовлены базовые образы на каждый поддерживаемый язык программирования.
В таких образах содержится все окружение языка плюс набор стандартных утилит, таких как curl, grpcurl. Настроены доступы для скачивания библиотек из приватных репозиториев, добавлены необходимые сертификаты.
По умолчанию все приложения стартуют в режиме live-reload. Для пересборки приложения используется инструмент CompileDaemon. Идея заключается в следующем: если был изменен хотя бы один файл исходного кода или конфиг, проект пересобирается и запускается вновь. То же работает и для изменений в env-переменных. Это очень удобно совмещать с IDE, главное выставить приемлемый период для сохранения изменяемых файлов.
Также при запуске приложения мы генерируем DSN строки для всех хранилищ (и не позволяем перезаписывать их) для минимизации возможных проблем с подключением к стартовавшим хранилищам из кода.
Для переиспользования кэша библиотек (чтобы каждый запущенный сервис не выкачивал пересекающиеся зависимости более одного раза), добавляем в качестве вольюма директорию /home/playground/.cache/go-build
в Go и /root/.cache/pip
в Python.
Используя механизм аллокации алиасов упомянутый выше, прокидываем необходимые порты на хост пользователя. Порты типичного приложения:
8080 — HTTP сервер
3009 — gRPC сервер
6060 — профилировщик (для Go используется pprof)
8081 — Swagger
9090 — Prometheus
Все это происходит при запуске команды sbm-cli service up
. Но есть еще команда sbm-cli service debug
. В чем её отличия? — При запуске на порту 2345 стартует инструмент для дебага (для Go используется delve).
Проверить подключение можно так:
dlv connect dummy-app.sbmt:2345
Delve можно использовать через терминал, либо подключить в любимой IDE. Тут инструкция для пользователей JetBrains, тут для VS Code. Любители vim, я уверен, тоже найдут для себя что-то интересное.
Хранилища
Начнём с того, что просто поднять хранилище в Docker недостаточно. Нужно применить миграции, которые хранятся в директории migrations.
↪ tree migrations
migrations
├── applied_migrations.sql
├── migrate
│ ├── 20210325120245_microservices.sql
│ ├── 20210412155445_deployments.sql
│ ├── 20220207153207_create_nodes.sql
│ └── 20231011113706_add_some_index.sql
└── structure.sql
Для этих целей у нас есть отдельный образ migrator. Контейнер с мигратором поднимается при старте Playground, обогащает хранилище (postgres, например) и завершается. Внутри контейнера-мигратора используется утилита goose.
Только после этого запускается контейнер с приложением.
Так это выглядит в формате Docker Compose.
dummy-migrator-local:
image: dreg.io/playground/migrator:latest
command:
- goose -allow-missing -dir "./migrations/migrate" postgres "host=dummy-postgres-local
user=postgres password=password dbname=db port=5432 sslmode=disable
TimeZone=UTC" up
container_name: dummy-migrator-local
depends_on:
dummy-postgres-local:
condition: service_healthy
labels:
com.playground.containertype: sidecar
com.playground.resourcename: migrator
com.playground.servicename: dummy
name: dummy-migrator-local
restart: on-failure:100
volumes:
- type: bind
source: ../..
target: /go/src/gitlab.com/paas/dummy
working_dir: /go/src/gitlab.com/paas/dummy
dummy-postgres-local:
image: dreg.io/playground/postgres-extended:latest
container_name: dummy-postgres-local
environment:
PGTZ: UTC
POSTGRES_DB: db
POSTGRES_PASSWORD: password
POSTGRES_PORT: "5432"
POSTGRES_USER: postgres
TZ: UTC
expose:
- "5432"
healthcheck:
test:
- pg_isready -U postgres
timeout: 3s
interval: 10s
retries: 5
labels:
com.playground.containertype: storage
com.playground.resourcename: postgres
com.playground.servicename: dummy
name: dummy-postgres-local
networks:
default:
aliases:
- dummy-postgres.sbmt
ports:
- host_ip: 127.7.7.11
target: 5432
published: 5432
protocol: tcp
restart: unless-stopped
volumes:
- type: volume
source: dummy-postgres-data
target: /var/lib/postgresql/data
Kafka
Kafka это единственный контейнер, который поднимается для всех сервисов запущенных в Playground. В values.yaml (манифест инфраструктуры) указаны топики, которые используются в текущем сервисе. У каждого топика есть опция type, может быть consumer или producer.
kafka:
enabled: true
topics:
- name: yc.paas-service.something.updated.0
type:
- producer
- name: yc.paas-service.something.created.0
type:
- consumer
Если сервис читает сообщения из топика и при этом запускается первым (kafka еще пустая), сервис упадет с ошибкой. Поэтому мы создаем такие топики перед запуском приложения.
В качестве базового образа используем wurstmeister/kafka-docker. В нем уже содержится необходимый набор скриптов для управления топиками. В планах переход на kafka без zookeeper для экономии ресурсов на хосте пользователя.
Как посмотреть статус запущенных сервисов?
Есть команда docker ps, которая работает похожим образом. Ее функционала нам недостаточно, поскольку ее уровень абстракции — контейнер. Playground же оперирует сервисами, которые всегда состоят из нескольких контейнеров.
Дальше в статье я расскажу почему статусы Docker не всегда нам подходят и почему мы добавили механизм, который проверяет не только статус контейнера, но и по логам определяет реальное состояние сервиса.
Для просмотра статуса запущенных приложений выполним эту команду. Kafka запускается единожды на все сервисы, поэтому вынесена в отдельную подгруппу.
Внизу доступны советы по быстрому доступу к данным через самые распространенные утилиты kcat, psql, redis-cli.
sbm-cli service status
CONTAINER TYPE STATUS UPTIME PORTS
dummy-app-local app running... 2 minutes 2345->2345, 3009->3009, ...
dummy-worker-local app running... 2 minutes 2345->2345, 3009->3009, ...
dummy-postgres-local storage running... 2 minutes 5432->5432
dummy-redis-local storage running... 2 minutes 6379->6379
dummy-migrator-local helper completed -- --
playground-kafka infra running... 2 hours 19092->19092, 29092->29092
playground-zookeeper infra running... 2 hours 2181->2181, 2888, 3888, 8080
Next steps:
- Connect to Kafka
- kcat -L -b kafka.sbmt:19092
- Connect to Postgres, service: `dummy`
- psql postgresql://postgres:sbermarket_paas@dummy-postgres.sbmt:5432/paas_db
- Connect to Redis, service: `dummy`
- redis-cli -h dummy-redis.sbmt -p 6379
Если хранилище имеет UI в своем контейнере, мы предоставляем доступ к нему через проброс портов на хост разработчика. В случае с S3 например, в советах будет ссылка на браузерное приложение.
Что делать если контейнер не запускается?
Случается, что сервис не запускается после вызова команды up.
sbm-cli service up
Удобство работы с контейнерами в том, что их пересоздание чаще всего решает проблему запуска. Для этого есть команда reset, она просто перезапустит сервис с сохранением информации хранящейся в вольюмах (данные БД, kafka, etc).
sbm-cli service reset
Если это не помогло, можно полностью очистить систему от следов Playground.
sbm-cli service purge
Purge удалит вольюмы, образы, контейнеры и временные файлы со спецификацией Docker Compose. После этого вызов команды up с нуля настроит все окружение.
Расширения
Высока вероятность, что однажды мы не успеем за потребностями разработчиков и им придется запускать что-то сильно кастомное в Playground. Чтобы пользователи не ждали пока мы привнесем необходимый функционал, мы добавили возможность расширять сетап проекта за счет пользовательского docker-compose.dev.yaml
файла.
Перед слиянием пользовательского файла с нашими сгенерированными, мы, конечно, делаем валидации, прокидываем пользовательские кастомные енвы и… на этом все.
По сути пользователь может добавить любой интересующий его образ через этот файл. Но в таком случае ответственность за запуск проекта с кастомным образом полностью возлагается на него. Настройка ожидания контейнеров и env переменных также на его плечах.
UI/UX
В самом начале статьи я упоминал о важности пользовательского интерфейса. Почему мы уделяем этому столько времени?
Платформенная разработка предполагает «переманивание» разработчиков, которые к моменту выпуска PaaS-инструмента уже написали свои. Нельзя так просто взять и запретить пользоваться «велосипедами», не предложив более удобное решение.
Как можно улучшить UX утилиты командной строки? Мы пришли к таким решениям:
Печатать в stdout самые часто используемые команды с именами контейнеров.
Для процессов, которые надолго блокируют консоль (e.g. скачивание образов, клонирование репозиториев, etc) показывать прелоадеры.
Вот пример успешного запуска sbm-cli service up
.
sbm-cli service up
. Starting `dummy` playground....
sbm-cli service up
✔ Services have been successfully started.
Changes:
- Container `dummy-redis-local` created
- Container `dummy-postgres-local` created
- Container `dummy-app-local` created
- Container `dummy-worker-local` created
- Project dummy started
Next steps:
- To stop service run `sbm-cli service down`
- To get application logs run (new terminal tab is preferable)
- `docker logs -n 100 -f dummy-app-local`
- `docker logs -n 100 -f dummy-worker-local`
- To check gRPC services run
- `grpcurl --plaintext dummy-app.sbmt:3009 list`
- `grpcurl --plaintext dummy-worker.sbmt:3009 list`
- To check HTTP services run
- `curl dummy-app.sbmt:8080`
- `curl dummy-worker.sbmt:8080`
Итак, сервис готов к разработке и тестированию.
Ошибки и негативные сценарии
PaaS предоставляет не только инструменты для инженеров. Поддержка также является частью продукта. Грамотно построенный интерфейс вкупе с обширной документацией помогает разгрузить специалистов первой линий.
В качестве стандарта в sbm-cli принята подробная обработка пользовательских ошибок.
Пример: если в начале рабочего дня пользователь забыл подключить корпоративный VPN и стал запускать Playground, образы контейнеров из корпоративного docker registry будут недоступны для него. Вместо internal error без дополнительного описания, мы анализируем ошибку и выдаем ее пользователю в таком формате:
sbm-cli service up
✘ Unable to establish connection to `gitlab.com` repository
What to do:
- Check your internet connection and VPN settings
- To setup VPN visit # ссылка на внутреннюю документацию
- To get access visit # ссылка на внутреннюю документацию
- To get sbm-cli logs run `cat ~/.sbm-cli/logs/last-run`
Если пользователь забыл запустить Docker, он увидит такую ошибку.
sbm-cli service up
✘ Docker daemon is not running
What to do:
- Run docker daemon
- For more information about running docker visit
- After fix errors, run command again
- To get sbm-cli logs run `cat ~/.sbm-cli/logs/last-run`
В каждом из таких сценариев есть ссылка на документацию с шагами для самостоятельного устранения проблемы. Первая линия может заниматься задачами посерьезнее.
Более сложный пример
Представим, пользователь запускает сервис с синтаксической ошибкой в коде. С точки зрения здоровья контейнера — всё ок, статус Up
. Поэтому sbm-cli service up
завершается с успехом (чем вводит пользователя в заблуждение).
Мы написали Observer — инструмент мониторинга запускаемого кода в контейнерах типа project
. Помимо health checks Observer считывает логи приложений и, в случае ошибки сборки, выдает пользовательскую ошибку. Логи из контейнера записываются в стандартный файл логов sbm-cli.
↪ sbm-cli service up
✘ Build of `dummy` service failed
What to do:
- Check service logs `docker logs -f dummy-app-local`
- To get sbm-cli logs run `cat ~/.sbm-cli/logs/last-run`
↪ sbm-cli service up
✘ Build of `dummy` service failed
What to do:
- Check service logs `docker logs -f dummy-app-local`
- To get sbm-cli logs run `cat ~/.sbm-cli/logs/last-run`
Запуск тестов
Многие команды в СберМаркет пишут интеграционные тесты с БД. Мы подготовили варианты запуска в локальном окружении и в CI/CD.
Для тестирования на локальной машине (не в контейнере) нужно запустить хранилища внутри Playground, поправить переменные среды и запустить тесты.
sbm-cli service up --storages-only # запускает postgres (и остальную инфру)
go test --tags=integration ./...
Чтобы каждый раз не менять переменные вручную, можно использовать команду, которая сделает это за вас.
sbm-cli service env
...
APP_NAME="dummy"
KAFKA_BROKERS="kafka.sbmt:9092"
PG_DSN="host=dummy-postgres.sbmt port=5432 user=postgres password=password dbname=db TimeZone=UTC"
...
Как вы могли заметить, PG_DSN содержит все необходимые настройки для подключения к базе, развернутой внутри Docker.
Вместо печати переменных в стандартный вывод можно запустить тесты передавая команду для запуска аргументами.
sbm-cli service env -- go test --tags=integration ./...
Пайплайн
Вряд ли кто-то будет спорить, что тесты без запуска в CI/CD не имеют особого смысла. При наличии зависимости от postgres (например), разработчик пишет примерно такую конструкцию в .gitlab-ci.yml.
integration-tests:
image: ${TEST_IMAGE_REPO}/${TEST_IMAGE_NAME}:${TEST_IMAGE_TAG}
stage: tests
services:
- name: postgres:latest
alias: postgres
variables:
PG_DSN_TEST: "host=postgres user=postgres password=postgres dbname=db_test port=5432 sslmode=disable"
POSTGRES_USER: "postgres"
POSTGRES_PASSWORD: "postgres"
POSTGRES_DB: "db_test"
before_script:
- go mod download
# применение миграций
- goose -dir migrations/migrate postgres "${PG_DSN_TEST}" up
# здесь реализуется ожидание запуска postgres
script:
# запуск тестов
- go test --tags=integration ./...
С Playground можно немного упростить себе жизнь. Мы предусмотрели возможность запуска хранилищ и инфры внутри GitLab-раннера. Это реализовано с помощью механизма Docker-In-Docker. Более подробно прочитать об этом можно здесь.
Итоговая джоба с тестами будет выглядеть вот так.
integration-tests:
stage: tests
extends:
# подготовка docker-in-docker окружения
- .playground-golang
script:
- go mod download
# запуск сервиса
- sbm-cli service up || ( cat ~/.sbm-cli/logs/last-run && exit 1 )
# настраивает доступ по домену к контейнерам Docker,
# который расположен в другом контейнере
- /scripts/connect.sh
- sbm-cli service env -- go test ./... -timeout 100s
Миграции применяются автоматически, реализовывать ожидание контейнеров инфры или хранилищ не нужно.
Выводы
Разрабатывая для разработчиков, важно не только суметь донести пользу от перехода с самодельных систем запуска, но и убедить, что на изучение нового инструмента вообще стоит потратить свое время.
Мы понимаем, что у продуктовых разработчиков свои заботы, не всегда удается выделить часть спринта на изменение привычного воркфлоу. Поэтому мы вложили несколько спринтов для донесения ценности и помощи в адаптации сервисов: от онлайн-воркшопов по использованию Playground, до создания исправляющих Merge Request в десятки сервисов.
За последний год-полтора Playground превратился в большой продукт, которым пользуется множество разработчиков на ежедневной основе, а многие наши команды запускают свои сервисы только в Playground.
У нас большие планы по развитию утилиты:
интерактивный режим;
поддержка большего числа расширений (хранилища, очереди, etc) «из коробки»
поддержка Ruby и NodeJS и развитие поддержки Python;
и, конечно, работа над стабильностью существующего функционала.
Надеюсь, что статья была вам полезна. Возможно, пока мы не выпустили Playground в OpenSource, вы занимаетесь построением чего-то подобного. В таком случае некоторые идеи могут вдохновить вас. А значит, все не зря.
Tech-команда СберМаркета ведет соцсети с новостями и анонсами. Если хочешь узнать, что под капотом высоконагруженного e-commerce, следи за нами в Telegram и на YouTube. А также слушай подкаст «Для tech и этих» от наших it-менеджеров.