2021-02-05

2020-09-14

技術ノート

eyecatch
minbase.io 構築シリーズのインフラ編その2です。AWS でサーバーを立てるためのインフラ基盤を構築していきます。今回はコマンドラインで VPC を構築します。

はじめに

ひとつ前の記事で「AWS Tools for PowerShell を使ってコマンドラインで VPC を作る」と宣言し、環境設定まで終わったので、今回は実際に構築していきます。CloudFormation を使った VPC 構築は巷にいろいろ情報がありますが、同じことをわざわざ PowerShell でやろうという記事はあまり見かけません。やりがいがありますね!

検証済の動作環境

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

動作条件

  • AWS Tools for PowerShell がセットアップされていること

PowerShell で VPC を構築する

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

  • VPC の作成
  • サブネットの作成
  • インターネットゲートウェイの作成
  • 仮想プライベートゲートウェイの作成
  • ルートテーブルの作成と設定
  • VPC エンドポイントの作成

スクリプト全体像

以下が全体像です。CIDR は今回「10.0.0.0/16」とします。

スクリプト

Set-DefaultAWSRegion -Region "ap-northeast-1" -Scope Script
Import-Module -Name AWS.Tools.EC2

# ------------------------------------
#  Name タグ設定用の関数
# ------------------------------------

