Systems ManagerでVSS(Volume Shadow Copy)を利用したスナップショットを取得する

アイキャッチ AWS EC2

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

はじめに

中山(順)です

もう1ヶ月以上前のアップデートになりますが、Windowsインスタンスに対してVSSに対応したスナップショットを取得できるようになりました。

Take Microsoft VSS-Enabled Snapshots Using Amazon EC2 Systems Manager

上記ブログの翻訳版はこちらです。

Amazon EC2 Systems Manager による Microsoft VSS を使用したスナップショットサポート

これまで、データの整合性を担保するためにはインスタンスを停止させてスナップショットを取得(もしくはAMIを作成)するかサードパーティーのバックアップソリューションを利用する必要がありました。
今後は、VSSに対応したアプリケーションがターゲットであればSystems Managerの"AWSEC2-CreateVssSnapshot"もしくは"AWSEC2-ManageVssIO"ドキュメントを利用することで、インスタンスの停止やサードパーティーのソリューションなしにデータの整合性を担保したスナップショットが取得できます。

Windowsインスタンスユーザーにとっては待望の機能だったのではないでしょうか?

というわけで使ってみました。

VSS(Volume Shadow Copy)とは

VSSとは、Windowsにおいてバックアップを取得する際にデータの一貫性を保証するためのフレームワークです。
VSSサービスおよび関連するコンポーネントが連携しながらバッファをディスクへフラッシュしたりディスクIOを一時的に停止させることでデータの一貫性を保証します。

VSSの詳細は、マイクロソフトのドキュメントをご確認ください。

Volume Shadow Copy Service

やってみた

マネージメントコンソールでの操作方法は最初のブログでご紹介したとおりですので、今回はコマンドラインですべての作業を実施していきます。

前提条件

以下の環境に対して、VSSによるスナップショットの取得を行っていきます。

  • デフォルトVPC上のパブリックサブネット上にEC2インスタンスを作成
    • インターネットにアクセス可能(=Systems ManagerのAPIにアクセス可能)
  • OSはWindows Server 2016
    • Windows_Server-2016-Japanese-Full-Base-2017.12.13 (ami-5d7af73b)
  • EBSボリュームは、ブートボリュームとデータボリュームの2つをアタッチ
  • インスタンスプロファイルはアタッチしていない状態

動作要件

VSS対応のスナップショットを取得するためには、以下の条件を満たす必要があります。

  • Windows Server 2008 R2以降
  • SSM Agent version 2.2.58.0以降
  • 対象のインスタンスに対してRun Commandを実行できること
  • VSS対応のスナップショットを取得するために必要な権限をインスタンスに付与すること(後述)
  • VSS componentsがインストールされていること(後述)

詳細はドキュメントを確認してください。

事前準備

インスタンスへの権限付与

インスタンスに対しては、Systems Managerを利用するために必要な権限とは別に以下の権限が必要です。
(いずれもEC2のAPIに対するアクション)

  • CreateSnapshot
  • CreateTags
  • DescribeInstances

インスタンスに権限を付与するため、以下の作業を実施します。

  • ポリシーの作成
  • ロールの作成
  • ルールに対してポリシーをアタッチ
  • インスタンスプロファイルの作成
  • インスタンスプロファイルにロールを追加
  • インスタンスにインスタンスプロファイルをアタッチ

まずは、ポリシーを作成します。

ポリシードキュメントはJSONで定義します。

POLICY_FILE="Policy.json"

cat << EOF > ${POLICY_FILE}
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "ec2:DescribeInstances",
                "ec2:CreateTags",
                "ec2:CreateSnapshot"
            ],
            "Resource": "*"
        }
    ]
}
EOF

jsonlint -q ${POLICY_FILE}

ポリシーを作成します。

POLICY_NAME="CreateVSSPolicy"

aws iam create-policy \
    --policy-name ${POLICY_NAME} \
    --policy-document file://${POLICY_FILE}
{
    "Policy": {
        "PolicyName": "CreateVSSPolicy",
        "CreateDate": "2017-12-28T13:56:23.978Z",
        "AttachmentCount": 0,
        "IsAttachable": true,
        "PolicyId": "ANPAJGSADDYIOXGUFCK3Y",
        "DefaultVersionId": "v1",
        "Path": "/",
        "Arn": "arn:aws:iam::XXXXXXXXXXXX:policy/CreateVSSPolicy",
        "UpdateDate": "2017-12-28T13:56:23.978Z"
    }
}

次にロールを作成します。

作成するロールは、EC2およびSystems Manager(旧SSM)を信頼する必要があります。
信頼関係を定義するトラストポリシーは以下のようにJSONで定義します。

TRUST_POLICY_FILE='TrustPolicyVSS.json'

cat << EOF > ${TRUST_POLICY_FILE}
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Principal": {
                "Service": [
                    "ec2.amazonaws.com",
                    "ssm.amazonaws.com"
                ]
            },
            "Action": "sts:AssumeRole"
        }
    ]
}
EOF

jsonlint -q ${TRUST_POLICY_FILE}

ロールを作成します。

ROLE_NAME="CreateVSSRole"

aws iam create-role \
    --role-name ${ROLE_NAME} \
    --assume-role-policy-document file://${TRUST_POLICY_FILE}
{
    "Role": {
        "AssumeRolePolicyDocument": {
            "Version": "2012-10-17",
            "Statement": [
                {
                    "Action": "sts:AssumeRole",
                    "Effect": "Allow",
                    "Principal": {
                        "Service": [
                            "ec2.amazonaws.com",
                            "ssm.amazonaws.com"
                        ]
                    }
                }
            ]
        },
        "RoleId": "AROAI2CLYWO5O3B5NOD6Y",
        "CreateDate": "2017-12-28T13:57:40.495Z",
        "RoleName": "CreateVSSRole",
        "Path": "/",
        "Arn": "arn:aws:iam::XXXXXXXXXXXX:role/CreateVSSRole"
    }
}

次にロールにポリシーをアタッチします。

まずは、Run Commandを実行するために必要な権限が含まれるポリシーをアタッチします。
今回はマネージドポリシーを利用します。

POLICY_ARN_SSM="arn:aws:iam::aws:policy/service-role/AmazonEC2RoleforSSM"

