AWS Tools for PowerShell を使って EC2 インスタンスを構築します。流れとしては、EC2 インスタンスを立てるのに必要な各種コンポーネントを順を追って作成していき、 最後に EC2 インスタンスを起動する際、それらをすべてパラメータとして渡します。
検証済の動作環境
OS | モジュール |
---|---|
Windows 10 Pro 2004 | Windows PowerShell 5.1 (.NET Framework 4.8) |
動作条件
一口に EC2 インスタンスを構築するといっても、やることはいろいろあります。ざっくり以下のような感じでしょうか。
以下はスクリプトの全体像です。前回の記事で作成した VPC のパブリックサブネットに構築します。
Set-DefaultAWSRegion -Region "ap-northeast-1" -Scope Script
Import-Module -Name AWS.Tools.EC2, AWS.Tools.IdentityManagement
# ------------------------------------
# Name タグ設定用の関数
# ------------------------------------
Function New-EC2NameTag
{
[OutputType([System.Object])]
[CmdletBinding()]
Param
(
[Parameter(Mandatory=$True)]
[ValidateNotNullOrEmpty()]
[string]$ResourceType,
[Parameter(Mandatory=$True)]
[ValidateNotNullOrEmpty()]
[string]$TagValue
)
$Tag = @{ Key="Name"; Value=$TagValue }
$Obj = New-Object -TypeName Amazon.EC2.Model.TagSpecification
$Obj.ResourceType = $ResourceType
$Obj.Tags.Add($Tag)
return $Obj
}
# ------------------------------------
# フィルタ作成用の関数
# ------------------------------------
Function New-EC2Filter
{
[OutputType([System.Object])]
[CmdletBinding()]
Param
(
[Parameter(Mandatory=$True)]
[ValidateNotNullOrEmpty()]
[string]$Name,
[Parameter(Mandatory=$True)]
[ValidateNotNullOrEmpty()]
[string[]]$Values
)
$Obj = New-Object -TypeName Amazon.EC2.Model.Filter
$Obj.Name = $Name
$Obj.Values = @($Values)
return $Obj
}
# ------------------------------------
# IPv4 許可設定用の関数
# ------------------------------------
Function New-EC2Ipv4Range
{
[OutputType([System.Object])]
[CmdletBinding()]
Param
(
[Parameter(Mandatory=$True)]
[ValidateNotNullOrEmpty()]
[string]$CidrIp,
[Parameter()]
[ValidateNotNullOrEmpty()]
[string]$Description,
[Parameter(Mandatory=$True)]
[ValidateNotNullOrEmpty()]
[string]$IpProtocol,
[Parameter(Mandatory=$True)]
[ValidateNotNullOrEmpty()]
[int]$FromPort,
[Parameter(Mandatory=$True)]
[ValidateNotNullOrEmpty()]
[int]$ToPort
)
$IpRange = New-Object -TypeName Amazon.EC2.Model.IpRange
$IpRange.CidrIp = $CidrIp
$IpRange.Description = $Description
$IpPermission = New-Object -TypeName Amazon.EC2.Model.IpPermission
$IpPermission.IpProtocol = $IpProtocol
$IpPermission.FromPort = $FromPort
$IpPermission.ToPort = $ToPort
$IpPermission.Ipv4Ranges.Add($IpRange)
return $IpPermission
}
# ------------------------------------
# EBS 設定用の関数
# ------------------------------------
Function New-EC2BlockDevice
{
[OutputType([System.Object])]
[CmdletBinding()]
Param
(
[Parameter(Mandatory=$True)]
[ValidateNotNullOrEmpty()]
[int]$VolumeSize,
[Parameter(Mandatory=$True)]
[ValidateSet("Gp2", "Io1", "Io2", "Sc1", "St1", "Standard")]
[Amazon.EC2.VolumeType]$VolumeType,
[Parameter()]
[ValidateNotNullOrEmpty()]
[bool]$DeleteOnTermination = $true,
[Parameter()]
[ValidateNotNullOrEmpty()]
[string]$DeviceName = "/dev/xvda"
)
$BlockDevice = New-Object -TypeName Amazon.EC2.Model.EbsBlockDevice
$BlockDevice.VolumeSize = $VolumeSize
$BlockDevice.VolumeType = $VolumeType
$BlockDevice.DeleteOnTermination = $DeleteOnTermination
$BlockDeviceMapping = New-Object -TypeName Amazon.EC2.Model.BlockDeviceMapping
$BlockDeviceMapping.DeviceName = $DeviceName
$BlockDeviceMapping.Ebs = $BlockDevice
return $BlockDeviceMapping
}
# ------------------------------------
# 対象 VPC/ サブネットの情報を取得
# ------------------------------------
$VpcName = "vpc-01"
$VpcTagFilter = New-EC2Filter -Name "tag:Name" -Values $VpcName
$TargetVpcId = (Get-EC2Vpc -Filter $VpcTagFilter).VpcId
$SubnetName = "subnet-pub-a"
$SubnetTagFilter = New-EC2Filter -Name "tag:Name" -Values $SubnetName
$TargetSubnetId = (Get-EC2Subnet -Filter $SubnetTagFilter).SubnetId
# ------------------------------------
# Ami の取得
# ------------------------------------
$AmiFilter = New-EC2Filter -Name "name" -Values "amzn2-ami-hvm-*-x86_64-gp2"
$TargetAmi = @(Get-EC2Image -Filter $AmiFilter | Sort-Object -Property "CreationDate" -Descending)[0]
$TargetAmiId = $TargetAmi.ImageId
# ------------------------------------
# セキュリティグループの作成
# ------------------------------------
$SgName = "sec-01"
$SgTag = New-EC2NameTag -ResourceType "security-group" -TagValue $SgName
$SgTagFilter = New-EC2Filter -Name "tag:Name" -Values $SgName
$IpRangeObjects = @(
[PSCustomObject]@{
CidrIp = "0.0.0.0/0"
IpProtocol = "tcp"
FromPort = 443
ToPort = 443
Description = "https: all"
}
[PSCustomObject]@{
CidrIp = "111.111.111.111/32"
IpProtocol = "tcp"
FromPort = 80
ToPort = 80
Description = "http: my-gip"
}
[PSCustomObject]@{
CidrIp = "111.111.111.111/32"
IpProtocol = "tcp"
FromPort = 22
ToPort = 22
Description = "ssh: my-gip"
}
[PSCustomObject]@{
CidrIp = "111.111.111.111/32"
IpProtocol = "icmp"
FromPort = -1
ToPort = -1
Description = "icmp: my-gip"
}
[PSCustomObject]@{
CidrIp = "10.0.0.0/16"
IpProtocol = "-1"
FromPort = 0
ToPort = 0
Description = "all: vpc"
}
)
If( -not (Get-EC2SecurityGroup -Filter $SgTagFilter))
{
$SgParams = @{
VpcId = $TargetVpcId
GroupName = $SgName
GroupDescription = $SgName
TagSpecification = $SgTag
}
$TargetSgId = New-EC2SecurityGroup @SgParams
$IpPermissions = @()
ForEach($IpRangeObject In $IpRangeObjects)
{
$IpPermissionParams = @{
CidrIp = $IpRangeObject.CidrIp
IpProtocol = $IpRangeObject.IpProtocol
FromPort = $IpRangeObject.FromPort
ToPort = $IpRangeObject.ToPort
Description = $IpRangeObject.Description
}
$IpPermissions += New-EC2Ipv4Range @IpPermissionParams
}
Grant-EC2SecurityGroupIngress -GroupId $TargetSgId -IpPermissions $IpPermissions
}
Else
{
throw "${SgName}: すでに存在する名前です。"
}
# ------------------------------------
# IAM ロールの作成
# ------------------------------------
$RoleName = "TEST-Role"
$RoleTag = New-Object -TypeName Amazon.IdentityManagement.Model.Tag -Property @{ Key="Name"; Value=$RoleName }
$AssumeRolePolicyDocument = @'
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "",
"Effect": "Allow",
"Principal": {
"Service": "ec2.amazonaws.com"
},
"Action": "sts:AssumeRole"
}
]
}
'@
$PolicyArn = "arn:aws:iam::aws:policy/AmazonS3ReadOnlyAccess"
$RoleCheck = Get-IAMRoleList | Where-Object -FilterScript { $_.RoleName -eq $RoleName }
If( -not $RoleCheck)
{
$RoleParams = @{
AssumeRolePolicyDocument = $AssumeRolePolicyDocument
RoleName = $RoleName
Description = $RoleName
Tag = $RoleTag
}
New-IAMRole @RoleParams
Register-IAMRolePolicy -PolicyArn $PolicyArn -RoleName $RoleName
}
Else
{
throw "${RoleName}: すでに存在する名前です。"
}
$ProfCheck = Get-IAMInstanceProfileList | Where-Object -FilterScript { $_.InstanceProfileName -eq $TestRole }
If( -not $ProfCheck)
{
New-IAMInstanceProfile -InstanceProfileName $RoleName
Add-IAMRoleToInstanceProfile -InstanceProfileName $RoleName -RoleName $RoleName
}
Else
{
throw "${RoleName}: すでに存在する名前です。"
}
# ------------------------------------
# キーペアの作成
# ------------------------------------
$KeyName = "TEST-Key"
$KeyTag = New-EC2NameTag -ResourceType "key-pair" -TagValue $KeyName
$KeyPath = "F:\Documents\Key\aws_ec2\${KeyName}.pem"
$KeyCheck = Get-EC2KeyPair | Where-Object -FilterScript { $_.KeyName -eq $KeyName }
If( -not $KeyCheck)
{
(New-EC2KeyPair -KeyName $KeyName -TagSpecification $KeyTag).KeyMaterial |
Out-File -FilePath $KeyPath -Encoding ascii
}
Else
{
throw "${KeyName}: すでに存在する名前です。"
}
# ------------------------------------
# インスタンス起動
# ------------------------------------
$InstanceName = "test-01"
$InstanceTag = New-EC2NameTag -ResourceType "instance" -TagValue $InstanceName
$EbsParams = @{
VolumeSize = "8"
VolumeType = "Gp2"
DeleteOnTermination = $true
DeviceName = "/dev/xvda"
}
$InstanceEbs = New-EC2BlockDevice @EbsParams
$ComposeUrl = "https://api.github.com/repos/docker/compose/releases/latest"
$ComposeVersion = ((Invoke-WebRequest -Method Get -Uri $ComposeUrl -UseBasicParsing).Content |
ConvertFrom-Json).name
$UserData = @"
#!/bin/bash
timedatectl set-timezone Asia/Tokyo
localectl set-locale LANG=ja_JP.UTF8
localectl set-keymap jp106
localectl set-keymap jp-OADG109A
hostnamectl set-hostname ${InstanceName}
yum update -y
yum install -y docker git
systemctl start docker
systemctl enable docker
usermod -a -G docker ec2-user
curl -L -v https://github.com/docker/compose/releases/download/${ComposeVersion}/docker-compose-`$(uname -s)-`$(uname -m) -o /usr/local/bin/docker-compose
chmod +x /usr/local/bin/docker-compose
ln -s /usr/local/bin/docker-compose /usr/bin/docker-compose
"@ -replace "`r`n", "`n"
$EC2Params = @{
MinCount = 1
MaxCount = 1
InstanceType = [Amazon.EC2.InstanceType]::T3Nano
CreditSpecification_CpuCredit = "standard"
PrivateIpAddress = "10.0.10.10"
AssociatePublicIp = $true
KeyName = $KeyName
ImageId = $TargetAmiId
SubnetId = $TargetSubnetId
SecurityGroupId = $TargetSgId
BlockDeviceMapping = $InstanceEbs
EbsOptimized = $false
DisableApiTermination = $false
#IamInstanceProfile_Name = $RoleName
TagSpecification = $InstanceTag
EncodeUserData = $true
UserData = $UserData
}
$Reservation = New-EC2Instance @EC2Params
$Instances = $Reservation.Instances
$InstanceId = $Instances.InstanceId
While((Get-EC2InstanceStatus -IncludeAllInstance $true -InstanceId $InstanceId).InstanceState.Name.Value -ne "running")
{
$LogTime = ((Get-Date).ToString("yyyy-MM-dd HH:mm:ss"))
Write-Host "[${LogTime}] Waiting for the instances to launch..."
Start-Sleep -Seconds 15
}
Start-Sleep -Seconds 180
$Instances | ForEach-Object -Process {
$ElasticIp = (New-EC2Address -Domain vpc).AllocationId
Register-EC2Address -InstanceId $_.InstanceId -AllocationId $ElasticIp
Register-EC2IamInstanceProfile -InstanceId $InstanceId -IamInstanceProfile_Name $RoleName
}
前回よりも長くなってしまいましたが、ひとつずつ見ていきます。まずお約束的な感じでデフォルトリージョンを設定します。モジュールも読み込んでおきましょう。必要なのは EC2 と IAM です。
Set-DefaultAWSRegion -Region "ap-northeast-1" -Scope Script
Import-Module -Name AWS.Tools.EC2, AWS.Tools.IdentityManagement
AWS コンポーネント特有のオブジェクト処理は基本的に関数化して記述が冗長にならないようにします。今回は以下4つを関数化しています。1, 2は前回と全く同じなので割愛します。
New-EC2NameTag
: Name タグを定義する関数(前回と同じ)New-EC2Filter
: AWS リソースに対するコマンド結果をフィルタする関数(前回と同じ)New-EC2Ipv4Range
: セキュリティグループに渡す IPv4 許可設定を定義する関数New-EC2BlockDevice
: EBS の構成を定義する関数
3番目のNew-EC2Ipv4Range
ですが、オブジェクトが二重構造なっていて複雑なので、見通しをよくするために関数にしています。
Function New-EC2Ipv4Range
{
[OutputType([System.Object])]
[CmdletBinding()]
Param
(
[Parameter(Mandatory=$True)]
[ValidateNotNullOrEmpty()]
[string]$CidrIp,
[Parameter()]
[ValidateNotNullOrEmpty()]
[string]$Description,
[Parameter(Mandatory=$True)]
[ValidateNotNullOrEmpty()]
[string]$IpProtocol,
[Parameter(Mandatory=$True)]
[ValidateNotNullOrEmpty()]
[int]$FromPort,
[Parameter(Mandatory=$True)]
[ValidateNotNullOrEmpty()]
[int]$ToPort
)
$IpRange = New-Object -TypeName Amazon.EC2.Model.IpRange
$IpRange.CidrIp = $CidrIp
$IpRange.Description = $Description
$IpPermission = New-Object -TypeName Amazon.EC2.Model.IpPermission
$IpPermission.IpProtocol = $IpProtocol
$IpPermission.FromPort = $FromPort
$IpPermission.ToPort = $ToPort
$IpPermission.Ipv4Ranges.Add($IpRange)
return $IpPermission
}
ちなみに公式ドキュメントとは少し違う方法でアプローチしています。まず子オブジェクトとして、IP レンジをAmazon.EC2.Model.IpRange
で定義します。このオブジェクトはDescription
が定義できるので、コンソールで手動作成した場合の結果に寄せることができます。リファレンスにもある通り、どちらも文字列が入ります。
PS C:\WINDOWS\system32> New-Object -TypeName Amazon.EC2.Model.IpRange -Property @{ CidrIp="0.0.0.0/0"; Description="全開" }
CidrIp Description
------ -----------
0.0.0.0/0 全開
続いて親オブジェクトとなるAmazon.EC2.Model.IpPermission
です。こんな構造です。上で作った子オブジェクトはIpv4Ranges
にAdd
します。
PS C:\WINDOWS\system32> New-Object -TypeName Amazon.EC2.Model.IpPermission
FromPort : 0
IpProtocol :
Ipv4Ranges : {}
Ipv6Ranges : {}
PrefixListIds : {}
ToPort : 0
UserIdGroupPairs : {}
サンプルは以下のようになります。これをいくつか作ってセキュリティグループに付与することでアクセス許可を定義します。
PS C:\WINDOWS\system32> $IpPermission = New-EC2Ipv4Range -CidrIp "0.0.0.0/0" -IpProtocol "tcp" -FromPort 80 -ToPort 80 -Description "http: all"
PS C:\WINDOWS\system32> $IpPermission
FromPort : 80
IpProtocol : tcp
Ipv4Ranges : {Amazon.EC2.Model.IpRange}
Ipv6Ranges : {}
PrefixListIds : {}
ToPort : 80
UserIdGroupPairs : {}
PS C:\WINDOWS\system32> $IpPermission | Select-Object -ExpandProperty Ipv4Ranges
CidrIp Description
------ -----------
0.0.0.0/0 http: all
PS C:\WINDOWS\system32> $IpPermission | ConvertTo-Json
{
"IpRanges": [
],
"FromPort": 80,
"IpProtocol": "tcp",
"Ipv4Ranges": [
{
"CidrIp": "0.0.0.0/0",
"Description": "http: all"
}
],
"Ipv6Ranges": [
],
"PrefixListIds": [
],
"ToPort": 80,
"UserIdGroupPairs": [
]
}
4番目のNew-EC2BlockDevice
は EC2 インスタンスにアタッチする EBS を構成するための関数です。関数化することでさっぱりします。
Function New-EC2BlockDevice
{
[OutputType([System.Object])]
[CmdletBinding()]
Param
(
[Parameter(Mandatory=$True)]
[ValidateNotNullOrEmpty()]
[int]$VolumeSize,
[Parameter(Mandatory=$True)]
[ValidateSet("Gp2", "Io1", "Io2", "Sc1", "St1", "Standard")]
[Amazon.EC2.VolumeType]$VolumeType,
[Parameter()]
[ValidateNotNullOrEmpty()]
[bool]$DeleteOnTermination = $true,
[Parameter()]
[ValidateNotNullOrEmpty()]
[string]$DeviceName = "/dev/xvda"
)
$BlockDevice = New-Object -TypeName Amazon.EC2.Model.EbsBlockDevice
$BlockDevice.VolumeSize = $VolumeSize
$BlockDevice.VolumeType = $VolumeType
$BlockDevice.DeleteOnTermination = $DeleteOnTermination
$BlockDeviceMapping = New-Object -TypeName Amazon.EC2.Model.BlockDeviceMapping
$BlockDeviceMapping.DeviceName = $DeviceName
$BlockDeviceMapping.Ebs = $BlockDevice
return $BlockDeviceMapping
}
こちらも二重構造です。子オブジェクトAmazon.EC2.Model.EbsBlockDevice
が EBS の定義になっており、親オブジェクトAmazon.EC2.Model.BlockDeviceMapping
が EBS のマッピング情報になっています。なお、VolumeType
は選べる値が決まっているので、ValidateSet()
でリストの中から選ばせるようにしています。DeleteOnTermination
はインスタンスが削除されたときに一緒に EBS も削除するかどうかのフラグです。サンプルで作ってみましょう。
PS C:\WINDOWS\system32> $InstanceEbs = New-EC2BlockDevice -VolumeSize "8" -VolumeType "Gp2" -DeleteOnTermination $true -DeviceName "/dev/xvda"
PS C:\WINDOWS\system32> $InstanceEbs | Format-List
DeviceName : /dev/xvda
Ebs : Amazon.EC2.Model.EbsBlockDevice
NoDevice :
VirtualName :
PS C:\WINDOWS\system32> $InstanceEbs | Select-Object -ExpandProperty Ebs
DeleteOnTermination : True
Encrypted : False
Iops : 0
KmsKeyId :
SnapshotId :
VolumeSize : 8
VolumeType : gp2
PS C:\WINDOWS\system32> $InstanceEbs | ConvertTo-Json
{
"DeviceName": "/dev/xvda",
"Ebs": {
"DeleteOnTermination": true,
"Encrypted": false,
"Iops": 0,
"KmsKeyId": null,
"SnapshotId": null,
"VolumeSize": 8,
"VolumeType": {
"Value": "Gp2"
}
},
"NoDevice": null,
"VirtualName": null
}
どちらの関数も横に長いので、実際使うときはスプラッティングしたほうがすっきりします。
$IpPermissionParams = @{
CidrIp = "0.0.0.0/0"
IpProtocol = "tcp"
FromPort = 80
ToPort = 80
Description= "http: all"
}
$IpPermission = New-EC2Ipv4Range @IpPermissionParams
$EbsParams = @{
VolumeSize = "8"
VolumeType = "Gp2"
DeleteOnTermination = $true
DeviceName = "/dev/xvda"
}
$InstanceEbs = New-EC2BlockDevice @EbsParams
前回の記事で作った VPC に構築するため、VPC とサブネットの情報がわかっている必要があります。フィルタ関数を使って ID を取得しておきます。
$VpcName = "vpc-01"
$VpcTagFilter = New-EC2Filter -Name "tag:Name" -Values $VpcName
$TargetVpcId = (Get-EC2Vpc -Filter $VpcTagFilter).VpcId
$SubnetName = "subnet-pub-a"
$SubnetTagFilter = New-EC2Filter -Name "tag:Name" -Values $SubnetName
$TargetSubnetId = (Get-EC2Subnet -Filter $SubnetTagFilter).SubnetId
マシンイメージは Amazon Linux 2の x86_64 の最新版を使います。フィルタと作成日付を使って自動で最新を取得するようにします。イメージ名での絞り込みですが、以下のようにワイルドカードで絞り込んでみます。
$AmiFilter = New-EC2Filter -Name "name" -Values "amzn2-ami-hvm-*-x86_64-gp2"
プロパティCreationDate
を使って、名前で絞り込んだ中でいちばん新しいイメージを取得します。
$TargetAmi = @(Get-EC2Image -Filter $AmiFilter | Sort-Object -Property "CreationDate" -Descending)[0]
$TargetAmiId = $TargetAmi.ImageId
EC2 インスタンスにアタッチするセキュリティグループを作ります。以下の流れです。
New-EC2SecurityGroup
で空のセキュリティグループを作成New-EC2Ipv4Range
関数で IPv4 許可用のオブジェクトを作成Grant-EC2SecurityGroupIngress
でセキュリティグループに IPv4 許可を設定今回作るのはブログ運営用の Web サーバーなので、以下のような感じにします。
タイプ | プロトコル | ポート範囲 | ソース | 説明 |
---|---|---|---|---|
HTTPS | TCP | 443 | 0.0.0.0/0 | https: all |
HTTP | TCP | 80 | 自分の GIP | http: my-gip |
SSH | TCP | 22 | 自分の GIP | ssh: my-gip |
すべての ICMP - IPv4 | すべて | 該当なし | 自分の GIP | icmp: my-gip |
すべてのトラフィック | すべて | すべて | 10.0.0.0/16 | all: vpc |
New-Ec2Ipv4Range
関数を使いますが、以下はセンスのないやり方です。可読性も低く、保守性も低いです。
$SgName = "sec-01"
$SgTag = New-EC2NameTag -ResourceType "security-group" -TagValue $SgName
・・・
# 空のセキュリティグループを作る
$TargetSgId = New-EC2SecurityGroup -VpcId $TargetVpcId -GroupName $SgName -GroupDescription $SgName -TagSpecification $SgTag
# IP 許可設定を定義する
$IpPermission1 = New-EC2Ipv4Range -CidrIp "0.0.0.0/0" -IpProtocol "tcp" -FromPort "443" -ToPort "443" -Description "https: all"
$IpPermission2 = New-EC2Ipv4Range -CidrIp "111.111.111.111/32" -IpProtocol "tcp" -FromPort "80" -ToPort "80" -Description "http: my-gip"
$IpPermission3 = New-EC2Ipv4Range -CidrIp "111.111.111.111/32" -IpProtocol "tcp" -FromPort "22" -ToPort "22" -Description "ssh: my-gip"
$IpPermission4 = New-EC2Ipv4Range -CidrIp "111.111.111.111/32" -IpProtocol "icmp" -FromPort "-1" -ToPort "-1" -Description "icmp: my-gip"
$IpPermission5 = New-EC2Ipv4Range -CidrIp "10.0.0.0/16" -IpProtocol "-1" -FromPort "0" -ToPort "0" -Description "all: vpc"
# IP 許可設定用の配列を用意する
$IpPermissions = @($IpPermission1, $IpPermission2, $IpPermission3, $IpPermission4, $IpPermission5)
# セキュリティグループに IPv4 許可設定を入れる
Grant-EC2SecurityGroupIngress -GroupId $TargetSgId -IpPermissions $IpPermissions
この場合 PowerShell ではどう書くのが適切なのか、まだ試行錯誤中なのですが、今回は以下のような感じで書きました。
$SgName = "sec-01"
$SgTag = New-EC2NameTag -ResourceType "security-group" -TagValue $SgName
・・・
# 空のセキュリティグループを作る
$SgParams = @{
VpcId = $TargetVpcId
GroupName = $SgName
GroupDescription = $SgName
TagSpecification = $SgTag
}
$TargetSgId = New-EC2SecurityGroup @SgParams
# IP 許可設定をオブジェクト配列として定義する
$IpRangeObjects = @(
[PSCustomObject]@{
CidrIp = "0.0.0.0/0"
IpProtocol = "tcp"
FromPort = 443
ToPort = 443
Description = "https: all"
}
[PSCustomObject]@{
CidrIp = "111.111.111.111/32"
IpProtocol = "tcp"
FromPort = 80
ToPort = 80
Description = "http: my-gip"
}
[PSCustomObject]@{
CidrIp = "111.111.111.111/32"
IpProtocol = "tcp"
FromPort = 22
ToPort = 22
Description = "ssh: my-gip"
}
[PSCustomObject]@{
CidrIp = "111.111.111.111/32"
IpProtocol = "icmp"
FromPort = -1
ToPort = -1
Description = "icmp: my-gip"
}
[PSCustomObject]@{
CidrIp = "10.0.0.0/16"
IpProtocol = "-1"
FromPort = 0
ToPort = 0
Description = "all: vpc"
}
)
# セキュリティグループに付与するためのオブジェクト配列に変換する
$IpPermissions = @()
ForEach($IpRangeObject In $IpRangeObjects)
{
$IpPermissionParams = @{
CidrIp = $IpRangeObject.CidrIp
IpProtocol = $IpRangeObject.IpProtocol
FromPort = $IpRangeObject.FromPort
ToPort = $IpRangeObject.ToPort
Description = $IpRangeObject.Description
}
$IpPermissions += New-EC2Ipv4Range @IpPermissionParams
}
# セキュリティグループに IPv4 許可設定を入れる
Grant-EC2SecurityGroupIngress -GroupId $TargetSgId -IpPermissions $IpPermissions
順を追って説明します。まず、New-EC2SecurityGroup
でハコを作ります。
$SgParams = @{
VpcId = $TargetVpcId
GroupName = $SgName
GroupDescription = $SgName
TagSpecification = $SgTag
}
$TargetSgId = New-EC2SecurityGroup @SgParams
IP 許可設定のデータ構造を定義します。IP レンジひとつひとつはPSCustomObject
で定義します。それらを配列に格納してオブジェクト配列として保持しておきます。こんな感じで、データを構造化しておくことでコードの見通しがよくなります。余談ですが、こういったデータ構造を外部ファイルとして持っておきたい場合は、JSON 形式で保存しておいてConvertFrom-Json
で変換して使う方法も考えられます。
# IP 許可設定を定義する
$IpRangeObjects = @(
[PSCustomObject]@{
CidrIp = "0.0.0.0/0"
IpProtocol = "tcp"
FromPort = 443
ToPort = 443
Description = "https: all"
}
[PSCustomObject]@{
CidrIp = "111.111.111.111/32"
IpProtocol = "tcp"
FromPort = 80
ToPort = 80
Description = "http: my-gip"
}
[PSCustomObject]@{
CidrIp = "111.111.111.111/32"
IpProtocol = "tcp"
FromPort = 22
ToPort = 22
Description = "ssh: my-gip"
}
[PSCustomObject]@{
CidrIp = "111.111.111.111/32"
IpProtocol = "icmp"
FromPort = -1
ToPort = -1
Description = "icmp: my-gip"
}
[PSCustomObject]@{
CidrIp = "10.0.0.0/16"
IpProtocol = "-1"
FromPort = 0
ToPort = 0
Description = "all: vpc"
}
)
ちなみに、こんな感じのデータ構造になります。
PS C:\WINDOWS\system32> $IpRangeObjects | Format-Table -AutoSize -Wrap
CidrIp IpProtocol FromPort ToPort Description
------ ---------- -------- ------ -----------
0.0.0.0/0 tcp 443 443 https: all
111.111.111.111/32 tcp 80 80 http: my-gip
111.111.111.111/32 tcp 22 22 ssh: my-gip
111.111.111.111/32 icmp -1 -1 icmp: my-gip
10.0.0.0/16 -1 0 0 all: vpc
配列の各要素に対して、ループの中でNew-EC2Ipv4Range
関数を使って IPv4 許可用のオブジェクトに変換し、配列にひとつずつ加算しなおします。管理しやすいように単純な構造でデータを保持しておいて、使うときは必要な形式に変換してから使うという発想です。
# IP 許可設定用の配列を用意する
$IpPermissions = @()
ForEach($IpRangeObject In $IpRangeObjects)
{
$IpPermissionParams = @{
CidrIp = $IpRangeObject.CidrIp
IpProtocol = $IpRangeObject.IpProtocol
FromPort = $IpRangeObject.FromPort
ToPort = $IpRangeObject.ToPort
Description = $IpRangeObject.Description
}
$IpPermissions += New-EC2Ipv4Range @IpPermissionParams
}
あとは変換後の配列をGrant-EC2SecurityGroupIngress
に渡すだけです。
# セキュリティグループに IPv4 許可設定を入れる
Grant-EC2SecurityGroupIngress -GroupId $TargetSgId -IpPermissions $IpPermissions
EC2 インスタンスに付与する IAM ロールを作ります。以下の流れです。
このあたりの概念はドキュメントを読まないとわかりませんでした。コンソールで手動作成するときには意識しなくてよいことを意識する必要があります。最初の IAM ロールの作成ですが、信頼ポリシーを定義する必要があります。信頼ポリシーというのは「この IAM ロールをアタッチすることになるであろう EC2 インスタンスに対してこの IAM ロールを引き受けることを許可しますよ」というポリシーです。なんとまわりくどい。平たく言えば「この IAM ロールに成りすます許可を与えますよ」ということです。一方アクセス許可ポリシーは「EC2 インスタンスがほかの AWS サービスを操作することを許可しますよ」というポリシーです。このふたつは明確に区別されています。IAM ロールを作成するには信頼ポリシーが必須です。アクセス許可ポリシーはあとからいくらでも取ったり付けたりできます。信頼ポリシーは以下のように json で定義しますが、これは多くの場合コピペで問題ありません。ec2
の部分がlambda
になったりする以外はそのまま流用できます。
$RoleName = "TEST-Role"
$RoleTag = New-Object -TypeName Amazon.IdentityManagement.Model.Tag -Property @{ Key="Name"; Value=$RoleName }
$AssumeRolePolicyDocument = @'
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "",
"Effect": "Allow",
"Principal": {
"Service": "ec2.amazonaws.com"
},
"Action": "sts:AssumeRole"
}
]
}
'@
$RoleParams = @{
AssumeRolePolicyDocument = $AssumeRolePolicyDocument
RoleName = $RoleName
Description = $RoleName
Tag = $RoleTag
}
New-IAMRole @RoleParams
空の IAM ロールを作成したら、アクセス許可ポリシーをアタッチします。今回は S3 からコンテンツを GET する権限でもつけておきましょう。ポリシーは ARN で指定します。
$PolicyArn = "arn:aws:iam::aws:policy/AmazonS3ReadOnlyAccess"
Register-IAMRolePolicy -PolicyArn $PolicyArn -RoleName $RoleName
ここでコンソールから手動作成するときには意識する必要のなかったインスタンスプロファイルという概念が出てきます。こういうことらしいです。
インスタンスプロファイルは、Amazon EC2 インスタンスの起動時にインスタンスにアタッチできるロールのコンテナです。インスタンスプロファイルに含めることができるロールは 1 つのみであり、緩和できません。AWS マネジメントコンソールを使用してロールを作成すると、ロールと同じ名前のインスタンスプロファイルが自動的に作成されます。
AWS CLI や AWS Tools for PowerShell でやる場合はインスタンスプロファイルも作成する必要があります。次のコマンドです。名前はロールと同じにします。
New-IAMInstanceProfile -InstanceProfileName $RoleName
最後に、上で作成した空のインスタンスプロファイルに IAM ロールを追加します。次のコマンドです。これで IAM ロールが準備できました。流れがわかれば難しくありません。
Add-IAMRoleToInstanceProfile -InstanceProfileName $RoleName -RoleName $RoleName
EC2 インスタンスへの初回ログインに必要なキーペアを作成します。基本的にはドキュメントの通りで問題ありません。コツが2点あります。
New-EC2KeyPair
するときにKeyMaterial
だけファイル出力する前者は、Teraterm 等で SSH する場合を考えるとプライベートキーだけをファイル出力しておいたほうがなにかと都合がいいからです。後者は、ascii を指定してエンコードしないと openssl 等を使った場合に正しくファイルを読み込めないらしいからです。以下のようにしておきます。
(New-EC2KeyPair -KeyName "TEST-Key").KeyMaterial | Out-File -Encoding ascii -FilePath プライベートキーの保存パス
なお、あとからGet-EC2KeyPair
してもKeyMaterial
は取れません。New-EC2KeyPair
するときにKeyMaterial
を取り逃してしまうとログインできなくなるので気を付けましょう。
いよいよ EC2 インスタンスを作成していきます。すべてを自動でうまいことやるにはかなり試行錯誤が必要でしたので、そのノウハウも書いていきます。インスタンスタイプはいちばん小さい t3.nano とします。まずはパラメータに渡す材料をそろえます。Name タグはもちろんですが、自作関数のNew-EC2BlockDevice
で EBS を定義しておきましょう。
$InstanceName = "test-01"
$InstanceTag = New-EC2NameTag -ResourceType "instance" -TagValue $InstanceName
$EbsParams = @{
VolumeSize = "8"
VolumeType = "Gp2"
DeleteOnTermination = $true
DeviceName = "/dev/xvda"
}
$InstanceEbs = New-EC2BlockDevice @EbsParams
続いて初期設定を自動で流し込むための UserData ですが、やりたいことは以下です。
yum update
する実はここが鬼門でして、かなりの試行錯誤を重ねました。次のようなインシデントや思惑が複合的に絡み合い、解決に時間を要しました。
yum
が失敗したcurl
が失敗したdocker-compose
の最新版を自動で取得したくて試行錯誤した
1,2 番目がいちばん苦戦しました。原因解明に時間を要しましたが、なんとか答えにたどり着くことができました。考えてみれば単純で、当然の話なのですが、、、今回作成する EC2 インスタンスは Web サーバーなので Elastic IP を付与するのですが、Elastic IP は EC2 インスタンスの起動が完了してから付与する流れになります。しかし UserData は起動中に実行されます。つまり、UserData で外部通信したいのにプライベート IP しか持っていない状態だったわけです。これだと当然、S3 にある yum リポジトリにアクセスできませんし、GitHub にもアクセスできませんし、そもそもcurl
も通りません。解決方法としては、New-EC2Instance
のパラメータに-AssociatePublicIp $true
を指定して、EC2 インスタンス起動中の状態でもパブリック IP がちゃんと払い出されるようにしました。
ちなみに、問題を複雑化させていた要因のひとつに S3 への VPC エンドポイントを作成済みだったというのがあります。この場合、パブリック IP がなくても S3 上のリポジトリと通信できてしまうので、yum
は問題なく通ります。一方で GitHub へのcurl
は通りません。そして、ここからさらに一段階あります。デバッグしていて気づいたのですが、docker-compose
を DL するためのリクエストは内部的に S3 にリダイレクトされていました。つまり「yum は VPC エンドポイントがあるからパブリック IP がなくても通信できる、curl は S3 にリダイレクトされているけど一度インターネットに出ているから通信できない」という状況だったと推測されます。今回、この複雑な状況があったがために、起動時にパブリック IP が付いていないことが原因という単純明快な答えになかなかたどり着けませんでした。なにが原因なのかまじでわかりづらかったです。。
3番目は地味に気づきづらかったのですが、作業マシンは Windows 10で改行コードは基本 CRLF です。一方ヒアドキュメントで定義した UserData を流し込むのは Amazon Linux 2で改行コードは基本 LF です。この場合どうなるかというと、UserData を流し込んだ際に CR が不要な文字列として残存し、コマンドが失敗します。これは UserData を定義する際に PowerShell 側できっちり置換すれば問題ありません。
4番目に関しては、バージョンをベタ書きするのがどうしてもイヤだったので、Invoke-WebRequest
を使って API でバージョンを取得してヒアドキュメントの中に埋め込むことにしました。これはかなりいい感じにできたかなと思います。以上を踏まえて、UserData のくだりはこんな感じです。
$ComposeUrl = "https://api.github.com/repos/docker/compose/releases/latest"
$ComposeVersion = ((Invoke-WebRequest -Method Get -Uri $ComposeUrl -UseBasicParsing).Content |
ConvertFrom-Json).name
$UserData = @"
#!/bin/bash
timedatectl set-timezone Asia/Tokyo
localectl set-locale LANG=ja_JP.UTF8
localectl set-keymap jp106
localectl set-keymap jp-OADG109A
hostnamectl set-hostname ${InstanceName}
yum update -y
yum install -y docker git
systemctl start docker
systemctl enable docker
usermod -a -G docker ec2-user
curl -L -v https://github.com/docker/compose/releases/download/${ComposeVersion}/docker-compose-`$(uname -s)-`$(uname -m) -o /usr/local/bin/docker-compose
chmod +x /usr/local/bin/docker-compose
ln -s /usr/local/bin/docker-compose /usr/bin/docker-compose
"@ -replace "`r`n", "`n"
これでパラメータがそろったので、ここぞとばかりにすべてNew-EC2Instance
に流し込みます。
$EC2Params = @{
MinCount = 1
MaxCount = 1
InstanceType = [Amazon.EC2.InstanceType]::T3Nano # 文字列でも書けますがオブジェクトとして書いています
CreditSpecification_CpuCredit = "standard" # バーストする CPU を持つ T2,T3,T3a のみ有効なパラメータです
PrivateIpAddress = "10.0.10.10"
AssociatePublicIp = $true # UserData を流し込む際は必須です
KeyName = $KeyName
ImageId = $TargetAmiId
SubnetId = $TargetSubnetId
SecurityGroupId = $TargetSgId
BlockDeviceMapping = $InstanceEbs
EbsOptimized = $false
DisableApiTermination = $true
TagSpecification = $InstanceTag
EncodeUserData = $true
UserData = $UserData
}
$Reservation = New-EC2Instance @EC2Params
$Instances = $Reservation.Instances
EC2 インスタンスを起動しましたが、これですべてではありません。起動後にやることがあります。以下です。
EC2 インスタンスの起動後に後追いでなにかやる場合は、EC2 インスタンスがなにかやれる状態になっているか、つまり EC2 インスタンスが起動しているかどうかを判定する必要があります。そこで以下のコードです。
While((Get-EC2InstanceStatus -IncludeAllInstance $true -InstanceId $Instances.InstanceId).InstanceState.Name.Value -ne "running")
{
$LogTime = ((Get-Date).ToString("yyyy-MM-dd HH:mm:ss"))
Write-Host "[${LogTime}] Waiting for the instances to launch..."
Start-Sleep -Seconds 15
}
Start-Sleep -Seconds 180
こちらを参考にしました。(Get-EC2InstanceStatus).InstanceState.Name.Value
というように、EC2 インスタンスの状態はGet-EC2InstanceStatus
のオブジェクト構造を掘っていけば取れるというのがポイントです。あともうひとつ重要なポイントがあるのですが、-IncludeAllInstance
にTrue
を渡さないと、状態がrunning
のインスタンスしか返してくれません。これは大きな落とし穴なので、注意すべきです。あとは15秒ごとに状態を確認し、running
だったらループを抜けるという典型的な While 文です。が、実は状態がrunning
となった場合でもしばらくの間はまだ初期化中となっており、完全体ではありません。そのために最後に3分間、放置しています。
さて、次で最後のブロックになります。EC2 インスタンスに対する後処理です。注意点ですが、MaxCount
MinCount
というパラメータがあることからもわかる通り、New-EC2Instance
が呼び出しているRunInstances
API は一度に複数のインスタンスを起動できるように設計されています。そのため後処理においても複数起動した場合の考慮が必要になります。なお、その場合は-PrivateIpAddress
パラメータによる IP 指定はできないようです。コードは以下のようにしました。
$Instances | ForEach-Object -Process {
$ElasticIp = (New-EC2Address -Domain vpc).AllocationId
Register-EC2Address -InstanceId $_.InstanceId -AllocationId $ElasticIp
Register-EC2IamInstanceProfile -InstanceId $_.InstanceId -IamInstanceProfile_Name $RoleName
}
ElasticIP のアタッチと IAM ロールの入ったインスタンスプロファイルのアタッチをしています。後者ですが、実はNew-EC2Instance
は-IamInstanceProfile_Name
-IamInstanceProfile_Arn
というパラメータを持っており、起動すると同時に IAM ロール(の格納されたインスタンスプロファイル)をアタッチすることができます。なぜそれをしなかったかというと、ひとつのスクリプトの中で IAM ロールの作成、EC2 インスタンスの作成、および IAM ロールのアタッチまでやるといった場合、EC2 インスタンス作成時、その前段で実行した IAM ロールの作成がまだ終わっていないという状態になり、IAM ロールが存在しない旨のエラーとなったためです。これを回避するために、EC2 インスタンスの起動を待ってから後付けするという方法を取っています。
本記事で「Amazon EC2, Docker, Django で技術ブログを作った」シリーズにおける AWS 基盤の構築が終了となります。とはいえインフラ編はもう少し続きます。今回はスクリプトの作成でかなりの検証と試行錯誤が必要だったので、おのずと勉強になりました。記事を書くために検証や試行錯誤のプロセスを経ることで知識が定着する=知識はアウトプットで定着する、という図式が腑に落ちた気がします。次回は Docker で Django を動かすためのコンテナを構築します。また、そろそろシリーズ以外の単発記事も書いていこうかと思っています。たとえば今回の記事で触れたセキュリティグループのくだりはもっと広げられるので、個別記事に切り出してみてもいいように思いました。そのうち書いてみたいと思います。