
Привет. Меня зовут Марко (я системный программист в 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.