aws iam attach-role-policy \
    --role-name ${ROLE_NAME} \
    --policy-arn ${POLICY_ARN_SSM}

併せて、先程作成したポリシーをアタッチします。

POLICY_ARN_VSS=$(aws iam list-policies \
    --query "Policies[?PolicyName=='${POLICY_NAME}'].Arn" \
    --output text) \
    && echo ${POLICY_ARN_VSS}

aws iam attach-role-policy \
    --role-name ${ROLE_NAME} \
    --policy-arn ${POLICY_ARN_VSS}

ロールにポリシーがアタッチできているか確認します。

aws iam list-attached-role-policies \
    --role-name ${ROLE_NAME}
{
    "AttachedPolicies": [
        {
            "PolicyName": "AmazonEC2RoleforSSM",
            "PolicyArn": "arn:aws:iam::aws:policy/service-role/AmazonEC2RoleforSSM"
        },
        {
            "PolicyName": "CreateVSSPolicy",
            "PolicyArn": "arn:aws:iam::XXXXXXXXXXXX:policy/CreateVSSPolicy"
        }
    ]
}

次にインスタンスプロファイルを作成します。
(インスタンスプロファイル名はロール名と同名とします)

aws iam create-instance-profile \
    --instance-profile-name ${ROLE_NAME}
{
    "InstanceProfile": {
        "InstanceProfileId": "AIPAJ46J3NR7JGYIXP5WY",
        "Roles": [],
        "CreateDate": "2017-12-28T14:06:07.724Z",
        "InstanceProfileName": "CreateVSSRole",
        "Path": "/",
        "Arn": "arn:aws:iam::XXXXXXXXXXXX:instance-profile/CreateVSSRole"
    }
}

次にインスタンスプロファイルにロールを追加します。

aws iam add-role-to-instance-profile \
    --instance-profile-name ${ROLE_NAME} \
    --role-name ${ROLE_NAME}

インスタンスプロファイルにロールが追加できたことを確認します。

aws iam get-instance-profile \
    --instance-profile-name ${ROLE_NAME}
{
    "InstanceProfile": {
        "InstanceProfileId": "AIPAJ46J3NR7JGYIXP5WY",
        "Roles": [
            {
                "AssumeRolePolicyDocument": {
                    "Version": "2012-10-17",
                    "Statement": [
                        {
                            "Action": "sts:AssumeRole",
                            "Effect": "Allow",
                            "Principal": {
                                "Service": [
                                    "ec2.amazonaws.com",
                                    "ssm.amazonaws.com"
                                ]
                            }
                        }
                    ]
                },
                "RoleId": "AROAI2CLYWO5O3B5NOD6Y",
                "CreateDate": "2017-12-28T13:57:40Z",
                "RoleName": "CreateVSSRole",
                "Path": "/",
                "Arn": "arn:aws:iam::XXXXXXXXXXXX:role/CreateVSSRole"
            }
        ],
        "CreateDate": "2017-12-28T14:06:07Z",
        "InstanceProfileName": "CreateVSSRole",
        "Path": "/",
        "Arn": "arn:aws:iam::XXXXXXXXXXXX:instance-profile/CreateVSSRole"
    }
}

最後にインスタンスに対してインスタンプロファイルをアタッチします。

INSTANCE_PROFILE_ARN=$(aws iam list-instance-profiles \
    --query "InstanceProfiles[?InstanceProfileName=='${ROLE_NAME}'].Arn" \
    --output text) \
    && echo ${INSTANCE_PROFILE_ARN}
INSTANCE_ID="i-0xxxxxxxxxxxxxxxx"

aws ec2 associate-iam-instance-profile \
    --iam-instance-profile Arn=${INSTANCE_PROFILE_ARN},Name=${ROLE_NAME} \
    --instance-id ${INSTANCE_ID}
{
    "IamInstanceProfileAssociation": {
        "InstanceId": "i-0xxxxxxxxxxxxxxxx",
        "State": "associating",
        "AssociationId": "iip-assoc-04f319e57a5c1ca1c",
        "IamInstanceProfile": {
            "Id": "AIPAJ46J3NR7JGYIXP5WY",
            "Arn": "arn:aws:iam::XXXXXXXXXXXX:instance-profile/CreateVSSRole"
        }
    }
}

Systems Managerの管理対象として認識されているか確認します。

aws ssm describe-instance-information \
    --query "InstanceInformationList[?InstanceId=='${INSTANCE_ID}']"
[
    {
        "IsLatestVersion": false,
        "ComputerName": "EC2AMAZ-1E44S4B.WORKGROUP",
        "PingStatus": "Online",
        "InstanceId": "i-0xxxxxxxxxxxxxxxx",
        "IPAddress": "172.31.19.171",
        "ResourceType": "EC2Instance",
        "AgentVersion": "2.2.93.0",
        "PlatformVersion": "10.0.14393",
        "PlatformName": "Microsoft Windows Server 2016 Datacenter",
        "PlatformType": "Windows",
        "LastPingDateTime": 1514471184.988
    }
]

VSSコンポーネントのインストール

動作要件の通り、VSSコンポーネントをインストールする必要があります。

インストールは、"AWS-ConfigureAWSPackage"ドキュメントを利用してインストールすることが可能です。
Run Commandを実行する際のパッケージ名 (Name) を"AwsVssComponents"とすることでインストールが可能です。

aws ssm send-command \
    --document-name "AWS-ConfigureAWSPackage" \
    --instance-ids ${INSTANCE_ID} \
    --parameters '{"action":["Install"],"version":["latest"],"name":["AwsVssComponents"]}'
{
    "Command": {
        "Comment": "",
        "Status": "Pending",
        "MaxErrors": "0",
        "Parameters": {
            "action": [
                "Install"
            ],
            "version": [
                "latest"
            ],
            "name": [
                "AwsVssComponents"
            ]
        },
        "ExpiresAfter": 1514478767.932,
        "ServiceRole": "",
        "DocumentName": "AWS-ConfigureAWSPackage",
        "TargetCount": 1,
        "OutputS3BucketName": "",
        "NotificationConfig": {
            "NotificationArn": "",
            "NotificationEvents": [],
            "NotificationType": ""
        },
        "CompletedCount": 0,
        "Targets": [],
        "StatusDetails": "Pending",
        "ErrorCount": 0,
        "OutputS3KeyPrefix": "",
        "RequestedDateTime": 1514471567.932,
        "CommandId": "bf594bad-0ad0-47a3-9490-90f8f2681046",
        "InstanceIds": [
            "i-0xxxxxxxxxxxxxxxx"
        ],
        "MaxConcurrency": "50"
    }
}

