【AWS】ディスク使用率を監視して、EBS拡張→ファイルシステム拡張まで完全自動化してみた

# 前提
AWSのEC2 (Windows Server) 上で稼働するシステムの肥大化し続けるログをEBSに格納するようにしています。

また、外部の監視ツールでEC2 (Windows Server) のディスク使用率を監視しており、対象ドライブの使用率が閾値を超えた際にはアラートを発報させ手動でEBSの拡張、ファイルシステムの拡張を行っています。

# 課題
- 手動拡張のため、人的運用コストがかかる
- 人的コストを抑えるため一度に大きめに拡張し拡張回数を減らしていたが、その分使用していないEBS分の運用コストが余計に発生している
- 有識者の確保、または運用手順の作成が必要になる

# 目的
- ディスク使用率を監視している外部の監視ツールから発報されるアラートをトリガーにサーバ側に仕掛けた自動拡張スクリプトをキックし、EBSの自動拡張からファイルシステムの自動拡張までを完全自動化し通知する仕組みを構築する

# ソース
~~~powershell:disk_auto_expantison_D.ps1
# ログ取得開始
Start-Transcript C:\Users\SweetDreams\Desktop\work\auto_expansion_tool\ps_expansion.log -append

########## 変数定義 ##########
# リージョン
$awsRegion = "ap-northeast-1"
# 拡張対象ドライブ
$driveLatter = "D"
# 100GB未満のストレージの拡張割合(%) ※ドライブの容量が50GBの状態で10(%)を指定した場合55GBに拡張される default=10
$addUnder100 = 10
# 100GB以上のストレージの拡張割合(%) ※ドライブの容量が200GBの状態で5(%)を指定した場合210GBに拡張される default=5
$addOver100 = 5
# volumeType
$TYPE = "gp3"
# IOPS
$IOPS = 3000
# Teams通知用URL
$TeamsURL = "https://xxxxxxxxxxxxxxxxxxxxxxx.webhook.office.com/webhookb2/xxxxxxxxxxxxxxxxxxxxxxx/IncomingWebhook/xxxxxxxxxxxxxxxxxxxxxxx/xxxxxxxxxxxxxxxxxxxxxxx"

########## Amazon EBSの拡張 ##########
# インスタンスメタデータからインスタンスIDを取得
[string]$token = Invoke-RestMethod -Headers @{"X-aws-ec2-metadata-token-ttl-seconds" = "21600"} -Method PUT -Uri http://169.254.169.254/latest/api/token
$instanceId = Invoke-RestMethod -Headers @{"X-aws-ec2-metadata-token" = $token} -Method GET -Uri http://169.254.169.254/latest/meta-data/instance-id

# ボリュームID取得
$volumId = aws ec2 describe-volumes --filters "Name=attachment.instance-id,Values=$instanceId" "Name=tag:Name, Values=*Log" --region $awsRegion --query "Volumes[].VolumeId"

# EBSボリュームサイズの取得
$beforeEbsSize = aws ec2 describe-volumes --filters "Name=attachment.instance-id,Values=$instanceId" "Name=tag:Name, Values=*Log" --query "Volumes[].Size"
$intBeforeEbs = [int]$beforeEbsSize

# EBS拡張量を計算
if ($intBeforeEbs -lt 100) {
    $EbsSize = [math]::Ceiling($intBeforeEbs + $intBeforeEbs * ($addUnder100/100))
    if ($EbsSize -gt $intBeforeEbs ) {
        Write-Output ($driveLatter + "ドライブのEBSボリュームを" + $EbsSize + "GBに拡張します。")
    } else {
        Write-Output "正しい拡張容量を指定してください。" | Out-File "error.log"
        exit
    }
} elseif ($intBeforeEbs -ge 100) {
    $EbsSize = [math]::Ceiling($intBeforeEbs + $intBeforeEbs * ($addOver100/100))
    if ($EbsSize -gt $intBeforeEbs ) {
        Write-Output ($driveLatter + "ドライブのEBSボリュームを" + $EbsSize + "GBに拡張します。")
    } else {
        Write-Output "正しい拡張容量を指定してください。" | Out-File "error.log"
        exit
    }
}

# EBSボリュームの変更
try {
    aws ec2 modify-volume --volume-type $TYPE --iops $IOPS --size $EbsSize --volume-id $volumId
} catch {
    $error[0] | Out-String | Out-File "error.log"
    exit
}
Start-Sleep -s 5

# 進行状況チェック
$time = 0
while (((aws ec2 describe-volumes-modifications --volume-id $volumId).Split("")[1] -ne ("optimizing")) -and ((aws ec2 describe-volumes-modifications --volume-id $volumId).Split("")[2] -ne ("completed"))) {
    Write-Output ([string]$time + " EBSのボリュームステータスが optimizing または completed になるのを待っています。")
    Start-Sleep -s 10
    [int]$time += 10
}

