[Перевод] История одного толстого бинарника

enter image description here


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

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

© Habrahabr.ru