Run Commandの実行後はコマンドの実行が正常に終了したか確認します。
(CommandIdは適宜読み替えてください)

aws ssm get-command-invocation \
    --command-id bf594bad-0ad0-47a3-9490-90f8f2681046 \
    --instance-id ${INSTANCE_ID} \
    --query "Status"
"Success"

スナップショットの取得

ここからが本題です(いつも前フリが長くてすみませんwww)

スナップショットの取得には"AWSEC2-CreateVssSnapshot"のドキュメントを利用したいと思います。 ("AWSEC2-ManageVssIO"については本検証の対象外とします)

このドキュメントでは、パラメーターとして以下のことを指定することができます。

  • tags
    • 作成されるスナップショットに付与するタグ
  • description
  • ExcludeBootVolume
    • Run Commandの実行時にブートボリュームをスナップショット取得の対象にするかどうか

今回は、ブートボリュームを対象に含めつつ、スナップショットの特定のタグを付与します。

aws ssm send-command \
    --document-name "AWSEC2-CreateVssSnapshot" \
    --instance-ids ${INSTANCE_ID} \
    --parameters '{"ExcludeBootVolume":["False"],"tags":["Key=Name,Value=VSS-Test"]}'
{
    "Command": {
        "Comment": "",
        "Status": "Pending",
        "MaxErrors": "0",
        "Parameters": {
            "ExcludeBootVolume": [
                "False"
            ],
            "tags": [
                "Key=Name,Value=VSS-Test"
            ]
        },
        "ExpiresAfter": 1514479555.668,
        "ServiceRole": "",
        "DocumentName": "AWSEC2-CreateVssSnapshot",
        "TargetCount": 1,
        "OutputS3BucketName": "",
        "NotificationConfig": {
            "NotificationArn": "",
            "NotificationEvents": [],
            "NotificationType": ""
        },
        "CompletedCount": 0,
        "Targets": [],
        "StatusDetails": "Pending",
        "ErrorCount": 0,
        "OutputS3KeyPrefix": "",
        "RequestedDateTime": 1514472355.668,
        "CommandId": "7d1338a6-66a1-4991-b9e8-9a79bc993b73",
        "InstanceIds": [
            "i-0xxxxxxxxxxxxxxxx"
        ],
        "MaxConcurrency": "50"
    }
}

結果の確認

結果を確認してみましょう。

aws ssm get-command-invocation \
    --command-id 7d1338a6-66a1-4991-b9e8-9a79bc993b73 \
    --instance-id ${INSTANCE_ID}
{
    "Comment": "",
    "ExecutionElapsedTime": "PT38.255S",
    "ExecutionEndDateTime": "2017-12-28T14:46:34.902Z",
    "StandardErrorContent": "",
    "InstanceId": "i-0xxxxxxxxxxxxxxxx",
    "StandardErrorUrl": "",
    "DocumentName": "AWSEC2-CreateVssSnapshot",
    "StandardOutputContent": "Beginning snapshot for drives C: D:\nPipe server started after 15494.7054 ms\nWaiting for Freeze pipe at 15625.3206 ms\nFreeze pipe connected at 36531.9478 ms\nEBS snapshot Freeze message received at 36922.6106 ms Freeze\nDisposing of pipes at 36922.6106 ms\nFreeze complete at 36922.6106 ms\nCreated snap-038e81081d03fc2c0 from vol-019caa615d4207c55  device:  /dev/sda1\nCreated snap-064ac08792b141425 from vol-0adcc6635a514cbed  device:  xvdb\nStarting Thaw at 37344.4859 ms\nThaw pipe waiting for connection at 37344.4859 ms\nThaw pipe connected at 37344.4859 ms\nPipe message read at 37360.0846 ms\nThaw successful at 37360.0846 ms\n",
    "Status": "Success",
    "StatusDetails": "Success",
    "PluginName": "runPowerShellScript",
    "ResponseCode": 0,
    "ExecutionStartDateTime": "2017-12-28T14:45:56.902Z",
    "CommandId": "7d1338a6-66a1-4991-b9e8-9a79bc993b73",
    "StandardOutputUrl": ""
}

ログにいろいろ出力されているので、ちょっと細かく確認してみます。

aws ssm get-command-invocation \
    --command-id 7d1338a6-66a1-4991-b9e8-9a79bc993b73 \
    --instance-id ${INSTANCE_ID} \
    --query "StandardOutputContent" \
    --output text
Beginning snapshot for drives C: D:
Pipe server started after 15494.7054 ms
Waiting for Freeze pipe at 15625.3206 ms
Freeze pipe connected at 36531.9478 ms
EBS snapshot Freeze message received at 36922.6106 ms Freeze
Disposing of pipes at 36922.6106 ms
Freeze complete at 36922.6106 ms
Created snap-038e81081d03fc2c0 from vol-019caa615d4207c55  device:  /dev/sda1
Created snap-064ac08792b141425 from vol-0adcc6635a514cbed  device:  xvdb
Starting Thaw at 37344.4859 ms
Thaw pipe waiting for connection at 37344.4859 ms
Thaw pipe connected at 37344.4859 ms
Pipe message read at 37360.0846 ms

ここで、ほとんどの方は「ディスクIOってどのくらい停止するのか?」ということが気になったのではないかと思います。
が、長くなりそう&今の知識では分からなさそうだったのでまた後日にさせてください。。。

一応、"AWSEC2-CreateVssSnapshot"ドキュメントの中身を見てみましょう。(読みにくいのはスルーしてください。公式のドキュメントのYAML化を期待!)
VssSnapshot関数がメインの処理で、その中で「ディスクIOの凍結」(358行目)、「スナップショットの取得」(362~371行目)、「ディスクIOの凍結解除」(374行目)が行われています。

