AWS CDKで作るEC2(3) ー EBS徹底ガイド / 安全にアタッチ:gp3・暗号化・AZ固定・DLM
前回
EBSボリュームをアタッチ
本当はEIP
もやりたいんだけど今回はEBSボリューム
に絞る。ここまでのリソースとEBSは性質が異なるのでじっくり説明する。なお、前回まででprodとかdevの環境切り替えができるようになっていてVpcStack
では正しくセットして切り替えられるようになっている。ただしEc2Stack
ではそのあたりも適当なので、これも併せて設定する。
EBSボリューム20ギガバイトをざっくり作る
@@ -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
にしてみよう。また暗号化もセットするのがベストプラクティスである。
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
タグを与える。
@@ -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でないと使えない
たとえばEC2
がap-northeast-1a
ならEBS
もap-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
に限らずクラウド運用においてはこれを意識する事が非常に重要になる。
さらにここではdev
とprod
という環境を想定している。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とは保持という意味だが、これを指定して消えないようにしてみよう。三項演算子によりprod
とdev
で切り替えている。
@@ -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-1c
とap-northeast-1d
とかにVPCレベルで固定してしまう。これは、1a
でもよいのだが、テストでは複雑な方がよいので敢えて1c
と1d
の組み合せのパターンを考えてみよう
まずはVpcStack
を変更し、maxAzs: 2
から具体的なAZ
へと変更する。
@@ -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
とすることで、prod
のVpcStack
がデプロイされる
これがデプロイされるとVPCのリソースマップにてサブネットがap-northeast-1c
とap-northeast-1d
に配置された事が確認できる
続いてEC2をap-northeast-1c固定にしてみる
+ 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
に固定されるはずだ。では以下によりprod
のEc2Stack
をデプロイして確認してみよう。
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
というようなあたりを使う。
@@ -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
に先に代入してそのまま渡すように変更した。prod
とdev
で分岐するため、const
ではなくlet
にしてある。dev
のブロックに関してはこの状態でokであろう、prod
は何も書いてないので以下に書いていく
prodブロックでアタッチしていく
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,
dev
とprod
でretainとかdestroyとか分ける必要が最早なくなったので、その処理もブロックに移動している。
Name
Env
のタグかつavailability-zone
でリソースを検索して見付かったものをアタッチしている(見付からないとエラーになる)
ここにおいてprod
とdev
をそれぞれ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のボリュームがアタッチされた
ここでボリュームID
vol-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の例。
@@ -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スナップショットとか
@@ -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ボリュームタグにBackup
がtrue
になっているものに関してのみ発動、7日キープでUTCの17:00すなわち日本時間の午前2:00に発動。タグ付けが必要なので興味があったら工夫してみて欲しい。
これとは別にAWS backupとかもあるけど、長くなってくるので参考に留める
Discussion