Данная статья является продолжением инструментальной темы, затронутой в прошлой публикации. Сегодня мы постараемся разобраться со сборкой релизов Golang приложений в виде единого исполняемого файла, включающего ресурсные зависимости, и вопросом оптимизации размера итоговой сборки. Также рассмотрим процесс построения рабочего окружения отвечающего следующим требованиям:
- Переносимость. Окружение должно быть легко воспроизводимо на различных машинах.
- Изолированность. Окружение не должно влиять на версии установленных библиотек и программ на машине разработчика.
- Гибкость. Окружение должно позволять собирать релизы для различных версий Golang и Linux (разные версии дистрибутивов и glibc).
- Повторяемость. Не должно быть магии и тайных знаний, то есть все шаги сборки проекта и зависимостей должны быть описаны кодом.
Введение
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.
Определим требования:
- Функциональность.
Приложение должно выполнять следующие функции:
- increment
- decrement
- reset
- value
- Целевая система: ubuntu 16.04 LTS
- Простейшее HTTP API
- Использование Golang >= 1.9
- Наличие начального интерфейс мониторинга внутренних процессов приложения
Архитектура
Архитектура приложения базируется на идее изоляции логически законченных единиц в отдельных пакетах. В приложении присутствуют следующие слои:
- 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 – позволяет определить специфичный набор опций для каждого сервиса .
Сервисы
Для корректной инициализации сервиса необходимо выполнить ряд условий:
Реализовать описание структуры сервиса, унаследовав стандартный интерфейс описания:
Пример для нашего сервиса:
type ACounter struct { core.Service http *http.Server }
Определить
init()
функцию, в которой создается и запускается экземпляр сервиса:
func init() { go (&ACounter{ Service: core.NewService(Acounter), }).start() }
- Реализовать логику запуска и остановки сервиса
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, чтобы он соответствовал следующим требованиям:
- Реализовывал исходное API log.
Вызовы log.* должны работать. - Позволял легко перейти на него в уже существующих проектах.
imprt “log”
заменяется наimport "github.com/Vonmo/relgo/log"
- Выводил микросекундные метки времени.
- Показывал имя файла и строку, в которой произошел вызов функции логирования.
- Имел типизацию сообщений: 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 можно воспроизвести довольно сложные окружения.
Уважаемые читатели, благодарю вас за уделенное время и проявленный к теме интерес.
sondern
Из-за рефлекса в исполняемом файле хранится названия всех ваших методов и глобальных переменных. Что раздувает код.
А ещё в 1.9.2 есть баг. github.com/golang/go/issues/23242 В бете 1.10 его исправили, но я так понял ещё не до конца.
Все рекомендуют использовать upx. Это решение уменьшает размер исполняемого файла но программа в ОЗУ начинает занимать больше места.
mr_elzor Автор
Про баг интересно. Насчет упаковщиков, зависит от требований, если память и время запуска не являются узким местом, можно использовать.