Перевод статьи инженера компании AeroFS о переводе их микросервис-архитектуры с Java на Go.

TLDR; Портировав некоторые наши микросервисы с Java на Go, мы уменьшили использование памяти на несколько порядков.

В начале была Java





Архитектура AeroFS Appliance состоит из многих микросервисов, и подавляющее большинство из них написаны на Java. Это никогда не создавало нам проблем, вся система обслуживает тысячи пользователей от разных клиентов без каких-либо проблем с производительностью.

Однако после нашего перехода на Docker, мы отметили резкое повышение использования памяти нашей системой. Опробовав несколько модных утилит для мониторинга докеров, мы остановились на этом несколько гиковском, но очень полезном скрипте:
for line in `docker ps | awk '{print $1}' | grep -v CONTAINER`; do     echo $(( `cat /sys/fs/cgroup/memory/docker/$line*/memory.usage_in_bytes` / 1024 / 1024 ))MB         $(docker ps | grep $line | awk '{printf $NF" "}') ; done | sort -n

Он выводит список запущенных контейнеров, отсортированных по количеству используемой резидентной памяти. Пример вывода скрипта:
46MB web
66MB verification
74MB openid
82MB havre
105MB logcollection
146MB sp
181MB sparta

Исследовав проблему, мы обнаружили, что некоторые Java сервисы использовали на удивление много памяти, зачастую никак не коррелируя с их сложностью или отсутствием таковой. Мы выделили несколько главных факторов, которые приводили к такому использованию памяти.
  1. увеличение количества запущенных JVM, так как каждый tomcat servlet бежал в отдельном контейнере
  2. урезанная возможность для нескольких JVM разделять read-only-память: саму JVM, все зависимые библиотеки, и, конечно, множество JAR-ов, используемых разными сервисами
  3. изоляция памяти в некоторых случаях сбивала с толку эвристику расчета памяти, что приводило к большим аллокациям кеша в некоторых сервисах


Будучи человеком старой закалки, привыкшим писать на ассемблере для Z80 для устройств с 64кб памяти на борту, меня очень воодушевляла мысль о том, как бы вернуть сотни мегабайт ценной оперативки. По счастливому случаю, наш следующий хакатон был всего через несколько дней, и это был отличный шанс, чтобы сфокусироваться на проблеме и отличное оправдание, чтобы попробовать новые инструменты.

Кодовое имя: Greasefire


(прим. переводчика: greasefire — «пожар, возникший от возгорания масла/жира на кухонной плите»)
Моей главной целью этого хакатона было прожечь слои метафорического жира, чтобы уменьшить общее количество используемой памяти AeroFS системы.

В частности, моими критериями успеха были:
  • использование CPU не должно заметно возрасти
  • стабильность и безопасность памяти должно остаться
  • использование резидентной памяти должно уменьшиться в 2 или более раз

В духе хакатона, я также хотел попробовать новые языки и инструменты, поэтому просто подправить существующие сервисы было не вариант.

И чтобы увеличить вероятность получения результата, который можно будет показать и, возможно, даже задеплоить, было важно выбрать адекватного размера и сложности цель. Очевидным выбором стал сервис TeamServer probe (team-servers на странице appliance status) — маленький tomcat servlet с единственным HTTP-вызовом и очень ясной внутренней логикой.

В итоге, цель была следующая — создать сервер:
  • с полностью идентичным API
  • упакованный в docker-имидж


Пробуем новые инструменты


Чтобы уложиться в критерии по CPU и памяти, основными кандидатами стали компилируемые языки, созданные для системного программирования. И хотя хакатон не подразумевал, что результат будет сразу же юзабельным, я всё же придерживался того, что код должен быть легко поддерживаемым и уходил от более тёмных альтернатив.

Пул кандидатов быстро сузился до двух участников — Go и Rust. Оба достаточно легко компилируют код в небольшие статические бинарные файлы, идеально заточенные для запуска в минимальных контейнерах. Оба обещали адекватную производительность, сохранность памяти, хорошую поддержку конкурентного программирования и, что было особо важно для меня, меньшее использование памяти, чем в случае с JVM.

