Оптимизация размера Go-бинарника
Если вы когда-нибудь писали на Go, то размер получающихся бинарников не мог пройти мимо вашего внимания. Конечно, в век гигабитных линков и терабайтных дисков это не должно быть большой проблемой. Но все-таки встречаются ситуации, когда хочется, чтобы размер бинарника был как можно меньше, и при этом вы не хотите расставаться с Go. О вариантах, как сделать так, чтобы Go-бинарник «похудел», пойдет речь ниже.
Цель aka «жертва»
Для начала немного контекста. Есть демон (постоянно запущенный процесс), который выполняет некоторую весьма несложную работу. Близкими аналогиями по манере работы могут быть DigitalOcean Agent или Amazon CloudWatch Agent, которые собирают метрики с машин и засылают их в централизованное хранилище. Наш демон выполняет немного другую задачу, но это не принципиально.
Еще несколько фактов о демоне:
- Написан на Go (и переписывать на другой язык желания нет);
- Устанавливается на множество машин;
- Периодически требует обновления.
На момент начала исследования размер Go-бинарника составлял 11 Мб.
Let Me See You Stripped
Скомпилированный бинарник содержит отладочную информацию. В моей ситуации она не нужна — отлаживать на целевых машинах все равно возможности нет ввиду отсутствия доступа. Так что можно смело удалить ее, скомпилировав с указанием нужных флагов или воспользовавшись утилитой strip. Процесс называется стриппингом (stripping) и для любителей Linux должен быть весьма знаком (описание флагов можно поглядеть в выводе go tool link
):
go build -ldflags "-s -w" ./...
После этой процедуры размер бинарника составил 8,5 Мб. То есть отладочная информация давала +30% к размеру в данном конкретном случае.
Сжатие
Следующий ход конем — это использование компрессии. Можно просто попробовать сделать tar.gz-архив и распространять его. В целом, это рабочее решение. Возможность распаковать архив на целевых системах присутствует.
Другой вариант — воспользоваться упаковщиком, который будет делать распаковку и запуск бинарника «на лету». Пожалуй, самый известный в этой сфере — это UPX. Первое мое знакомство с ним случилось, наверное, больше 20 лет назад, в эпоху dialup-модемов и crack/keygen-поделок. Несмотря на столь солидный возраст, UPX до сих пор находит своих пользователей и продолжает развиваться. Я пропустил момент эволюции, когда upx заработал из коробки для Go, но сегодня никаких дополнительных приседаний не требуется. Судя по истории, случилось это года 4 назад, так что все работает весьма стабильно.
Пробуем упаковать наш бинарник с помощью UPX:
upx agent
На упаковку потребовалось 1,5 секунды, и мы получили бинарник размером в 3,4 Мб! Отличный результат.
Поизучав немного опции упаковщика, можно обнаружить такие варианты, как --brute
и --ultra-brute
. Попробуем поиграться с первым:
upx --brute agent
Размер полученного бинарника составил 2,6 Мб, что в 4 раза меньше нашего первоначального варианта. Правда, процедура упаковки значительно замедлилась и заняла аж 134 секунды.
Ради любопытства пробуем и --ultra-brute
:
upx --ultra-brute agent
Размер бинарника — все те же 2,6 Мб (на самом деле, размер уменьшился, но всего на 8 Кб). К времени на упаковку добавилось еще 11 секунд, и суммарное время составило составило 145 секунд.
Мысль о том, что по скорости хочется как в первом варианте, а по размеру — как во втором и третьем, не давала покоя и привела к следующей команде:
upx --best --lzma agent
В результате мы имеем все те же 2,6 Мб по размеру, но времени требуется всего лишь 4 секунды.
Тяжелые зависимости
Легкость добавления внешних зависимостей может иметь свои негативные последствия. Например, довольно просто добавить какой-то «очень нужный» модуль, который увеличит размер бинарника несоизмеримо приносимой пользы.
Есть очень хорошая (но часто игнорируемая) практика — организовывать мониторинг размера дистрибутива. Когда есть график соответствия «ревизия — размер дистрибутива», то очень легко выяснить, какое из изменений привело к «ожирению».
В моем случае к «ожирению» привела интеграция с Sentry. Если никогда не сталкивались с Sentry, то в двух словах, это сервис, собирающий информацию об ошибках, которые происходят в приложении. Такие штуки прикручивают в первую очередь для повышения качества и поиска проблем, возникающих в промышленной эксплуатации сервиса или продукта. Возвращаясь к проблеме «ожирения», смотрим, что нам дает вариант без интеграции с Sentry. Упражнения опять начинаем с 11 Мб бинарника. Без «стрипания» после убирания интеграции размер составил 7,8 Мб, а после «стрипания» размер стал и вовсе 6,2 Мб. Почти в 2 раза меньше изначального!
Конечно, трекинг ошибок может хотеться сохранить. Но, в данном случае, мне дешевле организовать промежуточный сервис, куда отправлять сообщения об ошибках хоть по HTTP, а оттуда уже перенаправлять их в Sentry.
Еще раз про сжатие
После того, как разобрались с зависимостями пробуем еще раз воспользоваться upx:
upx --best --lzma agent
Результирующий размер бинарника: 1,9 Мб! Напомню, что путь начался с 11 Мб.
Платой за компактный размер будет время на запуск, так как предварительно требуется распаковка. Грубый замер с помощью утилиты time в моем случае показывал увеличение на 170–180 миллисекунд. В контексте демона, где время работы несоизмеримо больше накладных расходов на запуск, это совершенно не является проблемой. Но иметь ввиду этот аспект нужно.
Другие варианты
Куда идти, если хочется большего?
Один из вариантов по решению проблемы доставки обновлений минимального размера — это бинарные патчи. Например, распространение обновлений Google Chrome использует эту концепцию. Есть утилиты bsdiff/bspatch, которые позволяют легко организовать такой процесс. В моем случае, утилита bspatch на целевых машинах отсутствует, поэтому пока для себя посчитал эти упражнения нецелесообразными. Хотя предварительные эксперименты показали весьма хорошие результаты в плане небольшого размера патчей.
Другой вариант, вскользь упомянутый в самом начале, — переписать все на другом языке. Если поставить размер во главу угла, то закончится все на Си. Время на разработку мне дорого, да и хочется испытывать удовольствие от процесса, поэтому — тоже нет.
Еще один любопытный вариант — это gccgo. Если целевые машины более-менее однообразны, то можно воспользоваться этим способом и получить Go-бинарник с динамической линковкой. Размер бинарника сильно порадует.
Это не мой случай (ОСи самые разные), но эксперимент я тоже попробовал провести:
go build -compiler gccgo -gccgoflags "-s -w" ./...
Условия не совсем равные (это другия виртуалка и другая ОСь), но уже на старте мы получаем бинарник размером 1,8 Мб. Правда, с динамической линковкой. Применяем upx и получаем… всего 284 Кб! Главное, при переносе в другое окружение не удивляться потом ошибкам следующего характера:
./agent: error while loading shared libraries: libgo.so.16: cannot open shared object file: No such file or directory
В копилку экзотических компиляторов можно добавить TinyGo. Данный проект у меня им собрать не получилось — рассыпается с рядом ошибок на этапе компиляции. Но, в целом, успешные попытки уже были в контексте другого проекта (небольшого, но все-таки не «Hello, World!»). Бинарник будет с динамической линковкой, но количество зависимостей меньше, чем в варианте с gccgo (а значит, чуть меньше и проблем с портируемостью).
При наличии достаточного объема кода, зависящего от платформы, могут пригодиться build tags. Правила могут быть более хитрыми, чем просто именование файлов с суффиксами _windows.go или _linux.go. Размер экономии сильно зависит от конкретной ситуации. В моем случае экономии практически нет, так как основная целевая платформа — это Linux x86_64, а поддержка Mac и ARM — лишь эксперименты.
Docker
Нередко можно встретить, что Go-бинарник распространяется в виде Docker-контейнера. Например, для полной изоляции демона от хостовой системы и проброса только нужных файлов или директорий. В моей ситуации есть соседний демон, который распространяется именно так и используется на двух машинах. В этом случае более важны усилия по оптимизации размера не самого бинарника, а размера Docker-образа. Оптимальный по размеру Docker-образ создается с помощью довольно стандартного трюка с двухфазной сборкой:
FROM golang:1.15 as builder
ARG CGO_ENABLED=0
WORKDIR /app
RUN apt-get update && apt-get install -y upx
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN make release
FROM scratch
COPY --from=builder /app/server /server
ENTRYPOINT ["/server"]
Каждая фаза начинается с директивы FROM
. На первой фазе присутствуют все зависимости, необходимые для сборки, и формируется бинарник. А далее, с директивы FROM scratch
, по сути с нуля формируется новый образ, куда копируется полученный ранее бинарник и определяется команда запуска.
Под make release
скрываются вызовы go build
и upx
. Результирующий же размер образа составил всего 1,5 Мб (размер немного отличается в меньшую сторону, так речь идет о похожем, но немного другом демоне). Если собирать все в одну фазу используя golang-образ в качестве базового, то результат будет 902 Мб.
Выводы
Итак, мы прошли путь с 11 Мб до 1,9 Мб, то есть сократили размер Go-бинарника практически в 6 раз! Стрипание бинарника с последующей упаковкой его с помощью upx — очень эффективная мера по сокращению размера. Не стоит пренебрегать и убиранием лишних зависимостей. В моем случае, это приводило к очень заметному сокращению. Если нет особой вариативности в средах для запуска бинарника, то можно присмотреться к варианту с gccgo.