Claude CodeでEBSのgp2からgp3へ移行する — Terraformで性能不足を先に洗う
gp2のボリュームをgp3へ変える前に、CloudWatchの実測から必要なIOPSとスループットを割り出し、Terraformのplanで差分を確かめて段階的に適用する手順です。
gp2をgp3に変えるときの落とし穴は、既定のgp3が3,000 IOPS・125 MiB/sからしか始まらない点です。大きなgp2ボリュームは、それより高い性能を容量の大きさだけで得ています。そのままtype = "gp3"に書き換えると、性能が下がる可能性があります。
Claude Codeには、この不足を先に数字で出させます。CloudWatchの実測を読み、gp2の現行性能と突き合わせ、Terraformのplanで差分を確認し、数本ずつ適用する。その間、AWSの設定を直接変える経路は権限で閉じておきます。本記事は、この流れをsettings.jsonとプロンプトまで含めて示します。
gp3の初期値と、gp2から下がる条件
gp3は容量と性能を切り離したボリュームタイプです。gp2はGiB数に比例してIOPSが決まります。両者の数字を並べると、どこで性能が逆転するかが見えます。
gp3の既定値とgp2の上限
gp3の基本IOPS
3,000
追加で最大80,000まで指定
gp3の基本スループット
125 MiB/s
追加で最大2,000 MiB/sまで指定
gp2の基本IOPS
100〜16,000
1GiBあたり3IOPS
gp2のスループット
128〜250 MiB/s
容量が大きいほど上限に近づく
gp2の基本IOPSは、33.33 GiB以下で100、それより大きいと1 GiBあたり3 IOPSで、5,334 GiBで上限の16,000に達します。1,000 GiBなら3,000です。つまり1,000 GiBを超えるgp2は、既定のgp3より基本IOPSが高いことになります。
スループットでも同じ逆転が起きます。gp2は334 GiB以上で250 MiB/sです。gp3の基本は125 MiB/sなので、334 GiB以上のボリュームで実際に帯域を使っているなら、throughputを明示しない移行は帯域を半分に落とします。
逆に小さなgp2は有利になります。1 TiB未満のgp2は、I/Oクレジットがある間だけ3,000 IOPSまでバーストします。gp3はバーストの仕組みを使わず、3,000 IOPSを常時出し続けます。100 GiBのgp2は基本300 IOPSですから、gp3にすればクレジット切れの心配がなくなります。
容量別の判定早見表
| gp2の容量 | gp2の基本IOPS | gp3へ移すときの注意 |
|---|---|---|
| 33.33 GiB以下 | gp2の基本IOPS100 | gp3へ移すときの注意3,000 IOPSに上がる。IOPSの指定は不要 |
| 〜1,000 GiB未満 | gp2の基本IOPS3 × GiB | gp3へ移すときの注意基本は上がる。帯域は334 GiB以上で250 MiB/sに注意 |
| 1,000 GiB超 | gp2の基本IOPS3,000超 | gp3へ移すときの注意iopsを明示しないと下がる |
| 5,334 GiB以上 | gp2の基本IOPS16,000 | gp3へ移すときの注意iops = 16000を明示して初めて同等 |
最終的な判断は、容量ではなく実際の使用量で決めます。早見表は「実測を見るべきボリューム」を絞るための目安です。
Claudeに許す操作を3段に分ける
性能の確認は読み取りだけで済みます。変更はTerraform経由に限り、AWS CLIでの直接変更は塞ぎます。CLIでmodify-volumeを実行するとTerraformの状態とずれ、次のplanで意図しない差分が出るからです。
権限設定のページによると、ルールはdeny、ask、allowの順に評価され、最初に一致したものが結果を決めます。ルールの具体性は評価順を変えません。広いdenyの内側にallowで例外を作ることはできず、同じことがaskとallowの間にも言えます。したがって「止めたいコマンドだけをdenyに、読むものだけをallowに」と、重ならない形で書きます。
{
"permissions": {
"allow": [
"Bash(aws ec2 describe-volumes *)",
"Bash(aws ec2 describe-volumes-modifications *)",
"Bash(aws cloudwatch get-metric-statistics *)",
"Bash(terraform plan *)",
"Bash(terraform show *)"
],
"ask": [
"Bash(terraform apply *)"
],
"deny": [
"Bash(aws ec2 modify-volume *)",
"Bash(aws ec2 delete-volume *)",
"Bash(terraform destroy *)"
]
}
}末尾の*の前の半角スペースは、ルールの一部です。Bash(ls *)がls -laに一致してlsofに一致しないのと同じで、スペースを抜くと別のサブコマンドまで巻き込みます。複合コマンドは区切りごとに個別に判定されるため、aws ... | python3 ...のようにパイプでつなぐと、後ろの実行にも許可が要ります。整形はAWS CLIの--queryで済ませ、集計は後述のスクリプトファイルに分けます。ただし手順2のforループやpython3でのスクリプト実行は上のallowに一致しないので、初回は確認プロンプトが出ます。必要ならBash(python3 *)の許可を足すか、その都度承認します。
denyは、絶対パスでの呼び出しやsh -cの中身までは止めません。削除や変更を確実に防ぐ層は、Claudeに渡す認証情報のIAMポリシーを読み取り専用にすることです。権限ルールはClaudeの振る舞いを整える層、IAMは最終的な防壁と分けます。Terraformのapplyは人が自分の認証情報で実行する運用にすれば、どちらの層も崩れません。
同じ権限設計でAWSの棚卸しを行う例はAWSの未使用リソースを洗い出す記事にあります。
手順1: 対象ボリュームを一覧にする
まず、gp2のボリュームとその現行性能を出します。describe-volumesは、タイプで絞るフィルターを持ちます。
aws ec2 describe-volumes --profile audit \
--filters Name=volume-type,Values=gp2 \
--query 'Volumes[].{Id:VolumeId,GiB:Size,AZ:AvailabilityZone,
State:State,Attached:Attachments[0].InstanceId}' \
--output table出力された容量から、gp2側の基本IOPSをmax(100, 3 × GiB)、上限16,000で計算します。この列を足した表を作らせると、先ほどの早見表のどこに当たるかが一目で分かります。Stateがavailableのボリュームは、移行ではなく未使用リソースの整理の対象です。
手順2: CloudWatchの実測から必要IOPSを割り出す
EBSのボリューム単位の指標は、1分間隔で課金なしに取得できます。VolumeReadOpsとVolumeWriteOpsは、期間内の完了した操作の合計数です。EBSユーザーガイドの説明では、1秒あたりの値を出すには、合計を期間の秒数で割ります。
読みと書きの両方を足してから割る点が肝です。片方だけだと、必要性能を過小に見積もります。
for m in VolumeReadOps VolumeWriteOps; do
aws cloudwatch get-metric-statistics --profile audit \
--namespace AWS/EBS --metric-name "$m" \
--dimensions Name=VolumeId,Value=vol-0123456789abcdef0 \
--start-time 2026-10-23T00:00:00Z --end-time 2026-10-28T00:00:00Z \
--period 300 --statistics Sum \
--query 'sort_by(Datapoints,&Timestamp)[].[Timestamp,Sum]' \
--output json > "/tmp/$m.json"
done期間の指定には制約があります。1回の呼び出しで返るデータ点は最大1,440個です。period 300なら5日分が上限で、それより長く見たいなら日付を分けて呼びます。また、1分データが残るのは15日間、5分データは63日間です。15日より前の期間では、periodを300の倍数にしないとデータが返りません。
periodを長くすると、平均化されて短い山が見えなくなります。バッチ処理で数分だけ跳ねるボリュームは、最も忙しい日を1日だけperiod 60で測り直すと、5分値では隠れた山が現れます。
二つのJSONを足して秒あたりに直す作業は、Claudeにスクリプトとして書かせると繰り返せます。次のような形です。
import json
def load(path):
return {t: s for t, s in json.load(open(path))}
reads = load("/tmp/VolumeReadOps.json")
writes = load("/tmp/VolumeWriteOps.json")
period = 300 # 取得時のperiodと同じ秒数
iops = [(reads.get(t, 0) + writes.get(t, 0)) / period
for t in sorted(set(reads) | set(writes))]
print(f"peak={max(iops):.0f} IOPS points={len(iops)}")スループットはVolumeReadBytesとVolumeWriteBytesで同じ計算をします。バイト数の合計を期間の秒数と1,048,576で割ると、MiB/sになります。
実測を目標値に変える
ピーク値をそのまま設定値にはしません。次の式で目標値を作り、Terraformのiopsとthroughputに書きます。
- 目標IOPSは、次の3つのうち最も大きい値にします。実測ピークに余裕を足した値、3,000、gp2の現行基本IOPSです。
- 目標スループットは、実測の帯域ピークに余裕を足した値と、125 MiB/sのうち大きい方です。gp2が250 MiB/sを出せる容量なら、それを下限にすると退行を避けられます。
- gp3の制約も確認します。IOPSは1 GiBあたり500まで、スループットは1 IOPSあたり0.25 MiB/sまでで、たとえば帯域250 MiB/sには1,000 IOPS以上が要ります。
余裕の割合は運用で決める値で、公式に決まった数字はありません。ピークが月末などに偏る場合は、その時期を含めて測ってください。
手順3: Terraformで差分を作り、planで確かめる
Terraformのaws_ebs_volumeでは、typeの既定値がgp2です。iopsはgp3・io1・io2でのみ、throughputはgp3でのみ有効です。EC2インスタンスのルートボリュームを書くaws_instanceのroot_block_deviceは、volume_typeを省くとAMIの値を使います。移行対象には、この「書いていないから現状のまま」のボリュームが含まれます。
段階適用のために、gp3にするボリュームを変数で選びます。
variable "gp3_volume_keys" {
type = set(string)
default = [] # 段階ごとにキーを足していく
}
locals {
volumes = {
app-data = { az = "ap-northeast-1a", size = 2000, iops = 6000, throughput = 250 }
logs = { az = "ap-northeast-1a", size = 200, iops = 3000, throughput = 125 }
}
}
resource "aws_ebs_volume" "this" {
for_each = local.volumes
availability_zone = each.value.az
size = each.value.size
type = contains(var.gp3_volume_keys, each.key) ? "gp3" : "gp2"
iops = contains(var.gp3_volume_keys, each.key) ? each.value.iops : null
throughput = contains(var.gp3_volume_keys, each.key) ? each.value.throughput : null
}default = []のままplanを走らせると、差分が出ないことを確認できます。それから-var 'gp3_volume_keys=["logs"]'のようにキーを足して、差分を見ます。見る点は2つです。
- 対象ボリュームだけが変わるか。他のボリュームに差分が出るなら、書き換えの範囲が広すぎます
- 更新が「その場の更新」か「作り直し」か。作り直しを示す表示が出たら、その時点で止めます。データを持つボリュームの作り直しは取り返せません
この確認はClaudeがterraform planの出力を読んで行えます。許可ルールでterraform plan *を通してあるため、プロンプトなしで繰り返せます。
手順4: 数本ずつ適用し、完了を見てから次へ
AWS側のボリューム変更は、コンソールでもCLIでも、取り付けたまま実行できます。Elastic Volumesという機能で、サイズの拡大、性能の増減、タイプの変更を、デタッチせずに行えます。ただし対象のインスタンスが現行世代であることなど、対応条件があります。
適用の進め方
- 1
スナップショットを取る
AWSの手順にも、価値のあるデータを持つボリュームの変更前にスナップショットを作るのがよい、と書かれています。
- 2
影響の小さいボリュームから1本だけ
変数にキーを1つだけ足し、planを人が確かめて
terraform applyを実行します。applyはaskなので、確認プロンプトが出ます。 - 3
変更の状態を見る
describe-volumes-modificationsのModificationStateが、modifying、optimizing、completedのどれかを見ます。failedなら次へ進みません。 - 4
適用後の実測を取り直す
手順2のスクリプトで、変更後の同じ曜日・時間帯を測り、遅延や待ち行列が悪化していないかを見ます。
- 5
次の組を足す
問題がなければ、次のキーを足します。重要なボリュームは最後に回します。
状態の確認は読み取りコマンドで足ります。
aws ec2 describe-volumes-modifications --profile audit \
--volume-ids vol-0123456789abcdef0 \
--query 'VolumesModifications[].{Id:VolumeId,State:ModificationState,
From:OriginalVolumeType,To:TargetVolumeType,Progress:Progress}'ModificationStateにはmodifying、optimizing、completed、failedの4値があります。completedになってから次の組へ進みます。これは進め方の好みではなく、ModifyVolumeのAPI仕様です。同じボリュームについて、前の変更がcompletedに達するまで次の変更は開始できません。
Claudeに渡す指示の例
プロジェクトのCLAUDE.mdに、手順をそのまま書いておくと毎回の指示が短くなります。
## EBSのgp2からgp3への移行
- 変更はTerraformのみ。`aws ec2 modify-volume`は使わない
- 移行前に、対象ごとに次の4列の表を作る:
gp2基本IOPS(max(100, 3×GiB)、上限16000)/ 実測ピークIOPS /
実測ピーク帯域(MiB/s)/ 目標iops・throughput
- 目標iopsは3000以上かつgp2基本IOPS以上。帯域250 MiB/sには1000 IOPS以上が必要
- planで対象外のリソースに差分が出たら適用せず報告する
- 「作り直し」を示す差分が出たら止めて報告するこれで、ClaudeはCloudWatchの値を読んで表を作り、.tfの変更案とplan結果をひとまとめに出せます。適用は人の操作が残ります。
よくあるつまずき
落とし穴
1,000 GiBを超えるgp2を、型だけ書き換えた
gp2の基本IOPSは容量に比例するため、2,000 GiBなら6,000 IOPSです。type = "gp3"だけを書くと、gp3の既定である3,000 IOPSになります。iopsとthroughputを明示してからplanを読みます。
次の点も、切り分けでよく当たります。
- 実測の期間が短すぎる。月末や週次バッチを含まない5日分で決めると、ピークを逃します
- 移行後に性能が足りない。gp3の
iopsとthroughputはmodify-volumeの対象項目なので、Terraformの値を直して再度applyする道があります。ただし同じボリュームの再変更には条件があります。直前の変更がcompletedになるまで次の変更は始められず、ボリュームの状態がin-useかavailableであることも要ります。変更は24時間のうちに4回までで、超えると次に変更できる時刻がエラーで返ります。1回で当てるつもりで目標値を決めます
まとめ
gp2からgp3への移行で確認する対象は、容量の小さいボリュームではなく、1,000 GiBを超えるものと、334 GiB以上で帯域を使っているものです。この2つは、typeだけを変えると性能が下がることがあります。CloudWatchの読みと書きを足した実測を目標値に直し、planで範囲を確かめ、数本ずつ適用すれば、不足は適用前に見つかります。
AWS全体の移行計画を立てる場面では、migration-to-awsプラグインの記事が別の入口になります。
参照した一次ソース
この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。
- code.claude.com/docs/en/permissions
- docs.aws.amazon.com/ebs/latest/userguide/general-purpose.html
- docs.aws.amazon.com/ebs/latest/userguide/requesting-ebs-volume-modifications.h…
- docs.aws.amazon.com/ebs/latest/userguide/using_cloudwatch_ebs.html
- docs.aws.amazon.com/cli/latest/reference/cloudwatch/get-metric-statistics.html
- docs.aws.amazon.com/cli/latest/reference/ec2/describe-volumes-modifications.ht…
- docs.aws.amazon.com/AWSEC2/latest/APIReference/API_ModifyVolume.html