Готовим сборку Go-приложения в продакшн

    В июне на конференции РИТ++ мы с коллегой Игорем Должиковым делились опытом автоматизации процесса разработки сервисов на Go — от первого коммита и до релиза в продакшн-окружение Kubernetes (да-да, видео начинается с 07:16, и нам тоже это не нравится). С момента публикации мастер-класса время от времени я получаю вопросы по тем или иным темам, затронутым в нем. Пожалуй, самые горячие вопросы достойны отдельного рассмотрения, и сегодня я хотела бы поговорить о процессе сборки приложения. Затрагиваемые темы актуальны не только при подготовке сервисов, но и вообще для любых приложений, написанных на Go.

    Всё, что описано в этой статье, актуально для текущей версии Go — 1.9.

    Заветный GOPATH и расположение кода


    При подготовке приложения к продакшн нужно быть максимально уверенным в том, что в сборку попадёт именно тот код, который ожидает разработчик. По умолчанию Go по-прежнему не умеет работать с управлением зависимостями, а значит, при компиляции приложения все “внешние” зависимости инструменты Go будут искать внутри директории $GOPATH/src. Как узнать текущее значение GOPATH, если вы в нем не уверены? Это значение можно найти в списке переменных, выводимых командой go env.

    Кроме того, код самого проекта также должен находиться внутри $GOPATH/src, и я рекомендую заранее продумать путь, по которому он будет лежать. Когда проект окажется под управлением системы контроля версий, при его затягивании с использованием, например, команды go get, он должен попадать именно по тому пути, который мы определили для проекта изначально. Например, код сервиса, который хранится в моем гитхаб-аккаунте в репозитории rumyantseva/mif развернут у меня внутри $GOPATH/src/github.com/rumyantseva/mif. Если бы этот же код лежал внутри репозитория mif некоторого закрытого хранилища example.com в неймспейсе services, то путь к нему на машине разработчика выглядел бы, скорее всего, как $GOPATH/src/example.com/services/mif. Для того, чтобы избежать в будущем проблем или неоднозначностей, правило расположения кода необходимо соблюдать.

    Разные проекты можно хранить как внутри одной и той же директории GOPATH, так и внутри нескольких. Соответственно, во втором случае значение GOPATH придется переопределять. Для того, чтобы сделать это, необходимо будет переустановить соответствующую переменную окружения в нужное вам значение. В случае, если GOPATH не задан вообще, Go будет считать рабочим каталогом директорию go, находящуюся в домашнем каталоге пользователя. Чтобы лучше в этом всём разобраться, проведем несколько экспериментов с консолью:

    Разбираемся с GOPATH
    $ # По умолчанию GOPATH определен как $HOME/go:
    $ go env | grep GOPATH
    GOPATH="/Users/elena/go"
    $
    $ # Изменим значение переменной окружения GOPATH и посмотрим, что будет:
    $ GOPATH=/Users/tmp/something
    $ go env | grep GOPATH
    GOPATH="/Users/tmp/something"
    $
    $ # Теперь попробуем задать переменную непосредственно в процессе вызова команды go env:
    $ GOPATH=/pampam go env | grep GOPATH
    GOPATH="/pampam"
    $
    $ # А в рамках текущей сессии значение GOPATH по-прежнему не изменилось:
    elena:~ $ go env | grep GOPATH
    GOPATH="/Users/tmp/test"
    $
    $ # Уберем значение GOPATH вообще и посмотрим, что будет:
    $ GOPATH=
    $ go env | grep GOPATH
    GOPATH="/Users/elena/go"
    $ # Мы вернулись к значению по умолчанию :)


    Однако, если идея GOPATH вам всё же не по вкусу, можно обратиться к таким инструментам, как gb, который позволяет проводить сборки вне зависимости от расположения кода.

    Работа с внешними зависимостями


    Итак, в случае, если мы пишем приложение, использующее внешние (относительно текущего репозитория) зависимости, для успешной сборки нам необходимо, чтобы все эти зависимости находились внутри GOPATH. Притянуть зависимости автоматически можно с помощью вызова таких команд, как go get или go install внутри текущего рабочего проекта. При этом мы скачаем код зависимостей, находящихся в дефолтных ветках репозиториев. Этого достаточно для ситуации «здесь и сейчас», но в общем случае никто не гарантирует, что через 5 минут в тех же самых ветках внешних зависимостей не появятся обратно-несовместимые изменения. А значит, следующая попытка развернуть приложение (например, на билд-машине) может закончиться провалом. Что нам поможет в этой ситуации? Конечно же, вендоринг.

    Про директорию vendor неоднократно писали и на Хабре, и много где еще, и повторяться я не буду. Однако, кратко напомню, что все те же зависимости, которые мы притягивали в GOPATH/src, можно сложить и в директорию vendor текущего приложения. Как это сделать? Или вручную, или с помощью менеджера управления зависимостями. В качестве примера утилиты для работы с зависимостями наконец-то можно упомянуть dep, официальный эксперимент Go. Несмотря на статус «официального эксперимента» dep по-прежнему не является ни абсолютно стабильным, ни рекомендуемым. Тем не менее, мы рискнули попробовать dep в наших рабочих проектах, и нам понравилось! Если вы впервые сталкиваетесь с вопросом управления зависимостями, я очень рекомендую вам десятиминутное видео с конференции Gophercon 2017, в котором подробно и наглядно показаны принципы работы dep.

    Итак, по итогам использования менеджера управления зависимостями, мы получили директорию vendor, полную пакетов, и некоторый набор метафайлов, описывающих наши зависимости. Казалось бы, метафайлов с описанием используемых тегов и даже хэшей коммитов нам достаточно, и естественным желанием было бы убрать директорию vendor из-под управления системы контроля версий. Однако, в настоящих продакшн-проектах, не стоит этого делать. Хранение кода вместе с vendor — единственный путь защиты от таких аварий, как падение гитхаба или полное удаление своего репозитория сторонним разработчиком.

    Версионирование бинарников


    Пожалуй, почти все популярные консольные утилиты поддерживают команду или флаг version, позволяющую вывести информацию о текущий версии бинарника. Эта же практика может пригодиться и в случае с запущенным сервисом. Кроме того, иногда бывает полезно «зашить» в бинарник не только информацию о семантической версии, но и хэш коммита, дату сборки и другие полезные данные.

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

    Небольшой пример. Создадим файл hello.go с таким содержимым:

    package main
    
    import "fmt"
    
    var hello = "Hello"
    var world = "World"
    
    func main() {
        fmt.Printf("%s, %s!\n", hello, world)
    }

    При обычном запуске, например, с помощью команды go run hello.go, мы получим строку «Hello, World!»:

    $ go run hello.go
    Hello, World!


    А теперь добавим вызов линкера с флагами -X и новыми значениями переменных:

    $ go run -ldflags="-X main.hello=Привет -X main.world=Мир" hello.go
    Привет, Мир!

    Подменять можно не только переменные из пакета main, но и переменные из любых пакетов вообще. Таким образом, во время сборки приложения можно зашить в него любую необходимую метаинформацию.

    upd. Благодарю forsyte за уточнение про флаг -X

    Набор инструкций по сборке приложения


    Несмотря на солидный возраст, утилита make не теряет актуальности и популярности у тех, кому приходится собирать приложения (хотя бы под *nix). Вот и среди Go-разработчиков этот инструмент весьма распространен.

    Рассмотрим конкретный пример Makefile для некоторого сервиса. Я подготовила репозиторий go-zeroservice, содержащий «нулевой» сервис, единственная функциональность которого — запуститься и показать информацию о сборке.

    Команда make build собирает бинарный файл, подставляя указанную в Makefile версию, хэш последнего коммита и текущее время. При этом перед make build вызывается команда clean, которая удаляет уже существующий бинарник (если он был). Для обновления зависимостей предусмотрена команда make vendor, которая установит dep, если его еще нет, и выполнит команду dep ensure для актуализации пакетов внутри vendor. Для проверки качества кода предлагается команда make check, которая установит и запустит металинтер.

    В наших продакшн-проектах мы выносим в Makefile любые более или менее повторяющиеся действия — запуск проверок на стандарты кодирования и запуск тестов, запуск менеджера управления пакетами, сборку приложения под нужную ОС с нужными флагами, команды для сборки и запуска Docker-контейнера и даже команды, позволяющие запустить релиз сервиса в Kubernetes с использованием Helm.

    Наличие таких команд на разных окружениях позволяет быстро производить необходимые действия, например, запускать тесты и собирать и запускать контейнер на локальном окружении разработчика, или запускать тесты и сборку и проводить релиз в рамках процессов CI/CD. В случае с go-zeroservice можно посмотреть файл .travis.yml, который запускает сборку сервиса в рамках Travis CI и как раз состоит из команд, описанных в Makefile.

    Заключение


    Итак, для того, чтобы разобраться с вопросами сборки Go-приложений для продакшн, требуется не так уж много. Во-первых, нужно определиться с расположением кода приложения внутри GOPATH и убедиться, что оно соответствует расположению кода в системе контроля версий. Во-вторых, нужно решить, каким образом будет производиться взаимодействие с управлением внешними зависимостями и выбрать подходящий инструмент. В-третьих, удобно, когда под рукой есть все инструкции, которые приходится выполнять более или менее регулярно и на разных окружениях, в хранении таких инструкции помогает, например, Makefile.

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

    P.S. Кстати, мы придумали продолжение мастер-класса про Go и Kubernetes и планируем представить его в сентябре на конференции DevFest Siberia. Присоединяйтесь к нам! ;)
    Поделиться публикацией
    AdBlock похитил этот баннер, но баннеры не зубы — отрастут

    Подробнее
    Реклама

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

      0

      хорошо, что все, что может понадобится, чтобы получился контейнер размером ~5мб описано на докерхабе


      Dockerfile:


      FROM golang:1.8.3 as builder
      WORKDIR /go/src/github.com/user/application/server
      RUN go get -u github.com/golang/dep/cmd/dep
      ADD . ./
      RUN make build-alpine
      
      FROM alpine:latest
      WORKDIR /app
      COPY --from=builder /go/src/github.com/user/application/server/config.yaml .
      COPY --from=builder /go/src/github.com/user/application/server/bin/application_binary .
      EXPOSE 80
      
      ENTRYPOINT ["./application_binary", "--config=config.yaml"]

      Makefile:


      deps:
              dep ensure
      
      build-alpine: deps
          CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o ./bin/application_binary ./cmd/main.go
        +3

        А почему не FROM scratch? Мне казалось, для production сборок всегда советуют этот вариант. Его не удобно просматривать, но для прода вроде самое то.

          0
          А мы как раз делаем FROM scratch, вот примерно так :)

          Только если сертификаты надо перегенерить, отдельно разворчаиваем alpine и копируем нужные файлы, но это нечастое действие.
            0
            Всегда считал что alpine лучше scratch, в большинстве случаев, если придется лезть внутрь контейнера для траблшутинга. Тем более что в большинстве случаев scratch vs alpine уже не сильно решает, если использовать container registry близко к проду.
            P.S. теоретик ;)
              +1

              В таком случае лучше уж ubuntu/debian. Потому что у alpine иногда бывают веселые баги, в силу того, что у них не совсем стандартные библиотеки.


              Ну, например есть вот это. У меня был случай, когда после коннекта оно просто падало с sigfault и все.

                0
                Ну я бы выбирал для себя Ubuntu скорее всего или специальный образ от Google на базе ChromiumOS. Но для Golang и Alpine должна работать хорошо.
                  0

                  ubuntu весит 400mb,
                  правильно собранному бинарнику с го достаточно даже scratch, как писали выше (а это 4мб + размер бинарника)

                    0

                    Пожалуй не соглашусь с вами про 400mb. Образ ubuntu:16.04 это 119Mb по показаниям docker images. Плюс есть облегчённые сборки ещё меньшего размера. Вот тут отличная картинка есть с размерами https://hub.docker.com/r/blitznote/debootstrap-amd64/
                    Понятно, что всё равно больше, чем Alpine (3.6 == 3.97Mb), но всё же не в 100 раз.


                    P.S. 424Mb наблюдаю у вагрант-коробки trusty 14.04, а вот коробка xenial 16.04 уже 275Mb. Похоже, что были проведены какие-то работы по уменьшению образов.

                      0
                      alpine или scratch все-равно лучше в продакшене. Например если на железном серваке крутится десяток (а то и больше) сервисов по несколько экземпляров.
                      Да, память относительно не дорога, но и расточительно ей пользоваться ИМХО не стоит.
                        0
                        Тогда запускайте прямо на железе, без ОС. Опять микрооптимизациями занимаетесь
            0
            еще можно использовать старый добрый Maven (при помощи специального плагина) в котором вполне изолированно можно забилдить Go приложение (в том числе и мульти-модульное) и к тому же в котором есть много хороших плагинов что бы произвести скажем автоматическое формирование дистрибутива собранного приложения и при этом всё достаточно кросс-платформенно
            можно посмотреть что из себя скажем представляет мульти-модульный проект в этом случае сформировав его при помощи одно строчной команды
            mvn archetype:generate -B -DarchetypeGroupId=com.igormaznitsa -DarchetypeArtifactId=mvn-golang-hello-multi -DarchetypeVersion=2.1.6 -DgroupId=com.go.test -DartifactId=gohello-multi -Dversion=1.0-SNAPSHOT

            который при сборке командой mvn -Passembly обрабатывает два модуля и выдает архив с исполняемыми файлами в папке assembly-results/target
              +1

              Кстати, а сейчас есть какие-то тулзы, позволяющие избавиться от полных путей к репозиториям в импортах?

                +1

                -X может заменить только значение строковых переменных. Значения констант, к сожалению, не заменит.

                  0
                  Да, спасибо! Почему-то я прям была уверена, что где-то видела пример с константами, но действительно не заработает :) Поправила текст.
                  +1
                  Подскажите, а какой подход использовать для загрузки через go get приватных репозиториев, например gitlab || bitbucket?

                  И как вы на работе вообще задаете переменную GOPATH для рабочих проектов, ведь если тоже кидать в дефолтный GOPATH, тогда и импорты будут что-то аля company.gitlab.com/middleware/..., что наверное не лучший вариант. Заказчик может захотеть переехать на другой хостинг исходников и что, потом в коде все импорты заменять или я что-то не понимаю?
                    0
                    Спасибо за жизненный пример и интересный вопрос.

                    Чтобы работать с приватными репозиториями мы добавляем маленький хак в .gitconfig. По умолчанию go get тянет репозитории по http(s), мы подменяем на уровне git эту часть url'а на нужную нам. Наверное, не самое красивое решение, но оно работает. Примеры для github'а и абстрактного bitbucket'а:

                    [url "git@github.com:"]
                    insteadOf = https://github.com/

                    [url "ssh://git@my-secret-bitbucket-url:1234"]
                    insteadOf = https://my-secret-bitbucket-url/scm


                    А вот насчет второго вопроса, пожалуй, не всё так просто. У меня всего однажды был похожий случай, когда захотелось переехать на другой хостинг, но я просто пробежалась sed'ом по нужным репозиториям, перегенерила vendor и поправила пути импорта. Ну и путей импорта в коде, по-моему, все-таки не так уж много, как может иногда показаться :)
                      0
                      Спасибо за ответы!

                      По поводу второго вопроса, у меня еще вопросик, а как вы смотрите на то, что в каждом проекте будет своя папка /src и GOPATH будет начинаться именно рутовой директории проекта? Тогда в импортах вообще не будет фигурировать часть url с именем хостинга исходников.
                      Я так делал однажды, но получив некоторый опыт с go понял, что это вообще не лучший способ.
                        0
                        Интересный способ. По идее, каноны требуют использовать полные пути в качестве путей импорта. Но, наверное, если для конкретного проекта или конкретной команды удобнее от канонов отступить, то можно и так сделать.

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

                        Ну и в целом есть ощущение, что от отсутствия полных путей импорта головной боли будет больше, чем от их наличия. В случае, если полные пути импорта заданы, их всегда можно найти/заменить тем же sed'ом. А вот если захочется неполные пути потом изменить на полные, это будет, наверное, задача сложнее.
                          0
                          А вот если захочется неполные пути потом изменить на полные, это будет, наверное, задача сложнее.

                          Вот это точно:)

                          Спасибо за ответы!
                      0
                      хак с .gitconfig, описанный выше — очень помог в свое время, долго головой бился как через ssh использовать приватный репозиторий.

                      Столкнулся с другой проблемой, некоторые приватные репозитории нельзя было обновлять в силу архитектурных решения с помощью

                      go get -u <repo-url>


                      есть N микросервисов, они используют общие модули (подключаемых код через импорт) из приватного репозитория: jwt авторизация, управление пользователями, данные о микросервисах микросервиса (например сервис емейл рассылки) и т.п. не вдаваясь в подробности.

                      В общем, возник бизнес-кейс: нужно изменить поведение модуля (части кода который используют многие микросервисы) но таким образом, чтобы обновление было контролируемым.

                      1. внести изменения в модуль, адаптировать микросервисы которые его используют и сделать большой (огромный) апдейт, что в разы отсрочит введение в продакшн необходимых изменений;

                      2. обновлять по мере необходимости микросервисы по мере необходимости.

                      Для удобства отказались от плясков с gopath, много было хлопот

                      1. создаем в корне проектов ./lib, туда и клонируем все с гита
                      2. добавили в .gitignore lib
                      3. сделали скрипт обновления ./lib, и при необходимости запускаем что нужно:
                      ЗЫ: рабочая директория ./bin/linux

                      скрипт update-go-lib.sh

                      Repos=(repoA repoB repoC)
                      
                      cd ../../lib
                      
                      for repo in ${Repos[*]}
                      do
                          if [ -d $repo ]
                          then
                              cd $repo
                              git pull origin master
                              cd ..
                          else
                              git clone git@bitbucket.org:example/$repo.git
                          fi
                      done


                      все делает 1 команда и размещает нужный код в нужном месте

                      sh update-go-lib.sh


                      далее используем библиотеку

                      package main
                      
                      import "./lib/repoA"
                      
                      ...
                      


                      более сложный вариант update-go-lib.sh — использование тегов

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

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

                      похожий подход используем и для документации, каждый модуль может иметь свои ендпоинты и т.п., он содержит api-reference.json который отображается в доке используемых сервисов, изменения одного модуля отображается в доке зависимого микросервиса с сохранением версии апдейта.

                      Как-то так. Решение, возможно, не из лучших, но оно работает, хорошо зарекомендовало на разных серверах и ос.

                      пытался химичить и как-то использовать известные системы управления зависимостями — но все приводило в Ад.
                      0
                      Подскажите пожалуйста подходящий тул для миграции бд в go, с поддержкой PostgreSQL в идеале, чтобы работала по аналогии liquibase или flyway, в силу привычки использования. А именно чтобы можно было встроить в приложение, автоматическое управление миграцией в кластере инстансов прилжения с локом при миграции и поддержка автоматического поиска новых скриптов в некоторой директории по какому-то паттерну.
                        0
                        Сама я в продакшн пробовала только goose (как раз для PostgreSQL), встроить в приложение можно было, насчет локов точно не скажу, но да, в случае с несколькими инстансами проблем не было.

                        Вообще, я сейчас (вот прямо сейчас) как раз пишу управление миграциями для reform, но, думаю, пока там всё будет готово к продакшн, пройдет еще какое-то время.

                        Приходите к нам в русскоязычный слак — slack.golang-ru.com, у нас там есть канал #databases как раз для таких вопросов — может, кто что интересное посоветует из своего опыта.
                          0
                          Спасибо! И спасибо за приглашение!
                        0
                        GOPATH — такой же как и системный PATH, только для go. И точно также, как и в PATH мы указываем НЕСКОЛЬКО каталогов, в ОДНОМ GOPATH можно указать несколько точек входа к рабочим go-пространствам.

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

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