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


В этой статье будут показаны пути оптимизации исключительно за счет настроек сервера, таким образом переписывание и перекомпиляция приложений не потребуется. Будет достигнут результат 25 Мб в среднем на один микросервис.


2. Структура памяти процесса


В качестве первого шага следует определить, чем же занята память процесса, и значима ли данная оптимизация в конкретном случае. Для анализа структуры памяти нам понадобится одно приложение от Sysinternals — VMMap.


Откроем VMMap и рассмотрим структуру конкретного w3wp процесса, где хостится наш сайт. В верхней части приложения мы видим 3 горизонтальные диаграммы:
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 SDKs\Windows\v10.0A\bin\NETFX 4.6 Tools\

для получения справки о всех возможных опциях выполним


aspnet_intern.exe /?

для получения списка всех сборок без их фактического интернирования выполним


aspnet_intern -mode analyze -sourcedir "C:\Windows\Microsoft.NET\Framework64\v4.0.30319\Temporary ASP.NET Files" > C:\internReport.txt

*для 32 битного приложения используйте путь: C:\Windows\Microsoft.NET\Framework\v4.0.30319\Temporary ASP.NET Files
непосредственно для интернирования сборок к каталог C:\ASPNETCommonAssemblies выполним следующую команду


aspnet_intern -mode exec -sourcedir "C:\Windows\Microsoft.NET\Framework64\v4.0.30319\Temporary ASP.NET Files" -interndir C:\ASPNETCommonAssemblies

Пример на PowerShell


$intern_path = 'C:\Program Files (x86)\Microsoft SDKs\Windows\v10.0A\bin\NETFX 4.6 Tools\aspnet_intern.exe'
$intern_param = '-mode', 'exec', 
                '-sourcedir', 'C:\Windows\Microsoft.NET\Framework64\v4.0.30319\Temporary ASP.NET Files',
                '-interndir', 'C:\ASPNETCommonAssemblies'

& $intern_path $intern_param

Для загрузки сборок в GAC нам вновь потребуется обратиться к Windows SDK, но уже за приложением gacutil.exe. После интернирования мы получили каталог, содержащий подписанные и неподписанные сборки.
Так как инсталлировать в GAC можно только те из них, которые подписаны, то нам потребуется написать небольшой скрипт на Powershell для их инсталляции:


$asm_path = 'C:\git\IISSharingAssemblies\common-assemblies-legacy'
$gac_path = 'C:\Program Files (x86)\Microsoft SDKs\Windows\v10.0A\bin\NETFX 4.6 Tools\gacutil.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%\system32\inetsrv\appcmd.exe list site /xml | %windir%\system32\inetsrv\appcmd delete site /in"
#cleam all pools
cmd.exe /c "%windir%\system32\inetsrv\appcmd.exe list apppool /xml | %windir%\system32\inetsrv\appcmd delete apppool /in"

#To Export the Application Pools on IIS 7 :
#cmd.exe /c "%windir%\system32\inetsrv\appcmd list apppool /config /xml > c:\apppools.xml"
#To Export all you’re website:
#cmd.exe /c "%windir%\system32\inetsrv\appcmd list site /config /xml > c:\sites.xml"

#To import the Application Pools:
cmd.exe /c "%windir%\system32\inetsrv\appcmd add apppool /in < c:\apppools.xml"
#Stop all Application Pools:
cmd.exe /c "%windir%\system32\inetsrv\appcmd.exe list apppool /xml | %windir%\system32\inetsrv\appcmd stop apppool /in"
#To Import the website:
cmd.exe /c "%windir%\system32\inetsrv\appcmd 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:\git\IISSharingAssemblies\common-assemblies-legacy'
$ngn_path = 'C:\Windows\Microsoft.NET\Framework64\v4.0.30319\ngen.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 Мб



5. Результаты


Шаг Общее время "прогрева" Общий размер используемой оперативной памяти Средний размер одного микросервиса Примечание
Начальные условия 6 мин 43 сек 8 Гб 7 Гб / 47 = 152 Мб 1 Гб на ОС. Кэш нативных образов не используется IIS
Объединение сайтов в appPools, загрузка сборок в GAC 2 мин 33 сек 4.1 Гб 3.1 Гб / 47 = 67 Мб 1 Гб на ОС. Кэш нативных образов не используется IIS
Объединение сайтов в appPools, загрузка сборок в GAC, создание нативных образов 2 мин 12 сек 2.2 Гб 1.2 Гб / 47 = 26.1 Мб 1 Гб на ОС. IIS использует кэш нативных образов

Ссылки


  1. Windows Sysinternals Administrator’s Reference. Pages 216-218
  2. http://blogs.microsoft.co.il/sasha/2016/01/05/windows-process-memory-usage-demystified/
  3. https://blogs.msdn.microsoft.com/junfeng/2004/08/05/domain-neutral-assemblies/
  4. Pro .NET Performance: Optimize Your C# Applications. Page 289
  5. Introduction .NET 4.5 Alex Mackey,William Stewart Tulloch, Mahesh Krishnan. Page 149
  6. https://technet.microsoft.com/ru-ru/library/ee790599.aspx
  7. http://www.microsoftpro.nl/2011/01/27/exporting-and-importing-sites-and-app-pools-from-iis-7-and-7-5/
  8. https://technet.microsoft.com/en-us/library/ea8d442e-9a0c-49bb-b940-50b22fa64dd4
  9. https://docs.microsoft.com/en-us/dotnet/framework/tools/ngen-exe-native-image-generator

Исходный код


  1. https://github.com/sflusov/IISSharingAssemblies

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


  1. Marwin
    09.11.2017 11:49

    Подскажите, можно ли какой-то из этих сценариев применить следующей ситуации: есть множество webapi сайтов-субдоменов (сейчас это независимые сайты и апппулы). Но они отличаются ТОЛЬКО значениями переменных в web.config (connectionString к БД и путь к логам и еще несколько переменных). А dll сборки я копипастю одинаковые во все сайты-папки. Поэтому хочется, чтобы bin папка была одна по сути, а субдомены цепляли свой персональный web.config


    1. stus Автор
      09.11.2017 12:55

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


      загрузка подписанных сборок в GAC
      объединение сайтов в applicationPools
      создание нативных образов для загруженных в GAC сборок


      Поэтому хочется, чтобы bin папка была одна по сути, а субдомены цепляли свой персональный web.config

      Касательно деплоя приложений: если содержимое папок bin ничем не отличается и вы не хотите каждый раз руками копировать недостающие сборки в папку bin, то самое простое это сделать symbol link через команду mklink.


      1. Marwin
        09.11.2017 12:58

        mklink… спасибо за мысль. удивляюсь как не догадался


  1. dark58ru
    09.11.2017 12:01

    Вопрос, а как быть с релизом (выкладкой новых версий), т.е. все операции нужно выполнять каждый раз в таком порядке:
    1) деплой приложения
    2) помещать сборки в GAC
    3) создание нативных образов для загруженных в GAC сборок
    я правильно понял?


    1. stus Автор
      09.11.2017 12:21

      Процесс деплоя менять не нужно. Идея состоит в том, чтобы придерживаться одного и того же номера версий сборок в своих сервисах, например:


      • AutoMapper 6.1.0
      • Microsoft.AspNet.WebApi.Client 5.2.3
      • Newtonsoft.Json 9.0.1

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


      Периодически нужно анализировать используемые сборки и помещать их в GAC


  1. neoks
    09.11.2017 23:10

    Можно что то подобное проделать используя Linux в качестве сервера ?


    1. AndreevRu
      10.11.2017 15:55

      Если речь про dotnet core, то там есть crossgen.