aws ssm get-document \
    --name "AWSEC2-CreateVssSnapshot" \
    --query "Content" \
    --output text
{
  "schemaVersion": "2.2",
  "description": "Create a app consistent snapshot of all ebs volumes attached to an instance.",
  "parameters": {
    "ExcludeBootVolume": {
      "type": "String",
      "description": "(Optional) Exclude the boot volume.",
      "allowedValues": [
        "True",
        "False"
      ],
      "default": "False"
    },
    "description": {
      "type": "String",
      "default": "",
      "description": "(Optional) Specify description to add to snaphots.",
      "maxChars": 255
    },
    "tags": {
      "type": "String",
      "default": "Key=Name,Value=",
      "description": "(Optional) Specify tags to add to snaphots. Key=tag-key,Value=tag-value",
      "allowedPattern": "^([Kk]ey=(.*),[Vv]alue=(.*);?)*$"
    }
  },
  "mainSteps": [
    {
      "precondition": {
        "StringEquals": [
          "platformType",
          "Windows"
        ]
      },
      "action": "aws:runPowerShellScript",
      "name": "runPowerShellScript",
      "inputs": {
        "runCommand": [
"# Copyright 2017 Amazon.com, Inc. or its affiliates. All Rights Reserved.",
"",
"#",
"# Function to begin the VSS snapshot of a EBS volume",
"#",
"function EbsVssFreeze($driveLetter, $startTime = (Get-Date)) {",
"",
"    if ($script:inProgress -eq $true) {",
"        return",
"    }",
"",
"",
"    $namedPipe = '01C0026F-7357-49CD-BD74-657EAF079673'",
"    $pipeServer = new-object System.IO.Pipes.NamedPipeServerStream($namedPipe, ",
"        [System.IO.Pipes.PipeDirection]::In,",
"        1,",
"        [System.IO.Pipes.PipeTransmissionMode]::Byte)",
"",
"    Write-Host 'Pipe server started after' $(New-Timespan $startTime $(Get-Date)).TotalMilliseconds 'ms'",
"",
"    $script:inProgress = $true",
"",
"    $vssAgentPath = \"$env:ProgramFiles\\Amazon\\AwsVssComponents\\ec2-vss-agent.exe\"",
"    if (Test-Path \"$vssAgentPath\") {",
"        $exeString = \"/c `\"$vssAgentPath`\" \" + $driveLetter",
"        if ((get-process \"ec2-vss-agent\" -ea SilentlyContinue) -ne $Null) { ",
"            Write-Host \"ec2-vss-agent is current running, snapshot already in progress.\" ",
"            exit 1",
"        }",
"        $process = Start-Process $vssAgentPath $driveLetter -PassThru",
"    } else {",
"        Write-Host 'ec2-vss-agent.exe is not installed. To install, run command AWS-ConfigureAWSPackage with package AwsVssComponents'",
"        exit 1",
"    }",
"    Write-Host 'Waiting for Freeze pipe at' $(New-Timespan $startTime $(Get-Date)).TotalMilliseconds 'ms'",
"",
"    #",
"    # Wait for connection from provider indicating VSS freeze has begun",
"    #",
"    $pipeServer.WaitForConnection()",
"    Write-Host 'Freeze pipe connected at' $(New-Timespan $startTime $(Get-Date)).TotalMilliseconds 'ms'",
"    try {",
"        $pipeReader = new-object System.IO.StreamReader($pipeServer)",
"        $string = $pipeReader.ReadLine()",
"        Write-Host 'EBS snapshot Freeze message received at' $(New-Timespan $startTime $(Get-Date)).TotalMilliseconds 'ms' $string",
"",
"    } catch {",
"        Write-Host \"Freeze pipe read failed at\" $(New-Timespan $startTime $(Get-Date)).TotalMilliseconds 'ms'",
"        Write-Error  $_",
"    } finally {",
"        Write-Host 'Disposing of pipes at' $(New-Timespan $startTime $(Get-Date)).TotalMilliseconds 'ms'",
"        $pipeReader.Dispose()",
"        $pipeServer.Dispose()",
"    }",
"}",
"",
"",
"#",
"# Function to release VSS freeze of EBS volume after snapshot is complete.",
"#",
"function EbsVssThaw($startTime = (Get-Date)) {",
"",
"    # Return if a VSS snapshot is not in progress",
"    if ($script:inProgress -eq $false) {",
"        Write-Host 'script not in progress'",
"        return $false;",
"    }",
"",
"    $namedPipe = '8ef5c9e5-9c84-43eb-a8f7-c60b0efd7b72'",
"    $pipeClient = new-object System.IO.Pipes.NamedPipeClientStream(\".\",",
"        $namedPipe, ",
"        [System.IO.Pipes.PipeDirection]::In,",
"        [System.IO.Pipes.PipeOptions]::Asynchronous)",
"",
"    try {",
"        Write-Host \"Thaw pipe waiting for connection at\" $(New-Timespan $startTime $(Get-Date)).TotalMilliseconds 'ms'",
"        # Wait for thaw connection from provider",
"        $pipeClient.Connect(10000)",
"        Write-Host \"Thaw pipe connected at\" $(New-Timespan $startTime $(Get-Date)).TotalMilliseconds 'ms'",
"        try {",
"            $pipeReader = new-object System.IO.StreamReader($pipeClient)",
"",
"            $string = $pipeReader.ReadLine()",
"        } catch {",
"            Write-Host \"Thaw pipe read failed at\" $(New-Timespan $startTime $(Get-Date)).TotalMilliseconds 'ms'",
"            Write-Error  $_",
"        ",
"        } finally {",
"            $pipeReader.Dispose()",
"        }",
"    } catch {",
"        Write-Host \"Thaw pipe connection failed at\" $(New-Timespan $startTime $(Get-Date)).TotalMilliseconds 'ms'",
"        Write-Error  $_",
"        ",
"    } finally {",
"        $pipeClient.Dispose()",
"    }",
"",
"    Write-Host \"Pipe message read at\" $(New-Timespan $startTime $(Get-Date)).TotalMilliseconds 'ms'",
"",
"    $script:inProgress = $false",
"",
"    if ($string -like 'EBS done') {",
"        return $true",
"    } else {",
"        Write-Host \"Thaw pipe returned wrong value: \" $string ",
"        return $false",
"    }",
"}",
"",
"",
"#",
"# Helper function to retrieve EC2 instance meta-data.",
"#",
"function Get-EC2InstanceMetadata {",
"    param([string]$Path)",
"    Invoke-RestMethod -Uri \"http://169.254.169.254/latest/$Path\"",
"}",
"",
"",
"#",
"# Helper function to convert SCSI target ID to xvd* EBS device name.",
"#",
"function Convert-SCSITargetIdToDeviceName {",
"    param([int]$SCSITargetId)",
"    If ($SCSITargetId -eq 0) {",
"        return '/dev/sda1'",
"    }",
"    $deviceName = 'xvd'",
"    If ($SCSITargetId -gt 25) {",
"        $deviceName += [char](0x60 + [int]($SCSITargetId / 26))",
"    }",
"    $deviceName += [char](0x61 + $SCSITargetId % 26)",
"    return $deviceName",
"}",
"",
"",
"#",
"# Retrieve drive list with EBS volume ID for C5 and newer instance types",
"#",
"function DriveLetterToEbsVolumeId {",
"    ",
"    $disklist = @()",
"",
"    # Exit if we are not on at least Server 2012",
"    if (([Decimal]([environment]::OSVersion.Version).Major + [Decimal]([environment]::OSVersion.Version).Minor * .1) -lt 6.2) {",
"        return $null",
"    }",
"",
"    $physicalDisks = Get-PhysicalDisk | Where-Object {$_.AdapterSerialNumber -like \"vol*\"}",
"",
"    if ($physicalDisks.Count -eq 0) {",
"        return $null",
"    }",
"    ",
"    foreach ($pd in $physicalDisks) {",
"",
"        $pool1 = Get-StoragePool -PhysicalDisk $pd -IsPrimordial $False -ErrorAction SilentlyContinue",
"",
"        if ($pool1 -eq $null) {",
"            $sdv = $pd | Get-PhysicalDiskStorageNodeView -ErrorAction SilentlyContinue",
"",
"            $partition = $sdv | Get-Partition -ErrorAction SilentlyContinue",
"",
"            if ($partition -ne $null) {",
"                $vol = Get-Volume -Partition $partition",
"",
"                $diskList += New-Object PSObject -Property @{",
"                    Disk          = If ($pd -eq $null) { $null } Else { $pd.DeviceId };",
"                    Partitions    = 0;",
"                    DriveLetter   = If ($vol -eq $null) { $null } Else { ($vol.DriveLetter + ':') };",
"                    EbsVolumeId   = If ($pd -eq $null) { $null } Else { ($pd.AdapterSerialNumber).Replace(\"vol\", \"vol-\") };",
"                    Device        = If ($pd -eq $null) { $null } Else { $pd.FriendlyName };",
"                    VirtualDevice = If ($vol -eq $null) { $null } Else { $vol.UniqueId };",
"                    VolumeName    = If ($vol -eq $null) { $null } Else { $vol.FileSystemLabel };",
"                }",
"            }",
"        } else {",
"            foreach ($vol in Get-Volume) {",
"                $pool2 = Get-StoragePool -Volume $vol",
"                if ($pool1 -like $pool2) {",
"                    $diskList += New-Object PSObject -Property @{",
"                        Disk          = If ($pd -eq $null) { $null } Else { $pd.DeviceId };",
"                        Partitions    = 0;",
"                        DriveLetter   = If ($vol -eq $null) { $null } Else { ($vol.DriveLetter + ':') };",
"                        EbsVolumeId   = If ($pd -eq $null) { $null } Else { ($pd.AdapterSerialNumber).Replace(\"vol\", \"vol-\") };",
"                        Device        = If ($pd -eq $null) { $null } Else { $pd.FriendlyName };",
"                        VirtualDevice = If ($vol -eq $null) { $null } Else { $vol.UniqueId };",
"                        VolumeName    = If ($vol -eq $null) { $null } Else { $vol.FileSystemLabel };",
"                    }",
"                }",
"            }",
"        }",
"    }",
"",
"    return $disklist",
"}",
"",
"",
"",
"#",
"# Helper function to collect connected EBS volumes attached to local EC2 instance.",
"#",
"function Get-EbsConnectedVolume {",
"",
"    $diskList = DriveLetterToEbsVolumeId",
"    if ($diskList.Count -gt 0) {",
"        $BlockDeviceMappings = (Get-EC2Instance -Instance $InstanceId).Instances.BlockDeviceMappings",
"        foreach ($disk in $diskList) {",
"            $blockDevice = $BlockDeviceMappings | Where-Object { $_.Ebs.VolumeId -eq $disk.EbsVolumeId }",
"            $disk.Device = If ($BlockDevice -eq $null) { $null } Else { $blockDevice.DeviceName }",
"        }",
"        return $diskList",
"    }",
"",
"    Try {",
"        $InstanceId = Get-EC2InstanceMetadata \"meta-data/instance-id\"",
"        $BlockDeviceMappings = (Get-EC2Instance -Instance $InstanceId).Instances.BlockDeviceMappings",
"        $VirtualDeviceMap = @{}",
"        (Get-EC2InstanceMetadata \"meta-data/block-device-mapping\").Split(\"`n\") | ForEach-Object {",
"            $VirtualDevice = $_",
"            $BlockDeviceName = Get-EC2InstanceMetadata \"meta-data/block-device-mapping/$VirtualDevice\"",
"            $VirtualDeviceMap[$BlockDeviceName] = $VirtualDevice",
"            $VirtualDeviceMap[$VirtualDevice] = $BlockDeviceName",
"        }",
"    } Catch {",
"        Write-Host \"Could not access the AWS API, therefore, VolumeId is not available. ",
"        Verify that your instance role has Describe-Instances permission.\" -ForegroundColor Yellow",
"        throw",
"    }",
"",
"    $diskList = Get-WmiObject -Class Win32_DiskDrive | ForEach-Object {",
"        $DiskDrive = $_",
"        $Volumes = Get-WmiObject -Query \"ASSOCIATORS OF {Win32_DiskDrive.DeviceID='$($DiskDrive.DeviceID)'} WHERE AssocClass=Win32_DiskDriveToDiskPartition\" | ForEach-Object {",
"            $DiskPartition = $_",
"            Get-WmiObject -Query \"ASSOCIATORS OF {Win32_DiskPartition.DeviceID='$($DiskPartition.DeviceID)'} WHERE AssocClass=Win32_LogicalDiskToPartition\"",
"        }",
"        If ($DiskDrive.PNPDeviceID -like \"*PROD_PVDISK*\") {",
"            $BlockDeviceName = Convert-SCSITargetIdToDeviceName($DiskDrive.SCSITargetId)",
"            $BlockDevice = $BlockDeviceMappings | Where-Object { $_.DeviceName -eq $BlockDeviceName }",
"            $VirtualDevice = If ($VirtualDeviceMap.ContainsKey($BlockDeviceName)) { $VirtualDeviceMap[$BlockDeviceName] } Else { $null }",
"        } ElseIf ($DiskDrive.PNPDeviceID -like \"*PROD_AMAZON_EC2_NVME*\") {",
"            $BlockDeviceName = Get-EC2InstanceMetadata \"meta-data/block-device-mapping/ephemeral$($DiskDrive.SCSIPort - 2)\"",
"            $BlockDevice = $null",
"            $VirtualDevice = If ($VirtualDeviceMap.ContainsKey($BlockDeviceName)) { $VirtualDeviceMap[$BlockDeviceName] } Else { $null }",
"        } Else {",
"            $BlockDeviceName = $null",
"            $BlockDevice = $null",
"            $VirtualDevice = $null",
"        }",
"        New-Object PSObject -Property @{",
"            Disk          = $DiskDrive.Index;",
"            Partitions    = $DiskDrive.Partitions;",
"            DriveLetter   = If ($Volumes -eq $null) { $null } Else { $Volumes.DeviceID };",
"            EbsVolumeId   = If ($BlockDevice -eq $null) { $null } Else { $BlockDevice.Ebs.VolumeId };",
"            Device        = If ($BlockDeviceName -eq $null) { $null } Else { $BlockDeviceName };",
"            VirtualDevice = If ($VirtualDevice -eq $null) { $null } Else { $VirtualDevice };",
"            VolumeName    = If ($Volumes -eq $null) { $null } Else { $Volumes.VolumeName };",
"        }",
"    } | Sort-Object Disk",
"",
"    return $diskList",
"}",
"",
"",
"#",
"# Tag Snapshots",
"#",
"function Tag-Snapshots {",
"    param(",
"        [System.Object[]]$SnapshotsData,",
"        [boolean]$AppConsistent,",
"        [Parameter(Mandatory = $false)][amazon.EC2.Model.Tag[]]$Tags",
"    )",
"    $Tag = new-object amazon.EC2.Model.Tag",
"    $Tag.Key = \"AppConsistent\"",
"    $Tag.Value = \"$AppConsistent\"",
"    $Tags += $Tag",
"    foreach ($SnapshotData in $SnapshotsData) {",
"        $Tag = new-object amazon.EC2.Model.Tag",
"        $Tag.Key = \"Device\"",
"        $Tag.Value = $SnapshotData.Device",
"        $AllTags = $Tags + $Tag",
"        New-EC2Tag -Resources $SnapshotData.SnapshotId -Tags $AllTags",
"    }",
"}",
"",
"function VssSnapshot() {",
"    param(",
"        [boolean]$ExcludeBootVolume,",
"        [string]$Description,",
"        [string]$Tags",
"    )",
"    $startTime = Get-Date",
"    $VolumesToFreeze = @()",
"    $DrivesToFreeze = @()",
"    if ((Get-EC2InstanceMetadata \"meta-data/instance-type\").StartsWith(\"c5\",\"CurrentCultureIgnoreCase\")) {",
"        Write-Host \"C5 instances not currently supported\"",
"        exit 1",
"    }",
"",
"    $volumeList = Get-EbsConnectedVolume",
"    foreach ($v in $volumeList) {",
"        if ( ($v.DriveLetter -ne $null) -and ($v.EbsVolumeId -ne $null)) {",
"            if (($ExcludeBootVolume -eq $false) -or ($v.Device -ne \"/dev/sda1\")) {",
"                $VolumesToFreezeString += ($v.DriveLetter -join ' ') + \" \"",
"                $DrivesToFreeze += $v.DriveLetter",
"                $VolumesToFreeze += $v",
"            }",
"        }",
"    }",
"    if ($VolumesToFreeze.Count -eq 0) {",
"        Write-Host \"No mounted EBS drives detected\"",
"        exit 1",
"    }",
"    $DrivesToFreezeString = ($DrivesToFreeze | sort -Unique) -join ' '",
"",
"    Write-Host \"Beginning snapshot for drives\" $DrivesToFreezeString",
"",
"    EbsVssFreeze $DrivesToFreezeString $startTime",
"",
"    Write-Host 'Freeze complete at' $(New-Timespan $startTime $(Get-Date)).TotalMilliseconds 'ms'",
"    $SnapshotData = @()",
"    foreach ($v in $VolumesToFreeze) {",
"        $Snapshot = New-EC2Snapshot -VolumeId $v.EbsVolumeId -Description $Description",
"        $SnapshotData +=",
"        New-Object PSObject -Property @{",
"            EbsVolumeId = $v.EbsVolumeId",
"            Device      = $v.Device",
"            SnapshotId  = $Snapshot.SnapshotId",
"        }   ",
"        Write-Host \"Created\" $Snapshot.SnapshotId \"from\" $v.EbsVolumeId \" device: \" $v.Device",
"    }",
"",
"    Write-Host 'Starting Thaw at' $(New-Timespan $startTime $(Get-Date)).TotalMilliseconds 'ms'",
"    $AppConsistent = EbsVssThaw $startTime",
"    if ($AppConsistent) {",
"        Write-Host 'Thaw successful at' $(New-Timespan $startTime $(Get-Date)).TotalMilliseconds 'ms'",
"    } else {",
"        Write-Host 'Thaw unsuccesful, snapshots may not be app consistent, see event log for more details' $(New-Timespan $startTime $(Get-Date)).TotalMilliseconds 'ms'",
"    }",
"",
"    [amazon.EC2.Model.Tag[]]$TagArray = @()",
"    $Tags -split \";\" | ForEach-Object {",
"        if (-not [string]::IsNullOrEmpty($_)) {",
"            $TagParts = ($_ -split \",\", 2)",
"            if ($TagParts.Count -ne 2) {",
"                Write-Host \"Error parsing tags, tags need to be in the format Name=tag-key,Values=tag-value\"",
"                return $false",
"            }",
"            $TagName, $TagValue = $TagParts",
"            $Tag = new-object amazon.EC2.Model.Tag",
"            $Tag.Key = ($TagName -split \"=\", 2)[1].Trim()",
"            $Tag.Value = ($TagValue -split \"=\", 2)[1].Trim()",
"            $TagArray += $Tag",
"        }",
"    }",
"",
"",
"    Tag-Snapshots $SnapshotData $AppConsistent $TagArray",
"    return $AppConsistent",
"}",
"",
"if ($callLocal -ne $true) {",
"    [boolean]$ExcludeBootVolume = [System.Convert]::ToBoolean(\"{{ExcludeBootVolume}}\")",
"",
"    $description = @\"",
"{{description}}",
"\"@",
"",
"    $tags = @\"",
"{{tags}}",
"\"@",
"",
"    $AppConsistent = VssSnapshot $ExcludeBootVolume $description $tags",
"    exit [int](-not $AppConsistent)",
"}",
"",
"",
"",
"",
          ""
        ],
        "workingDirectory": "",
        "timeoutSeconds": "400"
      }
    }
  ]
}

