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

в 10:57, , рубрики: .net, Parallels, parallels automation, powershell, web-разработка, Блог компании Parallels

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:Usersuser> "%SystemRoot%system32WindowsPowerShellv1.0PowerShell.exe" -NoLogo 
-NoProfile -NonInteractive -inputformat none -Command Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Force -Scope LocalMachine

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

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

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

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

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

В каталог Common мы помещаем утилитарные модули PowerShell, которые используются несколькими провайдерами. Чтобы в модулях провайдера работали конструкции вида:

Import-Module ProviderUtils
Import-Module -Name UtilsExchangeADUtils.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. В нашем примере это единственный cmdlet: Get-PublicFolder.

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

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

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 FilesMicrosoftExchange ServerV15Bin.

Для удаленных вызовов, если есть возможность, лучше вызывать не набор 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

Автор: Mike_Y

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js