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 сервисы использовали на удивление много памяти, зачастую никак не коррелируя с их сложностью или отсутствием таковой. Мы выделили несколько главных факторов, которые приводили к такому использованию памяти.
- увеличение количества запущенных JVM, так как каждый tomcat servlet бежал в отдельном контейнере
- урезанная возможность для нескольких JVM разделять read-only-память: саму JVM, все зависимые библиотеки, и, конечно, множество JAR-ов, используемых разными сервисами
- изоляция памяти в некоторых случаях сбивала с толку эвристику расчета памяти, что приводило к большим аллокациям кеша в некоторых сервисах
Будучи человеком старой закалки, привыкшим писать на ассемблере для 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)
gurinderu
06.08.2015 10:35+1Вы переписали сервисы на go лишь из-за того, что у вас якобы жралось много памяти?
Не пробовали heap уменьшить и сделать class sharing? или хотя бы руками общие jar выложить на общий classpath?lazycommit
06.08.2015 15:11+2Судя по-всему, они поменяли подход в масштабируемости и оркестрации сервисов в пользу сервисов docker и там решение было продиктовано другими — более значущими профитами, которые не отпугнули (переписать всё с Java) этого залихватского Senior Software Architect ;)
khorpyakov
06.08.2015 15:22+5Вот об этом и речь, что за сутки без специфического глубокого знания тонкостей языка и инструментария получили решение с гораздо меньшим потреблением памяти. Оптимизировать можно что угодно, вопрос в затратах на решение.
divan0
06.08.2015 16:10+2Автор же в статье ответил конкретно на эти вопросы, подробно описав причины своего решения.
gurinderu
06.08.2015 17:28Мы с вами по-моему разные статьи читали. Человек взял и решил переписать все на Go основываясь, лишь на фразе «увеличение количества запущенных JVM»? Смешно)
M0sTH8
06.08.2015 18:12+1Вы и правда другую статью читали, в этой человек решил попробовать переписать всего один сервис и сравнить производительность и ресурсоёмкость.
Throwable
06.08.2015 10:40+13От статьи за километр разит пиаром.
Во-первых, непонятна сама цель портирования, если все работало без проблем.
Во-вторых, непонятен экономический выигрыш: стоимость портирования несравненно больше лишнего гигабайта памяти.
В-третьих, в разы увеличилась стоимость разработки и поддержки проекта, и как следствие, риски.
В-четвертых, почему сначала не попытались решить проблему в рамках экосистемы Java? «Маленький tomcat servlet с единственным HTTP-вызовом» говорит о заведомо неверно выбранной архитектуре. Пускать кучу Tomcat контейнеров по одному на каждый модуль и удивляться потом почему так много памяти отъедено. Есть куча легковесных решений на Java, умеющих работать с HTTP, и еще много чего.
В-пятых, зачем столько процессов? Специфика JVM такая, что процесс отъедает значительную память, поэтому лучше собирать решение по возможности внутри одной JVM, нежели пускать кучу процессов. Конечно, если не было целью продемонстрировать «Java vs Go».
В-шестых, вконце вскользь упомянулось быстродействие: улучшение лишь «в некоторых случаях». Видимо, хвастаться особо было не чем.cy-ernado
06.08.2015 12:09+1Во-первых, непонятна сама цель портирования, если все работало без проблем.
Просто ребята на хакатоне решили за один день
- Выучить go
- Попробовать переписать на нём какой-нибудь микро-сервис ради интереса
Насколько я понял. И поделились результатами.
В-третьих, в разы увеличилась стоимость разработки и поддержки проекта
Спорно, у них и так там не только java-only стэк.
Конечно, если не было целью продемонстрировать «Java vs Go».
Да, они специально перенесли всю инфраструктуру на Docker, чтобы продемонстрировать на примере одного микросервиса «Java vs Go»
1dash
06.08.2015 12:41+35.… внутри одной JVM, нежели пускать кучу процессов…
Громадное заблуждение. На собеседовании кандидата — это красный флаг.
В Java есть такое понятие stop-the-world. Да можно бороться, можно юзать off-heap, сделать свой сборщик мусора, написать VM внутри JVM на арреях (есть в бизнесе и даже такое). но побороть, к сожалению, невозможно. Может для студенческих проектов до Xms4Gb это не критично, но для бизнеса и >24Гб уже очень больно.gurinderu
06.08.2015 12:51Спорный вопрос, все зависит от задачи. Если есть low latency задача, то может быть.Ну и если у меня куча памяти, то stop world будет не частным явлением.
namespace
06.08.2015 12:57Кстати, в Go1.5 задержки GC не превышают 20 мс никогда и теперь он типа low latency.
1dash
06.08.2015 13:01Знаю, может и Java этот сборщик переберется. Хотя плата за это — производительность чуть ниже.
namespace
06.08.2015 13:12У divan0 есть перевод кейнота с последнего гоферкона, вот. Если интересует сабж, то могу посоветовать прочесть. Там, в частности, раскрывается тема «почему GC для Go нужно писать как GC для Go, а не как GC для Java». Вкраце, языки разные и подходы к разработке GC тоже разные. Когда народ это понял, все стало хорошо.
M0sTH8
06.08.2015 18:15В Java много разных сборщиков и в них есть разные настройки. В JVM сборщики пока ещё лучше чем в Go. Но у Go есть преимущество, он не всё аллоцирует в куче, большая часть данных размещается на стеке, если писать правильно.
Throwable
06.08.2015 14:49В данном примере основная потеря производительности будет при передаче данных от одного процесса к другому: работа с памятью внутри одной JVM в сотни раз быстрее, чем сериализация/десериализация и дерганье сетевого стека, даже если учесть накладки с GC. Если сервис построен правильно, при необходимости его можно горизонтально масштабировать. Запускается много однотипных JVM-процессов, каждый из которых реализует сразу весь требуемый функционал, но в ограниченном объеме ресурсов (память, процессор, etc). А вся нагрузка равномерно распределена среди всех запущенных процессов.
divan0
06.08.2015 16:13+5От статьи за километр разит пиаром.
Статьи подобного рода — естественная реакция тех, кто попробовал что-то новое, остался доволен и хочет поделиться этим с миром.
afiskon
06.08.2015 11:18+1Думается, что «хм, жрется много памяти, давайте все перепишем на X» — реакция скорее какого-то зеленого студента, а не «человека старой закалки». Вместо того, чтобы разобраться в проблеме и прокачать владение технологией, человек потянул в проект новую модную игрушку. Ну и конечно же его эксперимент нельзя повторить. Я не то, чтобы большой противник Go или фанат Java. И наверное это здорово, если язык и вправду позволяет получить более эффективный в плане потребления памяти код без необходимости ни в чем разбираться. Но следует отметить опасность, которую такие посты предоставляют для молодых и впечатлительных разработчиков.
khorpyakov
06.08.2015 15:25Если речь шла о микросервисе, то очень даже грамотный подход.
afiskon
06.08.2015 16:55Позвольте поинтересоваться, в чем же по вашему мнению заключается грамотность? Времени сэкономлено не было, так как посмотреть через YourKit, под что же используется память, и оптимизировать соответствующее место заняло бы явно меньше времени, чем изучение нового языка и переписывание на нем части системы. Не говоря уже о том, что в проекте теперь используется не один язык, а два, с необходимостью поддерживать клиентские библиотеки к разным сервисам на обоих. Не говоря уже о том, что такой подход — ни в чем не разбираться, а просто переписывать — в принципе неправильный. То есть, в следующий раз, когда что-то пойдет не так, автор статьи снова все с нуля перепишет?
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, как простой в освоении язык с хорошей стандартной библиотекой.
dax
07.08.2015 00:38increase in the number of running JVMs
Ну так в этом и есть корень зла. Микросервисы на Java реализуются не путем создания большого количества JVMs, а путем рамещения этих сервисов в одной JVM. Посмотрите как это работает в OSGi контейнерах. Автономность сервисов обеспечивается изоляцией класслоадеров. При этом жизнинный цикл сервисов полностью автономный (при надобности!). В тоже время, все библиотеки (бандлы) загружаются ровно один раз, понижая тот самый memory foot print.aparamonov
07.08.2015 09:25И чем это лучше монолита?
Как будете эти микросервисы масштабировать?gurinderu
07.08.2015 10:15Всегда можно поднять другой сервер со своей JVM, на котором будет работать часть сервисов.
lazycommit
07.08.2015 16:07Затем ещё, и ещё. Вот уже нужна какая-то контейнеризация, образы. А контейнеры толстые с JVM. Всё, в принципе, по статье ;)
gurinderu
07.08.2015 16:17По сути дела osgi и есть контейнер. Только в качестве образов используются jar.
M0sTH8
06.08.2015 18:18+1Автор не зелёный студент и ничего не переписывал. Он всего лишь на «хакатоне» попробовал реализовать один из кучи существующих микросервисов на другом инструменте. После сравнил и показал экономическую целесообразность данного процесса.
tangro
06.08.2015 11:20Кстати очень интересно это всё сошлось с внедрением докера. Не было бы докера — сразу бы начались вопросы: а какой там твоему Go нужен рантайм, а какие либы, а как это будет контачить с нашими системами X, Y и Z, установленными там же, а не сломает ли чего-то там-то? С докером всё просто: вот вам контейнер, в нём всё работает, ставьте куда хотите, ничего кроме него для запуска не нужно, ничего во внешней системе не сломается.
cy-ernado
06.08.2015 11:57+5С Go всё просто: вот вам статический бинарник, в нём все работает, ставьте куда хотите, ничего кроме него для запуска не нужно, ничего во внешней системе не сломается.
tangro
06.08.2015 14:03Это не «всё просто», чёрт его знает куда этот бинарник полезет писать файлы, какие захочет открыть порты, сколько занять места на диске своими данными и т.д. Докер ставит хорошие заборы.
cy-ernado
06.08.2015 14:09+2Ну это не специфичная для go проблема же. Я лишь указал, что проблемы рантайма, конфликтов либ и так далее, которые вы описывали, отсутствуют для go по большей части. То, что в сабже докер в том числе для изоляции микросервисов, я особо и не спорю :)
tangro
07.08.2015 10:22+1Проблема не специфичная для go. Я просто хотел отметить, что с докером сисадмины чуствуют значительно больше контроля над системой и по крайней мере от них не исходит сопротивление внедрению новых технологий. Им не надо знать, как там Go линкуется и где ищет либы. Это просто не их дело. А чем меньше сопротивления новым технологиям — тем быстрее они внедряются.
ShadowsMind
06.08.2015 11:37Автор(статьи, а не перевода естественно) видимо хотел запиариться. А вышло так, что со стороны выглядит, будто они сначала не правильно юзали Java, а потом еще и переписали на Go ради экономии 1ГБ оперативки, которая стоит куда дешевле чем труд разработчиков. Сомнительный мув, имхо…
1dash
06.08.2015 12:31+4Есть ситуации, когда 1Гб умножается на каждом истансе и суммарно получается очень-очень нехилая сумма. Знаю успешные переписывания Python->Java->C++->C++ с Asm, когда нанять ~100 инженеров для портирования на другой низкоуровневый язык выходит банально дешевле. У каждого проекта есть свои нюансы.
ShadowsMind
06.08.2015 14:44Согласен, ситуации бывают разные. Но я говорил конкретно в контексте истории описанной в статье. Если судить о каком-нибудь проекте, который запущен на 1000 машинах, то это логично, что 1ГБ в итоге сэкономит приличную сумму денег.
khorpyakov
06.08.2015 17:38+6Я сейчас сравниваю комментарии здесь с комментариями на известном англоязычном ресурсе. Там обсуждают, почему JVM потребляет столько памяти и разбирают конкретные примеры использования Java или Go и сколько у кого потребляется памяти на запущенный сервис. Здесь же понабежали крутые явисты и пытаются доказать, что автор мудак и надо было заниматься оптимизацией, а не переписывать велосипед. Что за привычка такая обсирать других и считать себя Д'Атаньяном?
SirEdvin
06.08.2015 18:52+1Наверное, дело в том, что тут все-таки обсуждают статью, а не языки.
И статья написана очень странно. По сути, в ней нет ни одной причины, которая бы объясняла, почему у них был такой странный и неоптимизированный код на Java.
В сущности, статья воспринимация как «у меня был фиговый код на Java, я решил не копатся в нем, а переписать все на Go. И внезапно, расходы памяти упали на двузначные числа.Java — плоха». Ну последней фразы нет, но вывод напрашивается.
По этому эта статья и воспринимация в штыки.
anthonio
07.08.2015 03:45+2> В сущности, статья воспринимация как «у меня был фиговый код на Java, я решил не копатся в нем, а переписать все на Go. И внезапно, расходы памяти упали на двузначные числа. Java — плоха».
Но если подумать…
А разве автор был способен за один день выучить язык и написать на нём крутой оптимизированный код?
Получается по сути сравнение плохого кода на Java и на Go.SirEdvin
07.08.2015 11:45На самом деле нет.
Архитектура приложений различается.
Если на Java они накручивали микросервисы на серверлеты и запускали ради этого tomcat сервера (а потом удивлялись, почему же там много памяти жрет), то на Go они написали чистый микросервис.
tangro
Это всё отлично выглядит на сервисах «с одним HTTP-запросом и 175 строками кода», но Java создавалась не для этого. Интересно было бы сравнение сложности написания, поддержки и прозводительности чего-нибудь этакого энтерпрайзного, ну, знаете, так чтобы на миллион строк кода.
lair
Так может победа-то как раз в том, чтобы сначала разбить задачу на 100500 кусочков по одному запросу и 175 строкам?
Но если серьезно, то архитектура определяет инструменты, а инструменты — архитектуру. Какие-то технологии тяготеют к монолитам, какие-то — к микросервисам.
HotWaterMusic
В теории, ничто не мешает писать на Go и монолиты. Есть мнение, что связка «Go + микросервисы» во многом появилась из-за нынешней моды на микросервисы в целом. Однако это уже переросло в стереотип насчет языка, и такими темпами мы монолитов на Go не увидим — а ведь было бы интересно.
lazycommit
Если монолит в одном бинарнике, то это просто пугает (IMHO). Ведь тут тебе не JVM, а самое обычное OS окружение. Возможно программистам инкапсуляция надиктовывает из подсознания «не вали всё в кучу» и мысли об упаковке enterprise server-side service в один бинарник развеиваются ;)
cy-ernado
Вроде бы гугл специально для этого Go и создавал, т.к. текущий стек их в чем-то не устраивал. Да и у ребят из AeroFS фокус на микросервисную архитекруту, причем запущенную в докере, что довольно специфично.
И вообще, откуда таким сравнениям взяться, кто в здавом уме будет полностью монолит на 1кк строк переписывать?
namespace
Мы тебя поняли, Hugues Bruant просто не умеет готовить Java.