取得されたスナップショットの存在を確認します。
仕様どおり、DeviceおよびAppConsistentのタグが付与されていることも確認できます。

AWS_ID=$(aws sts get-caller-identity --query "Account" --output text) && echo ${AWS_ID}

aws ec2 describe-snapshots \
    --owner-ids ${AWS_ID} \
    --filter Name=tag-key,Values="Name" Name=tag-value,Values="VSS-Test" 
{
    "Snapshots": [
        {
            "Description": "",
            "Tags": [
                {
                    "Value": "VSS-Test",
                    "Key": "Name"
                },
                {
                    "Value": "xvdb",
                    "Key": "Device"
                },
                {
                    "Value": "True",
                    "Key": "AppConsistent"
                }
            ],
            "Encrypted": false,
            "VolumeId": "vol-0adcc6635a514cbed",
            "State": "completed",
            "VolumeSize": 8,
            "StartTime": "2017-12-28T14:46:34.000Z",
            "Progress": "100%",
            "OwnerId": "XXXXXXXXXXXX",
            "SnapshotId": "snap-064ac08792b141425"
        },
        {
            "Description": "",
            "Tags": [
                {
                    "Value": "/dev/sda1",
                    "Key": "Device"
                },
                {
                    "Value": "VSS-Test",
                    "Key": "Name"
                },
                {
                    "Value": "True",
                    "Key": "AppConsistent"
                }
            ],
            "Encrypted": false,
            "VolumeId": "vol-019caa615d4207c55",
            "State": "completed",
            "VolumeSize": 30,
            "StartTime": "2017-12-28T14:46:34.000Z",
            "Progress": "100%",
            "OwnerId": "XXXXXXXXXXXX",
            "SnapshotId": "snap-038e81081d03fc2c0"
        }
    ]
}