Замысловатая система типов Rust выглядела особенно интригующе. Но при этом, Rust был намного менее зрелым, чем Go, на тот момент ещё даже не достигшим версии 1.0. Выбору Rust также мешало отсутствие хороших библиотек для HTTP и низкоуровневой работы с сетью.

Ранее мы уже пробовали портировать один из наших сервисов на Go, году этак в 2013-м, но на тот момент мы попали на какую-то утечку памяти, и решили прекратить эксперимент. Спустя два года, Go выглядел гораздо более зрелым и был выбрал как самый подходящий кандидат для нашего эксперимента.

Go достаточно похож на языки из C-семейства, что позволяет очень быстро его подхватить, но при этом содержит достаточно особенностей, требующих первые несколько дней постоянного переключения между кодом и документацией. К счастью, документация отлично написана и очень удачно инкорпорирована в исходные коды стандартной библиотеки, что было невероятно полезно, для прояснения различных моментов и понимания идиоматичного кода.

Я также был очень обрадован наличием единого стандарта форматирования языка, и магической утилиты gofmt, которая принуждает к нему и очень легко интегрируется с текстовым редактором вроде vim (небольшой инсайд: этот хакатон также был моей первой попыткой использовать vim для чего-то большего, чем однострочное редактирование)



Результаты


У меня заняло около дня, чтобы познакомиться с Go и портировать простой сервис, выбранный для хакатона. Результаты были очень многообещающими:
  • Размер кода был уменьшен вдвое, с 175 строк до 96
  • Использование резидентной памяти упало с 87MB до всего-лишь 3MB, 29х уменьшение!
  • Результирующий docker-имидж уменьшился с 668MB до 4.3MB — это 155х уменьшение! Согласен, что наибольшие слои докер-имиджей все равно переиспользовались разными сервисами, поэтому реальное уменьшение использования диска было намного меньше при использовании многих Java-сервисов. Тем не менее, эти цифры очень радовали глаз.


До хакатона оставался ещё почти целый день, и я обратил внимание на ещё один сервис — Certificate Authority (ca на appliance status page). Этот сервис принимает запросы на подпись сертификатов от внутренних сервисов и десктоп-клиентов и возвращает подписанные сертификаты, использующиеся для шифрования пересылки как peer-to-peer контента между клиентами, так и для клиент-серверной коммуникации.

Когда этот новый CA наконец-то заменил свой Java-эквивалент, через несколько дней после окончания хакатона, он уменьшил использование памяти на невероятные 100х!

Этот проект выиграл номинацию «Техническая крутизна» (ориг. «Technical Amazingness»), и превратился в продолжающиеся усилия по уменьшению использования памяти всей системы.

К версии 1.1.5 ещё четыре сервиса были портированы на Go — 6 в целом — и суммарная экономия памяти составила 1 Гигабайт. В каждом случае мы получали аналогичное уменьшение в размере кода, и в некоторых случаях мы даже получили значительное уменьшение использования процессора или лучшую пропускную способность.