# EBSボリュームサイズの取得
$afterEbsSize = aws ec2 describe-volumes --filters "Name=attachment.instance-id,Values=$instanceId" "Name=tag:Name, Values=**Log" --query "Volumes[].Size"
$intAfterEbs = [int]$afterEbsSize
if ($intAfterEbs -gt $intBeforeEbs ) {
    Write-Output ($driveLatter + "ドライブのEBSボリュームを" + $afterEbsSize + "GBに拡張しました。")
} else {
    Write-Output "EBSボリュームの拡張に失敗しました。" | Out-File "error.log"
    exit
}

########## Windowsファイルシステムの拡張 ##########
# 出力用一時ファイルの存在確認
$volumeFilePath = "C:\Users\SweetDreams\Desktop\work\auto_expansion_tool\Volume_tmp.txt"
if(Test-Path $volumeFilePath){
    Remove-Item $volumeFilePath
}

# 拡張前のパーティションサイズを確認
$hostname = hostname
"ホスト：" + $hostname + "`r`n" >> $volumeFilePath
"拡張ドライブ：" + $driveLatter + "`r`n" >> $volumeFilePath
"-----------------------------------------------拡張前-----------------------------------------------" >> $volumeFilePath
Get-Volume >> $volumeFilePath
if (((aws ec2 describe-volumes-modifications --volume-id $volumId).Split("")[1] -eq ("optimizing")) -or ((aws ec2 describe-volumes-modifications --volume-id $volumId).Split("")[2] -eq ("completed"))) {
    # ディスクの再スキャンを実施
    "rescan" | diskpart
    Start-Sleep -s 5
    Write-Output ($driveLatter + "ドライブのパーティションサイズを拡張します。")
    # 使用可能な最大サイズにパーティションを拡張
    try {
        Resize-Partition -DriveLetter $driveLatter -Size $(Get-PartitionSupportedSize -DriveLetter $driveLatter).SizeMax
    } catch {
        $error[0] | Out-String | Out-File "error.log"
        exit
    }
    Write-Output ($driveLatter + "ドライブのパーティションサイズを拡張しました。")
}

# 拡張後のパーティションサイズを確認
"-----------------------------------------------拡張後-----------------------------------------------" >> $volumeFilePath
Get-Volume >> $volumeFilePath
Start-Sleep -s 5

########## 処理完了後のTeamsへの通知 ##########
# 出力用ファイルの存在確認
$outputVolumeFilePath = "C:\Users\SweetDreams\Desktop\work\auto_expansion_tool\Volume.txt"
if(Test-Path $outputVolumeFilePath){
    Remove-Item $outputVolumeFilePath
}

# 出力用一時ファイルの各行頭に改行コードを追加し、出力用ファイルを作成
foreach ($line in Get-Content $volumeFilePath) {
    "<br>" + $line | Out-File ($outputVolumeFilePath) -Append -Encoding default
}

# Invoke-RestMethod に渡す Web API の引数を JSON で作成
$body = ConvertTo-JSON @{
    text = [string](Get-Content $outputVolumeFilePath | ForEach-Object {$_ -replace " ", "&thinsp;&thinsp;"})
}
$body = [Text.Encoding]::UTF8.GetBytes($body)

# API を叩く
Invoke-RestMethod -Method Post -Uri $TeamsURL -Body $body -ContentType 'application/json'

# 出力用一時ファイルの削除
if(Test-Path $volumeFilePath){
    Remove-Item $volumeFilePath
}

# 出力用ファイルの削除
if(Test-Path $outputVolumeFilePath){
    Remove-Item $outputVolumeFilePath
}
Write-Output "処理完了"

# ログ取得終了
Stop-Transcript
~~~

# 通知内容
今回は拡張完了後にTeamsに通知するようにしています。
通知処理の部分を差し替えれば、Slackや他のAPI連携できるチャットツール等に通知可能です。
~~~
ホスト：Sweet machine
拡張ドライブ：D
-----------------------------------------------拡張前----------------------------------------------- 
DriveLetter  FriendlyName  FileSystemType  DriveType  HealthStatus  OperationalStatus  SizeRemaining  Size 
-----------  ------------  --------------  ---------  ------------  -----------------  -------------  ---- 
E            NTFS          Fixed           Healthy    OK                               10.95  GB      10.98  GB 
D            NTFS          Fixed           Healthy    OK                               688.62  MB      9.98  GB 
H            NTFS          Fixed           Healthy    OK                               16.94  GB      16.98  GB 
C            NTFS          Fixed           Healthy    OK                               30.14  GB      50  GB 
 
 
-----------------------------------------------拡張後-----------------------------------------------
DriveLetter  FriendlyName  FileSystemType  DriveType  HealthStatus  OperationalStatus  SizeRemaining  Size 
-----------  ------------  --------------  ---------  ------------  -----------------  -------------  ---- 
E            NTFS          Fixed           Healthy    OK                               10.95  GB      10.98  GB 
D            NTFS          Fixed           Healthy    OK                               1.67  GB      10.98  GB 
H            NTFS          Fixed           Healthy    OK                               16.94  GB      16.98  GB 
C            NTFS          Fixed           Healthy    OK                               30.14  GB      50  GB
~~~

