Мы широко используем микросервисную архитектуру, хоть и не считаем ее панацеей, и чуть больше 2 лет назад начали переходить на язык Go. Он сравнительно прост и, на мой взгляд, очень хорошо подходит для создания простых, небольших и быстрых микросервисов. Эта простота имеет и обратную сторону: из-за неё возникает множество способов решить одну и ту же задачу.
Казалось бы, насколько сильно может отличаться один микросервис, который ходит в базу данных, от другого микросервиса, который ходит в соседнюю базу данных? Например, одна команда использует Go 1.9, glide, стандартный database/sql и одну структуру проекта, а в это же время другая команда использует Go 1.13, modules, sqlx и, конечно же, другую структуру проекта.
Когда один микросервис в компании отличается от другого, а он, в свою очередь, отличается от третьего — это замедляет разработку. А медленная разработка — это убытки повод для оптимизации.
Меня зовут Алексей Партилов, я техлид команды web-разработки в компании Lamoda. В этой статье я расскажу, как мы справляемся с разношерстностью около 40 наших микросервисов на Go. Статья будет полезна разработчикам, которые только вливаются в Go и не знают, с чего начать более сложный проект, чем “helloworld”.
Изначально Lamoda работала на одном коробочном монолите на PHP. Потом часть функций начала перемещаться в новые Python и PHP сервисы. Сейчас у нас есть монолиты на PHP с большой и сложной бизнес-логикой. Эти приложения занимаются автоматизацией нашей операционной деятельности: доставки, фотостудии, партнерского сервиса. Также у нас есть монолит на Java, который автоматизирует множество сложных процессов на складе.
В этом случае мы считаем использование монолитов оправданным, поскольку каждый из них разрабатывается своей командой, и у нас нет необходимости масштабировать определенные участки логики.
В e-commerce платформе мы уже достаточно давно перешли от монолитов на Python в пользу микросервисов на Golang. Впрочем, несколько больших приложений со сложным UI мы оставили на Python+Django.
В распиле монолита на микросервисы участвовали несколько команд, и каждая имела свой неповторимый стиль создания микросервисов. Это в дальнейшем создало некоторые трудности.
Микросервисы сильно различались между собой, что усложняло вхождение в проект новым разработчикам или создание новой фичи. Время backend-разработчика уходило на то, чтобы разобраться в работе сервиса, подстроиться под его стиль, научиться использовать набор библиотек и инструментарий, который используется в проекте.
Мы решили минимизировать время на эту работу и начали приводить наши сервисы к единому виду.
0. Spec first
Большинство наших команд уже давно соблюдают правило, которое гласит: прежде чем написать новый микросервис или ручку в уже существующий микросервис, нужно сделать спецификацию по стандарту OpenAPI.
Это правило приносит много плюсов:
- Backend-разработчики, которые пишут спецификацию, при этом прорабатывают детали бизнес-логики и могут сразу найти ошибки. На раннем этапе их исправление стоит очень дешево.
- Frontend-разработчики не ждут разработки backend, а сразу приступают к разработке. Из спецификации легко сделать mock запросов и ответов к backend и на основе этого разрабатывать клиентский функционал.
- По спецификации мы сразу генерируем boilerplate-код c обработкой параметров, валидацией и другими полезными свойствами. Это, в свою очередь, значительно сокращает время разработчика, необходимое на написание функционала.
В качестве генератора кода по OpenAPI-спецификации мы используем форк проекта go-swagger (и ласково зовем его gogi). Мы не выкладывали его в opensource, поскольку ищем ему альтернативу, но принципы работы с этим генератором кода подойдут и к другим генераторам.
В первую очередь, не советую тянуть генератор кода в проект в качестве зависимости. Как правило, генераторы кода имеют очень много зависимостей, которые никак не понадобятся, но сильно увеличат время сборки проекта.
Можно запускать генератор из бинарного файла, но мы используем docker-образ. Дело в том, что на разных проектах может потребоваться та или иная версия генератора. Для версионирования удобно использовать именно docker. Генерация кода из спецификации запускается командой в Makefile:
.PHONY: generate
generate:
# Удаление уже сгенерированного кода
rm -rf internal/generated/*
docker run -it
# Монтирование папки для сгененированного кода
-v $(PWD)/internal/generated:$(DOCKER_SOURCE_DIR)/internal/generated
# Монтирование папки с OpenAPI спецификациями
-v $(PWD)/specs:$(DOCKER_SOURCE_DIR)/specs
# Монтирование конфигурации для gogi
-v $(PWD)/gogi.yaml:$(DOCKER_SOURCE_DIR)/gogi.yaml
# Установка workdir
-w="$(DOCKER_SOURCE_DIR)"
gotools.docker.lamoda.ru/gogi:v1.3.2 generate
1. Структура проекта
Большинство разночтений у нас случается именно в организации кода в проекте. У каждой команды есть своя устоявшаяся структура проекта, а бывает, что и не одна. Чтобы сделать проекты разных команд максимально похожими друг на друга, мы выделили эталонный проект-пустышку. Его главная задача — продемонстрировать, как организовать код внутри своего проекта:
/
├── cmd
├── deployments
├── internal
├── migrations
├── specs
├── tests
├── go.mod
├── go.sum
├── Dockerfile
├── main.go
...
Любая команда может взять этот проект за основу своего нового микросервиса или скорректировать структуру уже существующего проекта.
Пустышка не делает ничего полезного, зато имеет подключения к базе данных (мы в основном используем Postgres), к одному из наших production-сервисов, Kafka и несколько ручек в API. Почему именно такая конфигурация? Всё просто. Потому что большинство наших сервисов используют именно такие источники данных.
Для новых сервисов мы сделали динамический шаблон проекта на cookiecutter. Благодаря этому мы не копируем структуру проекта вручную и не вычищаем все плейсхолдеры. Подробнее про шаблон можно прочитать в девятом пункте про динамический шаблон проекта. Пока речь пойдет про другие улучшения, которые попали в этот шаблон.
2. Go modules
Начиная с версии 1.11 сообщество языка Go перешло на новую систему управления зависимостями под названием go modules. С версии 1.14 модули объявлены как production-ready. Раньше у нас долгое время был glide, но мы достаточно быстро и почти безболезненно перешли на go modules, как, впрочем, и большая часть Go-сообщества. Go modules хранят все зависимости проектов в одном месте $GOPATH/pkg/mod и позволяют не размещать сами проекты в GOPATH.
Для поддержки модулей в самом Go были внесены изменения во многие команды (build, get, test). Например, build теперь загрузит недостающие зависимости и только потом начнет сборку проекта. Подробнее можно почитать в официальной документации.
В официальном блоге golang есть обширная статья о том, как лучше мигрировать на модули, если в проекте есть другие системы управления зависимостями. Мы же дадим свой короткий чеклист с нюансами, касающимся приватных репозиториев.
1. Активируем go modules. Если речь идет о проекте с Go до версии 1.13, который находится в $GOPATH, то советую форсированно активировать go mod
go env -w GOMODULE111="on"
2. Добавляем RSA-ключ для приватных репозиториев. Если для работы с репозиторием используется RSA-ключ c паролем, то лучше проследить, чтобы он был подгружен в ssh-agent. Это позволит не вводить пароль на каждую загрузку модуля.
ssh-add <путь до RSA ключа>
3. Добавляем исключение для Go proxy. Начиная с версии Go 1.13, пакеты по умолчанию загружаются из публичного прокси-репозитория proxy.golang.org, который не знает про приватные пакеты. Поэтому мы инструктируем модули, чтобы пакеты брались из нашего репозитория напрямую.
go env -w GOPRIVATE=<адрес вашего репозитория>
4. Создаем go.mod-файл. Для поддержки модулей мы сделали файл go.mod и наполнили его зависимостями. Если в проекте уже используется другой пакетный менеджер (например, glide), при создании mod-файла зависимости будут подтянуты из lock-файла. В новом проекте будет создан go.mod — файл, не имеющий зависимостей внутри себя.
go mod init <полное имя проекта>
Под полным именем понимается название проекта с адресом репозитория, где он живет, например, github.com/spf13/cobra.
5. Запускаем тесты. Даже если их еще нет в проекте. Запуск тестов провоцирует загрузку зависимостей, компиляцию и, собственно, запуск тестов. Если все три стадии пройдены, то внедрение модулей завершено.
go test ./...
3. Базовые Docker-образы
В Lamoda для доставки кода на прод мы используем свой Kubernetes-кластер, поэтому нам необходимы Docker-образы для каждого проекта. В принципе, для сборки Go-проектов можно использовать официальный Docker-образ golang. При сборке запускаем линтеры и тесты c построением отчетов по покрытию прямо внутри Docker-контейнера. Поэтому мы взяли за основу официальный Docker-образ и дополнили его необходимыми пакетами, сертификатами и ключами.
ARG version
FROM golang:$version
ENV GOOS linux
ENV GOARCH amd64
ENV CGO_ENABLED 0
ENV GO111MODULE on
# Установка вспомогательных зависимостей для сборки проекта
RUN go get github.com/axw/gocov/gocov &&
go get github.com/AlekSi/gocov-xml@d2f6da892a0d5e0b587526abf51349ad654ade51 &&
go get golang.org/x/tools/cmd/goimports &&
curl -sfL https://install.goreleaser.com/github.com/golangci/golangci-lint.sh | sh -s v1.23.7 &&
go get -u github.com/jstemmer/go-junit-report@af01ea7f8024089b458d804d5cdf190f962a9a0c &&
rm -rf /go/pkg/mod/
# Установка корпоративных ключей необходимый для того чтобы загружать код из корпоративных репозиториев
COPY ./ssh/id_rsa /root/.ssh/id_rsa
COPY ./ssh/id_rsa.pub /root/.ssh/id_rsa.pub
# Copy Lamoda Root certificate needed to go to the corporate sites without
# SSL warning
COPY ./certs/LamodaCA.crt /usr/local/share/ca-certificates/LamodaCA.crt
RUN echo "StrictHostKeyChecking no" > /root/.ssh/config &&
chmod 600 /root/.ssh/id_rsa &&
chmod 600 /root/.ssh/id_rsa.pub &&
chmod 755 /root/.ssh &&
update-ca-certificates &&
# Установка инструкции insteadOf. Полезна в случае если вы используете Bitbucket как мы.
git config --global url.ssh://git@stash.lamoda.ru:7999.insteadOf https://stash.lamoda.ru/scm
CMD ["/bin/sh"]
Все наши сборки golang-проектов основываются на этом базовом образе. Но не советую использовать образ golang как конечный для production-среды. Дело в том, что такие образы далеко не легковесны (они занимают порядка сотен мегабайт) и содержат в себе массу ненужного для прода: базовый образ debian или alpine, компилятор go, вспомогательные библиотеки. Нашим решением было использовать разные Docker-образы для сборки и релиза, а также Docker multistage.
Вкратце, Docker multistage — это способ сборки в Docker, благодаря которому проект можно собрать в одном образе, а потом перенести все необходимые артефакты в другой образ, где нет установленных зависимостей для сборки, временных файлов и т.д.
Вот пример сборки одного из наших микросервисов:
FROM gotools.docker.lamoda.ru/base-mod:1.14.0 as build
ENV GOOS linux
ENV GOARCH amd64
ENV CGO_ENABLED 0
ENV GO111MODULE on
WORKDIR /go/src/stash.lamoda.ru/ecom/discounts.endpoint
# Копирование файлов со списком зависимостей
COPY go.mod .
COPY go.sum .
# Загрузка go-зависимостей. Из-за особенностей системы кеширования Docker этот шаг будет повторен только при изменении в файлах go.mod и go.sum.
RUN go mod download
COPY . .
RUN make build
# Сборка легковесного docker образа который содержит только бинарный файл и ca-certificates.crt
FROM scratch
COPY --from=build /go/src/stash.lamoda.ru/ecom/discounts.endpoint/discounts /bin/discounts
COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
ENTRYPOINT ["/bin/discounts"]
4. Миграции БД
Во многих наших микросервисах используются реляционные базы данных, в основном, Postgres. Чем дольше живет сервис, тем больше изменений вносится в базу. Конечно, их можно вносить и вручную или руками DBA, если он есть. Но такой подход не позволит быстро развернуть тестовую БД. Или невозможно будет вспомнить, как в проекте появилось то или иное поле. У нас разработчики пишут SQL-скрипты для миграции и потом под контролем DBA и DevOps накатывают их в production среду.
Чтобы миграции не терялись где-нибудь в Jira, они лежат в коде проекта и накатываются при помощи библиотеки migrate. Наш выбор пал именно на эту библиотеку, потому что она сейчас хорошо поддерживается. Также, что немаловажно, migrate может запускаться в Docker-образе — это снимает необходимость вносить ее в зависимости проекта.
Принцип работы библиотеки достаточно прост — в папке migrations нужно создать файлы вида <№ миграции>_<название>.[up|down].sql
migrations
├── 00001_init.down.sql
├── 00001_init.up.sql
├── 00002_create_ui_users_table.down.sql
├── 00002_create_ui_users_table.up.sql
...
up — миграция вверх, т.е. накатывающая изменения,
down — миграция вниз, т.е. откатывающая изменения.
Содержимое файлов по сути является обычным SQL-файлом, который поддерживает БД.
Пример миграции «вверх»:
BEGIN;
CREATE SEQUENCE ui_users_id_seq INCREMENT BY 1 MINVALUE 1 START 1;
CREATE TABLE ui_users (
id INT NOT NULL,
username VARCHAR(180) NOT NULL,
username_canonical VARCHAR(180) NOT NULL,
email VARCHAR(180) NOT NULL,
email_canonical VARCHAR(180) NOT NULL,
enabled BOOLEAN NOT NULL,
salt VARCHAR(255),
password VARCHAR(255) NOT NULL,
last_login TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL,
confirmation_token VARCHAR(180) DEFAULT NULL,
password_requested_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL,
roles TEXT NOT NULL,
PRIMARY KEY(id)
);
COMMIT;
Пример миграции «вниз»:
BEGIN;
DROP SEQUENCE public.ui_users_id_seq;
DROP TABLE ui_users;
COMMIT;
Запустить миграции можно, указав базу данных и папку с миграциями:
migrate -database ${DB_DSN} -path db/migrations up
Создать новые миграции можно следующим образом:
migrate create -ext sql -dir migrations -seq create_table_foo
Мы вручную задаем содержимое миграций и, соответственно, тестируем. Поэтому хорошей идеей будет связать между собой оба процесса: запуск тестов проекта и накатывание миграций. О том, как это сделать, речь пойдет в следующем разделе. Подробнее о работе с migrate написано здесь.
5. Развертывание development-окружения
В Lamoda есть сервисы, которые могут использовать БД, очереди или другие внешние источники данных. При тестировании и/или разработке часто нужно развернуть микросервис с его внешними источниками данных, сконфигурировать и провести их подготовку. Например, сервис использует БД Postgres и определенную схему внутри этой базы. Поэтому перед запуском сервиса нужно развернуть базу Postgres и накатить на нее миграции. Эти действия придется выполнять при каждом развертывании/тестировании проекта в development окружении, поэтому мы их автоматизировали с помощью Docker Compose.
Compose управляет порядком запуска контейнеров для построения development окружения сервиса. Например, в одном нашем микросервисе контейнеры при полном развертывании запускаются в следующем порядке:
- Контейнер с БД Postgres,
- Контейнер migrate, выполняющий миграции на контейнере с БД Postgres,
- Контейнер с dev-версией приложения.
Необязательно соблюдать именно эту последовательность. Например, можно запустить только контейнер с базой и миграции. Последнее обычно бывает полезно, когда хочется запустить тесты локально, но держать базу в контейнере.
Пример конфигурации Docker-compose для одного из наших микросервисов:
version: "3.7"
services:
# Контейнер с dev-версией приложения
gift-certificates-dev:
container_name: gift-certificates-dev
# Инструкция о том что контейнер нужно пересобирать, а не делать pull из docker-репозитория
build:
context: ../
# В случае если Dockerfile поддерживает multistage, можно указать на каком месте в сборке
# нужно остановиться. В этом случае нужно остановиться на этапе сборки бинарного файла,
# потому что при этом в контейнере будет код и вспомогательные библиотеки необходимые для
# запуска проекта через go run.
target: build
# Файлы в которых хранятся environment переменные проекта.
env_file:
- local.env
# Зависимости текущего контейнера
depends_on:
- gift-certificates-db
# Монтирование папки с кодом проекта внутрь контейнера. Это необходимо так как приложение
# запускается через go run
volumes:
- "..:/app/"
working_dir: "/app"
# Команда запускаемая в контейнере
command: "go run main.go"
depends_on:
- gift-certificates-db
# Контейнер с БД Postgres
gift-certificates-db:
container_name: gift-certificates-db
image: postgres:11.4
# Передача переменных окружения для инициализации контейнера
# Здесь специально не используется директива env_file, так в файле default.env
# хранятся переменные приложения, к переменным базы они не имеют отношения
environment:
- POSTGRES_DB=gift_certificates
- POSTGRES_USER=gift_certificates
- POSTGRES_PASSWORD=gift_certificates
- POSTGRES_PORT=5432
# Порты которые открываются из docker контейнера
# Это полезно, например, при локальном запуске тестов с подключением к базе в docker контейнере
ports:
- 6543:5432
# Контейнер с миграциями для БД
gift-certificates-migrate:
container_name: gift-certificates-migrate
image: "migrate/migrate:v4.4.0"
depends_on:
- gift-certificates-db
volumes:
- "../migrations:/migrations"
command: ["-path", "/migrations/", "-database", "$SERVICE_DB_DSN", "up"]
Для удобного использования Docker-compose в Makefile я рекомендую добавить следующие команды:
# Запустить тесты проекта
# Перед запуском тестов разворачивается БД и на нее накатываются миграции
.PHONY: test
test: dev-migrate
go test -cover -coverprofile=coverage.out ./...
go tool cover -html=coverage.out -o coverage.html
# Развернуть все dev окружение
.PHONY: dev-server
dev-server:
docker-compose -f deployments/docker-compose.yaml up -d gift-certificates-dev
# Развернуть БД и провести миграции на ней
.PHONY: dev-migrate
dev-migrate:
docker-compose -f deployments/docker-compose.yaml run --rm --service-ports gift-certificates-migrate
# Свернуть все dev окружение
.PHONY: dev-down
dev-down:
docker-compose -f deployments/docker-compose.yaml down
6. Линтинг
Для языка Go есть утилита gofmt, которая может работать в режиме линтера, и другие инструменты для форматирования и линтинга: goimports, go-critic, gocyclo и т.д. Установка множества линтеров по отдельности, их интеграция в IDE и CI может стать очень тяжелой работой. Вместо этого я рекомендую использовать golangci-lint, который является агрегатором большинства известных в мире Go линтеров. Он позволяет запускать их параллельно, управлять запуском в зависимости от среды и конфигурировать всё из одного конфиг-файла.
Можно ускорить процесс и указать коммит, начиная с которого ошибки считаются «новыми». Так мы сразу внедряем линтер в проект, чтобы не допустить новых ошибок, и понемногу исправляем ошибки из «старого» кода, который был в проекте до его появления.
Установка выглядит так:
curl -sfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh| sh -s -- -b $(go env GOPATH)/bin v1.23.7
Пример конфигурации файла:
run:
tests: false
# Возможно отключать все линтеры на определенных папках и файлах
skip-dirs:
- generated
skip-files:
- ".*easyjson\.go$"
output:
print-issued-lines: false
issues:
# Показывать только ошибки после определенного коммита
new-from-rev: 7cdb5ce7d7ebb62a256cebf5460356c296dceb3d
exclude-rules:
- path: internal/common/writer.go
linters:
- structcheck
# Возможно отключать определенные линтеры на определенных файлах
text: "`size` is unused"
Запуск:
golangci-lint run ./...
Или же можно запускать golangci-lint при помощи утилиты pre-commit, о которой написано ниже.
7. Pre-commit hook’и
Часто перед созданием Pull Request код должен пройти ряд автоматических проверок: например, проверки линтеров и/или тесты, которые можно запустить локально. Обычно для этих целей мы пишем bash-скрипты и устанавливаем в качестве git pre-commit хуков. В таком случае скрипты достаточно сложно поддерживать, поскольку каждый разработчик пишет их по-своему. Есть и другая трудность: их приходится хранить в репозитории и устанавливать на каждую новую машину вручную.
Вместо этого советую использовать python утилиту pre-commit:
- Устанавливаем в систему:
pip install pre-commit
или любым другим способом, описанным в официальном руководстве.
- Оформляем конфиг-файл. Пример для проекта на go:
repos:
- repo: https://github.com/golangci/golangci-lint
rev: v1.23.7
hooks:
- id: golangci-lint
- Под конец генерируем и устанавливаем git pre-commit hook'ов:
pre-commit install
Теперь линтер golangci-lint будет запускаться при каждом коммите и проверять измененные или добавленные файлы. Либо можно запускать hook'и вручную. Опция all-files запускает проверки на всех файлах проекта, а не только на файлах в staging'e:
pre-commit run --all-files
Так можно генерировать hook'и для большого ассортимента линтеров утилит и test runner'ов. Подробнее можно прочитать на странице проекта https://pre-commit.com
8. Функциональные тесты
Gonkey — это инструмент тестирования микросервисов, разработанный в Lamoda. Как правило, он используется для написания функциональных тестов. Подробности о том, зачем появился Gonkey, и простые примеры его использования можно найти в нашей статье на habr.ru “Gonkey — инструмент тестирования микросервисов”.
Gonkey умеет:
- обстреливать сервис HTTP-запросами и следить, чтобы его ответы соответствовали ожидаемым,
- подготавливать базу данных к тесту, заполнив ее данными из фикстур (тоже задаются в YAML-файлах),
- имитировать ответы внешних сервисов с помощью моков (эта фича доступна, только если Gonkey подключена как библиотека),
- выдавать результат тестирования в консоль или формировать Allure-отчет.
Полная документация есть на GitHub.
9. Динамический шаблон проекта
Все вышесказанное собиралось далеко не один день и пережило другие, более «костыльные» решения. Оставалась только одна проблема: новые разработчики, да и некоторые старые, не будем лукавить, не сильно вчитывались в эту статью.
Согласитесь, не каждый готов осилить несколько страниц текста в Confluence в пятницу вечером. Хочется быстро создать сервис, в котором все вышесказанное будет «из коробки». Мы немного подумали и сделали динамический шаблон микросервиса на базе cookiecutter.
Если коротко, cookiecutter — это python-утилита, которая из jinja-шаблонов генерирует исходный код на любом необходимом языке. Нам остается только определить переменные в этих jinja-шаблонах и сделать инструкцию.
Для генерации новых микросервисов за пару минут теперь нужно лишь вызвать команду:
cookiecutter https://stash.lamoda.ru/gotools/cookiecutter-go
и заполнить несколько переменных, например, название проекта, его описание и т.д. На выходе получается готовый работоспособный код на основе всех вышесказанных возможностей. Наш шаблон для cookiecutter выпущен совсем недавно, поэтому наши команды только начинают его использовать.
Заключение
Сейчас Lamoda продолжает унифицировать свои сервисы. Все еще устаканиваются споры по некоторым вопросам. В то же время наши команды перенимают практики, про которые я рассказал, и разности между проектами постепенно сглаживаются.
В случае с микросервисами быть похожим на других — это хорошо. Одинаковость снижает наши трудозатраты на то, чтобы стартовать новый микросервис (потому что не надо писать всё с нуля), и ускоряет адаптацию инженера (потому что новый сервис будет похож на все остальные).
А, как мы знаем, если разработчик не занят рутиной, то он занят творчеством разработкой полезного функционала, который будет радовать наших пользователей.
Поэтому рекомендую всем, кто переезжает на/использует микросервисную архитектуру, как можно раньше задуматься над тем, чтобы сервисы были похожими друг на друга.
Автор: Алексей Партилов