リストア

リストアは以下の手順で実施します。

  • インスタンスの停止
  • インスタンスからボリュームをデタッチ
  • スナップショットからボリュームを作成
  • 作成したボリュームをインスタンスにアタッチ
  • インスタンスを起動

インスタンスの停止

まず、インスタンスを停止します。

aws ec2 stop-instances \
    --instance-ids ${INSTANCE_ID}
{
    "StoppingInstances": [
        {
            "InstanceId": "i-0xxxxxxxxxxxxxxxx",
            "CurrentState": {
                "Code": 64,
                "Name": "stopping"
            },
            "PreviousState": {
                "Code": 16,
                "Name": "running"
            }
        }
    ]
}

インスタンスからボリュームをデタッチ

ブートボリュームを含む全てのボリュームをでタッチします。

まずブートボリュームをでタッチします。

VOLUME_ID_C="vol-019caa615d4207c55"

aws ec2 detach-volume \
    --volume-id ${VOLUME_ID_C}
{
    "AttachTime": "2017-12-28T13:29:39.000Z",
    "InstanceId": "i-0xxxxxxxxxxxxxxxx",
    "VolumeId": "vol-019caa615d4207c55",
    "State": "detaching",
    "Device": "/dev/sda1"
}

データボリュームをでタッチします。

