[Перевод] История одного толстого бинарника
Привет. Меня зовут Марко (я системный программист в Badoo). И я представляю вашему вниманию перевод поста по Go, который мне показался интересным. Go действительно ругают за толстые бинарники, но при этом хвалят за статическую линковку и за удобство выкладки единственного файла. Если на современных серверах толстые бинарники — не проблема, то на встраиваемых системах — еще как. Автор описывает свою историю борьбы с ними в Go.
Маленький размер файлов важен для приложений, работающих в условиях очень ограниченных ресурсов. В этой статье мы рассмотрим создание программы-агента, которая должна работать на разных маломощных устройствах. Ресурсы памяти и процессора у них будут невелики, и я даже не могу предсказать, насколько.
Бинарники Go отличаются маленьким размером и самодостаточностью: создав программу на Go, вы получаете единственный двоичный файл, в котором находится всё необходимое. Сравните с такими платформами, как Java, Node.js, Ruby и Python, где ваш код занимает лишь небольшую часть приложения, а всё остальное — куча зависимостей, которые тоже приходится упаковывать, если хочется получить самодостаточный пакет.
Несмотря на такое важное удобство, как возможность создавать самодостаточные бинарники, в Go нет встроенного инструментария, помогающего оценить размеры зависимостей, чтобы разработчики могли принимать взвешенные решения о том, включать эти зависимости в файл или нет.
Инструмент gofat
поможет разобраться с размерами зависимостей в вашем Go-проекте.
Создание IoT-агента
Я немного расскажу о том, как мы продумывали и создавали один из наших сервисов — IoT-агент, который будет развёртываться на маломощных устройствах по всему миру. И рассмотрим его архитектуру с операционной точки зрения.
Пример кода можно скачать отсюда: https://github.com/jondot/fattyproject
Во-первых, нам нужна хорошая CLI-эргономика, поэтому воспользуемся kingpin
— это POSIX-совместимая библиотека CLI-флагов и опций (мне настолько нравится эта библиотека, что я использовал её во многих своих проектах). Но на самом деле я воспользуюсь своим проектом go-cli-starter
, включающим в себя эту библиотеку:
$ git clone https://github.com/jondot/go-cli-starter fattyproject
Cloning into 'fattyproject'...
remote: Counting objects: 55, done.
remote: Total 55 (delta 0), reused 0 (delta 0), pack-reused 55
Unpacking objects: 100% (55/55), done.
Раз наша программа — это агент, то она должна работать постоянно. В качестве примера для этого мы воспользуемся циклом, который бесконечно выполняет ерундовую операцию.
for {
f := NewFarble(&Counter{})
f.Bumple()
time.Sleep(time.Second * 1)
}
Во время длительной работы в памяти накапливается всякий хлам — небольшие утечки памяти, забытые дескрипторы открытых файлов. Но даже крохотная утечка может превратиться в гигантскую, если приложение работает безостановочно годами. К счастью, в Go есть встроенные метрики и средство контроля за состоянием системы — expvars
. Это очень поможет при анализе внутренней кухни агента: поскольку он должен длительное время работать без остановки, время от времени мы будем анализировать его состояние — потребление процессора, циклы сбора мусора и так далее. Всё это будут для нас делать expvars
и весьма удобный для решения подобных задач инструмент expvarmon
.
Для использования expvars
нам понадобится волшебный импорт. Волшебный — потому что в ходе импорта будет добавлен хэндлер к имеющемуся HTTP-серверу. Для этого нам нужен работающий HTTP-сервер из net/http
.
import (
_ "expvar"
"net/http"
:
:
go func() {
http.ListenAndServe(":5160", nil)
}()
Раз наша программа превращается в сложный сервис, можем добавить ещё и библиотеку логирования с поддержкой уровней, чтобы получать информацию об ошибках и предупреждениях, а также понимать, когда программа работает штатно. Для этого воспользуемся zap (от компании Uber).
import(
:
"go.uber.org/zap"
:
logger, _ := zap.NewProduction()
logger.Info("OK", zap.Int("ip", *ip))
Сервис, безостановочно работающий на удалённом устройстве, который вы не контролируете и, вероятнее всего, не сможете обновлять, должен быть крайне устойчивым. Так что целесообразно заложить в него гибкость. Например, чтобы он мог исполнять кастомные команды и скрипты, то есть обеспечить механизм изменения поведения сервиса без его переразвёртывания или перезапуска.
Добавим средство запуска произвольного удалённого скрипта. Хотя это и выглядит подозрительно, но если это ваш агент или сервис, то вы можете подготовить встроенную runtime-песочницу для запуска кода. Чаще всего встраивают runtime-среды на JavaScript и Lua.
Мы воспользуемся встраиваемым JS-движком otto.
import(
:
"github.com/robertkrimen/otto"
:
for {
:
vm.Run(`
abc = 2 + 2;
console.log("\nThe value of abc is " + abc); // 4
`)
:
}
Если предположить что контент, передающийся в Run
, мы получаем извне, мы получили сложный и самообновляемый IoT-агент!
Разбираемся с зависимостями двоичного файла Go
Итак, к чему мы пришли.
$ ls -lha fattyproject
... 13M ... fattyproject*
Будем считать, что нам нужны все добавленные зависимости, но в результате размер двоичного файла подбирается к 12 мегабайтам. Хотя это немного по сравнению с другими языками и платформами, однако с учётом скромных возможностей IoT-оборудования целесообразно будет уменьшить размер файла и затраты вычислительных ресурсов.
Давайте выясним, как добавляются зависимости в наш двоичный файл.
Для начала разберёмся с хорошо известным бинарником. GraphicsMagick — современная вариация популярной системы обработки изображений ImageMagick
. Вероятно, она у вас уже установлена. Если нет, то под OS X это можно сделать с помощью brew install graphicsmagick
.
otool
— альтернатива инструменту ldd, только под OS X. С его помощью мы можем проанализировать двоичный файл и узнать, с какими библиотеками он слинкован.
$ otool -L `which convert`
/usr/local/bin/convert:
/usr/local/Cellar/imagemagick/6.9.3-0_2/lib/libMagickCore-6.Q16.2.dylib (compatibility version 3.0.0, current version 3.0.0)
/usr/local/Cellar/imagemagick/6.9.3-0_2/lib/libMagickWand-6.Q16.2.dylib (compatibility version 3.0.0, current version 3.0.0)
/usr/local/opt/freetype/lib/libfreetype.6.dylib (compatibility version 19.0.0, current version 19.3.0)
/usr/local/opt/xz/lib/liblzma.5.dylib (compatibility version 8.0.0, current version 8.2.0)
/usr/lib/libbz2.1.0.dylib (compatibility version 1.0.0, current version 1.0.5)
/usr/lib/libz.1.dylib (compatibility version 1.0.0, current version 1.2.5)
/usr/local/opt/libtool/lib/libltdl.7.dylib (compatibility version 11.0.0, current version 11.1.0)
/usr/lib/libSystem.B.dylib (compatibility version 1.0.0, current version 1226.10.1)
Из списка можно вычленить и размер каждой зависимости:
$ ls -lha /usr/l/.../-0_2/lib/libMagickCore-6.Q16.2.dylib
... 1.7M ... /usr/.../libMagickCore-6.Q16.2.dylib
Можем ли мы таким образом получить достаточно полное представление о любом двоичном файле? Очевидно, что ответ — «нет».
По умолчанию Go линкует зависимости статично. Благодаря этому мы получаем единственный самодостаточный двоичный файл. Но это также означает, что otool
, как и любой другой подобный инструмент, будет бесполезен.
$ cat main.go
package main
func main() {
print("hello")
}
$ go build && otool -L main
main:
Если всё же пытаться разобрать двоичный файл Go на его зависимости, то нам придётся воспользоваться инструментом, который понимает формат этих двоичных файлов. Давайте поищем что-то подходящее.
Для получения списка доступных инструментов воспользуемся go tool
:
$ go tool
addr2line
api
asm
cgo
compile
cover
dist
doc
fix
link
nm
objdump
pack
pprof
trace
vet
yacc
Можем сразу обратиться к исходным кодам этих инструментов. Возьмём, к примеру, nm, и посмотрим его документацию пакета. Я умышленно упомянул этот инструмент. Как оказалось, возможности nm
очень близки к тому, что нам нужно, но этого всё-таки недостаточно. Он умеет выводить список символов и размеров объектов, но всё это бесполезно, если мы пытаемся составить общее представление о зависимостях двоичного файла.
$ go tool nm -sort size -size fattyproject | head -n 20
5ee8a0 1960408 R runtime.eitablink
5ee8a0 1960408 R runtime.symtab
5ee8a0 1960408 R runtime.pclntab
5ee8a0 1960408 R runtime.esymtab
4421e0 1011800 R type.*
4421e0 1011800 R runtime.types
4421e0 1011800 R runtime.rodata
551a80 543204 R go.func.*
551a80 543204 R go.string.hdr.*
12d160 246512 T github.com/robertkrimen/otto._newContext
539238 100424 R go.string.*
804760 65712 B runtime.trace
cd1e0 23072 T net/http.init
5e3b80 21766 R runtime.findfunctab
1ae1a0 18720 T go.uber.org/zap.Any
301510 18208 T unicode.init
5e9088 17924 R runtime.typelink
3b7fe0 16160 T crypto/sha512.block
8008a0 16064 B runtime.semtable
3f6d60 14640 T crypto/sha256.block
Хотя применительно к самим зависимостям указанные размеры (вторая колонка) могут быть точны, но в целом мы не можем просто взять и сложить эти значения.
Gofat
Остался последний трюк, который должен сработать. Когда вы компилируете свой двоичный файл, Go генерирует промежуточные файлы для каждой зависимости, прежде чем статически слинковать их в единый файл.
Представляю вашему вниманию gofat
— shell-скрипт, который является комбинацией кода на Go и некоторых Unix-инструментов. Он анализирует размеры зависимостей в двоичных файлах Go:
#!/bin/sh
eval `go build -work -a 2>&1` && find $WORK -type f -name "*.a" | xargs -I{} du -hxs "{}" | gsort -rh | sed -e s:${WORK}/::g
Если торопитесь, то просто скопируйте или скачайте этот скрипт и сделайте его исполняемым (chmod +x
). Потом запустите скрипт без каких-либо аргументов в директории своего проекта, чтобы получить информацию о его зависимостях.
Давайте разберёмся с этой командой:
eval go build -work -a 2>&1
Флаг -a говорит Go, чтобы он игнорировал кэш и собирал проект с нуля. В этом случае все зависимости будут пересобраны принудительно. Флаг –work выводит рабочую директорию, так что мы можем её проанализировать (спасибо разработчикам Go!).
find $WORK -type f -name "*.a" | xargs -I{} du -hxs "{}" | gsort -rh
Затем мы с помощью инструмента find
находим все файлы *.a
, представляющие собой наши скомпилированные зависимости. Затем передаём все строки (месторасположения файлов) в xargs
. Эта утилита позволяет применять команды к каждой передаваемой строке — в нашем случае в du
, который получает размер файла.
Наконец, воспользуемся gsort
(GNU-версия sort) для выполнения сортировки размеров файлов в обратном порядке.
sed -e s:${WORK}/::g
Убираем отовсюду префикс папки WORK и выводим на экран очищенную строку с данными по зависимости.
Переходим к самому интересному: что же занимает 12 Мб в нашем двоичном файле?
Сбрасываем вес
В первый раз запускаем gofat
применительно к нашему игрушечному проекту с IoT-агентом. Получаем такие данные:
2.2M github.com/robertkrimen/otto.a
1.8M net/http.a
1.4M runtime.a
960K net.a
820K reflect.a
788K gopkg.in/alecthomas/kingpin.v2.a
668K github.com/newrelic/go-agent.a
624K github.com/newrelic/go-agent/internal.a
532K crypto/tls.a
464K encoding/gob.a
412K math/big.a
392K text/template.a
392K go.uber.org/zap/zapcore.a
388K github.com/alecthomas/template.a
352K crypto/x509.a
344K go/ast.a
340K syscall.a
328K encoding/json.a
320K text/template/parse.a
312K github.com/robertkrimen/otto/parser.a
312K github.com/alecthomas/template/parse.a
288K go.uber.org/zap.a
232K time.a
224K regexp/syntax.a
224K regexp.a
224K go/doc.a
216K fmt.a
196K unicode.a
192K compress/flate.a
172K github.com/robertkrimen/otto/ast.a
172K crypto/elliptic.a
156K encoding/asn1.a
152K os.a
136K strconv.a
128K os/exec.a
128K github.com/Sirupsen/logrus.a
128K flag.a
112K vendor/golang_org/x/net/http2/hpack.a
104K strings.a
104K net/textproto.a
104K mime/multipart.a
Если поэкспериментируете, то заметите, что с gofat
время сборки значительно увеличивается. Дело в том, что мы запускаем сборку в режиме -a
, при котором всё пересобирается заново.
Теперь мы знаем, сколько места занимает каждая зависимость. Закатаем рукава, проанализируем и предпримем действия.
1.8M net/http.a
Всё, что связано с обработкой HTTP, тянет на 1,8 Мб. Пожалуй, можно это выкинуть. Откажемся от expvar
, вместо этого будем периодически сбрасывать в лог-файл критически важные параметры и информацию о состоянии программы. Если это делать часто, то всё будет хорошо.
Обновление: С выходом Go 1.8 net/http стал весить 2,2 Мб.
788K gopkg.in/alecthomas/kingpin.v2.a
388K github.com/alecthomas/template.a
А это большой сюрприз: около 1 Мб занимает весьма удобная POSIX-фича для парсинга флагов. Можно от неё отказаться и использовать пакет из стандартной библиотеки, или даже вообще покончить с флагами и считывать конфигурацию из переменных окружения (а это тоже займёт какой-то объём).
Newrelic
добавляет ещё 1,3 Мб, так что его тоже можно отбросить:
668K github.com/newrelic/go-agent.a
624K github.com/newrelic/go-agent/internal.a
`Zap тоже выкинем. Воспользуемся стандартным пакетом для логирования:
392K go.uber.org/zap/zapcore.a
Otto
, будучи встраиваемым JS-движком, весит немало:
2.2M github.com/robertkrimen/otto.a
312K github.com/robertkrimen/otto/parser.a
172K github.com/robertkrimen/otto/ast.a
В то же время logrus
занимает мало места для такой многофункциональной библиотеки журналирования:
128K github.com/Sirupsen/logrus.a
Можно оставить.
Заключение
Мы нашли способ вычислить размеры зависимостей в Go и сэкономили около 7 Мб. И решили, что не будем использовать определённые зависимости, а вместо них возьмем аналоги из стандартной библиотеки Go.
Более того, скажу, что, если сильно постараться и поэкспериментировать с набором зависимостей, то мы можем ужать наш двоичный файл с изначальных 12 Мб до 1,2 Мб.
Заниматься этим не обязательно, потому что зависимости в Go и так невелики по сравнению с другими платформами. Но вам обязательно нужно иметь под рукой инструменты, которые помогут лучше понимать то, что вы создаёте. И если вы разрабатываете ПО для окружений с весьма ограниченными доступными ресурсами, то одним из таких инструментов может быть gofat
.
P.S.: если хотите поэкспериментировать еще, вот референсный репозиторий: https://github.com/jondot/fattyproject.