Унифицируй это: как Lamoda делает единообразными свои Go сервисы

    Мы широко используем микросервисную архитектуру, хоть и не считаем ее панацеей, и чуть больше 2 лет назад начали переходить на язык Go. Он сравнительно прост и, на мой взгляд, очень хорошо подходит для создания простых, небольших и быстрых микросервисов. Эта простота имеет и обратную сторону: из-за неё возникает множество способов решить одну и ту же задачу.


    Казалось бы, насколько сильно может отличаться один микросервис, который ходит в базу данных, от другого микросервиса, который ходит в соседнюю базу данных? Например, одна команда использует Go 1.9, glide, стандартный database/sql и одну структуру проекта, а в это же время другая команда использует Go 1.13, modules, sqlx и, конечно же, другую структуру проекта.


    Когда один микросервис в компании отличается от другого, а он, в свою очередь, отличается от третьего — это замедляет разработку. А медленная разработка — это убытки повод для оптимизации.


    Меня зовут Алексей Партилов, я техлид команды web-разработки в компании Lamoda. В этой статье я расскажу, как мы справляемся с разношерстностью около 40 наших микросервисов на Go. Статья будет полезна разработчикам, которые только вливаются в Go и не знают, с чего начать более сложный проект, чем “helloworld”.


    image


    Изначально 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 окружения сервиса. Например, в одном нашем микросервисе контейнеры при полном развертывании запускаются в следующем порядке:


    1. Контейнер с БД Postgres,
    2. Контейнер migrate, выполняющий миграции на контейнере с БД Postgres,
    3. Контейнер с 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 продолжает унифицировать свои сервисы. Все еще устаканиваются споры по некоторым вопросам. В то же время наши команды перенимают практики, про которые я рассказал, и разности между проектами постепенно сглаживаются.


    В случае с микросервисами быть похожим на других — это хорошо. Одинаковость снижает наши трудозатраты на то, чтобы стартовать новый микросервис (потому что не надо писать всё с нуля), и ускоряет адаптацию инженера (потому что новый сервис будет похож на все остальные).


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


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

    Lamoda
    Russian Fashion Tech

    Комментарии 65

      +3
      4. Миграции БД

      А в го вобще есть нормальные инструменты для миграций? В чем проблема инструментов которые я смотрел, там обязательное требование к имени миграции «возрастающие integer», иногда даже обязательно последовательные, без пропусков, это исходит из того, что в базе хранится только одна строка — номер последней примененной миграции.

      Предположим ситуацию с migrate вы делаете новую ветку в git, создаете миграцию в момент времени t=100, ваш коллега делает новую ветку и создает миграцию в момент времени t=110, причем заканчивает фичу первым и заливает ее в продакшен. в итоге версия нашей базы на проде = 110. Т.е. при выкатке миграции с номером 100, мигратор ее просто проигнорирует, верно? Тесты тестами, но зачем такие головняки, почему бы не храить список всех миграций в базе и применять непримененные.
        +3
        Миграции должны иметь последовательность. Будет сложно предсказать результат если невыполненные миграции будут накатываться в произвольном порядке ибо одна миграция обычно зависит от другой. У нас бывает конечно такая проблема, что два и более разработчика работают в одном сервисе и пишут миграции. Но это случается очень редко и в таком случае у нас действует правило: «Кто первым встал — того и тапки». Касательно инструментов, некоторые команды в Lamoda еще используют goose, основное отличие только в том что миграции «вверх» и «вниз» описываются в одном файле.
          0
          Ну я и не говорю, что они должны применяться в случайном порядке. Они так же будут применяться подряд, если сложилось впечатление что я против нумерации, то это не так. Но ситуация
          что два и более разработчика работают в одном сервисе и пишут миграции

          уйдет автоматически. Как пример — django.
          т.е. я не вижу ни одного плюса в хранении только последней примененной миграции, но вижу минус.
          Положим миграция 100 у нас просто добавляет индекс, в тестах можно и не заметить, что мигратор ее проигнорировал.
          Если вы говорите что у вас все ок, я верю, но не может же у всех все быть ок.
            +3
            Как пример — django.

            В Django мы ловим те же самые проблемы. Собственно там мы чаще всего и ловим) Там же тоже внутри каждой миграции описывается от какой миграции она зависит. И вот когда два разработчика завязываются на одну базовую миграцию — возникает конфликт и Django точно так же не дает их накатить.
              +2
              В Django достаточно просто прикручивается что-то вроде этого и все конфликты миграций становятся конфликтами для merge, заставляя разработчиков решать все конфликты перед вмерживанием ветки.
              0
              Мы в качестве номера миграции используем таймстамп. Все накаченные миграции сохраняются в базе, при запуске миграций выполняется полный перебор файлов с миграциями, в порядке увеличения таймстампа, и те, которых нет в базе — накатываются.
              Я могу, чисто теоретически, представить конфликт — но для этого два разработчика должны параллельно работать с одной и той же таблицей, и производить над ней несовместимые изменения. Пока ни разу конфликтов не было, при том что ситуация когда три бэкендера параллельно пишут миграции каждый в своей ветке, а потом они сливаются в произвольном порядке — случается регулярно.
                0

                Почитайте https://github.com/pressly/goose/issues/63#issuecomment-428681694 — проблема вполне реальная. Порядковая нумерация безопаснее.

                  0
                  Это проблема конкретно данного инструмента, а не таймстампов — у нас самописный скрипт миграций занимается «ненужной» работой — вместо того чтобы проверять последнюю миграцию, он проходится по всему списку миграций, и все неприменённые(т.е. не имеющие записи в бд о успешном применении) -накатывает.
                    0

                    А ничего, что накат некоторых миграций не в том порядке может давать разные результаты?

                      0
                      Миграции из одной ветки в любом случае будут применены последовательно. Если миграции из двух параллельных веток разработки связаны логически, и должны накатываться в определённом порядке — то стоит уделить внимание организации постановки задач в команде.
                      Ну и, мне на самом деле сложно представить такой пример. Как правило, большинство миграций это альтеры на создание новых полей, или создание таблиц. Реже — изменение старых полей. Так же редко — инсерты справочных данных.
                      Вы можете привести последовательность из четырёх запросов, которые нормально работают попарно — каждый в своей ветке — так же нормально работают вместе в одной последовательности(сначала первая пара, потом вторая) но ломаются если их применить вперемешку(не меняя частные зависимости последовательности в паре)?
                        +1

                        Конечно, могу. Равно как и Вы можете эти примеры опровергнуть, сказав что они высосаны из пальца или что так делать не надо было и что есть другие варианты решения проблемы… но ведь на практике разработчики далеко не всегда делают всё правильно, и рассказывать постфактум о том, что существовало лучшее решение которое бы не привело к проблемам — бессмысленно.


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

                          0
                          Конечно, могу. Равно как и Вы можете эти примеры опровергнуть

                          А вдруг не получится опровергнуть, мне тоже интересно посмотреть. я даже сам могу придумать такую ситуацию, но она никогда не возникнет в рабочем процессе.
                            +1

                            Из того, что у меня недавно было на проекте — одна миграция это UPDATE который нормализует существующие записи, а вторая это INSERT который добавляет в справочник запись "по-старинке", ещё не нормализованную. В зависимости от порядка применения либо все записи в БД будут нормализованы, либо все кроме одной. При этом UPDATE — это часть срочного багфикса, INSERT — часть новой фичи. Ну т.е. один разработчик начал делать фичу, создал миграцию с INSERT, но процесс несколько затянулся, как обычно. А в это время нашли баг, и другой разработчик быстро пофиксил его UPDATE-ом. Багфикс смержили до фичи, хотя разработка багфикса началась позднее.

                              0
                              В истории полно дыр. я готов засчитать ее на 30%, почему? потому что тут человеческий фактор и порядковые номера миграций не спасли бы. Рассказываю.
                              2 пользователя создают 2 параллельные ветки для работы, 1 для фичи — b_insert, второй для багфикса — b_update.
                              В изначальной ветке у нас 99 миграйций.
                              Пользователь 1 создает миграцию 100_insert.sql
                              пользователь 2 создает миграцию 100_update.sql
                              пользователь 2 сливает свой коммит с мастером и пушит в репу.
                              пользователь 1 сливает свой коммит с мастером и пытается пропушить, git не разрешает, говоря о том что мастер убежал. Пользователь делает pull, к нему прилетает 100_update.sql, с мыслями «кто первый встал того и тапки» пользователь переименовывает свою миграцию 100_insert.sql -> 101_insert.sql, делает тесты и пушит на сервер.
                              Итог INSERT после UPDATE.
                                0

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


                                Но самое главное вовсе не это. Баг создать можно, протупить с миграцией и ревью тоже можно — это всё нормально. Главное, что в результате везде (локально у всех разработчиков, на стейдже, на проде, etc.) будет этот баг с некорректной записью в БД. БД будет везде одинаковая, не будет проблем с откатом последнего PR или можно надёжно (на всех площадках) пофиксить этот баг в 102_update.sql.

                                  0
                                  Погоди, а почему у нас при порядковой нумерации ревью есть. а без нее нету? Я ждал замечания про внимательного разработчика=)

                                  разработчик точно так же увидит что прилетела миграция 05.06.2020_update.sql посмотрит внуть и поправит свою insert в соответствии с новой схемой.

                                  миграция 100_update(05.06.2020_update.sql) уже накачена в прод и правильный путь это поправить 101_insert(01.01.2019) под новую схему. переименуешь свою 01.01.2019 в 10.06.2020_insert чтобы она у всех запускалась после update даже у тех кто все с нуля накатывает.

                                  в чем разница то? в чем тут преимущество порядковог ономера.
                                  А как на счет того что тебе все время придется переименовывать свою миграцию, даже когда нет разницы в порядке запуска? каждый свой коммит тебе надо переименовывать неконфликтующие миграции, в чем тут преимущество?

                                  Починить прод, на котором по немнивательности применили миграцию 05/05/2020, а потом 01/01/2019 новой третьей миграцией от 06/06/2021 наверно не всегда возможно,
                                  она должна быть написана таким образом чтобы порядок
                                  01 -> 05 -> 06
                                  и
                                  05 -> 01 -> 06
                                  давал один и тот же результат при том что 01 -> 05 и 05-> 01 дают разные результаты, но можно откатить миграции и переприменить в ручном режиме в крайнем случае.
                                    0
                                    откатить миграции

                                    откатить, лол, попробуйте откатить DELETE/UPDATE/ALTER на столбец. Скажем так — существуют миграции, которые возможно откатить, есть правила хорошего тона (писать чтобы все было откатываемым), но это совершенно не означает, что в реальном кейсе не случится какой-то факап.

                                      0
                                      я коментировал конкретный случай. откат здесь нужен для применения в правильном порядке. Зачем тебе откатывать DELETE чтобы его снова накатить в нужном порядке?
                                      ALTER? а ты уверен что у тебя возможно применить миграции в проивольном порядке при альтере? Ну типа добавил я not null колонку в update.sql, как ты после этого сделаешь INSERT, который об этой колонке ни сном ни духом?

                                      как минимум это так не работает. Вы те же миграции Алхимии под пайтон видели?

                                      а причем тут алхимия если обсуждается go и инструменты миграций с последовальныой нумерацией?
                                      разве алхимия сама по себе умеет миграции? я думал нужен alembic. Там хранится 1 идентефикатор последней миграции(хэш?), но с механизмом работы я не знаком. Но снова вопрос, какое отношение это имеет в го и последовательным номерам?)

                                      Если ты расскажешь юзкейс с которым справится алхимия или последовательная нумерация в go инструментах но не справятся django/diesel миграции я буду рад почитать.
                                      0
                                      Погоди, а почему у нас при порядковой нумерации ревью есть. а без нее нету?

                                      Потому что без неё оба PR могут спокойно пройти ревью, потому что по отдельности они оба корректные. Проблемы начинаются в момент, когда один из PR-ов смержили первым, но второй к этому моменту уже может быть аппрувнут. Ну и в целом такая ситуация усложняет процесс ревью, потому что нужно смотреть не только текущий PR, но и все остальные открытые, и не только в момент ревью, но и непосредственно перед мержем… в результате продолбать это на ревью становится намного проще, чем при порядковой нумерации.

                                        0
                                        и оно правда, ради одного такого случая стоит
                                        время придется переименовывать свою миграцию, даже когда нет разницы в порядке запуска

                                        Так уж вышло что с таким процессом работы не сталкивался. Как оно работает? Прилетают pr от 5 человек, везде миграции 100, где-то еще 101, 102, 103. Ревьювер 1 подтверждает, остальные на мороз перенумеровывать. В итоге прилетает 4 pr, с номерами 101 103 104?
                                        В каких то очень редких случаях, порядковая нумерация может быть помогает предотвратить косяк, но плата слишком высока, нет я не куплю=)

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

                                        т.е. звезды должны сойтись, чтобы взаимозависимые миграции прилетели практически одновременно. если апдейт влили в продакшен еще 3 дня назад, то сегодня разработчик заливающий свой insert перенумерует миграцию с номера 100 на номер 115 и ревьювер не будет просматривать кучу, да даже 1 предыдущую и молча пропустит, верно?
                                          +1

                                          Работает очень просто. Очень небольшое количество PR-ов затрагивает миграции, поэтому необходимость переименовывать их возникает редко, и, на моей памяти, ещё не разу это не затрагивало более одного PR. Даже если переименование продолбали — вторая миграция просто не накатится, проблему обнаружат на стейдже, и следом выкатят фикс переименующий миграцию.


                                          Иными словами, оба варианта подразумевают конфликтующие миграции, оба случаются достаточно редко (и хотя в моём варианте это "редко" в теории будет случаться чаще, всё-равно речь о временных интервалах порядка месяца-двух, так что это не принципиально), но в моём проблема быстро обнаруживается, легко исправляется, и гарантированно даёт одинаковый результат на всех площадках, а в вашем… всё значительно менее очевидно.

                                    0
                                    Пользователь 1 создает миграцию 100_insert.sql
                                    пользователь 2 создает миграцию 100_update.sql

                                    как минимум это так не работает. Вы те же миграции Алхимии под пайтон видели?

                      0
                      Ну у нас были проблемы, когда миграции затрагивали одни и те же таблицы.
                        0
                        Если два программиста одновременно работают над пересекающимися задачами — это уже плохая идея. Но даже в таком случае — если им нужно, например, добавить в таблицу новое поле, и они нормально именуют поля, вряд ли они назовут поле одинаково. А следовательно, нет никакой разницы, в каком порядке применять эти два альтера. Так же хорошая практика на дэйли рассказывать, кто над чем работает — это минимизирует вероятность, что два программиста независимо друг от друга создадут в одной таблице поле с одинаковым именем, заложив в него разную логику.
                      0
                      Если вы работает в разных ветках с одной базой и в обоих есть миграции, то ничто не спасет от наслоений и пред мержем всегда есть вероятность, что придется переименовывать миграции, если порядок вмерживания не совпадает нумерацией миграций.
                      0
                      goose, основное отличие только в том что миграции «вверх» и «вниз» описываются в одном файле.

                      На самом деле основное отличие в том, что goose поддерживает миграции в виде функций на Go, что важно, потому что далеко не всегда возможно описать требуемую миграцию на чистом SQL. И в каком проекте и на каком этапе это понадобится — заранее предсказать невозможно, поэтому лучше иметь такую возможность изначально, и пусть лучше не пригодится, чем наоборот.

                        0
                        Еще полезно, когда приходится менять очень много данных и чтобы не блокировать таблицу одной мегатранзакцией, из гоу менять порциями.
                      +1
                      github.com/rubenv/sql-migrate
                      Вот рабочий вариант миграций с атомарностью и раздельным хранением.
                      Не так популярно, как go-migrate, но мне тож так больше нравится. (не только верхняя версия, а весь список примененных миграций к конкретной БД и когда они были выполнены.)
                      Из плюшек — миграции вшиваются в бинарник, и деплой базы упирается в указание, какая из нод должна накатить изменения. Требования обратной совместимости и не мигрировать всеми разворачиваемыми нодами одновременно естественно на релиз инженере.
                      github.com/lancer-kit/service-scaffold/tree/master/dbschema — пример интеграции.
                      (надеюсь, что не обидел автора топика разместив ссылки в комментариях)
                        +1
                        Насколько я знаю migrate тоже поддерживает «вшивание» миграций в бинарник, через go-bindata. http://https://github.com/golang-migrate/migrate#migration-sources Мы у себя правда используем вариант с docker образом migrate и монтируем в него папку с миграциями (примерно также как в docker-compose.yml в статье). Все это осуществляется в рамках отдельной deploy'ной job'ы, вроде хватает. Но ваш вариант обязательно изучу.
                          0
                          Да, спасибо, вроде то что надо. Странно даже что непопулярно, мне вот непонятно, как можно работать с миграциями, сохраняя только последнюю примененную для опеределения состояния базы.
                            0
                            А потому что миграции с меньшей версией чем в базе не должны мигрироваться, потому что уже могут не соответствовать текущей структуре базы.
                              0
                              А если я займусь головняком по переименованию миграции, она резко начнет соответствовать?

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

                              а можно пример, порядок событий, который может привести к такому результату.
                            0
                            И все равно немного не то.
                            Заголовок спойлера
                            swelf@swelf-home:~/src/test$ sql-migrate status
                            +---------------------------+--------------------------------------+
                            | MIGRATION | APPLIED |
                            +---------------------------+--------------------------------------+
                            | 20200414145002-init.sql | 2020-04-14 11:50:10.30518 +0000 UTC |
                            | 20200414145011-second.sql | 2020-04-14 11:51:36.585836 +0000 UTC |
                            | 20200414145021-third.sql | 2020-04-14 11:50:38.786196 +0000 UTC |
                            | 20200414151630-new.sql | 2020-04-14 12:17:41.554844 +0000 UTC |
                            +---------------------------+--------------------------------------+

                            ##Делаем вид что переключаемся на ветку, в которой нет файла 20200414151630-new.sql, в тоже время в базе миграция отмечена как применная.

                            swelf@swelf-home:~/src/test$ mv migrations/20200414151630-new.sql.
                            swelf@swelf-home:~/src/test$ sql-migrate status
                            Could not find migration file: 20200414151630-new.sql
                            +---------------------------+--------------------------------------+
                            | MIGRATION | APPLIED |
                            +---------------------------+--------------------------------------+
                            | 20200414145002-init.sql | 2020-04-14 11:50:10.30518 +0000 UTC |
                            | 20200414145011-second.sql | 2020-04-14 11:51:36.585836 +0000 UTC |
                            | 20200414145021-third.sql | 2020-04-14 11:50:38.786196 +0000 UTC |
                            +---------------------------+--------------------------------------+
                            swelf@swelf-home:~/src/test$ sql-migrate up
                            Migration failed: Unable to create migration plan because of 20200414151630-new.sql: unknown migration in database

                            в итоге не совсем понятно, как работать с миграциями и системой контроля версий. Все время откатывать перед переключением веток?

                            droppoint
                            Ничего, что я здесь про django отвечу?)
                            В Django мы ловим те же самые проблемы. Собственно там мы чаще всего и ловим)

                            Ну они же бывают крайне реже, более того, у меня не было случая когда джанго сама бы не смержила миграции, спросив у меня разрешения.
                            гошный migrate в принципе не сладит с 2мя миграциями слитыми с 2 веток с одинаковым номером. или в разными номерами, но к базе в данный момент уже применена 110, таким образом 100 не применится. А джанго не сладит в каких-то крайних случаях, которые у меня и не случались даже. Более того, косяки джанго никак не «оправдывают» миграторов в го, я привел джанго как пример модели работы с миграциями, пусть и не всегда идеальной работы.
                              0
                              Изменения в ветке вы же тоже комитите или сташите, логично, что изменения в базе тоже нужно откатить. По этому у вас и есть down миграции.
                              Иначе как понять состояние базы, если база считает, что была миграция, а кодовая база не знает что в ней было, следовательно не понимает в каком состоянии база, и совпадает ли это состояние с кодом.
                              Там есть возможность перенакатить базу с форсом. Если это имеет смысл и локальный стейт не содержит нужных данных, или есть подготовленные скрипты наполнения базы случайными данными.
                              У мигрейта есть схожая функциональность. Можно указать на сколько шагов нужно откатить базу. Но при наложении миграций, или мерже «вчерашних» — он не сообщает о проблемах, что создает дополнительные риски при релизе.
                                0
                                Иначе как понять состояние базы, если база считает, что была миграция, а кодовая база не знает что в ней было

                                А всегда ли это надо кодовой базе. Допустим в нашей миграции я создал таблицу t1, потом переключился на старую ветку, где этой миграции нет. Зачем мне эту таблицу удалять? Код о ней ничего не знает, ну есть она и есть. Какую цель несет это требование?
                                Вариант когда я наоброт что-то удалил, например t2, потом вернулся в старую ветку, код думает что t2 должна быть, t2 на самом деле нету. Тут конечно надо запустить down, который создаст снова t2. Но помойму это и без ограничений со стороны мигратора понятно. В первом случае ограничения мешают, во втором бесполезны.
                                  0
                                  Вариант 1 — вторая ветка не имеет новых миграций для этой базы.
                                  Миграцию выполнять не нужно. Проблемы нет.
                                  Вариант 2 — во второй ветке есть невыполненная миграция. При ее выполнении будет создано новое состояние базы, которое не будет воспроизведено нигде. (результат мержа этих двух веток не всегда будет совпадать с результатом работы каждой из них отдельно выполненной в произвольном порядке)
                                  Если обе меняют схему в БД — откатили и накатили другую.
                                  Если нет — можно и так. По быстрому.
                                  Если же по нормальному — нам нужно работать с тем же состоянием и не создавать себе проблемы с хранением в голове веток, их миграций, взаимосвязей в коде и в базе. Лучше эти ресурсы потратить на разработку самой фичи, и концентрацию на ней.
                                    0
                                    и не создавать себе проблемы с хранением в голове веток, их миграций, взаимосвязей в коде и в базе

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

                                    а давай представим в каком кол-ве случаем они не будут совпадать, а в каком будут. И что проще, всегда откатывать миграции, ради 0.1%, либо откатить их только в случае 0.1%?

                                    Я вот совсем не могу себе представить, чтобы я создал 2 миграции, находящихся только у меня, в 2х параллельных независимых ветках, чтобы миграции зависели друг от друга, как это вобще возможно?

                                    более того, вот сломается у меня в этом 0.1% случае код локально, не будет схема совпадать с кодом, что мешает именно в этом случае все дропнуть и создать миграции с 0/накатить бэкап базы.
                                    Но зачем каждый раз то этим заниматься, это как с зонтиком ходить каждый день, даже когда на небе ни облачка.
                                      0
                                      Вы так говорите как будто каждая миграция выполняется по минуте.
                                      Для сравнения — на довольно старом (ruby) проекте 100 миграций испольняются секунд за 7. Поэтому я сразу делаю себе алиас а баше, который дропает базу, потом создает ее, и накатывает все миграции. Все! Нет больше никаких страданий «а тут мы написали неправильный down для миграции и все рассыпалось». Проще накатить начисто при переключении ветки.
                                        0
                                        Это если ты руками базу не забивал. Для чистых тестов я тоже с 0 накатываю миграции и создаю сущности, каждый раз. С рабочей базой, которая может быть даже дампом продакшена, это неудобно.
                                          0
                                          Да. Поэтому у меня есть второй алиас. Он удаляет базу, создает заново, вливает туда дамп с прода (кторый лежит локально по известному пути), и докатывает миграции которые отсуствуют на проде. После этого можнон прогать.
                                +1
                                Если говорить про прод, и конкретно про наш опыт в этом вопросе, то у нас такие кейсы вылавливаются либо при мердже feature ветки в основную, либо при накатке миграций на продовую базу. Как я упоминал ранее у нас есть отдельная deploy'ная job'а для накатки миграций и при ее работе сразу видно что накатилось, а что нет. Мы правда еще обычно и руками проверяем на всякий.

                                Если говорить про локальную машину — то в нашем случае если в разных ветках разные миграции и ребейзиться прямо вот сейчас не хочется, то никто не мешает убить контейнер с базой, а потом поднять его снова чистым и накатить миграции. Если посмотреть на docker-compose файл в статье, то мы делаем
                                make dev-down
                                make dev-server
                                

                                и живем дальше.
                                0
                                В migrate тоже можно вшивать в бинарник даже без go-bindata. Достаточно свой migrationSource написать. Делов на десяток другой строк. Не люблю возиться с миграциями где-то за пределами кода.
                              0
                              Спасибо за статью, думаю более-менее все команды, работающие с микросервисной архитектурой, двигаются в похожем направлении.

                              Интересно, а как вы решаете вопрос обновлений общего шаблона? Вот например был неплохой шаблон, его за полгода использовали для создания 10 новых микросервисов. Всё получилось замечательно и унифицировано. А через полгода осознали, что в шаблон надо бы добавить еще одну общую для всех микросервисов ручку для мониторинга. А может и не через полгода, а через месяц. Как вы поступаете в таких случаях?
                                0
                                Пока никак, шаблон создали сравнительно недавно. В любом случае будем что-то добавлять, и да, не ожидается что это будет просто. Но у шаблона есть меинтейнеры, он открыт для Pull Request'ов со стороны любого разработчика в Lamoda, да и меинтейнеры всегда готовы помочь адаптировать существующие проекты под шаблон.
                                  0

                                  Я обычно в таких случаях временно подключаю репо с шаблоном к репо с уже существующим сервисом (да, для git это вполне штатная ситуация, когда в "одном" репо по факту находится несколько несвязанных между собой "деревьев" коммитов с разными корневыми коммитами) как ещё один remote, после чего делаю cherry-pick нужных коммитов из шаблона.


                                  Раньше ещё пробовал вариант слияния этих двух деревьев коммитов в один общий "ствол", чтобы иметь возможность постоянно "подтягивать" изменения из репо шаблона в репо сервиса (фактически в репо сервиса при этом было два "апстрима" из которых затягивались изменения). Пробовал начинать каждый новый сервис с честного форка репо шаблона, чтобы было проще изменения затягивать. Более того, пробовал делать несколько таких корней-репо-шаблонов, которые не являлись полноценными шаблонами сервисов, а привносили в проект отдельные "фичи". Но, в целом, это работало не очень хорошо — конфликтов было прилично, разруливать их было не просто. Поэтому вместо постоянной работы в этом стиле я перешёл на периодические затягивания из шаблона отдельных обновлений через вышеупомянутый cherry-pick. Но чтобы это нормально работало крайне желательно очень аккуратно делать коммиты в репо шаблона, держа в уме что кто-то может этот отдельный коммит попытаться использовать для обновления своего сервиса.

                                    0

                                    А патчи git в этом случае не лучше подходят? не надо дополнительных remote создавать

                                      +1

                                      А разве git remote add … && git fetch … не проще, чем создавать и распространять патчи?

                                        0

                                        так если есть шаблон, то и накатывать надо не один раз, а сразу на несколько сервисов

                                          0

                                          Это в теории. А на практике — у каждого микросервиса сейчас свои собственные задачи, и обновление "до последнего писка моды по шаблону" может не вписываться в его приоритеты. Равно как и возможности отложить все задачи и срочно обновить все микросервисы тоже может не быть. Поэтому — каждый микросервис обновляется в своём темпе и выборочно.

                                  0

                                  Один из реальных способов поддерживать общий стиль и единообразие между микросервисами — держать их в одном репо. В частности, если проект не требует реально независимого деплоя отдельных микросервисов и разделение на микросервисы преследует скорее задачу упрощения разработки нежели обеспечения бесконечного масштабирования — имеет смысл использовать "монолит со встроенными микросервисами".


                                  При этом подходе бинарник у нас один, но общий для всех встроенных микросервисов main.go минимальный, все микросервисы по-прежнему полностью изолированы друг от друга (у каждого своя БД, каждый раздаёт API на отдельном порту, код каждого в его собственном internal/, etc.) и, при реальной необходимости, довольно легко выносятся из монолита в отдельное репо. Тем не менее, это даёт реальную возможность "наводить порядок" сразу во всех микросервисах одним PR-ом, дешевле рефакторить внутренние API между этими микросервисами, и, в целом, заметно ускоряет разработку если у нас небольшая команда (когда 4 человека бегают между 50 репо с 50 микросервисами — это несколько утомляет).

                                    +2
                                    У нас так. Но компания выросла, и теперь ты пол дня ждешь что бы замержить в мастер. Монорипо стало боттлнеком.

                                    Выпиливаем сейчас в repository per domain. Пишем на ноде, но тут это не важно.
                                      0

                                      Я вообще-то говорил про монолит, а не монорепо. В монорепо проекты разные и зачастую не связанные между собой, а монолит это одно приложение. Но в целом это не важно — даже если у Вас монорепо, то, учитывая тот факт, что у гугла и фейсбука тоже монорепо — оно само по себе не является узким местом, проблема в том, чтобы уметь его готовить.

                                        0
                                        Вначале это тоже был монолит. Потом его разбили на сервисы, воркеры и т.д. А теперь еще и на майкро. Вот и получилось в конце что все в монорипо.

                                        Один из реальных способов поддерживать общий стиль и единообразие между микросервисами — держать их в одном репо.

                                        Это не про монорепо?

                                        Да и знаю я про гугл и про фейсбук. Но увы, на практике у нас где то 40 девелоперов и ci реально стал проблемой. Поэтому было принято решение сверху о разделении на собственные репозитории. Конечно разрабатывать так сложнее, но должно ускорить деливери.
                                      0
                                      Идея монорепо у нас переодически всплывает в компании, но пока сторонников у нее сильно меньше, чем противников. Да, обновлять сервисы возможно станет проще. Одним коммитом ты можешь обновить все сервисы которые у тебя есть. Но это несет в себе дополнительные расходы в администрировании и целый пласт новых неизведанных проблем. Последнее пока перевешивает у нас в компании.
                                        0

                                        Не всё так страшно. Достаточно проконтролировать, чтобы встроенные микросервисы не использовали никаких глобальных объектов (вроде http.DefaultServeMux, prometheus.DefaultRegistrer и глобального хранилища миграций goose) плюс разрулить получение ими своих частей общей конфигурации (флаги/переменные окружения/etc монолита). Т.е. как только они стартанули (конфигурация, миграции) и если они не используют после этого никаких глобальных переменных — в целом всё будет гладко.


                                        Дальше могут быть проблемы если какой-то встроенный микросервис будет слишком уж жрать общие ресурсы (память, CPU, файловые дескрипторы) или у него очень специфический профиль работы (типа, он при запуске первые 15 минут разогревает кеши и в это время не может обслуживать запросы) — но вот на этом месте, если по-простому проблема не решается (напр. ограничением потребляемых ресурсов через пул горутин), то пора конкретно эти микросервисы деплоить отдельно (либо вынеся их из монолита, либо просто запуская ещё один инстанс этого монолита с флагом "запусти только вот эти встроенные микросервисы из всех имеющихся").


                                        P.S. Монорепо != монолит. Я говорил про монолит. А монорепо (в котором разные не связанные проекты) я и сам готовить не умею пока — раздельное тестирование и выкат этих проектов на CI/CD в зависимости от того, какой код затронул текущий PR — это большая боль, по крайней мере во всех вариантах, которые я рассматривал.

                                      0
                                      А как вы решаете такие инфраструктурные вещи?

                                      1. Service discovery
                                      2. Graceful shutdown
                                      3. Logging
                                      4. Metrics

                                      В нашей компании мы пишем на ноде. Но есть вещи которые работали бы намного эффективнее на GO. Но сейчас внедрить GO это проблема, потому сервисы на ноде основываются на обертке, которая и занимается теми вещами которые я перечислил. И что бы втиснуть в экосистему бинарник на GO, мне нужно будет обеспечить работу с консулом и т.д. А это уже выходит за рамки того что я как разработчик сервиса хочу делать. Обидно. А хочется просто кинуть бинарник, и что б работало =)
                                        0
                                        Наш генератор кода, который мы ласково зовем gogi генерирует по OpenAPI спецификации в том числе и серверный код, в котором есть код отвечающий за инициализацию логера, передачу метрик и т.д. То есть в вашем случае, возможно вам стоит начать с обертки которая будет заниматься всеми вещами что вы перечислили.

                                        Насчет инфраструктурных вещей. Для метрик у нас применяется Prometheus, для логов у нас применяется Elastic+Kibana, Graceful shutdown можно сказать встроен в Kubernetes, трафик внутри Kubernetes мы направляем через ingress либо напрямую между сервисами.

                                        Consul, насколько мне известно, мы сейчас не применяем.
                                          0

                                          Graceful shutdown лучше поддерживать из коробки внутри микросервисов. Потому что поддержка его докером (и, полагаю, кубом) сводится к тому, что он присылает сначала SIGTERM, а через 10 секунд SIGKILL. И вот неплохо бы на этот SIGTERM штатно отреагировать самому микросервису, аккуратно закрыв текущие подключения клиентов, остановив все фоновые процессы/горутины, и отключившись от используемых им самим БД/сервисов (иногда это прям критично, как в случае необходимости отключения от NATS Streaming, да и nats.Drain() неплохо бы сделать перед выходом).


                                          Помимо этого полноценная поддержка graceful shutdown (в виде context.Context, который получают все работающие горутины, и который будет отменён в момент начала graceful shutdown) позволяет инициировать его изнутри, если какой-то "вечный" процесс внутри микросервиса внезапно завершится с ошибкой (напр. подключение к consul отвалится и не сможет восстановиться, а продолжать работать без него слишком опасно).

                                            +1
                                            Конечно у нас в микросервисах есть обработка SIGTERM, высвобождение коннектов их повторная иницация в случае закрытия со стороны сервера и другие полезные вещи. Код для этого у нас тоже генерируется. Я думаю оригинальный вопрос был больше про то как погасить сервис перед этим перестав присылать на него запросы, чтобы они не падали в несуществующий upstream.
                                        0
                                        У меня следующие вопросы по списку:

                                        1. Получается, что все сервисы шарят общий базовый код? Ведь в литературе о микросервисах мы повсеместно находим что-то вроде этого:
                                        Microservices should adhere to a “shared nothing” approach where microservices do not possess shared code. Microservices should instead accept code redundancy and resist the urge to reuse code in order to avoid a close organizational link.

                                        “Don’t repeat yourself” isn’t a golden rule Microservices allow you to violate the DRY (don’t repeat yourself) principle safely. Traditional software design recommends that you generalize repetitive code so that you don’t end up maintaining many copies of slightly different code. Microservice design is exactly the opposite: each microservice is allowed to go its own way


                                        2. Если я ничего не упустил, у вас получается голый кубер, рассматривали ли какие-нибудь сервис меши для него? Чем закончились результаты рассмотрения, если были?
                                        3. Вопрос авторизации и аутентификации в микросервисной архитектуре: долго ли мучались, на чем остановились?
                                        4. Для чего вы используете в названиях пакетов несколько слов и underscores ?)
                                          +1
                                          1. Получается, что все сервисы шарят общий базовый код? Ведь в литературе о микросервисах мы повсеместно находим что-то вроде этого:

                                          В микросервисах написанных на python у нас была общая библиотека, которая шарилась между сервисами и мы на этом обожглись. Обслуживать эту библиотеку стало сложно, нужно постоянно следить за совместимостью версий, обновлять библиотеку в разных сервисах и при этом следить чтобы ничего не сломалось. Поэтому сейчас, в микросервисах на go у нас нет расшаренного кода. Весь код генерируется из OpenAPI спецификаций и сервисы независимы между собой. Шаблон микросервиса — это скорее история про эталон к которому должны подтягиваться проекты чтобы не сильно отличаться между собой. Он отрабатывает только на этапе генерации микросервиса и больше не является зависимостью. Можно сказать мы осознанно идем против принципа DRY здесь, чтобы не увеличивать связность между сервисами.
                                          Если я ничего не упустил, у вас получается голый кубер, рассматривали ли какие-нибудь сервис меши для него? Чем закончились результаты рассмотрения, если были?

                                          Очень хотим внедрить меши и скорее всего начнем какие-то движения в эту сторону уже в этом году. Насколько мне известно, наши DevOPS экспериментируют с Istio и Linkerd.
                                          Вопрос авторизации и аутентификации в микросервисной архитектуре: долго ли мучались, на чем остановились?

                                          У нас есть один сервис который занимается аутентификацией, к нему в основном обращаются 2 сервиса которые принимают трафик извне: наше мобильное API и сайт. Далее запросы от этих сервисов к микросервисам уже идут без повторной аутентификации. Авторизация при этом конечно остается. Мы обязательно проверяем, что пользователь запросил именно свои заказы, а не соседа) Насколько мне известно ощутимых болей при внедрении такого подхода никто особо не испытывал.
                                          Для чего вы используете в названиях пакетов несколько слов и underscores ?)

                                          Если речь про go пакеты, то подавляющее количество названий у нас — это отдельное слово. Названия в несколько слов встречаются, но редко. Обычно обусловлено предметной областью, например, «ограничители правил скидок по лояльности» тяжело впихнуть в одно слово.
                                          0

                                          подскажите, как решается вопрос с многофазной транзакцией в бд? Когда несколько микросервисов должны в транзакции записать/изменить данные, а потом при необходимости откатить их назад.

                                            0

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

                                              0
                                              Данными, которые необходимо изменять в одной транзакции, должен владеть один сервис

                                              Это теория. А практика такова, что не всегда это возможно. Я догадываюсь, что ответ будет — если нужна такая мулька, то отправьте свои сервисы на рефакторинг, но такое себе


                                              Ну, и да — распределенные транзакции — это всегда больно, но мне казалось, что паттерны типа saga отчасти решают эту боль

                                                +1

                                                Сага не решает боль. Сага просто даёт этой боли имя и направление, в котором нужно отправить больного. Но от того, что мы теперь знаем, как это называть — меньше болеть не будет.


                                                Я всё понимаю про практику и теорию. Тем не менее, в большинстве случаев перепроектировать будет дешевле, чем внедрять саги.

                                          Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                                          Самое читаемое