Уменьшение накладных расходов для утилит на golang

    Цель работы — сократить накладные расходы на хранение большого количества утилит, написанных на golang.

    Один из побочных эффектов статической компиляции golang — относительно большие накладные расходы на хранение рантайма и всех используемых библиотек внутри каждого исполняемого файла. Например небольшая утилитка, которая только и делает что обращается через сеть к серверу и выполняет простые полученные команды — весит 5.5Мб.
    Когда такая утилитка одна — это в современных условиях это еще не страшно. Когда утилиты накапливаются и их становится уже несколько десятков или сотен — чисто по-человечески становится жалко сотен мегабайтов, утекающих «вникуда».

    Для решения этой проблемы я написал библиотеку multiex, которой и делюсь с сообществом. С её помощью можно объединить несколько программ в один исполняемый файл с минимальными изменениями внутри кода программ и без изменения внешнего поведения. Мысль была взята у busybox — все программы компилируются в один файл, а выполняемый код выбирается при запуске, исходя из имени запускаемого файла.



    Для подключения программ нужно вызвать функцию-регистратор, например вот так:
    func f1(){
      if os.Args[1] == "asdf" {
        println("ok")
      }
    }
    
    multiex.Register(multiex.ExecutorDescribe{Name: "test", Function: f1})
    


    В структуре передается имя программы — если исполняемый файл вызывается с именем test, то будет вызвана функция f1. Дальше идет обычная работа функции без изменений, в т.ч. можно обычным образом разбираться переданные аргументы командной строки.
    ./test asdf

    ./test может быть именем исполняемого файла, жесткой или символьной ссылкой.

    Кроме этого можно вызвать функцию f1 если первым параметром исполняемого файла будет --multiex-command=test. При этом параметр --multiex-command=test будет удален и функция f1 так же сможет выполнять разбор своих аргументов как обычно.
    ./any --multiex-command=test asdf

    В библиотеке уже реализована одноименная программа с названием multiex, на данный момент она умеет:
    1. Выводить краткую справку о том что это за файл и какие программы в него включены — если файл вызван с непредусмотренным именем
    2. Создавать символьные ссылки на все программы, входящие в исполняемый файл. Ссылки создаются в той же папке, где лежит сам исполняемый файл.
    ./any --multiex-command=multiex install

    При этом ссылка на сам multiex не создается.

    В github.com/rekby/multiex-example показан пример подключения программ:
    1. Регистрация функций непосредственно в основном компилируемом модуле. При этом способе главный сборочный модуль знает о конкретных включаемых функциях и при добавлении новой функции — нужно добавлять её перечисление в список регистрации. В сами включаемые программы при этом нет необходимости добавлять зависимость от multiex — достаточно иметь экспортируемую функцию Main.
    2. В сборочном модуле импортируются подключаемые модули, а каждый модуль сам регистрирует свои функции в процессе инициализации. Тут сборочный модуль знает только список включаемых модулей, а не каждую функцию. Каждый модуль сам определяет какие функции он будет экспортировать и для экспорта новой функции достаточно поправить только модуль с этой функцией.
    3. Естественно оба способа можно комбинировать (в примере как раз показан комбинированный вариант).

    При этом каждая программа может сохранять свою независимость и при необходимости компилироваться отдельно от остальных в свой собственный файл и выполняться как обычно. Это делается добавкой подпапки main в исходники программы. Причем этот файл может быть одинаковым для всех проектов и просто копироваться без изменений везде где потребуется.

    github.com/rekby/multiex
    Поделиться публикацией

    Похожие публикации

    Комментарии 52
      0
      Сложно представить, конечно, сотню каких-то утилиток, но даже если и так, то в наше-то время дисковое пространство уже давно не проблема.
      Кроме того, если размер очень критичен, то почему не скомпилировать через gccgo?
      Ещё с большим размером может быть проблема с версионным контролем. Но опять же, в нём совершенно негоже хранить скомпилированные бинарники.
        0
        Бинарники при исполнении мапятся в оперативную память, причем разные инстансы одного бинарника в норме все ссылаются на одну и ту же память. Чем бинарник меньше — тем меньше потребление оперативки и меньше сопутствующие накладные расходы, за счет меньшего числа cache miss работать все будет немного быстрее.

        Но в целом экономия на спичках конечно.
        0
        А нельзя чтоли рантайм вынести в динамически подключаемую библиотеку?
          –2
          Добро пожаловать в мир golang'а.

          И после этого ещё спрашивают, почему go не заменит Си.
            0
            А он должет заменить Си?
              –4
              Были поползновения. Быстро закончилось, но как витающий стереотип — местами замечаю.
                0
                Ну как «убийцы» себя позиционируют, по моему, только Rust и Dlang. И то — «Убийцы C++».
                А Go если и будет убийцей, то, скорее, всяких PHP/Python/Perl и иже с ними.
                  –2
                  Ну, чисто формально, компилируемые языки всё-таки классом выше (ниже?) чем интерпретируемые с точки зрения операционной системы. Потому что ELF это ELF, а скрипты — это просто текстовые файлы. Ни suid'а, ни адекватного использования в роли init/udev/etc.

                  С другой стороны, потеснить perl/python в качестве «bash++» не получится по той же причине — компилять надо.
                    +1
                    Ну компилять первый раз можно и при запуске — не смертельно.

                    #!/usr/bin/gorun

                    package main

                    func main() {
                    println(«Hello world!»)
                    }

                    ufm@msi ~ $ time ./t.go
                    Hello world!

                    real 0m0.036s
                    user 0m0.023s
                    sys 0m0.015s
                    ufm@msi ~ $ time ./t.go
                    Hello world!

                    real 0m0.005s
                    user 0m0.000s
                    sys 0m0.005s

                    Для сравнения:
                    ufm@msi ~ $ time python -c 'print «Hello world!»'
                    Hello world!

                    real 0m0.008s
                    user 0m0.004s
                    sys 0m0.004s

                    ufm@msi ~ $ time php -r 'echo «Hello world!»;'
                    Hello world!
                    real 0m0.022s
                    user 0m0.015s
                    sys 0m0.011s
                      0
                      О, а вот в такой форме, да, сможет стать на один уровень.
            +1
            это было сделано намеренно, чтобы скопировал файл куда угодно и он работает, то есть когда на один сервер задеплоили 5 приложений у них не будет сломанных зависимостей. Хотя намечается тенденция сделать поддержку создания библиотек на go (в основном из-за android и там это как-то даже работает)
            Ну и сами библиотеки (пакеты) go поставляются в виде исходников, поэтому плодить библиотеки (so, dll) не имеет смысла
            Тажке компилятор go делает очень много inline оптимизаций, из-за этого один и тот же код может встречаться как в inline виде, так и обычном.
            0
            Попробуйте сжимать испольняемые бинари upx'ом.
              0
              А под *nix?
                +1
                upx работает для разных архитектур linux и работает для «386-х» бинарников для маков и freebsdb:
                upx.sourceforge.net/, таблица «Supported executable formats».
                0
                мимо
                  0
                  Попробовал. Штатно не работает — ругается на неправильный размер файла.

                  Есть проект, который правит бинарник перед сжатием upx, но с подробным устройством бинаников я не знаком и неизвестно насколько такие правки переносимы и будет ли это дополнительным источником багов когда-то потом, когда я уже привыкну что всё работает.
                    0
                    У меня отлично сжимает бинарники Go программ и на OSX, и под Linux.
                      0
                      Что я делаю не так?
                      Обычно работаю с go 1.2.1, для проверки скачал последнюю версию. upx — из репозитория ubuntu 14.04

                      Заголовок спойлера
                      cd utils
                      GOROOT=/home/rekby/Downloads/go/ GOTOOLDIR=/home/rekby/Downloads/go/pkg/tool/linux_amd64/ /home/rekby/Downloads/go/bin/go build
                      upx utils 
                      
                      
                                             Ultimate Packer for eXecutables
                                                Copyright (C) 1996 - 2013
                      UPX 3.91        Markus Oberhumer, Laszlo Molnar & John Reiser   Sep 30th 2013
                      
                              File size         Ratio      Format      Name
                         --------------------   ------   -----------   -----------
                      upx: utils: EOFException: premature end of file                                
                      
                      Packed 1 file: 0 ok, 1 error.
                      

                        0
                        bash-3.2$ cat server.go
                        package main
                        
                        import (
                        	"log"
                        	"fmt"
                        	"net/http"
                        )
                        
                        func main() {
                        	http.HandleFunc("/", func (rw http.ResponseWriter, req *http.Request) {
                        		log.Printf("new request")
                        		fmt.Fprintf(rw, "Hello %s", "World")
                        	})
                        	log.Printf("server starting")
                        	http.ListenAndServe(":8080", nil)
                        }
                        


                        bash-3.2$ go build -o server server.go
                        
                        bash-3.2$ ls -l
                        total 11320
                        -rwxr-xr-x  1 miolini  staff  5790996 Feb 24 15:00 server
                        -rw-r--r--  1 miolini  wheel      278 Feb 24 15:00 server.go
                        
                        bash-3.2$ upx --lzma server
                                               Ultimate Packer for eXecutables
                                                  Copyright (C) 1996 - 2013
                        UPX 3.91        Markus Oberhumer, Laszlo Molnar & John Reiser   Sep 30th 2013
                        
                                File size         Ratio      Format      Name
                           --------------------   ------   -----------   -----------
                           5790996 ->   1204224   20.79%   Mach/AMD64    server
                        
                        Packed 1 file.
                        
                        bash-3.2$ ls -l
                        total 2360
                        -rwxr-xr-x  1 miolini  staff  1204224 Feb 24 15:00 server
                        -rw-r--r--  1 miolini  wheel      278 Feb 24 15:00 server.go
                        
                        
                          0
                          Проверил подробнее — при компилировании с GOARCH=386 — действительно сжимается и с виду работает — попробую использовать в работе.

                          При компилировании 64-битного бинарника — ошибка.
                            0
                              0
                              Да, выше я говорил именно про него.
                                0
                                Вы говорили, но ссылку не дали. Исправление бинарников работает нормально, к багам не приводит.
                    0
                    Угу, или хранить только сжатые исходники, а компилировать прямо на RAM-диск, в /tmp каталог )))
                      0
                      Причем тут исходники?
                        0
                        Странный вопрос. Потому, что лучше сжимается.
                    0
                    Если уж стали делать «как в busybox» — то почему не использовали их вариант выбора команды до конца?

                    Иными словами, почему используется ./any --multiex-command=test asdf вместо ./any test asdf?
                      0
                      Это намеренное отступление — для унификации.

                      параметр --multiex-command включается глобально и работает с любым именем вызова, а не только с неизвестным.

                      Т.е. например если есть экспортированная функция test, которая принимает параметр run и собственно экспортированная функция run. Что должна выполнить команда ./test run?
                        0
                        Если имя файла совпадает с именем функции — выполняться должна именно функция. Формат с указанием функции первым аргументом — только для исполнимого файла с «основным» именем.

                        PS представил себе:
                        cat --busybox-command=bash -c ...
                        

                        Интересно, если бы кто-нибудь так и сделал, привело бы это к новым необычным уязвимостям?
                          0
                          Тут есть заметное отличие от busybox — busybox он всегда один, а вот такие multiex-ов может быть несколько, поэтому например он и свой модуль не инсталлирует по умолчанию и основного названия файла у него тоже нет — каждый разработчик может скомпилировать его по-своему и разные имена назначать кто символьными ссылками, кто жесткими. Как в этих условиях отличить главный вызов от неглавного (а просто с непредусмотренным именем) не понятно.

                          При необходимости такого же поведения первого параметра как у busybox можно написать модуль, вроде:

                          func asbb(){
                            os.Args = os.Args[1:]
                            multiex.Main()
                          }
                          
                          func main(){
                          multiex.Register(multiex.ExecutorDescribe{Name: "asbb", Function: asbb})
                          }
                          


                          Тогда при ./asbb cat 123 будет вызвана программа cat 123.

                          Про уязвимости — согласен, с sudo могут быть проблемы. Тут не досмотрел (в моём контексте это не актуально). Возможно такой глобальный параметр стоит сделать только опционально включаемым.
                            0
                            подумал, решил что такой параметр для принудительного вызова команд действительно в некоторых сценариях (например sudo) может приводить к уязвимостям, кроме того это менее удобно чем просто первый аргумент, как в busybox.

                            Переделал.

                            Заодно еще и русский перевод везде добавил — посмотрим насколько это будет удобно для русскоязычных коллег.
                        +1
                        Давно заметил, что Go-бинарники неплохо strip-аются, процентов на 20-25 худеют.
                          0
                          Ага, но не всегда работают потом, особенно reflection ;)
                          –2
                          Go перемудрили со своим рантаймом. Почему бы не сделать как в том-же языке Crystal где бинарник линкуентся только лишь с системными библиотеками и не содержит никакого мусора. Как результат простой HTTP сервер на Кристале занимает десятки килобойт а на Go мегабайты.

                          Зачем Го хранить весть этот мусор в бинарниках?
                            0
                            Что вы подразумеваете под «мусором»?

                            Дизайн и решения каждого языка — это всегда компромиссы между десятками вещей — памятью и скоростью, тем, что разрешено программисту, а что отдано на откуп компилятору и так далее. Выбор в пользу одного или другого нюанса выбирает с точки зрения реалий, опыта и здравого смысла.
                            Если 20 лет еще было актуально экономить каждый кило(мега)байт дискового пространства, в ущерб простоте деплоя и кросскомпиляции, то сейчас мир изменился — лишние 5 мб на диске — это буквально ничто, зато профит от упрощения процедур деплоя и кросскомпиляции — колоссальный.

                            Те, кто в 2015-м требуют бинарников размеров в килобайты любой ценой — что-то важное упускают, имхо.
                              0
                              Например чтобы один и тот же бинарник работал на любом линуксе, независимо от окружающих библиотек — это очень удобно.
                                0
                                1.5 уже умеет линковать динамически. Понимаю, что комментарий устарел, но мало ли)

                                image
                                  0
                                  а это вы как так собирали, что размер 9Кб получился?
                                  я разные режимы сборки попробовал — везде около 1Мб минимум. Если использовать пакет net (например net/http) — бинарник действительно получается с динамической линковкой, но размер всё равно большой.

                                  P.S. и заодно может знаете способ принудительно собирать статический бинарник даже при использовании сети? очень уж хорошо в прежних версиях иметь бинарник без зависимостей.
                                  В моём случае нагрузки оч. маленькие и как раз простое получение бинарника без зависимостей, да еще и с простой кросс-компиляцией для меня одно из самых больших преимуществ go перед другими языками.
                                    0
                                    Надо признать, что про динамическую линковку с net/http только узнал, посмотрел через ldd — действительно O_o.

                                    По поводу того, как собирал:

                                    go install -buildmode=shared std
                                    
                                    go build -linkshared hello.go
                                    


                                    Для примера, helloworld ниже после компиляции у меня занимает ~15KB, но сервер посложнее с mgo и gin может и мегабайт-два занимать. В теории, если эти библиотеки скомпилировать с -buildmode=shared, то тоже можно прилинковать динамически. На практике пока особо не вникал, в golang только неделю как пришел.

                                    package main
                                    
                                    import (
                                    	"io"
                                    	"log"
                                    	"net/http"
                                    )
                                    
                                    func main() {
                                    	http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
                                    		io.WriteString(w, string("Hello world"))
                                    	})
                                    
                                    	log.Print("Server started at http://localhost:8080")
                                    	log.Fatal(http.ListenAndServe(":8080", nil))
                                    }
                                    


                                      0
                                      да, тоже получилось — я
                                      go install -buildmode=shared std

                                      не делал до этого.

                                      Но есть но:
                                      1. При распространении придется таскать за собой скомпилированный libstd.so, а это в данный момент 37МБ.
                                      2. Снова возвращаемся к зависимостям что для этого бинарника нужна одна версия libstd, для другого — другая и т.п.

                                      Так что меня даже больше интересует какой-то способ принудительно собирать статический бинарник даже при работе с сетью. Чтобы dns-запросы как раньше работали «медленно и неэффективно», в отдельном процессе (не горутине) и т.п., но без внешних зависимостей.
                                        +1
                                        Ну здесь уже палка о двух концах. К примеру на сервере, где крутится сотня-другая микросервисов — имеет смысл вынести libstd. Ну и если go все же получит распространение, и в репозитариях операционок будет достаточное количество приложений с зависимостью от пакета libstd, то он установится только с первой программой.

                                        По поводу второго вопроса, попробуйте сделать так (нагуглил):

                                        go build -ldflags "-linkmode external -extldflags -static" hello.go
                                        
                                            +1
                                            К сожалению не знаком с windows
                                              +1
                                              Значит вам ближе будет эта ссылка: ru.wikipedia.org/wiki/Dependency_hell
                                                +1
                                                Там же и решение приведено: «Хороший пакетный менеджер».
                                                  –1
                                                  Сферический и в вакууме :-)

                                                  Although these repositories are often huge it is not possible to have every piece of software in them, so dependency hell can still occur. In all cases, dependency hell is still faced by the repository maintainers.


                                                  А вот этот кусок мне особенно нравится:

                                                  В таком случае удовлетворение долгой цепи зависимостей пакетов даже может привести, например, к запросу другой версии библиотеки glibc, одной из крайне важных, основополагающих системных библиотек. Если это случается пользователю будет предложено удалить тысячи пакетов, что по сути будет равноценно удалению, например, 80% системы, включая графические оболочки и сотни различных программ.
                                                    0
                                                    Насколько я помню, сейчас уже ничего не мешает ставить две разные версии glibc в систему.
                                          0
                                          По поводу сборки — отлично, как раз то, что нужно.
                                            0
                                            По поводу stdlib я как раз про то же что и vintage парой коментов выше.
                                            Для каждого бинарника с динамической линковкой потребуется stdlib, собранный из той же версии исходников что и бинарник. Это не зависит от операционки — в Linux будет так же.

                                            Т.е. например включат в дистрибутив stdlib от go 1.5, потом кто-то что-то напишет кто-то что-то на 1.5.1 и нужен уже новый stdlib, т.к. там код поменялся (ошибки поправлены), то же с 1.5.2, потом 1.6 — там уже и реализация runtime скорее всего поменяется и т.д.
                                            Т.е. рассчитывать на системную библиотеку не получится и runtime придётся всё равно надо таскать с собой.
                                            т.е. это может иметь смысл в случае как я описывал — когда сотня мелких утилит по три строчки в каждой, они все вместе компилируются и потом копируются на целевые серверы и тогда с ними можно в нагрузку дать stdlib, правда придется еще учитывать настройки окружения — чтобы подцеплялся именно этот stdlib.so, а не какой-то еще.
                                              0
                                              Версии библиотек неплохо разруливаются пакетными менеджерами ОС. Если библиотеки обратно совместимы, то достаточно их переодически обновлять. Мне кажется проблема надумана.

                                              Если же компилировать микросервисы в один экзешник, то теряются многие плюсы такой структуры. Например, обновления (или, не дай бог, ошибки) одного микросервиса, будут затрагивать все остальные, чего очень хотелось бы, особенно с проектами в активной фазе разработки.
                                                0
                                                Насколько я понял docs.google.com/document/d/1nr-TQHw_er6GOQRsF6T43GGhFDelrAP0NqSS_00RgZQ/edit?pli=1 раздел Multiples copies of a Go package — пока что обратной совместимости нет. Она может появиться когда-то потом, но без конкретных планов.

                                                Если же компилировать микросервисы в один экзешник, то теряются многие плюсы такой структуры. Например, обновления (или, не дай бог, ошибки) одного микросервиса, будут затрагивать все остальные, чего очень хотелось бы, особенно с проектами в активной фазе разработки.


                                                Об этом я думал, но не всё так плохо:
                                                1. Работают микросервисы независимо друг от друга, хоть и находятся в одном бинарнике — кодовая база у них раздельна, зависимостей нет. Просто они разделяют общие внешние зависимости. Т.е. если в коде одного из них есть ошибка и он будет падать — на других микросервисах это не отразится, т.к. будут работать другие копии процесса, где ошибочный код не выполняется, т.к. работает другой микросервис/команда.
                                                При запуске каждой внешней команды запускается только одна команда/сервис. С другими она не пересекается. Единственное исключение — код из init-функций выполняется до вызова main в любом случае из всех модулей.

                                                2. Есть проблема что при обновлении бинарника обновляется сразу всё что в него скомпилировано. Но это вполне решается на уровне сборки — т.е. можно разрабатывать микросервисы/команды отдельно, а в общем сборщике для публикации внешним инструментом фиксировать версии, которые туда попадают, независимо от того на каких этапах разработка происходит. Это например легко решается через системы контроля версий: submodules в git и externals на конкретную ревизию в SVN. Обновлять можно так же по одному сервису за раз — как и обычная публикация обычно происходит.
                                                Тогда бинарник включает в себя нужные версии микросервисов — собственно когда мы просто копируем этот набор команд/микросервисов на целевую систему (в бинарниках) — там тоже получается свой набор.
                                                Просто тут он фиксируется на этапе сборки, а не на этапе публикации.

                                Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                                Самое читаемое