Как подружить ежа и ужа: опыт использования PowerShell в web-приложениях

    imageЭта статья не претендует на полноценное руководство по программированию на PowerShell или пошаговую инструкцию по разработке высоконагруженных сервисов .NET. Но в ней собраны полезные приемы и разъяснение некоторых особенностей интеграции PowerShell с .NET, которые пока сложно или даже невозможно найти в Сети.

    Стоит сразу отметить: PowerShell версий 1.0 и 2.0 не является языком программирования .NET. Система типов ETS PowerShell версии 3.0 уже базируется на .NET, т.е. PSObject — это dynamic объект DLR. Поскольку эти и прочие нововведения PowerShell 3 не принципиальны для основной темы статьи, я буду рассматривать PowerShell версии 2.0. Когда не указано явно, под PowerShell понимается именно версия 2.0.

    Термины:
    • Cmdlet – команда PowerShell
    • Runspace – Объект класса .NET, представляющий среду исполнения PowerShell объектов
    • Snap-In – сборка .NET с набором cmdlet’ов, расширяющая оболочку PowerShell с помощью новой функциональности

    В нашем продукте PA (Parallels Automation) есть web-приложение для активации и управления различными сервисами у провайдеров облачных услуг, такими как Microsoft Exchange, IIS, SharePoint и прочие. Для управления большинством своих сервисов, например, сервером Exchange, Microsoft предоставляет набор PowerShell cmdlet'ов, поэтому было решено писать все наши скрипты на PowerShell. Наш же web-сервис написанный на .NET является по сути драйвером, который обеспечивает низкоуровневую инфраструктуру для запуска скриптов и реализует обработку сетевых запросов по SOAP, транзакционность, логгирование и прочее. При использовании PowerShell во многих случаях можно внести изменения в логику приложения «на лету» и избежать пересборки всего приложения, что удобно как для разработчиков, так и для службы поддержки.

    Сегодня поговорим о следующем:
    1. Настройка параметров PowerShell для запуска скриптов из .NET приложений, задание способа поиска PS-модулей (PowerShell-модулей), используемых скриптом, а также обработка ошибок
    2. Удаленный и локальный запуски скриптов из .NET и передача параметров в PowerShell
    3. Способы уменьшения памяти, используемой объектами Runspace

    Типичные проблемы интеграции .NET с PowerShell


    Разрешение на выполнение скриптов PowerShell

    По умолчанию удаленное выполнение скриптов PowerShell запрещено. Политика выполнения должна быть установлена в RemoteSigned или Unrestricted (не рекомендуется по соображениям безопасности). Что может быть сделано следующей командой:
    C:\Users\user> "%SystemRoot%\system32\WindowsPowerShell\v1.0\PowerShell.exe" -NoLogo 
    -NoProfile -NonInteractive -inputformat none -Command Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Force -Scope LocalMachine
    

    У нас данное действие выполняется скриптом установки приложения.

    Настройка каталогов поиска модулей PowerShell

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


    В нашем приложении подсистемы для управления конечными сервисами, такими как Exchange, называются провайдеры. Они разнесены по отдельным каталогам. Провайдер содержит набор PowerShell модулей, каждый из которых выполняет какую-то одну функцию. На рисунке выше выделен каталог с провайдером управления Exchange’ем. Модулям мы стараемся давать «говорящие» названия:

    • CreateMailbox.ps1 — создание почтового ящика
    • CreateGlobalAddressList.ps1 – создание объекта GAL Exchange
    • И т.п.

    В каталог Common мы помещаем утилитарные модули PowerShell, которые используются несколькими провайдерами. Чтобы в модулях провайдера работали конструкции вида:
    Import-Module ProviderUtils
    Import-Module -Name Utils\ExchangeADUtils.ps1
    

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

    Обработка ошибок

    PowerShell разделяет ошибки на терминирующие (terminating) и нетерминирующие (nonterminating). Проще говоря, терминирующие ошибки — это ошибки, после которых нормальное продолжение работы скрипта невозможно, все прочие ошибки нетерминирующие. Например, синтаксические ошибки являются терминирующими, и выполнение скрипта будет завершено в любом случае. Переменная $ErrorActionPreference позволяет задать способ обработки нетерминирующих ошибок.

    При установке переменной в значение Continue нетерминирующие ошибки не будут вызывать аварийного завершения скрипта. Нетерминирующие ошибки можно обработать в коде .NET следующим образом:
    result = PowerShell.Invoke();
    checkErrors(PowerShell.Streams.Error);
    ...
    // Проброс нетерминирующей ошибки 
    private static void CheckErrors(PSDataCollection<ErrorRecord> error)
    {
    	if (error.Count == 1)
    	{
    		ErrorRecord baseObject = error[0];
    		throw baseObject.Exception;
    	}
    
    	if (error.Count > 1)
    	{
    		foreach (ErrorRecord baseObject in error)
    		{
    			if (baseObject != null)
    			{
    				throw baseObject.Exception;
    			}
    		}
    	}
    }
    

    Виды вызовов PowerShell команд


    Для удаленных вызовов PowerShell использует протокол WinRM, который является реализацией открытого протокола WS-Management Protocol. По сути это расширение протокола SOAP и весь транспорт идет по http(s). Для сериализации объектов, которые передаются от удалённого хоста к локальному PowerShell, используется xml. Это тоже необходимо помнить в контексте производительности. По опыту, достаточно много времени занимает процесс установки соединения с удаленным хостом по WinRM – отсюда желание как-то закэшировать объекты Runspace, уже установившие соединения с хостами.

    Локальный вызов скрипта из .NET выглядит так:
    // Создаем runspace
    using (Runspace runspace = RunspaceFactory.CreateRunspace())
    {
    	runspace.Open();
    	// Пробрасываем ошибки PowerShell в приложение
    	runspace.SessionStateProxy.SetVariable("ErrorActionPreference", stopOnErrors ? "Stop" : "Continue");
    	using (PowerShell powershell = PowerShell.Create())
    	{
    		var command = new PSCommand();
    		command.AddCommand("Get-Item");
    		command.AddArgument(@"c:\Windows\*.*");
    		powershell.Commands = command;
    		powershell.Runspace = runspace;
    		ICollection<PSObject> result = powershell.Invoke();
    		// Проверим нетерминирующие ошибки исполнения
    		if (!stopOnErrors)
    			CheckErrors(powershell.Streams.Error);
    
    		foreach (PSObject psObject in result)
    		{
    			Console.WriteLine("Item: {0}", psObject);
    		}
    	}
    }
    


    Вызов скрипта или cmdlet'а на удаленном хосте выглядит так:
    var connectionInfo = new WSManConnectionInfo(
    	new UriBuilder("http", server, 5985).Uri,
    	"http://schemas.microsoft.com/powershell/Microsoft.PowerShell",
    	new PSCredential(user, passw)) {AuthenticationMechanism = AuthenticationMechanism.Basic};
    
    using (Runspace runspace = RunspaceFactory.CreateRunspace(connectionInfo))
    {
    	// Далее все тоже, что и при локальном подключении...
    }
    

    ВНИМАНИЕ: В примере для упрощения я использовал протокол http и Basic аутентификацию. В реальном приложении необходимо использовать протокол https и аутентификацию Kerberos или Digest.

    При удаленных вызовах cmdlet'ов, например, Exchange, если у вас отсутствуют сборки, в которых находятся типы возвращаемых объектов, вам придется выполнять десериализацию объектов, полученных с удалённого хоста самостоятельно. В случае с Exchange для правильной десериализации необходимо наличие установленных Exchange Management Tools. В случае, когда сборки сериализуемых типов отсутствуют или нет необходимости в типизированной десериализации, можно работать с объектами как с примитивными типами: строками и массивами строк. Сделать это можно так:
    public static string GetPsObjectProperty(PSObject obj, string propName)
    {
    	// Проверки аргументов на null и пустые значения убраны
    	// Может вернуться null и это нормально, например, для свойств с
    	// неинициализированными значениями
    	return obj.Properties[propName].Value == null
    				? null
    				: obj.Properties[propName].Value.ToString();
    }
    
    public static string[] GetPSObjectCollection(PSObject obj, string propName)
    {
    	object psColl = GetPsObjectProperty<object>(obj, propName);
    
    	// Локальные и удаленные вызова возвращают объекты разных типов
    	var psObj = psColl as PSObject;
    	if (psObj == null)
    	{
    		var coll = GetPsObjectProperty<ICollection>(obj, propName);
    		if (coll != null)
    		{
    			var arr = new string[coll.Count];
    
    			int idx = 0;
    			foreach (object item in coll)
    			{
    				arr[idx++] = item.ToString();
    			}
    
    			return arr;
    		}
    	}
    	else
    	{
    		var collection = (ArrayList) psObj.BaseObject;
    		return (string[]) collection.ToArray(typeof (string));
    	}
    
    	return null;
    }
    

    Вызов скриптов на удаленном хосте

    Зачастую над полученными с помощью cmdlet'а объектами необходимо выполнить какие-либо действия. Можно это делать на клиенте, но с точки зрения производительности и уменьшения сетевого трафика эффективнее делать это на сервере. Для этих целей можно воспользоваться возможностью PowerShell выполнять скрипты на удаленном сервере. Прежде всего, создадим локальную сессию PowerShell:
    public class PsRemoteScript : IDisposable
    {
    	private readonly Runspace _runspace = RunspaceFactory.CreateRunspace();
    	private bool _disposed;
    
    	public PsRemoteScript()
    	{
    		_runspace.Open();
    	}
    ...
    

    Передача входных и выходных параметров скрипта реализуется с помощью изменения переменных сессии, делается это вызовом метода Runspace.SessionStateProxy.SetVariable.

    ...
    foreach (string varName in variables.Keys)
    {
    	object varValue = variables[varName];
    
    	if (varValue == null)
    	{
    		throw new ArgumentNullException(
    			"variables",
    			String.Format("Variable '{0}' has null value", varName));
    	}
    
    	_runspace.SessionStateProxy.SetVariable(varName, varValue);
    }
    ...
    

    Далее необходимо исполнить PowerShell-скрипт в текущей сессии, в которой создать новую удаленную сессию PowerShell с помощью cmdlet'а New-PSSession, а затем импортировать ее вызовом cmdlet’а Import-PSSession:
    private void OpenRemoteSession(WSManConnectionInfo connInfo, string[] importExchangeCommands)
    {
    	_runspace.SessionStateProxy.SetVariable("_ConnectionUri", connInfo.ConnectionUri.AbsoluteUri);
    	_runspace.SessionStateProxy.SetVariable("_Credential", connInfo.Credential);
    	_runspace.SessionStateProxy.SetVariable("_CommandsToImport", importExchangeCommands);
    
    	Pipeline pipeline = _runspace.CreatePipeline();
    	pipeline.Commands.AddScript(@"
    $_my_session = $null
    $_my_session = New-PSSession -ConfigurationName Microsoft.Exchange -ConnectionUri $_ConnectionUri -Credential $_Credential -Authentication Kerberos
    Import-PSSession $_my_session -CommandName $_CommandsToImport");
    	pipeline.Invoke();
    }
    

    Обратите внимание, что список необходимых cmdlet’ов, которые будут вызываться в удаленной сессии, передается параметром при вызове Import-PSSession.

    Решение проблем с памятью


    Производительность и память

    PowerShell сам по себе достаточно требователен к памяти, особенно для удаленных вызовов.
    Еще неприятная новость: объект Runspace после явного вызова Dispose() не полностью освобождает за собой память. Дело в том, что Runspace хранит во внутреннем кэше выполняемые куски кода PowerShell, скрипты и сгенерированные модули (прокси) для удаленных вызовов. Причем ссылки действительно остаются «живыми», ибо GC.Collect(), вызванный непосредственно в приложении, не снижает потребление физической памяти. При анализе профайлером дампов процесса IIS пула, в котором работает приложение, обнаруживается большое количество объектов-строк с лексемами запускаемых скриптов.
    Способов борьбы с чрезмерным потреблением памяти как минимум три:
    1. Настроить перезапуск пула приложения в IIS после достижения какого-то неразумного значения использованной памяти (см. Configure an Application Pool to Recycle after Reaching Maximum Used Memory (IIS 7))
    2. Наиболее сложный путь: исполнять команды и скрипты PowerShell в отдельном домене приложения (.NET Application Domain). При этом придется решить несколько проблем:
      • Передача данных между основным доменом и доменами-потомками, в которых будут исполняться скрипты. Решается с помощью методов .NET AppDomain.SetData и/или AppDomain.DoCallBack.
      • Настройка путей загрузки сборок в доменах-потомках. Решается с помощью создания обработчика события AppDomain.AssemblyResolve
      • Какие-то глобальные вещи, например, запись лога работы приложения в файл, скорее всего, придется вызывать в контексте домена-родителя. Мы использовали Logging Application Block из Microsoft Enterprise Library – все записи в лог пришлось выполнять в контексте основного домена.
    3. Через .NET Reflection получить доступ к кэшу и периодически форсировать его очистку

    Мы пробовали все способы, проблему с памятью полностью решает только первый.
    Совет: используйте Import-PSSession с явным указанием cmdlet'ов, которые будете использовать в теле скрипта. Без явного указания PowerShell сгенерирует специальный прокси-модуль PowerShell для всех экспортируемых Snap-In’ами cmdlet'ов. Например, для Microsoft.Exchange прокси это порядка 2Мб кода на PowerShell. По невыясненным причинам PowerShell иногда не удаляет модули прокси. Данные файлы постепенно скапливаются в каталоге с временными файлами, и его приходится периодически очищать. Более того, при накоплении большого количества прокси-модулей (несколько тысяч) скорость выполнения скриптов существенно снижается. Exchange Management Tools сами управляют генерацией прокси-объектов для удаленных вызовов, тем самым позволяя избежать генерации прокси на каждую удаленную сессию. Подробнее смотрите RemoteExchange.cs и ConnectXXX.cs из MS Exchange Management Tools. Для Exchange 2013 эти файлы находятся в папке C:\Program Files\Microsoft\Exchange Server\V15\Bin.


    Для удаленных вызовов, если есть возможность, лучше вызывать не набор cmdlet'ов с последующей обработкой результатов на локальном хосте средствами C#, а переносить обработку в скрипт на PowerShell и вызывать его. При этом надо помнить о том, что скрипту необходимо подгрузить snapins (т.е. сборки .NET) с используемыми cmdlet’ами.

    Использование RunspacePool для кэширования объектов исполнения

    Основное предназначение класса RunspacePool — это организация асинхронных вызовов cmdlet’ов и скриптов. Но поскольку объекты исполнения, полученные через RunspacePool, не удаляются средой, а кэшируются для последующего использования, то возникает естественное желание использовать этот механизм для ускорения «холодного» старта PowerShell скриптов. На деле все выглядит не так радужно. Действительно, время исполнения скриптов уменьшается, хотя возникают проблемы:
    • PowerShell не очищает пространство имен переменных после возврата объекта Runspace в пул. Расход памяти растет, особенно это заметно при частом выбросе исключений из скриптов.
    • Надо внимательно следить за тем, чтобы после использования экземпляра RunspacePool (или перед началом его использования в новой сессии) все конфиденциальные данные, такие как пароли, были уничтожены, т.е., как минимум озаботиться удалением/очисткой экземпляров переменных, оставшихся в пуле после исполнения какого-либо скрипта.

    Итог или «Будет ли сахар после восстания?»


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

    Вот и все. Как говорят в MSFT: “Happy Scripting!”.

    Полные исходные тексты к статье тут
    Я готов ответить на ваши вопросы в комментариях.

    Отдельное спасибо хочу сказать Дмитрию Маслакову, Алексею Варченко и Никите Попову за помощь в написании статьи.


    Ссылки


    Windows PowerShell Owner's Manual
    System.Management.Automation.Runspaces Namespace
    Windows Remote Management
    Exchange 2013 cmdlets
    An Introduction to Error Handling in PowerShell
    Configure WinRM to Use HTTP
    Parallels
    144.73
    Мировой лидер на рынке межплатформенных решений
    Share post

    Comments 12

      0
      Использование данного антикросплатформенного средства может быть оправдано в гомогенной среде, при использовании Windows-only фич (Exchange, AD и т.п.)
      Но как обстоят дела с отладкой?
        +1
        С отладкой дела обстоят так:
        1. Есть PowerShell ISE, но с помощью него можно отлаживать только локальные скрипты по факту, т.е. подцепиться к скриптам которые выполняются через .NET (System.Management.Automation) не получится
        2. Можно породить свой PowerShell Host и туда попытаться прикрутить отладчик

        Но самый простой вариант через логи. У нас логика достаточно простая в модулях, там особо отлаживать нечего интерактивно. :)
          +3
          Средство не совсем уж и «антикроссплатформенное». Мы успешно работаем над портированием под операционные системы.
          0
          С какой вервии POA + WPE начали управлять подписками через командлеты Powershell?
            0
            Не уверен, что понял ваш вопрос. WPE изначально задумывался как замена MPS, ибо Microsoft сделала ему EOL. И примерно в тоже время появился Exchange 2010 (насколько я помню), который хорошо провизился через PowerShell. Была идея: сделать MPS-like движок, который бы понимал клиентские MPS -запросы (там довольно сложный язык на базе xml), мог бы запускать на стороне сервера старые MPS-провайдеры (кроме native) и работать с новыми провайдерами написанными на PowerShell. Т.е. первая версия WPE сразу умела запускать провайдеры написанные на PowerShell. Но это не совсем: «работать через командлеты PowerShell».
              0
              Да, вы меня правильно поняли. Спасибо за ответ. До вчерашнего дня я был уверен, что wpe не работает с PowerShell провайдерами.

              Если я верно понял, то изначально WPE сделали «MPS-like» и провайдеры PowerShell использовались в единичных задачах. Но скоро будут внесены изменения, правильно?

              Еще интересный вопрос. MS Lync 2013 так же отлично управляется через PowerShell. Почему для него нет провайдера?
                0
                Сейчас новые провайдеры не разрабатываются, поскольку все новые сервисы создаются с помощью APS. С помощью этой технологии значительно легче выпускать новые сервисы для PA. Также важно что APS-пакеты отвязаны от релизов основного продукта PA, т.е. можно делать свои релиз-циклы для пакетов, добавлять новые фичи, не дожидаясь пока будет очередной релиз PA.

                Что касается MS Lync 2010-2013 они реализованы как APS -пакеты как раз. :)

                Строго говоря WPE это уже legacy — он поддерживается, но новые вещи туда не добавляются. Теоретически, прикрутив какой-нибудь frontend UI, WPE можно было бы выдвинуть на уровень большого enterprise, который настолько богат, что может позволить себе собственные инсталяции Exchange, Sharepoint и прочего. Но пока планов нет.
                  0
                  А почему вы приняли решение провизить Lync через APS, а не с помощью вызовов PowerShell?
                    0
                    Если просто, то APS это все в одном флаконе: бизнес-логика, UI и возможность выполнять скрипты на конечном сервисе. А программная часть, которая непосредственно управляет Lync (активация и управление) может быть написана на чем угодно — в частности на PowerShell. Я не помню, пакет Lync'а разрабатывала другая команда, но помоему у них как раз PS используется для управления Lync'ом. Ну и вторая (а может быть первая) причина — APS стратегическое направление Parallels, сейчас вся поддержка новых сервисов делается на нем.
            +3
            Но ведь это крокодил и белка
              +1
              Это чтобы еще больше все запутать :)
              0
              Простите, промахнулся.

              Only users with full accounts can post comments. Log in, please.