Привет, Хабр! Меня зовут Никита, и я Go-разработчик. В свободное от работы время я интересуюсь платформенной разработкой, а в рабочее — практикую в команде PaaS в СберМаркете. Моя специализация — локальное окружение разработчика и тулинг.

Главная метрика, на которую работает моя команда, — Тime-Тo-Мarket, совокупное время, затраченное на разработку фичи от самого начала разработки и до релиза на пользователей.

В сложившихся процессах разработки всегда найдется место для оптимизации затрат ресурсов разработчика. Будь то написание boilerplate кода, подготовка инфраструктуры, ручной рефакторинг или перезапуск кода при внесении изменений. Список далеко не исчерпывающий.

Уже сейчас PaaS может предложить многое для сокращения времени разработки фичи. Сегодня хочу рассказать о том, как именно наша команда помогает выпускать релизы быстрее с помощью инструмента Playground. С помощью него можно легко и быстро запустить ряд сервисов прямо на вашем Mac или Linux.

  1. Когда возникает мысль: «Было бы неплохо иметь единственную команду для запуска и дебага любого сервиса в компании»

  2. Playground: локальная среда разработки

  3. Собираем требования. Что Playground должен уметь

  4. Выбираем технологию

  5. Конфигурация

  6. Межсетевое взаимодействие

  7. Контейнеры

  8. Приложения

  9. Хранилища

  10. Kafka

  11. Как посмотреть статус запущенных сервисов?

  12. Что делать если контейнер не запускается?

  13. Расширения

  14. UI/UX

  15. Ошибки и негативные сценарии

  16. Запуск тесов

  17. Выводы

Единственная команда для запуска и дебага любого сервиса в компании

Перед тем как погрузимся в особенности реализации инструмента, разберем пару ситуаций, которые позволят более полно прочувствовать важность решаемой задачи.

#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 должен уметь?

Отличный способ начать проектировать — идти от интерфейса пользователя.

Так мы и поступили. Вот что получилось в результате:

  1. sbm-cli service up — запускает сервис c хранилищами и инфраструктурными зависимостями.

    -storages-only — разворачивает только хранилища и kafka, приложение разрабатывается на хосте (большинство из нас не понаслышке знает про низкую скорость Docker на Mac OS).

    -up-timeout — запускает сервис c заданным таймаутом (спойлер: Playground используется в CI/CD для запуска интеграционных тестов).

  2. sbm-cli service debug — отличается от up только тем, что позволяет подключить инструмент дебага приложения.

    -wait — указывает какое из приложений проекта нужно отладить.

  3. sbm-cli service down — останавливает сервис и хранилища.

    -all — останавливает все запущенные сервисы, хранилища и kafka.

  4. sbm-cli service status — показывает таблицу со статусами контейнеров.

    -all — показывает статусы для всех запущенных сервисов.

  5. sbm-cli service reset — alias для команд up & down.

    -all — перезапускает все сервисы.

    -up-timeout — для вызова up под капотом.

  6. sbm-cli service purge — удаляет артефакты Playground из Docker.

  7. 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. Как видно, настраивать переменные окружения для них можно раздельно.

Из дополнительных сервисов для запуска потребуется:

  • postgres;

  • redis;

  • kafka (если топик указан как consumer, необходимо создать его перед запуском приложения).

Перечисленное выше послужит входными данными для развертывания приложения с помощью 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

Плюсы

Минусы

удобный доступ по имени сервиса в контейнер с хоста

реализация значительно сложнее

для перенаправления запросов на нужный хост потребуется изменять /etc/hosts пользователя (без sudo не обойтись)

Взвесив все «за» и «против», мы решили проинвестировать больше времени в разработку. При этом сохранить более приятный интерфейс для пользователей.

Алиасы для 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) может обращаться сразу к нужному контейнеру, даже при смене сервиса или добавлении новых зависимостей.

<service-name>-<resource-name>.<domain-name>`

Что делать с sudo?

На мой взгляд, запрос прав суперпользователя при каждом старте сервиса — не лучшее решение:

  1. Страдает UX.

  2. Предоставлять root права всей утилите sbm-cli небезопасно. Однако доступ необходим при изменений /etc/hosts и списка алиасов хоста.

Мы выбрали наименьшее из зол — запросить root права только один раз.

Как это реализовать? Вероятно для некоторых из нас окажется новостью, что в Unix можно конфигурировать файл /etc/sudoers и его включения из папки /etc/sudoers.d. По сути у нас есть возможность выбрать команды, которые не будут запрашивать root доступ у конкретного пользователя. Подробнее об этом можно прочесть здесь.

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

Пример файла sudoers прикладывать не буду по просьбе безопасников.

Про сеть уже сказано достаточно. Можем переходить к работе с контейнерами.

Контейнеры

Итак, нам нужно запустить один или несколько сервисов, обеспечить их доступность и поддержать персистентность данных там, где это необходимо.

Запустить сервис в контейнерах значит:

  1. Заранее подготовить набор образов для запуска хранилищ, приложений, инфры.

  2. Сконфигурировать entrypoint каждого контейнера (может быть в виде bash скриптов или готовых бинарников)

  3. Выполнить команду 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

✔ 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 <https://wiki.com> # ссылка на внутреннюю документацию
- To get access visit <https://wiki.com> # ссылка на внутреннюю документацию
- 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 <https://wiki.com>
- 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-менеджеров.

Комментарии (3)


  1. WondeRu
    27.10.2023 12:26
    +1

    Подскажите, почему не решили использовать VSCode или Idea DevContainers?


    1. Anarchist
      27.10.2023 12:26

      Думаю, ответ очевиден: санкции и импортозамещение. :)


      1. WondeRu
        27.10.2023 12:26

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