VOLUME_ID_D="vol-0adcc6635a514cbed"    

aws ec2 detach-volume \
    --volume-id ${VOLUME_ID_D}
{
    "AttachTime": "2017-12-28T13:29:39.000Z",
    "InstanceId": "i-0xxxxxxxxxxxxxxxx",
    "VolumeId": "vol-0adcc6635a514cbed",
    "State": "detaching",
    "Device": "xvdb"
}

スナップショットからボリュームを作成

ブートボリュームのスナップショットからボリュームを作成します。

SNAPSHOT_ID_C="snap-038e81081d03fc2c0"

aws ec2 create-volume \
    --availability-zone ap-northeast-1a \
    --snapshot-id ${SNAPSHOT_ID_C} \
    --volume-type gp2
{
    "AvailabilityZone": "ap-northeast-1a",
    "Encrypted": false,
    "VolumeType": "gp2",
    "VolumeId": "vol-0b8b08a563e7e1e55",
    "State": "creating",
    "Iops": 100,
    "SnapshotId": "snap-038e81081d03fc2c0",
    "CreateTime": "2017-12-28T15:55:43.093Z",
    "Size": 30
}

データボリュームのスナップショットからボリュームを作成します。

SNAPSHOT_ID_D="snap-064ac08792b141425"

aws ec2 create-volume \
    --availability-zone ap-northeast-1a \
    --snapshot-id ${SNAPSHOT_ID_D} \
    --volume-type gp2
{
    "AvailabilityZone": "ap-northeast-1a",
    "Encrypted": false,
    "VolumeType": "gp2",
    "VolumeId": "vol-0a30e229e526a124e",
    "State": "creating",
    "Iops": 100,
    "SnapshotId": "snap-064ac08792b141425",
    "CreateTime": "2017-12-28T15:56:51.367Z",
    "Size": 8
}

