В июне на конференции РИТ++ мы с коллегой Игорем Должиковым делились опытом автоматизации процесса разработки сервисов на 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. Присоединяйтесь к нам! ;)

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


  1. ngalayko
    05.09.2017 10:10

    хорошо, что все, что может понадобится, чтобы получился контейнер размером ~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


    1. SirEdvin
      05.09.2017 10:59
      +3

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


      1. len Автор
        05.09.2017 11:06

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

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


      1. ngreduce
        05.09.2017 12:38

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


        1. SirEdvin
          05.09.2017 12:42
          +1

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


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


          1. ngreduce
            05.09.2017 12:58

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


          1. ngalayko
            06.09.2017 09:54

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


            1. diafour
              08.09.2017 10:31

              Пожалуй не соглашусь с вами про 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. Похоже, что были проведены какие-то работы по уменьшению образов.


              1. RidgeA
                08.09.2017 10:41

                alpine или scratch все-равно лучше в продакшене. Например если на железном серваке крутится десяток (а то и больше) сервисов по несколько экземпляров.
                Да, память относительно не дорога, но и расточительно ей пользоваться ИМХО не стоит.


                1. ngreduce
                  08.09.2017 13:04

                  Тогда запускайте прямо на железе, без ОС. Опять микрооптимизациями занимаетесь


  1. raydac
    05.09.2017 17:44

    еще можно использовать старый добрый 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. nwalker
    05.09.2017 18:47
    +1

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


  1. forsyte
    06.09.2017 04:38
    +1

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


    1. len Автор
      06.09.2017 05:17

      Да, спасибо! Почему-то я прям была уверена, что где-то видела пример с константами, но действительно не заработает :) Поправила текст.


  1. proshik
    06.09.2017 13:55
    +1

    Подскажите, а какой подход использовать для загрузки через go get приватных репозиториев, например gitlab || bitbucket?

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


    1. len Автор
      06.09.2017 14:45

      Спасибо за жизненный пример и интересный вопрос.

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


      1. proshik
        06.09.2017 15:13

        Спасибо за ответы!

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


        1. len Автор
          06.09.2017 15:41

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

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

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


          1. proshik
            06.09.2017 15:43

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

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

            Спасибо за ответы!