— Hugues & the AeroFS Team.

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


  1. tangro
    06.08.2015 09:48
    +6

    Это всё отлично выглядит на сервисах «с одним HTTP-запросом и 175 строками кода», но Java создавалась не для этого. Интересно было бы сравнение сложности написания, поддержки и прозводительности чего-нибудь этакого энтерпрайзного, ну, знаете, так чтобы на миллион строк кода.


    1. lair
      06.08.2015 09:53
      +9

      Так может победа-то как раз в том, чтобы сначала разбить задачу на 100500 кусочков по одному запросу и 175 строкам?

      Но если серьезно, то архитектура определяет инструменты, а инструменты — архитектуру. Какие-то технологии тяготеют к монолитам, какие-то — к микросервисам.


      1. HotWaterMusic
        06.08.2015 11:31
        +1

        В теории, ничто не мешает писать на Go и монолиты. Есть мнение, что связка «Go + микросервисы» во многом появилась из-за нынешней моды на микросервисы в целом. Однако это уже переросло в стереотип насчет языка, и такими темпами мы монолитов на Go не увидим — а ведь было бы интересно.


        1. lazycommit
          06.08.2015 14:30

          Если монолит в одном бинарнике, то это просто пугает (IMHO). Ведь тут тебе не JVM, а самое обычное OS окружение. Возможно программистам инкапсуляция надиктовывает из подсознания «не вали всё в кучу» и мысли об упаковке enterprise server-side service в один бинарник развеиваются ;)


    1. cy-ernado
      06.08.2015 10:02
      +1

      поддержки и прозводительности чего-нибудь этакого энтерпрайзного, ну, знаете, так чтобы на миллион строк кода.

      Вроде бы гугл специально для этого Go и создавал, т.к. текущий стек их в чем-то не устраивал. Да и у ребят из AeroFS фокус на микросервисную архитекруту, причем запущенную в докере, что довольно специфично.

      И вообще, откуда таким сравнениям взяться, кто в здавом уме будет полностью монолит на 1кк строк переписывать?


    1. namespace
      06.08.2015 12:51

      Мы тебя поняли, Hugues Bruant просто не умеет готовить Java.


  1. gurinderu
    06.08.2015 10:35
    +1

    Вы переписали сервисы на go лишь из-за того, что у вас якобы жралось много памяти?
    Не пробовали heap уменьшить и сделать class sharing? или хотя бы руками общие jar выложить на общий classpath?


    1. lazycommit
      06.08.2015 15:11
      +2

      Судя по-всему, они поменяли подход в масштабируемости и оркестрации сервисов в пользу сервисов docker и там решение было продиктовано другими — более значущими профитами, которые не отпугнули (переписать всё с Java) этого залихватского Senior Software Architect ;)


    1. khorpyakov
      06.08.2015 15:22
      +5

      Вот об этом и речь, что за сутки без специфического глубокого знания тонкостей языка и инструментария получили решение с гораздо меньшим потреблением памяти. Оптимизировать можно что угодно, вопрос в затратах на решение.


    1. divan0
      06.08.2015 16:10
      +2

      Автор же в статье ответил конкретно на эти вопросы, подробно описав причины своего решения.


      1. gurinderu
        06.08.2015 17:28

        Мы с вами по-моему разные статьи читали. Человек взял и решил переписать все на Go основываясь, лишь на фразе «увеличение количества запущенных JVM»? Смешно)


        1. M0sTH8
          06.08.2015 18:12
          +1

          Вы и правда другую статью читали, в этой человек решил попробовать переписать всего один сервис и сравнить производительность и ресурсоёмкость.


  1. Throwable
    06.08.2015 10:40
    +13

    От статьи за километр разит пиаром.
    Во-первых, непонятна сама цель портирования, если все работало без проблем.
    Во-вторых, непонятен экономический выигрыш: стоимость портирования несравненно больше лишнего гигабайта памяти.
    В-третьих, в разы увеличилась стоимость разработки и поддержки проекта, и как следствие, риски.
    В-четвертых, почему сначала не попытались решить проблему в рамках экосистемы Java? «Маленький tomcat servlet с единственным HTTP-вызовом» говорит о заведомо неверно выбранной архитектуре. Пускать кучу Tomcat контейнеров по одному на каждый модуль и удивляться потом почему так много памяти отъедено. Есть куча легковесных решений на Java, умеющих работать с HTTP, и еще много чего.
    В-пятых, зачем столько процессов? Специфика JVM такая, что процесс отъедает значительную память, поэтому лучше собирать решение по возможности внутри одной JVM, нежели пускать кучу процессов. Конечно, если не было целью продемонстрировать «Java vs Go».
    В-шестых, вконце вскользь упомянулось быстродействие: улучшение лишь «в некоторых случаях». Видимо, хвастаться особо было не чем.


    1. cy-ernado
      06.08.2015 12:09
      +1

      Во-первых, непонятна сама цель портирования, если все работало без проблем.

      Просто ребята на хакатоне решили за один день
      1. Выучить go
      2. Попробовать переписать на нём какой-нибудь микро-сервис ради интереса

      Насколько я понял. И поделились результатами.
      В-третьих, в разы увеличилась стоимость разработки и поддержки проекта

      Спорно, у них и так там не только java-only стэк.
      Конечно, если не было целью продемонстрировать «Java vs Go».

      Да, они специально перенесли всю инфраструктуру на Docker, чтобы продемонстрировать на примере одного микросервиса «Java vs Go»


    1. 1dash
      06.08.2015 12:41
      +3

      5.… внутри одной JVM, нежели пускать кучу процессов…

      Громадное заблуждение. На собеседовании кандидата — это красный флаг.

      В Java есть такое понятие stop-the-world. Да можно бороться, можно юзать off-heap, сделать свой сборщик мусора, написать VM внутри JVM на арреях (есть в бизнесе и даже такое). но побороть, к сожалению, невозможно. Может для студенческих проектов до Xms4Gb это не критично, но для бизнеса и >24Гб уже очень больно.


      1. gurinderu
        06.08.2015 12:51

        Спорный вопрос, все зависит от задачи. Если есть low latency задача, то может быть.Ну и если у меня куча памяти, то stop world будет не частным явлением.


      1. namespace
        06.08.2015 12:57

        Кстати, в Go1.5 задержки GC не превышают 20 мс никогда и теперь он типа low latency.


        1. 1dash
          06.08.2015 13:01

          Знаю, может и Java этот сборщик переберется. Хотя плата за это — производительность чуть ниже.


          1. namespace
            06.08.2015 13:12

            У divan0 есть перевод кейнота с последнего гоферкона, вот. Если интересует сабж, то могу посоветовать прочесть. Там, в частности, раскрывается тема «почему GC для Go нужно писать как GC для Go, а не как GC для Java». Вкраце, языки разные и подходы к разработке GC тоже разные. Когда народ это понял, все стало хорошо.


          1. M0sTH8
            06.08.2015 18:15

            В Java много разных сборщиков и в них есть разные настройки. В JVM сборщики пока ещё лучше чем в Go. Но у Go есть преимущество, он не всё аллоцирует в куче, большая часть данных размещается на стеке, если писать правильно.


            1. gurinderu
              06.08.2015 20:09

              Дык в Java тоже не все в хипе. Все локальные примитивы лежат на стеке.


        1. ivanzoid
          12.08.2015 20:28
          +1

      1. Throwable
        06.08.2015 14:49

        В данном примере основная потеря производительности будет при передаче данных от одного процесса к другому: работа с памятью внутри одной JVM в сотни раз быстрее, чем сериализация/десериализация и дерганье сетевого стека, даже если учесть накладки с GC. Если сервис построен правильно, при необходимости его можно горизонтально масштабировать. Запускается много однотипных JVM-процессов, каждый из которых реализует сразу весь требуемый функционал, но в ограниченном объеме ресурсов (память, процессор, etc). А вся нагрузка равномерно распределена среди всех запущенных процессов.


    1. divan0
      06.08.2015 16:13
      +5

      От статьи за километр разит пиаром.

      Статьи подобного рода — естественная реакция тех, кто попробовал что-то новое, остался доволен и хочет поделиться этим с миром.


  1. afiskon
    06.08.2015 11:18
    +1

    Думается, что «хм, жрется много памяти, давайте все перепишем на X» — реакция скорее какого-то зеленого студента, а не «человека старой закалки». Вместо того, чтобы разобраться в проблеме и прокачать владение технологией, человек потянул в проект новую модную игрушку. Ну и конечно же его эксперимент нельзя повторить. Я не то, чтобы большой противник Go или фанат Java. И наверное это здорово, если язык и вправду позволяет получить более эффективный в плане потребления памяти код без необходимости ни в чем разбираться. Но следует отметить опасность, которую такие посты предоставляют для молодых и впечатлительных разработчиков.


    1. khorpyakov
      06.08.2015 15:25

      Если речь шла о микросервисе, то очень даже грамотный подход.


      1. afiskon
        06.08.2015 16:55

        Позвольте поинтересоваться, в чем же по вашему мнению заключается грамотность? Времени сэкономлено не было, так как посмотреть через YourKit, под что же используется память, и оптимизировать соответствующее место заняло бы явно меньше времени, чем изучение нового языка и переписывание на нем части системы. Не говоря уже о том, что в проекте теперь используется не один язык, а два, с необходимостью поддерживать клиентские библиотеки к разным сервисам на обоих. Не говоря уже о том, что такой подход — ни в чем не разбираться, а просто переписывать — в принципе неправильный. То есть, в следующий раз, когда что-то пойдет не так, автор статьи снова все с нуля перепишет?


        1. khorpyakov
          06.08.2015 17:34

          Ага, а автор заметки вообще нуб, не знает такого слова как оптимизация. Лучше внимательнее почитайте статью. Они не просто с бухты барахты решили вечером за пивком «а не выучить ли нам новый язык и переписать пару программулин на яве», а провели анализ в результате которого выяснили: 1) increase in the number of running JVMs 2) reduced opportunity for the many JVMs to share read-only memory 3) memory isolation could in some cases confuse some sizing heuristics, which lead to larger caches being allocated by some services. И как это вы это сможете быстро оптимизировать, не переписывая весь микросервис с нуля? Потом пришли к идее переписать его на компилируемом AOT языке, потом выбрали Go, как простой в освоении язык с хорошей стандартной библиотекой.


          1. dax
            07.08.2015 00:38

            increase in the number of running JVMs

            Ну так в этом и есть корень зла. Микросервисы на Java реализуются не путем создания большого количества JVMs, а путем рамещения этих сервисов в одной JVM. Посмотрите как это работает в OSGi контейнерах. Автономность сервисов обеспечивается изоляцией класслоадеров. При этом жизнинный цикл сервисов полностью автономный (при надобности!). В тоже время, все библиотеки (бандлы) загружаются ровно один раз, понижая тот самый memory foot print.


            1. aparamonov
              07.08.2015 09:25

              И чем это лучше монолита?
              Как будете эти микросервисы масштабировать?


              1. gurinderu
                07.08.2015 10:15

                Всегда можно поднять другой сервер со своей JVM, на котором будет работать часть сервисов.


                1. lazycommit
                  07.08.2015 16:07

                  Затем ещё, и ещё. Вот уже нужна какая-то контейнеризация, образы. А контейнеры толстые с JVM. Всё, в принципе, по статье ;)


                  1. gurinderu
                    07.08.2015 16:17

                    По сути дела osgi и есть контейнер. Только в качестве образов используются jar.


    1. M0sTH8
      06.08.2015 18:18
      +1

      Автор не зелёный студент и ничего не переписывал. Он всего лишь на «хакатоне» попробовал реализовать один из кучи существующих микросервисов на другом инструменте. После сравнил и показал экономическую целесообразность данного процесса.


  1. tangro
    06.08.2015 11:20

    Кстати очень интересно это всё сошлось с внедрением докера. Не было бы докера — сразу бы начались вопросы: а какой там твоему Go нужен рантайм, а какие либы, а как это будет контачить с нашими системами X, Y и Z, установленными там же, а не сломает ли чего-то там-то? С докером всё просто: вот вам контейнер, в нём всё работает, ставьте куда хотите, ничего кроме него для запуска не нужно, ничего во внешней системе не сломается.


    1. cy-ernado
      06.08.2015 11:57
      +5

      С Go всё просто: вот вам статический бинарник, в нём все работает, ставьте куда хотите, ничего кроме него для запуска не нужно, ничего во внешней системе не сломается.


      1. tangro
        06.08.2015 14:03

        Это не «всё просто», чёрт его знает куда этот бинарник полезет писать файлы, какие захочет открыть порты, сколько занять места на диске своими данными и т.д. Докер ставит хорошие заборы.


        1. cy-ernado
          06.08.2015 14:09
          +2

          Ну это не специфичная для go проблема же. Я лишь указал, что проблемы рантайма, конфликтов либ и так далее, которые вы описывали, отсутствуют для go по большей части. То, что в сабже докер в том числе для изоляции микросервисов, я особо и не спорю :)


          1. tangro
            07.08.2015 10:22
            +1

            Проблема не специфичная для go. Я просто хотел отметить, что с докером сисадмины чуствуют значительно больше контроля над системой и по крайней мере от них не исходит сопротивление внедрению новых технологий. Им не надо знать, как там Go линкуется и где ищет либы. Это просто не их дело. А чем меньше сопротивления новым технологиям — тем быстрее они внедряются.


  1. ShadowsMind
    06.08.2015 11:37

    Автор(статьи, а не перевода естественно) видимо хотел запиариться. А вышло так, что со стороны выглядит, будто они сначала не правильно юзали Java, а потом еще и переписали на Go ради экономии 1ГБ оперативки, которая стоит куда дешевле чем труд разработчиков. Сомнительный мув, имхо…


    1. 1dash
      06.08.2015 12:31
      +4

      Есть ситуации, когда 1Гб умножается на каждом истансе и суммарно получается очень-очень нехилая сумма. Знаю успешные переписывания Python->Java->C++->C++ с Asm, когда нанять ~100 инженеров для портирования на другой низкоуровневый язык выходит банально дешевле. У каждого проекта есть свои нюансы.


      1. ShadowsMind
        06.08.2015 14:44

        Согласен, ситуации бывают разные. Но я говорил конкретно в контексте истории описанной в статье. Если судить о каком-нибудь проекте, который запущен на 1000 машинах, то это логично, что 1ГБ в итоге сэкономит приличную сумму денег.


  1. khorpyakov
    06.08.2015 17:38
    +6

    Я сейчас сравниваю комментарии здесь с комментариями на известном англоязычном ресурсе. Там обсуждают, почему JVM потребляет столько памяти и разбирают конкретные примеры использования Java или Go и сколько у кого потребляется памяти на запущенный сервис. Здесь же понабежали крутые явисты и пытаются доказать, что автор мудак и надо было заниматься оптимизацией, а не переписывать велосипед. Что за привычка такая обсирать других и считать себя Д'Атаньяном?


    1. SirEdvin
      06.08.2015 18:52
      +1

      Наверное, дело в том, что тут все-таки обсуждают статью, а не языки.
      И статья написана очень странно. По сути, в ней нет ни одной причины, которая бы объясняла, почему у них был такой странный и неоптимизированный код на Java.

      В сущности, статья воспринимация как «у меня был фиговый код на Java, я решил не копатся в нем, а переписать все на Go. И внезапно, расходы памяти упали на двузначные числа. Java — плоха». Ну последней фразы нет, но вывод напрашивается.
      По этому эта статья и воспринимация в штыки.


      1. anthonio
        07.08.2015 03:45
        +2

        > В сущности, статья воспринимация как «у меня был фиговый код на Java, я решил не копатся в нем, а переписать все на Go. И внезапно, расходы памяти упали на двузначные числа. Java — плоха».

        Но если подумать…

        А разве автор был способен за один день выучить язык и написать на нём крутой оптимизированный код?
        Получается по сути сравнение плохого кода на Java и на Go.


        1. SirEdvin
          07.08.2015 11:45

          На самом деле нет.
          Архитектура приложений различается.
          Если на Java они накручивали микросервисы на серверлеты и запускали ради этого tomcat сервера (а потом удивлялись, почему же там много памяти жрет), то на Go они написали чистый микросервис.