Function New-EC2NameTag
{
    [OutputType([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([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
}

# ------------------------------------
#  VPC の作成
# ------------------------------------

$CidrBlock = "10.0.0.0/16"
$VpcName = "vpc-01"
$VpcTag = New-EC2NameTag -ResourceType "vpc" -TagValue $VpcName
$VpcTagFilter = New-EC2Filter -Name "tag:Name" -Values $VpcName

If( -not (Get-EC2Vpc -Filter $VpcTagFilter))
{
    $TargetVpc = New-EC2Vpc -CidrBlock $CidrBlock -InstanceTenancy default -TagSpecification $VpcTag
    $TargetVpcId = $TargetVpc.VpcId
}
Else
{
    throw "${VpcName}: すでに存在する名前です。"
}

Edit-EC2VpcAttribute -VpcId $TargetVpcId -EnableDnsSupport $True
Edit-EC2VpcAttribute -VpcId $TargetVpcId -EnableDnsHostname $True

# ------------------------------------
#  サブネットの作成
# ------------------------------------

$Subnets = [ordered]@{
    "subnet-pub-a" = "10.0.0.0/24";
    "subnet-pub-c" = "10.0.1.0/24";
    "subnet-pri-a" = "10.0.2.0/24";
    "subnet-pri-c" = "10.0.3.0/24";
}

$SubnetTag = New-Object -TypeName Amazon.EC2.Model.TagSpecification
$SubnetTag.ResourceType = "subnet"

ForEach($Subnet In $Subnets.GetEnumerator())
{
    $SubnetTagFilter = New-EC2Filter -Name "tag:Name" -Values $Subnet.Key

    If( -not (Get-EC2Subnet -Filter $SubnetTagFilter))
    {
        $Tag = @{ Key="Name"; Value=$Subnet.Key }
        $SubnetTag.Tags.Add($Tag)

        If($Subnet.Key -match ".*-a$")
        {
            $AvailabilityZone = "ap-northeast-1a"
        }
        ElseIf($Subnet.Key -match ".*-c$")
        {
            $AvailabilityZone = "ap-northeast-1c"
        }
        Else
        {
            throw "$($Subnet.Key): サブネット名が条件外です。"
        }

        $SubnetParams = @{
            AvailabilityZone = $AvailabilityZone
            CidrBlock = $Subnet.Value
            VpcId = $TargetVpcId
            TagSpecification = $SubnetTag
        }
        New-EC2Subnet @SubnetParams
        $SubnetTag.Tags.Clear()
    }
    Else
    {
        throw "$($Subnet.Key): すでに存在する名前です。"
    }
}

# ------------------------------------
#  インターネットゲートウェイの作成
# ------------------------------------

$IgwName = "Igw-01"
$IgwTag = New-EC2NameTag -ResourceType "internet-gateway" -TagValue $IgwName
$IgwTagFilter = New-EC2Filter -Name "tag:Name" -Values $IgwName

If( -not (Get-EC2InternetGateway -Filter $IgwTagFilter))
{
    $TargetIgw = New-EC2InternetGateway -TagSpecification $IgwTag
    $TargetIgwId = $TargetIgw.InternetGatewayId
}
Else
{
    throw "${IgwName}: すでに存在する名前です。"
}

Add-EC2InternetGateway -VpcId $TargetVpcId -InternetGatewayId $TargetIgwId

# ------------------------------------
#  仮想プライベートゲートウェイの作成
# ------------------------------------

$VgwName = "vgw-01"
$VgwTag = New-EC2NameTag -ResourceType "vpn-gateway" -TagValue $VgwName
$IgwTagFilter = New-EC2Filter -Name "tag:Name" -Values $VgwName

If( -not (Get-EC2VpnGateway -Filter $VgwTagFilter))
{
    $TargetVgw = New-EC2VpnGateway -Type ipsec.1 -TagSpecification $VgwTag
    $TargetVgwId = $TargetVgw.VpnGatewayId
}
Else
{
    throw "${VgwName}: すでに存在する名前です。"
}

Add-EC2VpnGateway -VpcId $TargetVpcIdId -VpnGatewayId $TargetVgwId

# ------------------------------------
#  ルートテーブルの作成と設定
# ------------------------------------

$RtbNames = @("rtb-public-01", "rtb-private-01")
$VpcIdFilter = New-EC2Filter -Name "vpc-id" -Values $TargetVpcId

ForEach($RtbName In $RtbNames)
{
    $RtbTag = New-EC2NameTag -ResourceType "route-table" -TagValue $RtbName
    $RtbTagFilter = New-EC2Filter -Name "tag:Name" -Values $RtbName

    If( -not (Get-EC2RouteTable -Filter $RtbTagFilter))
    {
        $TargetRtb = New-EC2RouteTable -VpcId $TargetVpcId -TagSpecification $RtbTag
        $TargetRtbId = $TargetRtb.RouteTableId
    }
    Else
    {
        throw "${RtbName}: すでに存在する名前です。"
    }

    $PubSwitch = "*pub*"
    $PriSwitch = "*pri*"

    If($RtbName -like $PubSwitch)
    {
        New-EC2Route -RouteTableId $TargetRtbId -GatewayId $TargetIgwId -DestinationCidrBlock "0.0.0.0/0"
        $SwitchTagFilter = New-EC2Filter -Name "tag:Name" -Values $PubSwitch
        $PublicRtbId = $TargetRtbId
    }
    ElseIf($RtbName -like $PriSwitch)
    {
        New-EC2Route -RouteTableId $TargetRtbId -GatewayId $TargetVgwId -DestinationCidrBlock "0.0.0.0/0"
        $SwitchTagFilter = New-EC2Filter -Name "tag:Name" -Values $PriSwitch
        $PrivateRtbId = $TargetRtbId
    }
    Else
    {
        throw "${RtbName}: ルートテーブル名が条件外です。"
    }

    $TargetSubnets = (Get-EC2Subnet -Filter $VpcIdFilter, $SwitchTagFilter).SubnetId
    ForEach($TargetSubnet In $TargetSubnets)
    {
        Register-EC2RouteTable -RouteTableId $TargetRtbId -SubnetId $TargetSubnet
    }
}

$OldMainRtbAssoc = (Get-EC2RouteTable -Filter $VpcIdFilter).Associations | Where-Object -FilterScript { $_.Main }
$OldMainRtbId = $OldMainRtbAssoc.RouteTableId
$AssociationId = $OldMainRtbAssoc.RouteTableAssociationId
Set-EC2RouteTableAssociation -AssociationId $AssociationId -RouteTableId $PublicRtbId
Remove-EC2RouteTable -RouteTableId $OldMainRtbId -Force

# ------------------------------------
#  S3 への VPC エンドポイントの作成
# ------------------------------------

$EndPointName = "vpce-s3-get-all"
$EndPointTag = New-EC2NameTag -ResourceType "vpc-endpoint" -TagValue $EndPointName
$EndPointFilter = New-EC2Filter -Name "tag:Name" -Values $EndPointName
$ServiceName = "com.amazonaws.ap-northeast-1.s3"
$PolicyDocument = @'
{
    "Version": "2008-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Principal": "*",
            "Action": "s3:GetObject",
            "Resource": "*"
        }
    ]
}
'@

If( -not (Get-EC2VpcEndpoint -Filter $EndPointFilter))
{
    $EndPointParams = @{
        ServiceName = $ServiceName
        VpcId = $TargetVpcId
        RouteTableId = $PublicRtbId, $PrivateRtbId
        PolicyDocument = $PolicyDocument
        TagSpecification = $EndPointTag
    }
    New-EC2VpcEndpoint @EndPointParams
}
Else
{
    throw "${EndPointName}: すでに存在する名前です。"
}

前処理

ひとつずつ見ていきます。まず、スクリプト内でのみ有効なデフォルトリージョンを設定します。


Set-DefaultAWSRegion -Region "ap-northeast-1" -Scope Script

今回使うモジュールを読み込んでおきます。(環境によってはいらないかもしれません)


Import-Module -Name AWS.Tools.EC2

VPC を構成する各コンポーネントを作成する際、同時にタグ付けまでしたい場合は-TagSpecificationパラメータにAmazon.EC2.Model.TagSpecificationオブジェクトを渡す必要があります。各コンポーネントを操作する際、ID がわからない場合は Name タグから判定することになるので、非常に重要です。これは何度も出てくるので、関数にしておきましょう。名前はNew-EC2NameTagとでもしておきます。


Function New-EC2NameTag
{
    [OutputType([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
}

Amazon.EC2.Model.TagSpecificationは以下のように、単一のResourceTypeと複数のTagsを持った構造になっています。


PS C:\WINDOWS\system32> New-Object -TypeName Amazon.EC2.Model.TagSpecification

ResourceType Tags
------------ ----
             {}

まず Name タグを指定するための連想配列を定義し、


$Tag = @{ Key="Name"; Value=$TagValue }

オブジェクトを初期化し、


$Obj = New-Object -TypeName Amazon.EC2.Model.TagSpecification

単一の値をとるResourceTypeに文字列を格納し、


$Obj.ResourceType = $ResourceType

複数の連想配列を格納できるTagsにさきほど作った$TagAddします。


$Obj.Tags.Add($Tag)

ResourceTypeが取りうる値の一覧についてはこちらを参照してください。余談ですが、[OutputType()]は付ける癖をつけたほうがいいですね。私はこちらを読むまで付けていませんでした。

もうひとつ関数を作ります。コマンド結果をフィルタするためのAmazon.EC2.Model.Filterオブジェクトを扱う関数です。これはAmazon.EC2.Model.TagSpecificationの次によく使います。ワンライナーで書けるので必ずしも関数にしなくていいのですが、今回は記述が冗長になるのを避けるために関数にしました。


Function New-EC2Filter
{
    [OutputType([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
}

単一のNameに対して、Valuesは配列です。例えば以下のような形になります。


PS C:\WINDOWS\system32> New-EC2Filter -Name "tag:Name" -Values "test-01", "test-02", "test-03"

Name     Values                     
----     ------                     
tag:Name {test-01, test-02, test-03}

ちなみにオブジェクトの構造を確認したい場合は、いっそ json に変換したほうが一目瞭然で把握できていいかもしれません。インデントが微妙ですが。


PS C:\WINDOWS\system32> New-EC2Filter -Name "tag:Name" -Values "test-01", "test-02", "test-03" | ConvertTo-Json

{
    "Name":  "tag:Name",
    "Values":  [
                   "test-01",
                   "test-02",
                   "test-03"
               ]
}

VPC の作成

基盤となる VPC を作成します。まずはさきほど作ったNew-EC2NameTag関数を使い、New-EC2Vpcコマンドに渡すためのタグ情報を定義します。


$VpcName = "vpc-01"
$VpcTag = New-EC2NameTag -ResourceType "vpc" -TagValue $VpcName

中身はこんな感じになっています。


PS C:\WINDOWS\system32> $VpcTag | ConvertTo-Json

{
    "ResourceType":  {
                         "Value":  "vpc"
                     },
    "Tags":  [
                 {
                     "Key":  "Name",
                     "Value":  "vpc-01"
                 }
             ]
}

続いてNew-EC2Filter関数でフィルタを作ります。


$VpcName = "vpc-01"
・・・
$VpcTagFilter = New-EC2Filter -Name "tag:Name" -Values $VpcName

ネタがそろったので、VPC を起動します。ユニークな Name タグでのリソース管理を想定しているので、$VpcTagFilterを使って、すでに同じタグ名が存在していたらエラーをthrowして終了するようにしました。VPC、サブネット、ゲートウェイ、ルートテーブルと、New-EC2*コマンドでコンポーネントを作るときは基本的にこの形で処理するようにしています。なお、コンポーネントを作成した後にそのコンポーネントを編集したい場合は採番されたばかりの ID が必要なので、実行結果を変数に格納して後段で利用できるようにしておきます。


$CidrBlock = "10.0.0.0/16"
・・・
If( -not (Get-EC2Vpc -Filter $VpcTagFilter))
{
    $TargetVpc = New-EC2Vpc -CidrBlock $CidrBlock -InstanceTenancy default -TagSpecification $VpcTag
    $TargetVpcId = $TargetVpc.VpcId
}
Else
{
    throw "${VpcName}: すでに存在する名前です。"
}

起動した VPC に対して DNS サポートに関する設定を入れていきます。これは公式ドキュメントに詳しく書かれています。取り扱う属性について噛み砕くと、ざっくりこんな感じかと思います。

属性 ざっくり説明
EnableDnsSupport Amazon が提供する DNS サーバー (Amazon Route 53 Resolver) を使うかどうか
EnableDnsHostnames パブリック IP アドレスを持つインスタンスがパブリック DNS ホスト名を取得するかどうか

ドキュメントの日本語がよくわからないのはさておき、以下引用の通り、AWS Tools for PowerShell で作った場合はEnableDnsSupportTrueかつEnableDnsHostnameFalseの状態で作成されます。DNS を適切に設定したい場合はどちらも有効にする必要があります。

デフォルトでは、両方の属性がデフォルト VPC で true に設定されるか、VPC が VPC ウィザードによって作成されます。デフォルトでは、その他の方法で作成された VPC で、enableDnsSupport 属性のみが true に設定されます。

直前に作成した VPC に対して以下2つのコマンドを実行します。各パラメータに$Trueを渡すことで有効化します。


# DNS 解決を有効化 ( デフォルト値が True なので、必要に応じて )
Edit-EC2VpcAttribute -VpcId $TargetVpcId -EnableDnsSupport $True

# DNS ホスト名を有効化
Edit-EC2VpcAttribute -VpcId $TargetVpcId -EnableDnsHostname $True

サブネットの作成

基盤となる VPC に対してサブネットを切っていきます。要件は以下とします。

サブネット名 CIDR Public/Private AvailabilityZone
subnet-pub-a 10.0.0.0/24 Public ap-northeast-1a
subnet-pub-c 10.0.1.0/24 Public ap-northeast-1c
subnet-pri-a 10.0.2.0/24 Private ap-northeast-1a
subnet-pri-c 10.0.3.0/24 Private ap-northeast-1c

いらないかもしれませんが、基本事項を書いておきます。ざっくりですが、Public/Private に関しては以下の通りです。

種類 説明
パブリックサブネット ルートテーブルにインターネットゲートウェイへのルートが含まれているサブネット
プライベートサブネット ルートテーブルにインターネットゲートウェイへのルートが含まれていないサブネット

AvailabilityZone とは、こちらもざっくり言ってしまうと、同じリージョン内で複数ある独立したデータセンターのことです。冗長化の際に重要です。公式のナレッジに記載があるのですが、同じゾーン名でも異なる AWS アカウント間では必ずしも同じゾーンを意味しないということがあるようです。また、こんな記事も見かけました。奥が深いですね。

これら4つのサブネットを作成します。まず サブネット名 =CIDR の形で連想配列を用意します。


$Subnets = [ordered]@{
    "subnet-pub-a" = "10.0.0.0/24";
    "subnet-pub-c" = "10.0.1.0/24";
    "subnet-pri-a" = "10.0.2.0/24";
    "subnet-pri-c" = "10.0.3.0/24";
}

次に各サブネットに渡す Name タグのResourceTypeだけを定義したオブジェクトを用意します。


$SubnetTag = New-Object -TypeName Amazon.EC2.Model.TagSpecification
$SubnetTag.ResourceType = "subnet"

連想配列をループ処理します。VPC 作成の時と同様に、Name タグの重複確認も組み込みます。


ForEach($Subnet In $Subnets.GetEnumerator())
{
    $SubnetTagFilter = New-EC2Filter -Name "tag:Name" -Values $Subnet.Key
    If( -not (Get-EC2Subnet -Filter $SubnetTagFilter))
    {
        # 実際の処理
    }
    Else
    {
        throw "$($Subnet.Key): すでに存在する名前です。"
    }
}

各ループごとに、Name タグを設定するための連想配列を作って$Tagに格納し、冒頭で定義した$SubnetTag.TagsAddします。


$Tag = @{ Key="Name"; Value=$Subnet.Key }
$SubnetTag.Tags.Add($Tag)

正規表現を使ってアベイラリティゾーンを判定します。AZ-A でも AZ-C でもない場合はエラーを投げて終了します。


If($Subnet.Key -match ".*-a$")
{
    $AvailabilityZone = "ap-northeast-1a"
}
ElseIf($Subnet.Key -match ".*-c$")
{
    $AvailabilityZone = "ap-northeast-1c"
}
Else
{
    throw "$($Subnet.Key): サブネット名が条件外です。"
}

ネタがそろったのでNew-EC2Subnetします。パラメータがたくさんあって横に見切れる場合は可読性を上げるためにスプラッティングするようにしています。また、Name タグを設定するためのオブジェクトはループの外で作ったものを再利用するので、Tagsの中身は次のループに行く前にクリアしておきます。


$SubnetParams = @{
    AvailabilityZone = $AvailabilityZone
    CidrBlock = $Subnet.Value
    VpcId = $TargetVpcId
    TagSpecification = $SubnetTag
}
New-EC2Subnet @SubnetParams
$SubnetTag.Tags.Clear()

インターネットゲートウェイの作成

考え方は一緒です。タグとフィルタを定義し、


$IgwName = "igw-01"
$IgwTag = New-EC2NameTag -ResourceType "internet-gateway" -TagValue $IgwName
$IgwTagFilter = New-EC2Filter -Name "tag:Name" -Values $IgwName

同一の Name タグがない場合だけ処理します。コマンドが違うだけです。コマンド結果を変数に格納しておきます。


If( -not (Get-EC2InternetGateway -Filter $IgwTagFilter))
{
    $TargetIgw = New-EC2InternetGateway -TagSpecification $IgwTag
    $TargetIgwId = $TargetIgw.InternetGatewayId
}
Else
{
    throw "${IgwName}: すでに存在する名前です。"
}

VpcIdInternetGatewayIdもわかっているので、VPC にインターネットゲートウェイを紐づけます。


Add-EC2InternetGateway -VpcId $TargetVpcId -InternetGatewayId $TargetIgwId

仮想プライベートゲートウェイの作成

仮想プライベートゲートウェイは、別のネットワークと VPC を VPN 接続するための、VPC 側のコンポーネントです。今回の目的では必ずしも必要ではありませんが、ゆくゆくは自宅 LAN との接続も検討したいため、アタッチしておきます。やることはインターネットゲートウェイとまったく同じなので説明は割愛します。


$VgwName = "vgw-01"
$VgwTag = New-EC2NameTag -ResourceType "vpn-gateway" -TagValue $VgwName
$IgwTagFilter = New-EC2Filter -Name "tag:Name" -Values $VgwName

If( -not (Get-EC2VpnGateway -Filter $VgwTagFilter))
{
    $TargetVgw = New-EC2VpnGateway -Type ipsec.1 -TagSpecification $VgwTag
    $TargetVgwId = $TargetVgw.VpnGatewayId
}
Else
{
    throw "${VgwName}: すでに存在する名前です。"
}

Add-EC2VpnGateway -VpcId $TargetVpcIdId -VpnGatewayId $TargetVgwId

ルートテーブルの作成と設定

ルートテーブルを作成して必要な設定を入れていきます。流れとしては以下です。

  1. Public/Private のルートテーブルをそれぞれ作る
  2. Name タグから Public/Private を判定する
  3. Public ならルート 0.0.0.0/0 のターゲットをインターネットゲートウェイにする
  4. Private ならルート 0.0.0.0/0 のターゲットを仮想プライベートゲートウェイにする
  5. Public なら Public 用のサブネットを Name タグで判定して関連付ける
  6. Private なら Private 用のサブネットを Name タグで判定して関連付ける
  7. 作成した Public ルートテーブルをメインルートテーブルに設定する
  8. VPC 作成時に自動的に作成されたルートテーブルを削除する

Name タグに入れるためのルートテーブル名を配列で用意します。


$RtbNames = @("rtb-public-01", "rtb-private-01")

ルートテーブルとサブネットの関連付けで、紐づく VPC の判定が必要なので、ID で判定するフィルタを用意します。


$VpcIdFilter = New-EC2Filter -Name "vpc-id" -Values $TargetVpcId

ループ処理ですが、やることはいろいろあります。まず例によってタグとフィルタを作ります。


$RtbTag = New-EC2NameTag -ResourceType "route-table" -TagValue $RtbName
$RtbTagFilter = New-EC2Filter -Name "tag:Name" -Values $RtbName

いままでと同じ制御でNew-EC2RouteTableします。


If( -not (Get-EC2RouteTable -Filter $RtbTagFilter))
{
    $TargetRtb = New-EC2RouteTable -VpcId $TargetVpcId -TagSpecification $RtbTag
    $TargetRtbId = $TargetRtb.RouteTableId
}
Else
{
    throw "${RtbName}: すでに存在する名前です。"
}

作成したルートテーブルに対してルートを追加します。Public/Private で処理内容が微妙に異なるので、ルートテーブル名で処理を分岐させます。Public なら-GatewayIdパラメータにインターネットゲートウェイを指定してNew-EC2Routeします。Private なら-GatewayIdパラメータに仮想プライベートゲートウェイを指定してNew-EC2Routeします。次の処理にそなえ、フィルタもそれぞれ作っておきます。またルートテーブル ID はあとで使うので、Public/Private でそれぞれ別の変数に格納しておきます。


$PubSwitch = "*pub*"
$PriSwitch = "*pri*"

If($RtbName -like $PubSwitch)
{
    New-EC2Route -RouteTableId $TargetRtbId -GatewayId $TargetIgwId -DestinationCidrBlock "0.0.0.0/0"
    $SwitchTagFilter = New-EC2Filter -Name "tag:Name" -Values $PubSwitch
    $PublicRtbId = $TargetRtbId
}
ElseIf($RtbName -like $PriSwitch)
{
    New-EC2Route -RouteTableId $TargetRtbId -GatewayId $TargetVgwId -DestinationCidrBlock "0.0.0.0/0"
    $SwitchTagFilter = New-EC2Filter -Name "tag:Name" -Values $PriSwitch
    $PrivateRtbId = $TargetRtbId
}
Else
{
    throw "${RtbName}: ルートテーブル名が条件外です。"
}

ルートテーブルにサブネットを関連付けます。冒頭で作った$VpcIdFilterとループ内で作った$SwitchTagFilterを組み合わせ、関連付けるサブネットを特定します。このフィルタの組み合わせは「今回の処理で作成した VPC に紐づくサブネット、かつ Public 用のサブネット」または「今回の処理で作成した VPC に紐づくサブネット、かつ Private 用のサブネット」という条件になります。

関連付けるサブネットが特定できたので、$TargetSubnetsに配列として格納します。この中身をひとつずつ取り出してRegister-EC2RouteTableすることでルートテーブルにサブネットを関連付けます。


$TargetSubnets = (Get-EC2Subnet -Filter $VpcIdFilter, $SwitchTagFilter).SubnetId
ForEach($TargetSubnet In $TargetSubnets)
{
    Register-EC2RouteTable -RouteTableId $TargetRtbId -SubnetId $TargetSubnet
}

ルートテーブル作成の後処理ですが、いちばん苦戦した部分になります。メインルートテーブルの置き換えと、置き換えが終わったあとの不要ルートテーブルの削除を行います。VPC を作成すると自動でメインルートテーブルが作成されますが、今回はすべてスクリプトで作ったあとにメインルートテーブルを置き換える方法を取りました。メインルートテーブルは基本的にパブリック側に設定するようなので、そのようにします。

メインルートテーブルを置き換えるにはSet-EC2RouteTableAssociationコマンドを使いますが、そのためには必須パラメータ-AssociationIdに渡す値をなんらかの方法で取得する必要があります。試しにルートテーブルのオブジェクト構造を見てみると、Associationsというメンバがいることがわかります。


PS C:\WINDOWS\system32> Get-EC2RouteTable -RouteTableId "rtb-XXXXXXXXXXXXXXXXX" | Get-Member

   TypeName: Amazon.EC2.Model.RouteTable

Name            MemberType Definition
----            ---------- ----------
・・・
Associations    Property   System.Collections.Generic.List[Amazon.EC2.Model.RouteTableAssociation] Associations {get;set;}
・・・

さらにAssociationsの中身を見てみましょう。


PS C:\WINDOWS\system32> Get-EC2RouteTable -RouteTableId "rtb-XXXXXXXXXXXXXXXXX" | Select-Object -ExpandProperty Associations | Get-Member

   TypeName: Amazon.EC2.Model.RouteTableAssociation

Name                    MemberType Definition
----                    ---------- ----------
・・・
AssociationState        Property   Amazon.EC2.Model.RouteTableAssociationState AssociationState {get;set;}
GatewayId               Property   string GatewayId {get;set;}
Main                    Property   bool Main {get;set;}
RouteTableAssociationId Property   string RouteTableAssociationId {get;set;}
RouteTableId            Property   string RouteTableId {get;set;}
SubnetId                Property   string SubnetId {get;set;}

このAssociationsとは、ルートテーブル、サブネット、インターネットゲートウェイ、または仮想プライベートゲートウェイの間の関連付けを司るオブジェクトで、メインルートテーブルであるかどうかを司るMainというメンバも含まれています。またSet-EC2RouteTableAssociationコマンドの必須パラメータである-AssociationIdには、この中にあるRouteTableAssociationIdというメンバを指定する必要があります。

オブジェクト構造がわかったところで、メインルートテーブルを置き換えるためのネタをそろえましょう。まずは現在のメインルートテーブルの関連付け情報を取得します。$VpcIdFilterでルートテーブルを絞り込み、プロパティAssociationsを取得します。その中でMain$TrueとなっているAssociationsWhere-Object -FilterScript { $_.Main }で特定します。特定出来たらあとは必要なプロパティを変数に入れるだけです。


$OldMainRtbAssoc = (Get-EC2RouteTable -Filter $VpcIdFilter).Associations | Where-Object -FilterScript { $_.Main }
$OldMainRtbId = $OldMainRtbAssoc.RouteTableId
$AssociationId = $OldMainRtbAssoc.RouteTableAssociationId

ネタがそろったので処理します。Set-EC2RouteTableAssociationコマンドですが、-AssociationIdパラメータには現在のメインルートテーブルのAssociationIdを指定し、-RouteTableIdパラメータにはこれからメインにしたいルートテーブルの ID を指定します。


Set-EC2RouteTableAssociation -AssociationId $AssociationId -RouteTableId $PublicRtbId

メインルートテーブルが置き換わったら、不要となったデフォルトのルートテーブルを削除します。これで後始末は完了です。


Remove-EC2RouteTable -RouteTableId $OldMainRtbId -Force

VPC エンドポイントの作成

最後の工程になります。基本的にはルートテーブルを作成・設定するところまでで完了なのですが、VPC エンドポイントがあるとなにかと便利なのでサンプルで作ってみます。内容としては、VPC と S3 を直で接続してインターネットを経由せずにコンテンツを GET できるようにしてみます。まずは他と同じでタグとフィルタです。


$EndPointName = "vpce-s3-get-all"
$EndPointTag = New-EC2NameTag -ResourceType "vpc-endpoint" -TagValue $EndPointName
$EndPointFilter = New-EC2Filter -Name "tag:Name" -Values $EndPointName

接続する AWS サービスの名前を指定します。


$ServiceName = "com.amazonaws.ap-northeast-1.s3"

ポリシーを json 形式で渡す必要があります。この場合は、すべての S3 オブジェクトを GET できます。json の作成に関しては AWS Policy Generator が便利です。


$PolicyDocument = @'
{
    "Version": "2008-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Principal": "*",
            "Action": "s3:GetObject",
            "Resource": "*"
        }
    ]
}
'@

ネタがそろったのでエンドポイントを作成します。これでやっと最後のコードです!New-EC2VpcEndpointコマンドに接続先 AWS サービス、接続元 VPC、対象ルートテーブル、ポリシー、タグを渡します。もちろん Name タグで制御します。


If( -not (Get-EC2VpcEndpoint -Filter $EndPointFilter))
{
    $EndPointParams = @{
        ServiceName = $ServiceName
        VpcId = $TargetVpcId
        RouteTableId = $PublicRtbId, $PrivateRtbId
        PolicyDocument = $PolicyDocument
        TagSpecification = $EndPointTag
    }
    New-EC2VpcEndpoint @EndPointParams
}
Else
{
    throw "${EndPointName}: すでに存在する名前です。"
}

おわりに

思い付きで VPC を PowerShell で作ろうとしたところ、このように長い記事となってしまいました。タグやフィルタ等、PowerShell で EC2 を操作する場合の基本的な方法がわかったのは大きな収穫ですが、やはり素直に CloudFormation でやったほうがいいだろうな、というのが感想です。当たり前ですね。今回のつらいところとして、VPC とその各コンポーネントを自動で作る際に、途中でエラーが発生してもロールバックできない、という点が挙げられます。PowerShell にはトランザクションを扱うための仕組みが備わっていて、-UseTransactionパラメータをつけることができるコマンドに限りトランザクションに参加させることができます。しかし AWS Tools for PowerShell のコマンド群は基本的にトランザクションには参加できないため、エラー処理に気を配る必要がありました。この一連の処理をトランザクション化できるとさらに管理しやすいのにな、と思った次第です。ただ、そうなってくるとなおさら CloudFormation のほうがいいわけで、今後同じことを CloudFormation でもやってみようかな、とも思いました。記事にするかはわかりませんが。VPC の構築が終わったので、次回は EC2 インスタンスを構築します。もちろん PowerShell でやる予定です。