作成したボリュームをインスタンスにアタッチ

ブートボリュームをインスタンスにアタッチします。

RESTORED_VOLUME_ID_C="vol-0b8b08a563e7e1e55"

aws ec2 attach-volume \
    --device "/dev/sda1" \
    --instance-id ${INSTANCE_ID} \
    --volume-id ${RESTORED_VOLUME_ID_C}
{
    "AttachTime": "2017-12-28T15:59:14.610Z",
    "InstanceId": "i-0xxxxxxxxxxxxxxxx",
    "VolumeId": "vol-0b8b08a563e7e1e55",
    "State": "attaching",
    "Device": "/dev/sda1"
}

データボリュームをインスタンスにアタッチします。

RESTORED_VOLUME_ID_D="vol-0a30e229e526a124e"

aws ec2 attach-volume \
    --device "xvdb" \
    --instance-id ${INSTANCE_ID} \
    --volume-id ${RESTORED_VOLUME_ID_D}
{
    "AttachTime": "2017-12-28T16:00:18.236Z",
    "InstanceId": "i-0xxxxxxxxxxxxxxxx",
    "VolumeId": "vol-0a30e229e526a124e",
    "State": "attaching",
    "Device": "xvdb"
}

インスタンスを起動

インスタンスを起動します。

aws ec2 start-instances \
    --instance-ids ${INSTANCE_ID}
{
    "StartingInstances": [
        {
            "InstanceId": "i-0xxxxxxxxxxxxxxxx",
            "CurrentState": {
                "Code": 0,
                "Name": "pending"
            },
            "PreviousState": {
                "Code": 80,
                "Name": "stopped"
            }
        }
    ]
}

動作確認

正常に起動できたか確認してみます。

まずはSystems ManagerのAgentが正常に機能しているか(オンラインになっているか)を確認します。
以下の通り、オンラインになっていることを確認できました。

aws ssm describe-instance-information \
    --query "InstanceInformationList[?InstanceId=='${INSTANCE_ID}']"
[
    {
        "IsLatestVersion": false,
        "ComputerName": "EC2AMAZ-1E44S4B.WORKGROUP",
        "PingStatus": "Online",
        "InstanceId": "i-0xxxxxxxxxxxxxxxx",
        "IPAddress": "172.31.19.171",
        "ResourceType": "EC2Instance",
        "AgentVersion": "2.2.93.0",
        "PlatformVersion": "10.0.14393",
        "PlatformName": "Microsoft Windows Server 2016 Datacenter",
        "PlatformType": "Windows",
        "LastPingDateTime": 1514477313.886
    }
]

次に"Get-Disk"コマンドをインスタンス内で実行してステータスを確認してみます。

aws ssm send-command \
    --document-name "AWS-RunPowerShellScript" \
    --instance-ids ${INSTANCE_ID} \
    --parameters '{"commands":["Get-Disk"],"executionTimeout":["3600"]}'
{
    "Command": {
        "Comment": "",
        "Status": "Pending",
        "MaxErrors": "0",
        "Parameters": {
            "commands": [
                "Get-Disk"
            ],
            "executionTimeout": [
                "3600"
            ]
        },
        "ExpiresAfter": 1514494013.772,
        "ServiceRole": "",
        "DocumentName": "AWS-RunPowerShellScript",
        "TargetCount": 1,
        "OutputS3BucketName": "",
        "NotificationConfig": {
            "NotificationArn": "",
            "NotificationEvents": [],
            "NotificationType": ""
        },
        "CompletedCount": 0,
        "Targets": [],
        "StatusDetails": "Pending",
        "ErrorCount": 0,
        "OutputS3KeyPrefix": "",
        "RequestedDateTime": 1514486813.772,
        "CommandId": "0b74c1ec-4b9b-4cfd-b30c-c4d0cef7d28d",
        "InstanceIds": [
            "i-0xxxxxxxxxxxxxxxx"
        ],
        "MaxConcurrency": "50"
    }
}

実行結果を確認します。
ブートボリュームもデータボリュームもステータスが正常であることが確認できました。

aws ssm get-command-invocation \
    --command-id 0b74c1ec-4b9b-4cfd-b30c-c4d0cef7d28d \
    --instance-id ${INSTANCE_ID} \
    --query "StandardOutputContent" \
    --output text
Number F Serial Number                    HealthStatus         OperationalStatu
       r                                                       s
       i
       e
       n
       d
       l
       y

       N
       a
       m
       e
------ - -------------                    ------------         ----------------
0      A 0000                             Healthy              Online
1      A 0001                             Healthy              Online

次にリモートデスクトップ接続してみます。

正常にログインできましたが、オンラインで取得したイメージからインスタンスを復元する際に出るダイアログが表示されました。

これは、Shutdown Event Trackerでシャットダウンイベントが記録されていなかったことに起因して表示されるもののため、 無視して問題ない(はず)です。

まとめ

これまではインスタンスを停止させたりサードパーティー製品が必要だったバックアップのユースケースにも対応できるようになりました。

とはいっても、すべてのユースケースに対応できるわけではありません。
以下のようなユースケースではこれまでどおりのソリューションでなければ実現できない/使いやすい場合があると思います。
(他に何かあればコメントください!)

  • VSS未対応のアプリケーションにおけるオンラインバックアップ
  • ファイル/オブジェクトレベルのリストア

Run Commnad / Automationを利用すれば(PowerShellが実行できるので)実現したいことはほとんど実現できると思います。
しかし、それが現実的なイニシャル/ランニングコストで実現できるかは別問題です。
なんでもかんでもAWSに寄せればいいわけではないので、そのあたりはちゃんと検討しましょう。

最後に、バックアップの設定を行ったらリストアができることを確認しましょう。
また、重要なシステム(長期間停止したりデータが失われると本業の売上に影響する・責任者のクビが飛ぶシステムなど)については、定期的にリストアのテストを行いましょう。

バックアップは(万が一のときに)リストアするためにあるんですよ!

コメントは受け付けていません。