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


  1. Переносимость. Окружение должно быть легко воспроизводимо на различных машинах.
  2. Изолированность. Окружение не должно влиять на версии установленных библиотек и программ на машине разработчика.
  3. Гибкость. Окружение должно позволять собирать релизы для различных версий Golang и Linux (разные версии дистрибутивов и glibc).
  4. Повторяемость. Не должно быть магии и тайных знаний, то есть все шаги сборки проекта и зависимостей должны быть описаны кодом.

Введение


Golang предлагает путь статической сборки приложений. Это вполне удобно и подходит для многих случаев. При разработке утилит с web-интерфейсом или же полноценных web-сервисов неизбежно появляются зависимости от:


  • файлов конфигурации
  • шаблонов, стилей, скриптов и картинок для пользовательского интерфейса
  • чувствительной информации, например ключей для SSL, если ваш сервис работает напрямую с https
  • и многого другого

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


  • файл конфигурации сервиса
  • исполняемый файл релиза
  • файл конфигурации сервиса для systemd или его аналога.

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


  • упростить доставку кода конечным потребителям.
  • обеспечить контроль за ресурсами
  • обеспечить безопасность (невозможность изменения третьими лицами)

Схожий подход применяют, например, авторы популярных открытых проектов, таких как mailhog, consul, vault.


Рабочее окружение


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


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


В прошлой статье можно найти объяснение базовых идей при проектировании рабочего окружения для Erlang проекта. На схожих принципах построена песочница и для Golang проектов.


Исходный код демонстрационного приложения и окружения для разработки Golang проектов можно найти по адресу https://github.com/Vonmo/relgo


Замечание: Код проверен на debian-like дистрибутивах. Для успешной работы с данной статьей на вашей машине должен быть установлен docker, docker-compose и GNU Make. Установка docker не занимает много времени, необходимо лишь помнить, что ваш пользователь должен быть добавлен в группу docker.


В make предопределены следующие цели:


  • build_imgs. Отвечает за сборку базовых контейнеров для запуска контейнеров рабочей среды.
  • up. Создает или обновляет контейнеры песочницы и после этого запускает их.
  • down. Останавливает и удаляет контейнеры песочницы.
  • test. Запускает тесты
  • rel. Готовит итоговый релиз приложения.
  • run. Запускает приложение в песочнице
  • deps. Получает и обновляет зависимости
  • new_migration. Создает миграцию базы данных
  • migrate. Применяет миграции
  • format_code. Форматирует исходный код с помощью gofmt

Демонстрационное приложение


Разрабатывать будем атомарный счетчик (atomic counter). Для демонстрации усложним приложение путем ввода зависимости от базы данных. В данном случае значения счетчиков будут храниться в postgresql.
Определим требования:


  1. Функциональность.
    Приложение должно выполнять следующие функции:
    • increment
    • decrement
    • reset
    • value
  2. Целевая система: ubuntu 16.04 LTS
  3. Простейшее HTTP API
  4. Использование Golang >= 1.9
  5. Наличие начального интерфейс мониторинга внутренних процессов приложения

Архитектура


Архитектура приложения базируется на идее изоляции логически законченных единиц в отдельных пакетах. В приложении присутствуют следующие слои:


  • core – ядро системы, предоставляет базовые функции: конфигурация, логирование, метрики; обеспечивает корректный запуск и остановку сервисов.
  • config – отвечает за парсинг конфигурации
  • models – реализует интерфейс общения с базой данных.
  • migrations – миграции для схемы базы данных.
  • log – добавляет уровни логирования в стандартный log.
  • metrics – реализует простейшие метрики приложения.
  • services – пакет включающий в себя все сервисы системы.

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


Ядро


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


  • Конфигурация
  • Логгер
  • Метрики
  • Реестр сервисов

Конфигурация узла


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


Мне импонирует формат yaml, поэтому в данном примере используется именно он, но заменить парсер можно простой доработкой в config.go функции Parse/1 и самих атрибутов, отвечающих за парсинг в декларации структуры конфига.


Вся конфигурация разбита на секции, группирующие логически связанные параметры:


  • Node – группа параметров, определяющих название узла и его положение в кластере (имя ноды, датацентр, полка)
  • Runtime – позволяет настроить runtime go, например степень параллелизации.
  • Dirs – задает список директорий, которые автоматически создаст ядро. При этом обращаться к ним в коде довольно просто, пример получения пути к папке с данными – System.Config.Dirs.Data
  • Log – определяет параметры логгера. Мы можем писать логи в stdout либо в файл, при этом гибко настроить количество событий через уровни логирования.
  • Metrics – предоставляет параметры метрик приложений. Файл метрик позволяет получить представление о происходящих внутри приложения процессах, например если каких-то событий стало слишком много, или слишком мало. Также метрики полезны при тестировании, поскольку позволяют отправить запрос в систему и увидеть, как изменились счетчики в процессе обработке запроса.
  • DataSources – содержит параметры подключения к внешним источникам данных, таким, например, как базы данных.
  • Services – позволяет определить специфичный набор опций для каждого сервиса .

