2021-02-18

2020-09-28

技術ノート

eyecatch
minbase.io 構築シリーズのインフラ編その3です。前回で VPC の構築が完了したので、EC2 インスタンスを構築していきます。

はじめに

AWS Tools for PowerShell を使って EC2 インスタンスを構築します。流れとしては、EC2 インスタンスを立てるのに必要な各種コンポーネントを順を追って作成していき、 最後に EC2 インスタンスを起動する際、それらをすべてパラメータとして渡します。

検証済の動作環境

OS モジュール
Windows 10 Pro 2004 Windows PowerShell 5.1 (.NET Framework 4.8)

動作条件

  • AWS Tools for PowerShell がセットアップされていること
  • サブネット、ルートテーブル等、VPC 内の最低限のコンポーネントが整備されていること

PowerShell で EC2 インスタンスを構築する

一口に EC2 インスタンスを構築するといっても、やることはいろいろあります。ざっくり以下のような感じでしょうか。

  • 対象 VPC 情報の取得
  • Amazon マシンイメージの取得
  • セキュリティグループの作成
  • IAM ロールの作成
  • キーペアの作成
  • 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は前回と全く同じなので割愛します。

  1. New-EC2NameTag: Name タグを定義する関数(前回と同じ)
  2. New-EC2Filter: AWS リソースに対するコマンド結果をフィルタする関数(前回と同じ)
  3. New-EC2Ipv4Range: セキュリティグループに渡す IPv4 許可設定を定義する関数
  4. 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です。こんな構造です。上で作った子オブジェクトはIpv4RangesAddします。


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 に構築するため、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 マシンイメージの取得

マシンイメージは 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 インスタンスにアタッチするセキュリティグループを作ります。以下の流れです。

  1. New-EC2SecurityGroupで空のセキュリティグループを作成
  2. New-EC2Ipv4Range関数で IPv4 許可用のオブジェクトを作成
  3. 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

IAM ロールの作成

EC2 インスタンスに付与する IAM ロールを作ります。以下の流れです。

  1. IAM ロールの作成
  2. IAM ロールにアクセス許可ポリシーをアタッチ
  3. インスタンスプロファイルの作成
  4. インスタンスプロファイルに 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だけファイル出力する
  • ファイル出力するときは ascii にエンコードする

前者は、Teraterm 等で SSH する場合を考えるとプライベートキーだけをファイル出力しておいたほうがなにかと都合がいいからです。後者は、ascii を指定してエンコードしないと openssl 等を使った場合に正しくファイルを読み込めないらしいからです。以下のようにしておきます。


(New-EC2KeyPair -KeyName "TEST-Key").KeyMaterial | Out-File -Encoding ascii -FilePath プライベートキーの保存パス

なお、あとからGet-EC2KeyPairしてもKeyMaterialは取れません。New-EC2KeyPairするときにKeyMaterialを取り逃してしまうとログインできなくなるので気を付けましょう。

EC2 インスタンスの起動

いよいよ 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する
  • docker, docker-compose, git をインストールして使える状態にする

実はここが鬼門でして、かなりの試行錯誤を重ねました。次のようなインシデントや思惑が複合的に絡み合い、解決に時間を要しました。

  1. http/https を全開にしたのに、UserData 内で実行する場合に限りyumが失敗した
  2. http/https を全開にしたのに、UserData 内で実行する場合に限りcurlが失敗した
  3. Amazon Linux 2上で UserData を実行したときに不要な文字列が混在してコマンドが失敗した
  4. 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 インスタンスを起動しましたが、これですべてではありません。起動後にやることがあります。以下です。

  • Elastic IP を作成し、アタッチする
  • インスタンスプロファイルをアタッチする

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のオブジェクト構造を掘っていけば取れるというのがポイントです。あともうひとつ重要なポイントがあるのですが、-IncludeAllInstanceTrueを渡さないと、状態がrunningのインスタンスしか返してくれません。これは大きな落とし穴なので、注意すべきです。あとは15秒ごとに状態を確認し、runningだったらループを抜けるという典型的な While 文です。が、実は状態がrunningとなった場合でもしばらくの間はまだ初期化中となっており、完全体ではありません。そのために最後に3分間、放置しています。

さて、次で最後のブロックになります。EC2 インスタンスに対する後処理です。注意点ですが、MaxCountMinCountというパラメータがあることからもわかる通り、New-EC2Instanceが呼び出しているRunInstancesAPI は一度に複数のインスタンスを起動できるように設計されています。そのため後処理においても複数起動した場合の考慮が必要になります。なお、その場合は-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 を動かすためのコンテナを構築します。また、そろそろシリーズ以外の単発記事も書いていこうかと思っています。たとえば今回の記事で触れたセキュリティグループのくだりはもっと広げられるので、個別記事に切り出してみてもいいように思いました。そのうち書いてみたいと思います。