# 監視ツールを使わない場合
処理の一番初めに対象ドライブの容量と使用量を取得し、使用率を算出する処理を加え、タスクスケジューラで定期実行することで同等のことが可能です。
~~~powershell
︙
# Teams通知用URL
$TeamsURL = "https://xxxxxxxxxxxxxxxxxxxxxxx.webhook.office.com/webhookb2/xxxxxxxxxxxxxxxxxxxxxxx/IncomingWebhook/xxxxxxxxxxxxxxxxxxxxxxx/xxxxxxxxxxxxxxxxxxxxxxx"

########## 使用率判定 ##########
# 対象ドライブの容量を取得
$DiskInfo = Get-WmiObject Win32_LogicalDisk
$cDrive = $DiskInfo | Where-Object {$_.DeviceID -eq $driveLatter + ":"}
$diskSize = [Math]::Round($CDrive.Size / 1GB, 1)

# 対象ドライブの使用量を取得
$useDisk = [Math]::Round(([long]$CDrive.Size - [long]$CDrive.FreeSpace) / 1GB, 1)

# 使用率を判定
$useRate = [Math]::Round($useDisk / $diskSize * 100, 1)
if ($useRate -le 90) {
    Write-Output ("ドライブの使用率は" + $useRate + "%です。処理を終了します。")
    exit
}

########## Amazon EBSの拡張 ##########
# インスタンスメタデータからインスタンスIDを取得
︙
~~~

# ポイント
**対象EBSボリュームの判定にはタグを使う**
前段で取得したインスタンスIDにアタッチされているEBSボリュームが複数存在する場合、拡張対象のボリュームを判断することができないので、あらかじめボリュームの`Nameタグ`に名前を付けておきます。
例 : `Data-Log`
`Values=`に対象としたいボリュームのNameタグを指定すればOKです。(今回は`*Log`としています)

~~~powershell
# EBSボリュームサイズの取得
$beforeEbsSize = aws ec2 describe-volumes --filters "Name=attachment.instance-id,Values=$instanceId" "Name=tag:Name, Values=*Log" --query "Volumes[].Size"
~~~

**ファイルシステム拡張タイミング**
EBSボリュームを拡張後のステータスは以下のように遷移します。
`modifying` → `optimizing` → `completed`
`modifying`の状態ではWindows側で認識されている状態になっていないのでファイルシステムの拡張ができません。
従って、`optimizing`または`completed`の状態になるまで待機する必要があります。
とは言っても基本的に数秒で`optimizing`になります。
また、私も最初勘違いしていたのですが、`completed`まで待機する必要はありません。

参考:
> 開始する前に
┗ボリュームの変更が成功し、optimizing または completed 状態になっていることを確認します。
https://docs.aws.amazon.com/ja_jp/AWSEC2/latest/UserGuide/recognize-expanded-volume-linux.html
~~~powershell
# 進行状況チェック
$time = 0
while (((aws ec2 describe-volumes-modifications --volume-id $volumId).Split("")[1] -ne ("optimizing")) -and ((aws ec2 describe-volumes-modifications --volume-id $volumId).Split("")[2] -ne ("completed"))) {
    Write-Output ([string]$time + " EBSのボリュームステータスが optimizing または completed になるのを待っています。")
    Start-Sleep -s 10
    [int]$time += 10
}
~~~

**EBS拡張の制約**
一度EBSボリュームの拡張を行うと、拡張後6時間は同一ボリュームを拡張することができないため要件に合った拡張量を決める必要があります。

参考:
> 制約事項
┗ボリュームの変更後は 6 時間以上待機
https://docs.aws.amazon.com/ja_jp/AWSEC2/latest/WindowsGuide/modify-volume-requirements.html


# まとめ
このスクリプトを使用して予期せぬ動作をした場合、損失が発生した場合などの保証は出来かねますのでご了承ください。
また、予期せぬ拡張のリスクは拭いきれないため、EBSの拡張上限サイズは決めておこうと思います。
EBSの制約で6時間は同一ボリュームの再拡張が行われないため、無限ループに陥った際の無限拡張ということは起こりえないはずですが、保険をかけるに越したことはないので...
今回のスクリプトはWindows Server用に作成していますが、一部読み替えればLinux用に使用することが可能です。用途に応じてチューニングし、使用していただければと思います。

# 最後に
今回初めてPowerShellでスクリプトを書いてみて、かなり癖を感じましたが新たな発見も多く楽しかったです。
私は最近、運用→開発→運用に戻ってきたエンジニアのはしくれなのですが、やはり自分が思い描くことをプログラムに書き起こし、運用を自動化できる瞬間は気持ち良さを感じます。


最後まで読んでいただきありがとうございました！