Сервисы


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


  1. Реализовать описание структуры сервиса, унаследовав стандартный интерфейс описания:
    Пример для нашего сервиса:


    type ACounter struct {
    core.Service
    http *http.Server
    }

  2. Определить init() функцию, в которой создается и запускается экземпляр сервиса:


    func init() {
    go (&ACounter{
       Service: core.NewService(Acounter),
    }).start()
    }

  3. Реализовать логику запуска и остановки сервиса

func (srv *ACounter) start() {
  waitCore()
  srv.Ready = true
  srv.ShutdownFun = func(reason string) {
     log.Debug("acounter: soft shutdown")
     ...
  }
  core.Register(&srv.Service)
  ...
}

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


  • core.WaitCore() – вызов ждет завершения полной инициализации ядра: настройка метрик, логгера, подключение к базе данных и других внутренних процессов
  • core.WaitService(srv string) – позволяет дождаться завершения инициализации другого сервиса. Данный механизм позволяет определить порядок запуска связанных сервисов.

В случае получения от операционной системы сигнала остановки, ядро сообщает об этом всем сервисам путем вызова их srv.ShutdownFun.


Управление зависимостями


В базовый образ включен Dep. Данная утилита позволяет произвести vendor locking и в настоящее время является стандартом. Для внесения зависимости в проекте импортируем ее в любом файле проекта и запускаем предопределенную цель:


  • $ make deps

Все новые зависимости будут помещены в vendor директории. Также хочется заметить, что Dep автоматически удаляет неиспользуемые зависимости.


Логгер


Существует масса библиотек для логирования на go. Например, glog от google кастомизируем, имеет уровни и прекрасно справляется с задачей. Но мне не хотелось дополнительной зависимости в проекте, да и многие библиотеки не подходили по той или иной причине.


В качестве решения пришлось расширить стандартный log, чтобы он соответствовал следующим требованиям:


  1. Реализовывал исходное API log.
    Вызовы log.* должны работать.
  2. Позволял легко перейти на него в уже существующих проектах.
    imprt “log” заменяется на import "github.com/Vonmo/relgo/log"
  3. Выводил микросекундные метки времени.
  4. Показывал имя файла и строку, в которой произошел вызов функции логирования.
  5. Имел типизацию сообщений: Debug, Info, Error, Panic, Fatal и настройку уровня логирования.

Миграции


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


Выбор инструмента зависит от предпочтения разработчика и окружения проекта. Если вы работаете на нескольких языках одновременно и хотели бы единообразия в миграциях, можно использовать внешнюю утилиту. Для себя я выбрал sql-migrate. Данная утилита генерирует plain text, в который необходимо поместить sql миграции и отката.


Тестирование


В мире go предлагают тестировать http api без запуска сервера, путем прямого вызова обработчиков (см. https://golang.org/pkg/net/http/httptest/). Однако хочется отойти от юнит тестов и провести полное тестирование интерфейса чтобы приблизиться к интеграционному тестированию.


В примере можно найти реализацию тестирования полноценного ядра и всех определенных сервисов в тестовом окружении.


Замечание: Наверняка я недостаточно хорошо искал, но есть ли на Golang фреймворк для тестирования, который по функциональности близок к Common Test из Erlang?


Размер итогового файла


Возникает вопрос: почему сборки Golang такие большие? Например если мы соберем простой main(){ fmt.Println("done.") } то получим примерно 1.9 Мб, из которых runtime golang занимает около 1 Мб (данные актуальны для go 1.9.2 amd64).


С реальными проектами дела обстоят еще интереснее:


  • consul-Linux-x86_64 – 42.8 Мб
  • vault-Linux-x86_64 – 75.5 Мб
  • mailhog-Linux-x86_64 – 11.2 Мб

Поскольку в релиз идет стабильный код, можно отключить DWARF debuggin information и общую отладочную информацию, задав -w и -s ключи в качестве опций сборки. Данная мера позволит сократить размер примерно на 30%.


Дальнейшим шагом является применение упаковщиков исполняемых файлов. Одним из популярных упаковщиков является upx. После применения упаковщика итоговый размер сборки сократился с начальных 1.9 Мб до 423 кб, таким образом мы сократили размер исходного файла почти на 78%. С этим уже можно жить.


Итоги


Нам удалось создать среду, в которой можно легко разрабатывать приложения. Так же, как и с рабочим окружением для Erlang, данная среда позволяет получать повторяющийся результат вне зависимости от машины, на которой ведется разработка, избавиться от конфликтов библиотек и версий инструментов, упростить дальнейший CI и – самое главное – выявить проблемы на ранних этапах разработки, так как с помощью docker можно воспроизвести довольно сложные окружения.


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

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


  1. sondern
    20.01.2018 19:31

    Возникает вопрос: почему сборки Golang такие большие?

    Из-за рефлекса в исполняемом файле хранится названия всех ваших методов и глобальных переменных. Что раздувает код.
    А ещё в 1.9.2 есть баг. github.com/golang/go/issues/23242 В бете 1.10 его исправили, но я так понял ещё не до конца.
    Все рекомендуют использовать upx. Это решение уменьшает размер исполняемого файла но программа в ОЗУ начинает занимать больше места.


    1. mr_elzor Автор
      20.01.2018 19:47

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