В микросервисном мире добавление новой функциональности осуществляется путем написания нового сервиса. При этом стоимость добавления новой единицы составляет минимум 150 Мб оперативной памяти, хотя нашего кода в нем достаточно мало и используются, как правило, одни и те же сборки с небольшими отличиями в версиях.
В этой статье будут показаны пути оптимизации исключительно за счет настроек сервера, таким образом переписывание и перекомпиляция приложений не потребуется. Будет достигнут результат 25 Мб в среднем на один микросервис.
2. Структура памяти процесса
В качестве первого шага следует определить, чем же занята память процесса, и значима ли данная оптимизация в конкретном случае. Для анализа структуры памяти нам понадобится одно приложение от Sysinternals — VMMap.
Откроем VMMap и рассмотрим структуру конкретного w3wp процесса, где
Committed — память “доступная” для процесса
Private Bytes — виртуальная память
Working Set — физическая память (RAM)
Цветами в диаграммах обозначены различные типы памяти, приведем лишь некоторые из них, составляющие топ 4 для Working Set:
Image — исполняемые файлы, такие как .exe или .dll, которые могут быть загружены в процесс image loader
Managed Heap — память выделенная .NET runtime, обычно содержащая данные приложения
Page Table — область памяти, отвечающая за отображение виртуальных адресов в физические
Heap — память выделенная С или C++ runtime, обычно содержащая данные приложения
Для каждого типа памяти можно получить детальную информацию о том, как и где он выделен:
Total WS — кол-во физической памяти
Private WS — кол-во физической памяти, которое не может быть использовано совместно (shared) с другими процессами
Sharable WS — кол-во физической памяти, которое может быть использовано совместно с другими процессами
Shared WS — кол-во физической памяти, которое в настоящее время используется совместно с другими процессами.
Подробное описание приложения VMMap см. по ссылкам [1], [2]. Но вернемся к нашему w3wp процессу и рассмотрим топ 3 по типам памяти:
73 из 220 Мб отходит к Image
60 из 220 мб к Heap
58 из 220 Мб к Managed Heap
Следует дополнительно отметить, что в Managed Heap включается память алоцированная JIT. Ее можно вычислить как размер Managed Head минус размер GC.
2.1 Начальные условия
В нашем распоряжении имеется инстанс Amazon t2.large с 8 Gb оперативной памяти, 2 ядрами Intel Xeon ES-2676 v3 2.40GHz и Windows Server 2012 R2 в качестве ОС. Внутри 47 микро-сервисов под управлением IIS.
В каждом микросервисе есть контроллер с методом, возвращающим версию сборки (см. пример ниже). Именно его мы будем вызывать для “прогрева” сайтов.
public class VersionController : ApiController
{
[Route("version")]
[HttpGet]
public IHttpActionResult Version()
{
return Ok(Assembly.GetExecutingAssembly().GetName().Version);
}
}
Теперь последовательно запустим и “прогреем” наши сайты, чтобы увидеть картину в целом. Для автоматизации этого процесса приведем скрипт на PowerShell (ссылка на github).
В результате 47 сайтов запустились за 6 минут 43 секунды и заняли всю оперативную память сервера. Средний размер одного микросервиса составил 7 Гб / 47 = 152 Мб (1 Гб на ОС).
3. Sharing сборок между доменами в одном приложении, понятие домен нейтральной сборки (domain neutral assembly)
Теперь рассмотрим w3wp процесс через призму ProcessExplorer. Перейдя на вкладку .NET Assemblies, мы увидим 3 т.н. application domains: sharedDomain, defaultDomain и siteDomain (/LM/W3SVC/3/). Последнее справедливо, впрочем, только когда мы создаем для каждого сайта свой application pool.
Какие изменения последуют в случае, если мы объединим несколько сайтов в один application pool? Будут добавлены N appDomains с именами /LM/W3SVC/3/… — siteDomain, где N число добавленных сайтов.
Теперь обратим внимание, что часть сборок находятся в sharedDomain, а часть сборок в appDomains. При этом сборки находящиеся в sharedDomain присутствуют в приложении в единственном экземпляре, а сборки находящиеся siteDomains будут загружаться независимо для каждого домена.
Следует отметить, что сборки находящиеся в sharedDomain имеют флаг DomainNeutral и путь, который указывает либо в GAC (Global Assembly Cache), либо в кэш нативных образов (native images).
MSDN [3] дает следующее определение “Domain Neutral Assembly”:
- сборка, которая существует в единственном экземпляре и разделяется между appDomains в рамках одного процесса
- сборка, которая Jitted единожды и разделяет с другим appDomain общие структуры данных: MethodTables, MethodDescs
- сборка может быть Domain Neutral, если она и все её зависимости помещены в GAC (только подписанные сборки могут быть помещены в GAC)
В книге “Pro .NET Performance: Optimize Your C# “ [4] рекомендуется помещать подписанные сборки (strong name assemblies) в GAC, в противном случае загрузка сборки потребует её полного чтения для подтверждения ее цифровой подписи.
Последнее также облегчает создание нативных образов для всех приложений, ссылающихся на эту сборку.
Следовательно для облегчения размера приложения нам потребуется поместить подписанные сборки в GAC и сгруппировать сайты в applicationPools по их профилю нагрузки.
Для решения первой задачи существует консольное приложение aspnet_intern.exe, поставляемое вместе с Windows SDK.
Приложение анализирует используемые сборки и далее копирует их в указанный каталог, при этом заменяя исходный файл на символическую ссылку, чем экономит место на диске и ускоряет запуск w3wp процесса [5].
Пример:
откроем командную строку в режиме администратора и перейдем в каталог Windows SDK
cd C:Program Files (x86)Microsoft SDKsWindowsv10.0AbinNETFX 4.6 Tools
для получения справки о всех возможных опциях выполним
aspnet_intern.exe /?
для получения списка всех сборок без их фактического интернирования выполним
aspnet_intern -mode analyze -sourcedir "C:WindowsMicrosoft.NETFramework64v4.0.30319Temporary ASP.NET Files" > C:internReport.txt
*для 32 битного приложения используйте путь: C:WindowsMicrosoft.NETFrameworkv4.0.30319Temporary ASP.NET Files
непосредственно для интернирования сборок к каталог C:ASPNETCommonAssemblies выполним следующую команду
aspnet_intern -mode exec -sourcedir "C:WindowsMicrosoft.NETFramework64v4.0.30319Temporary ASP.NET Files" -interndir C:ASPNETCommonAssemblies
Пример на PowerShell
$intern_path = 'C:Program Files (x86)Microsoft SDKsWindowsv10.0AbinNETFX 4.6 Toolsaspnet_intern.exe'
$intern_param = '-mode', 'exec',
'-sourcedir', 'C:WindowsMicrosoft.NETFramework64v4.0.30319Temporary ASP.NET Files',
'-interndir', 'C:ASPNETCommonAssemblies'
& $intern_path $intern_param
Для загрузки сборок в GAC нам вновь потребуется обратиться к Windows SDK, но уже за приложением gacutil.exe. После интернирования мы получили каталог, содержащий подписанные и неподписанные сборки.
Так как инсталлировать в GAC можно только те из них, которые подписаны, то нам потребуется написать небольшой скрипт на Powershell для их инсталляции:
$asm_path = 'C:gitIISSharingAssembliescommon-assemblies-legacy'
$gac_path = 'C:Program Files (x86)Microsoft SDKsWindowsv10.0AbinNETFX 4.6 Toolsgacutil.exe'
#install assembly to GAC
Get-ChildItem -recurse $asm_path | where {$_.extension -eq ".dll"} | ForEach-Object {
Write-Host "Try to install assembly $_"
& $gac_path "/i", $_.FullName
}
#uninstall assembly from GAC
#Get-ChildItem $asm_path | where {$_.extension -eq ".dll"} | ForEach-Object {
# & $gac_path "/u", ([System.Reflection.AssemblyName]::GetAssemblyName($_.FullName).FullName)
#}
Проводить эксперимент с объединением сайтов в applicationPools будем руками, хотя последнее возможно сделать средствами PowerShell через модуль WebAdministration [7].
В конкретном случае мы будем объединять 47 микросервисов в 6 applicationPools по их профилю нагрузки. Для бэкапа, восстановления или переноса конфигурации на другие машины, рекомендую обратить внимание на appcmd.exe [7], [8]
#clean all sites
cmd.exe /c "%windir%system32inetsrvappcmd.exe list site /xml | %windir%system32inetsrvappcmd delete site /in"
#cleam all pools
cmd.exe /c "%windir%system32inetsrvappcmd.exe list apppool /xml | %windir%system32inetsrvappcmd delete apppool /in"
#To Export the Application Pools on IIS 7 :
#cmd.exe /c "%windir%system32inetsrvappcmd list apppool /config /xml > c:apppools.xml"
#To Export all you’re website:
#cmd.exe /c "%windir%system32inetsrvappcmd list site /config /xml > c:sites.xml"
#To import the Application Pools:
cmd.exe /c "%windir%system32inetsrvappcmd add apppool /in < c:apppools.xml"
#Stop all Application Pools:
cmd.exe /c "%windir%system32inetsrvappcmd.exe list apppool /xml | %windir%system32inetsrvappcmd stop apppool /in"
#To Import the website:
cmd.exe /c "%windir%system32inetsrvappcmd add site /in < c:sites.xml"
После проделанных изменений мы имеем следующую картину: 47 сайтов прогрелись за 2 минуты и 33 секунды, что быстрее в 2.6 раза. Общий размер используемой оперативной памяти составил 4.1 Гб. При этом средний размер одного микросервиса составил 3.1 Гб / 47 = 67 Мб, что меньше в 2.2 раза.
4. Sharing сборок между различными приложениями, понятие нативного образа (native image)
Помимо совместного использования (sharing) сборок между доменами в рамках одного процесса возможно совместное использование сборок между различными процессами, но последнее требует создания нативного образа и помещения его в кэш. Для этой цели мы будем использовать ngen.exe [9].
Перечислим плюсы от использования нативных образов:
- могут использоваться совместно между процессами
- могут использоваться совместно между доменами в рамках одного процесса
- используют меньше оперативной памяти, поскольку не требуют JIT компиляции
- загружаются быстрее, поскольку не требует JIT компиляции и type-safety верификации
Создание нативных образов возможно как для подписанных, так и для неподписанных сборок. Однако здесь есть нюансы: если сборка будет загружена не в sharedDomain, то она не будет иметь возможности совместно использоваться с другими appDomains.
$asm_path = 'C:gitIISSharingAssembliescommon-assemblies-legacy'
$ngn_path = 'C:WindowsMicrosoft.NETFramework64v4.0.30319ngen.exe'
#install native images from cache
Get-ChildItem -Recurse $asm_path | where {$_.extension -eq ".dll"} | ForEach-Object {
& $ngn_path "install", ([System.Reflection.AssemblyName]::GetAssemblyName($_.FullName).FullName)
}
#uninstall native images from cache
#Get-ChildItem $asm_path | where {$_.extension -eq ".dll"} | ForEach-Object {
# & $ngn_path "uninstall", ([System.Reflection.AssemblyName]::GetAssemblyName($_.FullName).FullName)
#}
На данном этапе нам придется повторить все предыдущие шаги и выполнить один новый:
- загрузка подписанных сборок в GAC
- объединение сайтов в applicationPools
- создание нативных образов для загруженных в GAC сборок
В результате мы увидим следующую картину:
- общее время прогрева 2 минуты 12 сек
- общий размер используемой оперативной памяти 2.2 Гб
- средний размер одного микросервиса 1.2 Гб / 47 = 26.1 Мб
Ссылки
- Windows Sysinternals Administrator’s Reference. Pages 216-218
- http://blogs.microsoft.co.il/sasha/2016/01/05/windows-process-memory-usage-demystified/
- https://blogs.msdn.microsoft.com/junfeng/2004/08/05/domain-neutral-assemblies/
- Pro .NET Performance: Optimize Your C# Applications. Page 289
- Introduction .NET 4.5 Alex Mackey,William Stewart Tulloch, Mahesh Krishnan. Page 149
- https://technet.microsoft.com/ru-ru/library/ee790599.aspx
- http://www.microsoftpro.nl/2011/01/27/exporting-and-importing-sites-and-app-pools-from-iis-7-and-7-5/
- https://technet.microsoft.com/en-us/library/ea8d442e-9a0c-49bb-b940-50b22fa64dd4
- https://docs.microsoft.com/en-us/dotnet/framework/tools/ngen-exe-native-image-generator
Исходный код
Автор: stus