🐕

AWS CDKで作るEC2(3) ー EBS徹底ガイド / 安全にアタッチ:gp3・暗号化・AZ固定・DLM

に公開

前回

https://zenn.dev/catatsumuri/articles/24584e68d39d34

EBSボリュームをアタッチ

本当はEIPもやりたいんだけど今回はEBSボリュームに絞る。ここまでのリソースとEBSは性質が異なるのでじっくり説明する。なお、前回まででprodとかdevの環境切り替えができるようになっていてVpcStackでは正しくセットして切り替えられるようになっている。ただしEc2Stackではそのあたりも適当なので、これも併せて設定する。

EBSボリューム20ギガバイトをざっくり作る

lib/ec2-stack.ts
@@ -41,5 +41,23 @@ export class Ec2Stack extends cdk.Stack {
         });
 
         instance.connections.allowFromAnyIpv4(ec2.Port.tcp(80), 'HTTP');
+
+        const dataVolume = new ec2.CfnVolume(this, 'DataVolume', {
+            availabilityZone: instance.instanceAvailabilityZone,
+            size: 20, // GiB
+        });
+        new ec2.CfnVolumeAttachment(this, 'DataVolumeAttachment', {
+            device: '/dev/sdf', 
+            instanceId: instance.instanceId,
+            volumeId: dataVolume.ref,
+        });
     }
 }

これをdeployすると


20GiBのボリュームがアタッチされた

こんな感じでディスクがアタッチされる。


確認すべき所。gp2になっているのと、暗号化が「なし

EBSボリュームに対してのベストプラクティス

gp2は旧世代のボリュームであり今時きついのだが、デフォルトではこれが選択される。ここではgp3にしてみよう。また暗号化もセットするのがベストプラクティスである。

lib/ec2-stack.ts
         const dataVolume = new ec2.CfnVolume(this, 'DataVolume', {
             availabilityZone: instance.instanceAvailabilityZone,
             size: 20, // GiB
+            volumeType: 'gp3',
+            encrypted: true, // 暗号化
         });
     }


gp3になった

基本的に何も考えがなければIOPSはデフォルト値で、ボリュームタイプをgp3にして暗号化onでよいと思う

EC2内部からのディスクの見え方とファイルシステムの作成、マウント

ここからはlinuxの操作になるのだが、ログインしたらlsblkすると


lsblkでアタッチされた20Gが見える

mkfs.ext4 /dev/nvme1n1 # パーティショニングしない
tune2fs -r0 /dev/nvme1n1 # 一応予約ブロックを0にする
mount /dev/nvme1n1 /mnt
df -h /mnt

こんな感じで実行していくと

$ df -h /mnt

Filesystem     1K-blocks  Used Available Use% Mounted on
/dev/nvme1n1    20466256    24  20449848   1% /mnt

のような結果となるだろう。一応この段階でこのディスクは利用可能となる。後でこの操作もCDKで自動化してみよう。

EBSボリュームのリストに名前が無いのを修正したりするタグの管理


雑に管理されたボリューム、名前がないので何がなんだかわからない

このようにボリュームに名前が付いていないと管理面で極めて都合が悪いので、ちゃんと管理するようにしたい。これはNameタグを与える。

lib/ec2-stack.ts
@@ -46,7 +46,18 @@ export class Ec2Stack extends cdk.Stack {
             size: 20, // GiB
             volumeType: 'gp3',
             encrypted: true, // 暗号化
+            tags: [
+                {
+                    key: 'Name',
+                    value: `${this.stackName}/web-data-${props.envName}`, // web-dataという名前を基本軸にしている
+                },
+            ],
         });
