[2024年12月版] 新しいRemote Desktop Service SALを使ってVisual Studio AMIを試してみた
しばたです。
少し前にAWS License ManagerでRemote Desktop Services SAL (RDS SAL)の単体購読が可能になった件を紹介しました。
こちらの記事でVisual StudioとMicrosoft Officeのサブスクライブに使うRDS SALも刷新されると記載しました。
本記事では実際にVisual Studioのサブスクリプションを購読する事でどの様な変化があるのか確認しようと思います。
試してみた
それでは早速試していきます。
0. 検証環境
前掲の記事で構築したAWS Managed Microsoft ADとRDS SALのサブスクリプションを現在まで継続しており、今回はこちらの環境を使います。
サブスクライブするソフトウェアは環境構築の容易さと利用費を考慮してVisual Studio Professionalにしました。
1. Visual Studio Professional のサブスクライブ
最初にマネジメントコンソールからAWS License Managerを開き、左ペインにある「設定」→「ユーザーベースのサブスクリプション」をクリックします。
完全な初期状態では下図の様に
- AWS Marketplaceから当該製品(Visual Studio)のサブスクライブ
- AWS MarkteplaceからRDS SALのサブスクライブ
を求められるのでAWS Maketplaceに移動して両者をサブスクライブしておきます。
2. Active Directoryの登録
AWS Marketplaceのサブスクライブを完了した後で再度「設定」の「ユーザーベースのサブスクリプション」にアクセスすると今度はActive Directoryとの紐づけを求められます。
「Active Directoryを登録」をクリックすると登録画面に遷移するのでRDS SALと連携済みのAWS Managed Microsoft AD環境を選択して「登録」ボタンをクリックします。
すると登録作業が開始されるので完了までしばらく待ちます。
エラー無く完了するとIDプロバイダーのステータスが「登録済み」になります。
併せてRDS SAL関連のエンドポイント情報も一緒に表示される形となります。
3. EC2インスタンスの起動
次にAWS MarketplaceからVisual Studio AMIを使ってEC2インスタンスを起動します。
本日時点では2024.11.08
版が最新バージョンで
- Windows Server 2022 - 2024.11.08
- Windows Server 2019 - 2024.11.08
- Windows Server 2016 - 2024.11.08
の3OS選択可能となっていました。
今回はWindows Server 2022 AMIからEC2インスタンスを起動することにします。
EC2インスタンスの起動方法は任意ですが、所定の権限をもったインスタンスプロファイルの設定を忘れない様にしてください。
Visual Studio AMIではSSMを使うため所定のインスタンスプロファイルの設定が必須
インスタンス作成後しばらく待ち、所定のSSM Run Commandが実施されてActive Directoryドメインに参加できていれば完了です。
このインスタンスがAWS Reserved
配下のインスタンス別OUに所属するのは従来通りの挙動です。
ただ、現在はRD Licensing Server等の設定を強制するLicense Manager Policy
が親OUから継承されるので自動でRDSライセンスサーバーエンドポイントを使う様になっています。
補足 : (2024年12月版)ドメイン参加スクリプト
また、ドメイン参加スクリプトも変更が入っており、スクリプト内でRDSHの機能を追加する-InstallSessionHost
パラメーターが増えていました。
これによりWindowsの役割・機能側の設定も適切な形に行われます。
Function Install-SessionHost {
Param (
[String]$Username,
[String]$DomainRoot,
[System.Management.Automation.PSCredential]$Credential
)
# In order to create a session with a user credential in CredSSP, the user needs to be in local administrators group.
Write-Output 'Adding LocalGroupMember'
Add-LocalGroupMember -group administrators -member $Username -ErrorAction SilentlyContinue
# only enable CredSSP if it's not configured
$CredSSPStatus = Get-CredSSP
Write-Output "CredSSPStatus before enabling is. $_"
if (-not ($CredSSPStatus -match "This computer is configured to receive credentials from a remote client computer.")) {
Enable-CredSSP -DomainRoot $DomainRoot
$CredSSPStatus = Get-CredSSP
Write-Output "CredSSPStatus after enabling is. $_"
}
$script = {
Function Write-TerminatingError {
<#
.SYNOPSIS
write the error to the console and exit the program.
#>
Param (
[String]$Message
)
Write-Error $Message
exit 1
}
Import-Module ServerManager -Force
Add-WindowsFeature rds-rd-server
}
# Invoke the script with Reserved OU Admin Credential. This is done because the default system creds with which
# SSM would run this script, would not have the permission to create or modify GPO.
$session = New-PSSession -Credential $Credential -Authentication Credssp
Invoke-Command -session $session -ScriptBlock $script
}
参考情報としてスクリプト全体は以下に載せておきます。
ドメイン参加スクリプト(全体) : クリックして展開
<#
.SYNOPSIS
Domain joins an instance to the customers MAD
.DESCRIPTION
This script will domain join an instance in the MAD under the given OU, this script will run on customers instance using SSM
It will perform the follow actions:
1. Validation : Check if instance is part of domain already and under the correct OU, if not proceed
2. Install AD tools on the instance
3. Set the DNS Address to a MAD Domain Controllers IP
4. Domain join the instance to the computer object already created using One time machine pascode
5. Exit with status 3010 which tells ssm to restart the computer and rerun the script for validation on 1
As this script is running on the cutomers instance we will not be placing it on the instance rather will be running it by directly passing the script in a SSM RunPowershell document.
#>
#==================================================
# Utility Functions
#==================================================
Function Write-TerminatingError {
<#
.SYNOPSIS
write the error to the console and exit the program.
#>
Param (
[String]$Message
)
throw $Message
}
Function Enable-CredSSP {
Param(
[String]$DomainRoot
)
<#
.SYNOPSIS
Enable local to local Cred SSP for the running computer.
#>
Write-Output 'Enabling CredSSP'
Try {
Enable-PSRemoting -force -ErrorAction Stop
} Catch {
Write-TerminatingError "Failed to enable PS Remoting $_"
}
Write-Output 'Enabling CredSSP for Server'
Try {
Enable-WSManCredSSP -Role Server -Force -ErrorAction Stop
} Catch {
Write-TerminatingError "Failed to enable PS Remoting for Server $_"
}
Write-Output 'Enabling CredSSP for Client'
Try {
Enable-WSManCredSSP -Role Client -DelegateComputer localhost -Force -ErrorAction Stop
Enable-WSManCredSSP -Role Client -DelegateComputer $env:COMPUTERNAME -Force -ErrorAction Stop
Enable-WSManCredSSP -Role Client -DelegateComputer $DomainRoot -Force -ErrorAction Stop
Enable-WSManCredSSP -Role Client -DelegateComputer "*.$DomainRoot" -Force -ErrorAction Stop
} Catch {
Write-TerminatingError "Failed to enable PS Remoting for Client $_"
}
Set-Item -Path "wsman:\localhost\service\auth\credSSP" -Value $True -Force
New-Item -Path HKLM:\SOFTWARE\Policies\Microsoft\Windows\CredentialsDelegation -Name AllowFreshCredentialsWhenNTLMOnly -Force
Try {
New-ItemProperty -Path HKLM:\SOFTWARE\Policies\Microsoft\Windows\CredentialsDelegation\AllowFreshCredentialsWhenNTLMOnly -Name 1 -Value * -PropertyType String -ErrorAction Stop
} Catch {
Write-TerminatingError "Failed to add new registry key to enable CredSSP $_"
}
}
Function Get-CredSSP {
return Out-String -InputObject (Get-WSManCredSSP)
}
Function Install-SessionHost {
Param (
[String]$Username,
[String]$DomainRoot,
[System.Management.Automation.PSCredential]$Credential
)
# In order to create a session with a user credential in CredSSP, the user needs to be in local administrators group.
Write-Output 'Adding LocalGroupMember'
Add-LocalGroupMember -group administrators -member $Username -ErrorAction SilentlyContinue
# only enable CredSSP if it's not configured
$CredSSPStatus = Get-CredSSP
Write-Output "CredSSPStatus before enabling is. $_"
if (-not ($CredSSPStatus -match "This computer is configured to receive credentials from a remote client computer.")) {
Enable-CredSSP -DomainRoot $DomainRoot
$CredSSPStatus = Get-CredSSP
Write-Output "CredSSPStatus after enabling is. $_"
}
$script = {
Function Write-TerminatingError {
<#
.SYNOPSIS
write the error to the console and exit the program.
#>
Param (
[String]$Message
)
Write-Error $Message
exit 1
}
Import-Module ServerManager -Force
Add-WindowsFeature rds-rd-server
}
# Invoke the script with Reserved OU Admin Credential. This is done because the default system creds with which
# SSM would run this script, would not have the permission to create or modify GPO.
$session = New-PSSession -Credential $Credential -Authentication Credssp
Invoke-Command -session $session -ScriptBlock $script
}
Function Write-Log {
Param (
[String]$Message
)
Write-Host "$(Get-Date) - $Message"
}
Function Install-ADTools {
$attempts = 3
$sleepInSeconds = 1
do {
try {
Write-Log -Message 'Installing AD tools on the instance...'
Install-WindowsFeature -Name 'RSAT-AD-Tools' -ErrorAction Stop
Write-Log -Message 'Successfully installed AD tools on the instance...'
break;
} catch {
Write-Log -Message "Unable to install AD tools on the machine, exception: $_"
if ($attempts -eq 1) {
Write-TerminatingError -Message "Unable to install AD tools on the machine, failing error: $_"
}
}
Write-Log -Message 'Will retry installing tools...'
$attempts--
if ($attempts -gt 0) { sleep $sleepInSeconds }
} while ($attempts -gt 0)
}
Function Remove-Computer-From-CurrentDomain {
<#
.SYNOPSIS
In case of recovery, we also need to remove it from any domain explicitly even if it's already in the target domain.
#>
Write-Log -Message 'Removing the computer from the current domain if it is in any domain, failure to do so will not terminate the script.'
$hostname = hostname
netdom remove $hostname /domain: /force
}
Function Get-GP-Update-Output {
Param(
[string]$Domain,
[string]$GPErrorMsg,
[string]$CurrentDomain
)
#Only check for GPUpdate result if computer is part of domain
if ($CurrentDomain -eq $Domain) {
$attempts = 15
$sleepInSeconds = 5
do {
$GPUpdateResult = gpupdate /force
$GPUpdateResult = Out-String -InputObject $GPUpdateResult
Write-Log -Message "GPUpdateResult : $GPUpdateResult"
Start-Process gpupdate -ArgumentList "/force" -NoNewWindow -Wait
$exitCode = $LASTEXITCODE
Write-Log -Message "exitCode : $exitCode"
if (-not ($GPUpdateResult -match $GPErrorMsg)) {
return $GPUpdateResult
}
Write-Log -Message "Retrying GPUpdateResult"
$attempts--
sleep $sleepInSeconds
} while ($attempts -gt 0)
return $GPUpdateResult
}
Write-Log -Message "Computer is not domain joined so its the first run hence skipping GPUpdateResult"
return ""
}
Function Successfully-Exit-And-Restart-Instance {
exit 3010
}
#==================================================
# Main
#==================================================
Function Domain-Join-Instance {
<# The script accepts the below parameters
DCIpAddress1,DCIpAddress2 : Domain Controller IPs for DCs of the directory, Domain: customers domain, OneTimeMachinePasscode : Password for the Computer Object for this instance
#>
Param(
[string]$DCIpAddress1,
[string]$DCIpAddress2,
[string]$Domain,
[string]$OneTimeMachinePasscode,
[string]$ROUserPass,
[string]$ROUsername,
[string]$Region,
[string]$InstanceId,
[string]$IsOfficeProductCode,
[string]$InstallSessionHost,
[string]$AdminUsername,
[string]$AdminPassword
)
$LMReservedOUName = 'LicenseManager'
$sleepInSeconds = 1
Write-Log -Message "****** OneTimeMachinePasscode: $OneTimeMachinePasscode *******"
try {
$CurrentDomain = Get-CimInstance -ClassName 'Win32_ComputerSystem' -ErrorAction Stop | Select-Object -ExpandProperty 'Domain'
} catch [System.Exception] {
Write-TerminatingError -Message "Unable to Computer details to check current domain: $_"
}
# We validate if gpo update is working, if not we need to perform the domain join.
# when we are recovering a broken instance, it's possible that the local domain name is right but the computer object has been replaced.
Write-Log -Message "Checking GPUpdateResult"
$GPErrorMsg = 'Computer policy could not be updated successfully.'
$GPUpdateResult = Get-GP-Update-Output -Domain $Domain -GPErrorMsg $GPErrorMsg -CurrentDomain $CurrentDomain
Write-Log -Message "CurrentDomain of the instance: $CurrentDomain and GP update output is $GPUpdateResult"
$GPUpdateResult
if (($CurrentDomain -eq $Domain) -and (-not ($GPUpdateResult -match $GPErrorMsg))) {
Write-Log -Message "Instance is part of domain: $CurrentDomain"
$username = "$Domain\$ROUsername"
$password = $ROUserPass | ConvertTo-SecureString -asPlainText -Force
$credential = New-Object System.Management.Automation.PSCredential($username, $password)
#Getting OU Path of computer object
$attempts = 3
do {
try {
Write-Log -Message "Getting AD Computer details for computer : $ENV:ComputerName"
$ADComputer = Get-ADComputer -Identity $ENV:ComputerName -Credential $credential -ErrorAction Stop
break;
} catch [System.Exception] {
Write-Log -Message "Unable to get AD Computer details..., exception: $_"
if ($attempts -eq 1) {
Write-TerminatingError -Message "Unable to fetch details for the computer object: $_"
}
}
$attempts--
if ($attempts -gt 0) { sleep $sleepInSeconds }
} while ($attempts -gt 0)
$DistinguishedName = $ADComputer | Select-Object -ExpandProperty DistinguishedName
# Example for DistinguishedName : CN=EC2AMAZ-PCQS7MN,OU=i-05f83f681c65dfd6c-OU,OU=IAD,OU=LicenseManager,OU=AWS Reserved,DC=example,DC=com
$OUPathArr = $DistinguishedName -split ',', 2
$CurrentOUPath = $OUPathArr[1]
#Building the OU Path from customers domain and reserved ou path
$attempts = 3
do {
try {
Write-Log -Message 'Getting AD Domain details...'
$ADDomain = Get-ADDomain -credential $credential -ErrorAction Stop
break;
} catch [System.Exception] {
Write-Log -Message "Unable to get AD Domain details......, exception: $_"
if ($attempts -eq 1) {
Write-TerminatingError -Message "Unable to fetch AD domain details: $_"
}
}
$attempts--
if ($attempts -gt 0) { sleep $sleepInSeconds }
} while ($attempts -gt 0)
$DomainFQDN = $ADDomain.DNSRoot
$BasePath = $ADDomain.DistinguishedName
$ReservedOUPath = "OU=$LMReservedOUName,OU=AWS Reserved"
$ExpectedOUPath = "OU=$InstanceId-OU,OU=$Region,$ReservedOUPath,$BasePath"
#SUCCESS : If computer is part of OU - exit 0
if ($CurrentOUPath -eq $ExpectedOUPath) {
Write-Log -Message "Success... Instance was domain joined under the correct OU, at path: $ExpectedOUPath"
Get-LocalGroupMember -Group “Administrators”
Write-Log -Message "---Adding the Domain Admins to the Administrators group---"
Add-LocalGroupMember -Group “Administrators” -Member “$env:userdomain\AWS Delegated Server Administrators”
Get-LocalGroupMember -Group “Administrators”
# Check common registry paths for pending reboot status
$pendingReboot = (Get-ItemProperty -Path "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Component Based Servicing\").RebootPending -or
(Get-ItemProperty -Path "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate\Auto Update\").RebootRequired -or
(Get-ItemProperty -Path "HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager\").PendingFileRenameOperations
if ($pendingReboot) {
Write-Output "A reboot is pending."
} else {
Write-Output "No reboot is pending."
}
return
} else {
Write-TerminatingError -Message "Failure... Instance is joined under OU path: $CurrentOUPath `nInstead of expected OU path: $ExpectedOUPath"
}
} else {
Write-Log -Message "Instance is not part of domain so proceeding to join it to the domain: $Domain"
}
#Remove-Computer-From-CurrentDomain
Remove-Computer-From-CurrentDomain
#Install AD Tools
Install-ADTools
Write-Log -Message "Office instance launched: $IsOfficeProductCode"
# Set DNS address only for VS. For Office we are taking Route53 resolver approach
if ($IsOfficeProductCode -eq 'false') {
Write-Log -Message 'Setting the DNS address to MAD Domain Controller IP for VS Product...'
try {
$dns = Get-DnsClientServerAddress
$OriginalDNSAddress = $dns.ServerAddresses[0]
Write-Log -Message ("Current Value for index " + $dns.InterfaceIndex[0] + "-" + $dns.ServerAddresses)
Write-Log -Message "OriginalDNSAddress : $OriginalDNSAddress"
Set-DnsClientServerAddress -InterfaceIndex $dns.InterfaceIndex[0] -ServerAddresses ($DCIpAddress1, $DCIpAddress2) -ErrorAction Stop
$dns = Get-DnsClientServerAddress
Write-Log -Message ("Updated Value for index " + $dns.InterfaceIndex[0] + "-" + $dns.ServerAddresses)
Write-Log -Message 'Successfully! updated the DNS address to MAD Domain Controller IP...'
} catch [System.Exception] {
Write-TerminatingError -Message "Unable to set DNS address: $_"
}
}
#Domain join this instance to the AD and connect to the Computer Object created already using OneTimeMachinePasscode
$OneTimeMachinePasscodeSecure = $OneTimeMachinePasscode | ConvertTo-SecureString -AsPlainText -Force
Write-Log -Message 'Domain joining the instance...'
try {
$JoinCred = New-Object -TypeName 'System.Management.Automation.PSCredential' -ArgumentList ([PSCustomObject]@{
UserName = $Null
Password = ($OneTimeMachinePasscodeSecure)[0]
})
Add-Computer -Domain $Domain -Options UnsecuredJoin, PasswordPass -Credential $JoinCred -ErrorAction Stop
Write-Log -Message 'Successfully! domain joined the instance'
} catch [System.Exception] {
if ($IsOfficeProductCode -eq 'false') {
Set-DnsClientServerAddress -InterfaceIndex $dns.InterfaceIndex[0] -ServerAddresses $OriginalDNSAddress -ErrorAction Stop
Write-Log -Message "Reset the dns name for VS as the domain join failed to : $OriginalDNSAddress"
}
Write-TerminatingError -Message "Unable to domain join the instance: $_"
}
$attempts = 3
$sleepInSeconds = 1
do {
try {
Write-Log -Message 'Getting AD Domain details...'
$username = "$Domain\$ROUsername"
$password = $ROUserPass | ConvertTo-SecureString -asPlainText -Force
$credential = New-Object System.Management.Automation.PSCredential($username, $password)
$ADDomain = Get-ADDomain -credential $credential -server $DCIpAddress1 -ErrorAction Stop
Write-Log -Message 'Successfully retrieved ADDomain...'
if ($InstallSessionHost -eq 'true') {
$adminUsername = "$Domain\$AdminUsername"
$adminPwd = $AdminPassword | ConvertTo-SecureString -asPlainText -Force
$adminCredential = New-Object System.Management.Automation.PSCredential($adminUsername, $adminPwd)
Write-Log -Message 'Installing session host...'
Install-SessionHost -Username $adminUsername -DomainRoot $ADDomain.DNSRoot -Credential $adminCredential
Write-Log -Message 'Installed session host... Checking installation status'
Get-WindowsFeature -Name RDS-RD-Server
}
break;
} catch [System.Exception] {
Write-Log -Message "Unable to Install Session Host, exception: $_"
if ($attempts -eq 1) {
Write-TerminatingError -Message "Failing : Unable to Install Session Host: $_"
}
}
$attempts--
if ($attempts -gt 0) { sleep $sleepInSeconds }
} while ($attempts -gt 0)
Write-Log -Message 'Restarting the instance to reflect changes...'
#exit 3010 sends a request to ssm to restart instance and rerun the script again : https://docs.aws.amazon.com/systems-manager/latest/userguide/send-commands-reboot.html
Successfully-Exit-And-Restart-Instance
}
#Function call that runs the script, it will be replaced in code with param and values while calling the script
Function Main {
Param(
[string]$DCIpAddress1,
[string]$DCIpAddress2,
[string]$Domain,
[string]$OneTimeMachinePasscode,
[string]$ROUserPass,
[string]$ROUsername,
[string]$Region,
[string]$InstanceId,
[string]$IsOfficeProductCode,
[string]$InstallSessionHost,
[string]$AdminUsername,
[string]$AdminPassword
)
Domain-Join-Instance -DCIpAddress1 $DCIpAddress1 -DCIpAddress2 $DCIpAddress2 -Domain $Domain -OneTimeMachinePasscode $OneTimeMachinePasscode -ROUsername $ROUsername -ROUserPass $ROUserPass -Region $Region -InstanceId $InstanceId -IsOfficeProductCode $IsOfficeProductCode -InstallSessionHost $InstallSessionHost -AdminUsername $AdminUsername -AdminPassword $AdminPassword
}
Main -DCIpAddress1 '10.0.21.34' -DCIpAddress2 '10.0.22.141' -Domain 'corp.contoso.com' -OneTimeMachinePasscode 'xxxxxxxxxxxxxxxxxxxxxxxxxx' -ROUsername 'RO-LM-NRT' -ROUserPass 'xxxxxxxxxxxxxxxxxxxxxxxxxx' -Region 'NRT' -InstanceId 'i-010d5dd65f9e774a1' -IsOfficeProductCode 'false' -InstallSessionHost 'true' -AdminUsername 'admin' -AdminPassword 'xxxxxxxxxxxxxxxxxxxxxxxxxx'
4. Visual Studio Professionalのサブスクライブ
マネジメントコンソールからAWS License Managerの「ユーザーベースのサブスクリプション」→「ユーザーの関連付け」を選ぶとインスタンス一覧が表示されます。
ここから「ユーザーをサブスクライブして関連付ける」をしてやればVisual Studio Professionalのサブクライブとインスタンスの紐づけを一気に行えます。
ユーザーの関連付けが完了すればインスタンスにRDP接続可能になります。
今回は意図的に一度サブスクリプションを解除したadmin
ユーザーで登録してみたのですが、RDS SALのサブスクリプションが再登録される形になっていました。
Visual Studioの方はこんな感じです。
5. EC2インスタンスへの接続
最後にEC2インスタンスにRDP接続してやります。
このインスタンスにはRDSH自体はインストールされているのですが「リモート デスクトップ ライセンス診断ツール」は未インストールだったのでPowerShellコマンドからRD Licensin Serverの指定状況を確認してやります。
# 機能がインストールされているか確認
Get-WindowsFeature -Name RDS-RD-Server
# ライセンスサーバー設定等の確認
$obj = Get-WmiObject -Namespace "Root/CIMV2/TerminalServices" Win32_TerminalServiceSetting
Write-Output ("サーバーモード : {0}" -f $obj.TerminalServerMode)
Write-Output ("現在のライセンスタイプ : {0} ({1})" -f $obj.LicensingType, $obj.LicensingName)
Write-Output ("ライセンスサーバー : {0}" -f $($obj.GetSpecifiedLicenseServerList().SpecifiedLSList))
結果は下図の通り、適切なライセンスタイプ(接続ユーザー数)でRDSライセンスサーバーエンドポイントの指定がされていました。
- サーバーモード : 1 (AppServer) → RDSHサーバーとして構成済み
- 現在のライセンスタイプ : 4 (接続ユーザー数) → RDS SALを利用するための適切な設定
- ライセンスサーバー : RDSエンドポイントのホスト名が設定済み → RDS SALを利用するための適切な設定
グループポリシーによる強制なので当然と言えば当然の結果ではあります。
Visual Studio AMI提供当初とは異なり、2024年12月現在では適切にRD Licensing Serverの設定がされていることが実際に確認できました。
おわりに
以上となります。
本記事では費用的に3人以上での同時接続まで実行することは出来ませんが、この設定であれば間違いなく3人以上で同時接続可能です。
開発者が使用するEC2を集約することで開発環境の統一化やインスタンスの稼働費を抑えることできるので実施可能な環境であれば検討してみると良いでしょう。