+
         new ec2.CfnVolumeAttachment(this, 'DataVolumeAttachment', {
             device: '/dev/sdf',
             instanceId: instance.instanceId,

その他のタグ管理もいろいろと考えられるが、とりあえずこのようにしておこう。


こんな感じに暗号化されたボリュームがEc2Stack-dev/web-data-devとかの名前タグで作成された。stack名がEc2Stack-devになってるのでちょっと過剰かもですが

その他注意点: EBSは同一AZでないと使えない

たとえばEC2ap-northeast-1aならEBSap-northeast-1aに配置されていないと駄目で、1cとか1dに配置されたボリュームには直接アクセスできない。
この点において

const dataVolume = new ec2.CfnVolume(this, 'DataVolume', {
    // このコードでインスタンスと同じAZに明示的に配置している
    availabilityZone: instance.instanceAvailabilityZone,
// ...

としている点でコードによって「インスタンスと同一AZ」に配置するよう強制している点に注目しておこう。

DestroyとRetain

CDKの運用に関しては壊れていいものと壊してはだめなものがあるはずだ。たとえばEC2は基本的に 「壊れていいもの」として扱う のが鉄則で、これに関してはcdk deployでデプロイされcdk destroyで消滅するというサイクルで全く問題がない。ところがEBSの本番運用に関しては実運用中にデーターが消滅したらもはや取り返しがつかない。CDKに限らずクラウド運用においてはこれを意識する事が非常に重要になる。

さらにここではdevprodという環境を想定している。devに関してはdestroyされたときに全て灰になってくれた方がいいわけだ(残っているとだらだら課金される問題などもあるため)。

現在のコードは

const dataVolume = new ec2.CfnVolume(this, 'DataVolume', {
    availabilityZone: instance.instanceAvailabilityZone,
    size: 20, // GiB
});

となっており、コード内でnewして生成している。これはdestroyしてみるとわかるけどこの状態でdestroyするとweb-dataは普通に消滅する。

# devを全部deployして(env=devはdefaultなので明示的に指定しなければ勝手にそうなるが)
cdk deploy -c env=dev --all 

prodにRETAINの対応を入れ、消えないようにする

retainとは保持という意味だが、これを指定して消えないようにしてみよう。三項演算子によりproddevで切り替えている。

lib/ec2-stack.ts
@@ -58,6 +58,12 @@ export class Ec2Stack extends cdk.Stack {
             ],
         });
 
+
+        // envName によって DeletionPolicy / UpdateReplacePolicy を切替
+        // prod: RETAIN(スタック削除でもボリューム残す)
+        // dev : DESTROY(スタック削除時にボリュームも削除)
+        dataVolume.applyRemovalPolicy(props.envName === 'prod' ? cdk.RemovalPolicy.RETAIN : cdk.RemovalPolicy.DESTROY);
+
         new ec2.CfnVolumeAttachment(this, 'DataVolumeAttachment', {
             device: '/dev/sdf',
             instanceId: instance.instanceId,

このようにRETAINすることでdestroyしても残り続ける。

以下のコマンドによりprodの環境とdevの環境をそれぞれdeploy後、即座に、destroyするテストを仕掛ける

cdk deploy --all -c env=prod --require-approval never && \
cdk destroy --all -c env=prod --force && \
cdk deploy --all -c env=dev --require-approval never && \
cdk destroy --all -c env=dev --force

で確認(このコマンドは非常にデンジャラスなので開発段階で明確に作って壊すとかを自動化するときとか以外には使わないように)

それではRETAINされたものでcreate → destroyを繰り返すと?

という疑問に至ると思うんだが、やってみるとわかるけど(インスタンスの生成と破壊が入ってくると長いんだけど、これはもう仕方ないね...) RETAINされたことによりボリュームが延々と残り続ける。つまり2回作成と破壊すればボリュームが2つ出来たままとかになるわけだ、これをヨシとする運用もあるだろう。そもそもprodは本当にサービスを終えるときしかdestroyしないって運用ならこれでもokっちゃそうかもしれない。ただprodもちゃんとdestroy→生成を繰り返して綺麗に再構築したいとかなるとストレージのライフサイクルをまともに考えないといけない。このようにRETAINは残骸が増えるので、prodでは 外部管理の既存ボリュームをアタッチ運用に寄せる方法も、以下で考えてみよう。

ボリュームをあらかじめ作成し、それをアタッチする手法

prodの場合は最初から別途ボリュームを作っておいて、これをアタッチする。ここでボリュームの作成においては、専用のStackを作成しても手動で作成してもどっちでもいいのであるが、ここまでで述べてきているようにEBSボリュームは同一AZでないとアタッチできない制約があるので間違ったAZにEBSボリュームを作ると一生アタッチできない。

さて、現状のEC2は

const instance = new ec2.Instance(this, 'WebServer', {
    vpc: props.vpc, // AZはどこでもいいのでvpcにぶん投げて作る
    instanceType: new ec2.InstanceType('t3.micro'),
    machineImage: ami,
    role,
    userData,
});

となっており、これはVPCのAZのどれに出来るかの保証はない。現状のVpcStackはここまでの手順通りだと

maxAzs: 2

としているので、2つ作成されるが、ap-northeast-1c, ap-northeast-1dという組合せもありえるし、将来的にap-northeast-1eなんてのができるかもしれないということなので、たまたまうまくいったのがアテにならない事があるのである。これに対応する。

ここで以降のハンズオンを実行するなら ここで一度

cdk destroy --all -c env=prod
cdk destroy --all -c env=dev

して全削除しておこう(既に潰れているならok)

AZを固定し、事前にボリュームを作る

VpcStackを1cと1dにしてみる

まず、ap-northeast-1cap-northeast-1dとかにVPCレベルで固定してしまう。これは、1aでもよいのだが、テストでは複雑な方がよいので敢えて1c1dの組み合せのパターンを考えてみよう

まずはVpcStackを変更し、maxAzs: 2から具体的なAZへと変更する。

lib/vpc-stack.ts
@@ -10,7 +10,7 @@ export class VpcStack extends cdk.Stack {
 
         // NATなし、パブリックサブネットのみのVPC
         this.vpc = new ec2.Vpc(this, 'MyVpc', {
-            maxAzs: 2, // 2つのAZに配置
+            availabilityZones: ['ap-northeast-1c', 'ap-northeast-1d'],
             natGateways: 0, // NAT Gatewayは作らない
             subnetConfiguration: [
                 {

この変更を

cdk deploy -c env=prod VpcStack-prod

とすることで、prodVpcStackがデプロイされる


これがデプロイされるとVPCのリソースマップにてサブネットがap-northeast-1cap-northeast-1dに配置された事が確認できる

続いてEC2をap-northeast-1c固定にしてみる

lib/ec2-stack.ts
+        const targetAz = 'ap-northeast-1c';
+        const subnetSel = props.vpc.selectSubnets({
+            subnetGroupName: 'PublicSubnet',
+            availabilityZones: [targetAz],
+        });
+        if (subnetSel.subnets.length === 0) {
+            throw new Error(`PublicSubnet(${targetAz}) が見つかりません。`);
+        }
+        const primarySubnet = subnetSel.subnets[0];
+
         const instance = new ec2.Instance(this, 'WebServer', {
             vpc: props.vpc,
+            vpcSubnets: { subnets: [primarySubnet] },

この変更によりEC2デプロイ先AZがap-northeast-1cに固定されるはずだ。では以下によりprodEc2Stackをデプロイして確認してみよう。

cdk deploy -c env=prod Ec2Stack-prod

これでap-northeast-1cに配置された。これはWebからサブネット追いかけて確認してもよいが、ここではawscliで確認した。

aws ec2 describe-instances \
    --instance-ids <インスタンスID> \
    --query "Reservations[].Instances[].Placement.AvailabilityZone" \
    --output text


ap-northeast-1cになった

先にEBSボリュームを作ってそれをアタッチする、が、

今現在やったことはVPCを1c, 1d固定にし、EC2を1c固定にしただけなのでまだボリュームに関しては何も処理していない。ここで1cにボリュームを作成し、それをアタッチする、のだが今、作成されたEc2Stackがボリュームをap-northeast-1cに既に作っているので、それをdestroyすればretainされて勝手に残るから、その残ったやつを使うとよいだろう。

というわけでdestroyする

cdk destroy -c env=prod Ec2Stack-prod


EC2はdestroyされたがボリュームはRETAINされて残っている。これを再度使おう

ここでボリュームIDvol-0881c05e29f213d10を再利用するので、ここではメモしておく。これは各自異なる値なのでそれぞれメモする。

ボリュームを探し出してきてアタッチする

ここでprodはボリュームを探し出すという処理、devはボリュームを作成するという処理で分岐する。ボリュームを探し出すにはAwsCustomResourceというようなあたりを使う。

lib/ec2-stack.ts
@@ -2,6 +2,7 @@ import * as cdk from 'aws-cdk-lib';
 import { Construct } from 'constructs';
 import * as ec2 from 'aws-cdk-lib/aws-ec2';
 import * as iam from 'aws-cdk-lib/aws-iam';
+import { AwsCustomResource, AwsCustomResourcePolicy, PhysicalResourceId } from 'aws-cdk-lib/custom-resources';
 
 interface Ec2StackProps extends cdk.StackProps {
     vpc: ec2.IVpc;
@@ -52,23 +53,29 @@ export class Ec2Stack extends cdk.Stack {
 
         instance.connections.allowFromAnyIpv4(ec2.Port.tcp(80), 'HTTP');
 
-        const dataVolume = new ec2.CfnVolume(this, 'DataVolume', {
-            availabilityZone: instance.instanceAvailabilityZone,
-            size: 20, // GiB
-            volumeType: 'gp3',
-            encrypted: true, // 暗号化
-            tags: [
-                {
-                    key: 'Name',
-                    value: `${this.stackName}/web-data-${props.envName}`, // web-dataという名前を基本軸にしている
-                },
-                {
-                    key: 'AZ',
-                    value: instance.instanceAvailabilityZone, // AZを明示
-                },
-            ],
-        });
-
+        let volumeId: string;
+        if (props.envName === 'prod') {
+            // TODO
+            // volumeId = ...
+        } else {
+            dataVolume = new ec2.CfnVolume(this, 'DataVolume', {
+                availabilityZone: instance.instanceAvailabilityZone,
+                size: 20, // GiB
+                volumeType: 'gp3',
+                encrypted: true, // 暗号化
+                tags: [
+                    {
+                        key: 'Name',
+                        value: `${this.stackName}/web-data-${props.envName}`, // web-dataという名前を基本軸にしている
+                    },
+                ],
+            });
+            volumeId = dataVolume.ref;
+        }
 
         // envName によって DeletionPolicy / UpdateReplacePolicy を切替
         // prod: RETAIN(スタック削除でもボリューム残す)
@@ -78,7 +85,7 @@ export class Ec2Stack extends cdk.Stack {
         new ec2.CfnVolumeAttachment(this, 'DataVolumeAttachment', {
             device: '/dev/sdf',
             instanceId: instance.instanceId,
-            volumeId: dataVolume.ref,
+            volumeId,
         });
     }
 }

CfnVolumeAttachmentはかつてはdataVolume.refを渡していたが、それをvolumeIdに先に代入してそのまま渡すように変更した。proddevで分岐するため、constではなくletにしてある。devのブロックに関してはこの状態でokであろう、prodは何も書いてないので以下に書いていく

prodブロックでアタッチしていく

lib/ec2-stack.ts
lib/ec2-stack.ts
@@ -54,9 +54,26 @@ export class Ec2Stack extends cdk.Stack {
         instance.connections.allowFromAnyIpv4(ec2.Port.tcp(80), 'HTTP');
 
         let volumeId: string;
+        let dataVolume: ec2.CfnVolume | undefined;
+        const nameTag = `${this.stackName}/web-data-${props.envName}`;
         if (props.envName === 'prod') {
-            // TODO
-            // volumeId = ...
+            // prod: 既存 Volume を Name, AZ, Envタグで検索してアタッチ
+            const findVolume = new AwsCustomResource(this, 'FindExistingWebDataVolume', {
+                onUpdate: {
+                    service: 'EC2',
+                    action: 'describeVolumes',
+                    parameters: {
+                        Filters: [
+                            { Name: 'tag:Name', Values: [nameTag] },
+                            { Name: 'tag:Env', Values: [props.envName] },
+                            { Name: 'availability-zone',  Values: [targetAz] }, // これはタグではない
+                        ],
+                    },
+                    physicalResourceId: PhysicalResourceId.of(`${this.stackName}-FindWebDataVolume`),
+                },
+                policy: AwsCustomResourcePolicy.fromStatements([
+                    new iam.PolicyStatement({
+                        actions: ['ec2:DescribeVolumes'],
+                        resources: ['*'],
+                    }),
+                ]),
+            });
+            volumeId = findVolume.getResponseField('Volumes.0.VolumeId');
         } else {
             dataVolume = new ec2.CfnVolume(this, 'DataVolume', {
                 availabilityZone: instance.instanceAvailabilityZone,
@@ -74,14 +91,10 @@ export class Ec2Stack extends cdk.Stack {
                     },
                 ],
             });
+            dataVolume.applyRemovalPolicy(cdk.RemovalPolicy.DESTROY);
             volumeId = dataVolume.ref;
         }
 
-        // envName によって DeletionPolicy / UpdateReplacePolicy を切替
-        // prod: RETAIN(スタック削除でもボリューム残す)
-        // dev : DESTROY(スタック削除時にボリュームも削除)
-        dataVolume.applyRemovalPolicy(props.envName === 'prod' ? cdk.RemovalPolicy.RETAIN : cdk.RemovalPolicy.DESTROY);
-
         new ec2.CfnVolumeAttachment(this, 'DataVolumeAttachment', {
             device: '/dev/sdf',
             instanceId: instance.instanceId,

devprodでretainとかdestroyとか分ける必要が最早なくなったので、その処理もブロックに移動している。

  • Name
  • Env

のタグかつavailability-zoneでリソースを検索して見付かったものをアタッチしている(見付からないとエラーになる)

ここにおいてproddevをそれぞれdeployしてみるのがいいだろう

cdk deploy --all -c env=prod
cdk deploy --all -c env=dev # defaultはdevになるから-cは厳密には必要ないけど

起動した「prodの」インスタンスおよびボリュームを確認する

手順通りだと2つ起動しているのでprodの、と明示しておく事にする。


起動したインスタンス i-0de4643b1435ffefc


アタッチされたボリューム(vol-0881c05e29f213d10)

ここではインスタンスidが i-0de4643b1435ffefcだったので、このボリュームをawscliで確認

aws ec2 describe-volumes \
  --filters "Name=attachment.instance-id,Values=<インスタンスID>" \
  --query "Volumes[].{VolumeId:VolumeId, AZ:AvailabilityZone, Device:Attachments[0].Device, Name:Tags[?Key=='Name']|[0].Value}" \
  --region ap-northeast-1


vol-0881c05e29f213d10のidのボリュームがアタッチされた

ここでボリュームIDvol-0881c05e29f213d10を覚えておく

で書いたIDと同一のものがアタッチされている。これで成功。

この段階で破壊するテストもやっておくとよい(し、もちろんdevも確認するべき)

# devのdeployを確認
cdk deploy -c env=dev Ec2Stack-dev

# devのdestroyを確認
cdk destroy -c env=dev Ec2Stack-dev

アタッチ後、OS(Linux)で使うための設定をuserdataに書き出して自動実行させる

これにおいては、ボリュームを新規作成でも、既存のボリュームでもfsを作成する必要がある。冒頭で一度実行しているmkfs.ext4あたりの作業だ。これをuserdataに追い出してそこで自動的にやらせよう。

以下は /data へのmount/etc/fstab による再起動自動mountの例。

lib/ec2-stack.ts
@@ -105,5 +105,42 @@ export class Ec2Stack extends cdk.Stack {
             instanceId: instance.instanceId,
             volumeId,
         });
+
+    userData.addCommands(
+      '# ---- EBS 初期化/マウント(Ubuntu向け: /sys/devices/.../nvme*/serial を利用 → UUID/fstab)',
+      `TARGET_VOL_ID='${volumeId}'`,
+      // Ubuntu環境では serial 値が "vol" プレフィックス付き + ハイフン無し
+      'TARGET_VOL_ID_WITH_PREFIX=${TARGET_VOL_ID//-/}',
+      'echo "Target EBS Volume: ${TARGET_VOL_ID} (serial match: ${TARGET_VOL_ID_WITH_PREFIX})"',
+
+      'DEV=""',
+      '# NVMeコントローラの serial ファイルを直接検索 (Ubuntu仕様)',
+      'for SERIAL_PATH in $(find /sys/devices/ -path "*/nvme/nvme*/serial" -type f); do',
+      '  S=$(cat "$SERIAL_PATH" 2>/dev/null || true)',
+      '  if [ "$S" = "$TARGET_VOL_ID_WITH_PREFIX" ]; then',
+      '    NVME_NAME=$(basename "$(dirname "$SERIAL_PATH")")',
+      '    DEV="/dev/${NVME_NAME}n1"',
+      '    [ -b "${DEV}p1" ] && DEV="${DEV}p1"',
+      '    break',
+      '  fi',
+      'done',
+
+      'if [ -z "$DEV" ]; then echo "ERROR: target NVMe device not found for serial ${TARGET_VOL_ID_WITH_PREFIX}"; exit 1; fi',
+      'echo "Resolved device: ${DEV}"',
+
+      '# 未フォーマットなら作成(初回のみ)',
+      'if ! blkid "${DEV}" >/dev/null 2>&1; then',
+      '  mkfs.ext4 -L DATA "${DEV}"',
+      '  tune2fs -r 0 "${DEV}"',
+      'fi',
+
+      'UUID=$(blkid -s UUID -o value "${DEV}")',
+      'mkdir -p /data',

ハンズオン通りであれば現在はprodを動作させているはずなので、一度破壊し、再生成することでこのuserdataが適用され、ボリュームにfsが作成される

cdk destroy -c env=prod Ec2Stack-prod
cdk deploy -c env=prod Ec2Stack-prod

このように長いuserdataは動かなくなると結構面倒なので、可能なら避けたい。推奨はやはり事前フォーマットしたディスクをmountするに留めることだ

SSMでログインして確認する

今現在SSMログインできるはずなので、このコンソールで確認してみる

sudo su - # rootになってから
df -h
cat /etc/fstab
lsblk
blkid


重要めのところだけに○を付けた

より手厚くする(optional)

既に長くなっているので、あとは参考程度にどうぞ

EBSスナップショットとか

lib/ec2-stack.ts
@@ -3,6 +3,7 @@ import { Construct } from 'constructs';
 import * as ec2 from 'aws-cdk-lib/aws-ec2';
 import * as iam from 'aws-cdk-lib/aws-iam';
 import { AwsCustomResource, AwsCustomResourcePolicy, PhysicalResourceId } from 'aws-cdk-lib/custom-resources';
+import * as dlm from 'aws-cdk-lib/aws-dlm';
 
 interface Ec2StackProps extends cdk.StackProps {
     vpc: ec2.IVpc;
@@ -144,5 +145,32 @@ export class Ec2Stack extends cdk.Stack {
             'chown root:root /data',
             'chmod 755 /data',
         );
+
+        const dlmExecRole = new iam.Role(this, 'DlmExecRole', {
+            assumedBy: new iam.ServicePrincipal('dlm.amazonaws.com'),
+        });
+        dlmExecRole.addManagedPolicy(
+            iam.ManagedPolicy.fromAwsManagedPolicyName('service-role/AWSDataLifecycleManagerServiceRole'),
+        );
+
+        new dlm.CfnLifecyclePolicy(this, 'DailySnapshotPolicy', {
+            description: 'Daily snapshot for volumes tagged Backup true',
+            state: 'ENABLED',
+            executionRoleArn: dlmExecRole.roleArn,
+            policyDetails: {
+                policyType: 'EBS_SNAPSHOT_MANAGEMENT',
+                resourceTypes: ['VOLUME'],
+                targetTags: [{ key: 'Backup', value: 'true' }],
+                schedules: [
+                    {
+                        name: 'daily',
+                        createRule: { interval: 24, intervalUnit: 'HOURS', times: ['17:00'] }, // UTC
+                        retainRule: { count: 7 },
+                        copyTags: true,
+                        tagsToAdd: [{ key: 'SnapshotPolicy', value: 'daily-keep-7' }],
+                    },
+                ],
+            },
+        });
     }

これはEBSボリュームタグにBackuptrueになっているものに関してのみ発動、7日キープでUTCの17:00すなわち日本時間の午前2:00に発動。タグ付けが必要なので興味があったら工夫してみて欲しい。

これとは別にAWS backupとかもあるけど、長くなってくるので参考に留める

https://aws.amazon.com/jp/backup/

Discussion