はじめに
業務で、S3 Glacier Deep Archive によるコスト削減に取り組むことになりました。
これを機に、移す前に知っておきたい仕様を検証環境で確かめながらまとめてみました。
対象読者
- S3 のライフサイクルで、Deep Archive への移行を考えている方
- 移行の前に、対象の件数や費用を見積もりたい方
- S3 Inventory を使ったことがない方
概要
- Deep Archive への移行費用は件数で、削減額は容量で決まります。
- S3 Inventory + Athena なら、件数と容量をサイズ別・プレフィックス別に SQL で数えられます。
- ライフサイクルの既定では、128KiB 未満は移りません。
- ただし、東京の単価だと、128KiB のファイルは元を取るのに約 25か月かかります。
- 「180日で移行・366日で削除」のルールでは、元が取れるのは約 490KiB 以上のファイルです。
移す前に知りたかったこと: 件数と容量
移行のルール
今回は、業務で扱った次のルールを例に考えます。
- 作成から 180日で Deep Archive へ移す
- 作成から 366日で削除する
Deep Archive には、最低 180日分の料金がかかります。
今回は Deep Archive に 186日置いてから消すので、早期削除の料金は出ません。
※ルールを入れた初日だけは例外です。詳しくは後半の注意点で触れます。
移行費用は件数、削減額は容量で決まる
Deep Archive へ移すと、費用が2種類動きます。
| 費用 | 何で決まるか | タイミング |
|---|---|---|
| 移行のリクエスト料金 | 件数 | 移すときに一度だけ |
| 毎月の保管料金 | 容量 | 毎月(下がる) |
同じ 1GiB でも、件数が違うと結果が大きく変わります。
| 1GiB の持ち方 | 件数 | 移行費用(一度だけ) | 毎月の削減額 | 元が取れるまで |
|---|---|---|---|---|
| 1MiB × 1,024件 | 1,024 | 約 0.067 USD | 約 0.023 USD | 約 3か月 |
| 128KiB × 8,192件 | 8,192 | 約 0.53 USD | 約 0.021 USD | 約 25か月 |
※東京リージョンの単価で試算(2026-10-22 時点)。計算の前提は後半に書きます。
小さいファイルが多いと、移行費用が膨らみます。
そのため、容量だけでなく件数も数える必要があります。
CloudWatch の数字は、そのままでは使えない
バケットの件数と容量は、CloudWatch のメトリクスで手軽に見られます。
ただし、この2つの数字には、移行の対象にならないものも入っています。
S3 のメトリクスの説明によると、NumberOfObjects と BucketSizeBytes は次も含めて数えます。
- バージョニングの旧バージョン
- 削除マーカー(
NumberOfObjectsのみ) - 完了していないマルチパートアップロードの部品
また、サイズごとの内訳は分かりません。
128KiB 未満が何件あるかは、別の方法で数える必要があります。
数え方: LIST と S3 Inventory
数え方は2つ考えました。
| 項目 | LIST API で全件取る | S3 Inventory + Athena |
|---|---|---|
| 取れるもの | 実行した時点の一覧 | 毎日(または毎週)の一覧 |
| 回数 | 1回 1,000件まで。100万件なら約 1,000回 | 設定は1回 |
| 集計 | 自分でスクリプトを書く | SQL で書ける |
| 待ち時間 | すぐ | 初回は最大 48時間 |
| 移行前後の比較 | 前後で2回取って突き合わせる | 日付ごとに残るので、同じ SQL で比べられる |
Inventory は日付ごとに一覧が残ります。
そのため、移行前の費用の見積もりと、移行後の「問題なく移ったか」の確認を、同じ SQL でできます。
この記事では、Inventory + Athena で数える方法を試しました。
S3 Inventory + Athena の仕組み
全体の流れ
Inventory は、バケットの中身の一覧を、別のバケットへ毎日書き出す機能です。
書き出した一覧を、Athena のテーブルとして読みます。
出力先の構造
出力先には、次の形でファイルが届きます。
inventory/
└── <元バケット>/
└── <Inventory の名前>/
├── data/ ← 一覧の本体(Parquet)
├── 2026-10-23T01-00Z/
│ └── manifest.json ← その日の一覧に含まれるファイル
└── hive/
└── dt=2026-10-23-01-00/
└── symlink.txt ← Athena が読む入口
ポイントは hive/ の下です。
dt=日付 のフォルダごとに symlink.txt があり、その日の Parquet ファイルの場所が書かれています。
Athena は、この symlink.txt をたどって Parquet を読みます。
そのため、テーブルは dt で区切ります。
SQL で dt を指定すると、その日の一覧だけを数えられます。
CREATE EXTERNAL TABLE IF NOT EXISTS inventory (
bucket string,
key string,
size bigint,
last_modified_date timestamp,
storage_class string
)
PARTITIONED BY (dt string)
ROW FORMAT SERDE 'org.apache.hadoop.hive.ql.io.parquet.serde.ParquetHiveSerDe'
STORED AS INPUTFORMAT 'org.apache.hadoop.hive.ql.io.SymlinkTextInputFormat'
OUTPUTFORMAT 'org.apache.hadoop.hive.ql.io.IgnoreKeyTextOutputFormat'
LOCATION 's3://<出力先バケット>/inventory/<元バケット>/<Inventory の名前>/hive/'
TBLPROPERTIES (
"projection.enabled" = "true",
"projection.dt.type" = "date",
"projection.dt.format" = "yyyy-MM-dd-HH-mm",
"projection.dt.range" = "2026-10-01-00-00,NOW",
"projection.dt.interval" = "1",
"projection.dt.interval.unit" = "HOURS"
);
projection(パーティション射影)を使うと、日付が増えるたびにパーティションを足す作業が要りません。
range の開始日は、Inventory を作った日より前にしておきます。
出力形式は Parquet にした
形式は CSV・ORC・Parquet から選べます。
今回は Parquet にしました。
Parquet は、表のデータを列ごとに圧縮して持つ形式です。
SQL で使う列だけを読むので、読む量が小さくなります。
また、列を名前で読むので、CSV のように DDL と列の順番を合わせなくて済みます。
出力先のバケットポリシーは自分で書く
コンソールで Inventory を作ると、出力先のバケットポリシーが自動で付きます。
Terraform や CLI で作るときは、自分で書く必要があります。
resource "aws_s3_bucket_policy" "dest" {
bucket = aws_s3_bucket.dest.id
policy = jsonencode({
Statement = [{
Effect = "Allow"
Principal = { Service = "s3.amazonaws.com" }
Action = "s3:PutObject"
Resource = "${aws_s3_bucket.dest.arn}/inventory/*"
Condition = {
ArnLike = { "aws:SourceArn" = aws_s3_bucket.src.arn }
# (アカウント ID と ACL の条件は省略)
}
}]
})
}
Inventory の設定には、このポリシーへの参照がありません。
そのため、depends_on でポリシーを先に作るようにしました。
検証の条件
答えが分かっているデータを置き、Athena の結果と突き合わせました。
| 項目 | 内容 |
|---|---|
| リージョン | 東京(ap-northeast-1) |
| 構築 | Terraform(hashicorp/aws 6.x) |
| Inventory | 毎日・Parquet・現行バージョンのみ |
| 一覧に含めた列 | サイズ・最終更新日時・ストレージクラス |
| ライフサイクル | 0日で Deep Archive へ移行。最小サイズは既定(128KiB) |
置いたデータは、次の 161件です。
| プレフィックス | 件数 | 1件のサイズ | 置いたときのクラス | 狙い |
|---|---|---|---|---|
logs/ |
120 | 1KiB | Standard | 移らないはず |
edge/131071/ |
2 | 128KiB の1バイト手前 | Standard | 境界の手前 |
edge/131072/ |
2 | ちょうど 128KiB | Standard | 境界ちょうど |
edge/131073/ |
2 | 128KiB の1バイト先 | Standard | 境界の先 |
backups/ |
30 | 200KiB | Standard | 移るはず |
archived/ |
5 | 200KiB | Deep Archive | 最初から移っている |
結果1: Athena で件数と容量を数える
Inventory が届くまで約 40時間かかった
バケットを作ったのは、10/22 の 12:03 でした。
最初の一覧が届いたのは、10/24 の 04:01 です。
作成から約 40時間かかりました。
計測は今回の1回だけです。ドキュメントでは「最大 48時間」とされているので、翌日にクエリを流す予定は立てないほうが安全です。
ストレージクラスごとの件数
| storage_class | 件数 | バイト数 |
|---|---|---|
| DEEP_ARCHIVE | 5 | 1,024,000 |
| STANDARD | 156 | 7,053,312 |
置いたデータと一致しました。
読んだ量は 189 バイトでした。
128KiB の境界の前後
SELECT
CASE
WHEN size < 131072 THEN '< 128KiB'
WHEN size = 131072 THEN '= 128KiB'
ELSE '> 128KiB'
END AS size_class,
count(*) AS objects,
sum(size) AS bytes
FROM inventory
WHERE dt = (SELECT max(dt) FROM inventory)
AND storage_class = 'STANDARD'
GROUP BY 1
ORDER BY 1;
| size_class | 件数 | バイト数 |
|---|---|---|
| < 128KiB | 122 | 385,022 |
| = 128KiB | 2 | 262,144 |
| > 128KiB | 32 | 6,406,146 |
logs/ の 120件と edge/131071/ の2件で、122件です。
これも期待どおりでした。
読む量が小さい
| クエリ | 読んだ量 |
|---|---|
| ストレージクラスごと | 189 バイト |
| 128KiB の前後 | 238 バイト |
| プレフィックスごと | 965 バイト |
Parquet なので、使う列だけを読んでいます。
なお、Athena は1クエリあたり最低 10MB 分の料金がかかります。
今回の規模では、料金の差は出ません。
※数値は検証時点のものです。
結果2: ちょうど 128KiB は移るのか
気になった理由
2024年9月から、ライフサイクルの既定が変わりました。
ドキュメントには、128KB より小さいオブジェクトは移行しない、と書かれています。
ここで2つ疑問がありました。
- 128KB は 128,000 バイトか、131,072 バイト(128KiB)か
- ちょうど 128KiB は移るのか
移行費用の SQL を size > 131072 と書くか size >= 131072 と書くかが、これで変わります。
結果
ライフサイクルを入れた翌日、list-objects-v2 で確かめました。
Inventory は1日1回なので、移行後の一覧が届くのはさらに後になります。
見たいのは数件だけなので、先に API で直接見ました。
aws s3api list-objects-v2 --bucket <元バケット> \
--query 'Contents[].[Key,Size,StorageClass]' --output text
| プレフィックス | 1件のサイズ | 翌日のクラス |
|---|---|---|
edge/131071/ |
131,071 バイト | STANDARD |
edge/131072/ |
131,072 バイト | DEEP_ARCHIVE |
edge/131073/ |
131,073 バイト | DEEP_ARCHIVE |
backups/ |
200KiB | DEEP_ARCHIVE |
logs/ |
1KiB | STANDARD |
その後に届いた Inventory でも、プレフィックスごとに数え直しました。
SELECT
regexp_replace(key, '/[^/]+$', '') AS prefix,
storage_class,
count(*) AS objects
FROM inventory
WHERE dt = (SELECT max(dt) FROM inventory)
GROUP BY 1, 2
ORDER BY 1, 2;
| prefix | storage_class | 件数 |
|---|---|---|
| archived | DEEP_ARCHIVE | 5 |
| backups | DEEP_ARCHIVE | 30 |
| edge/131071 | STANDARD | 2 |
| edge/131072 | DEEP_ARCHIVE | 2 |
| edge/131073 | DEEP_ARCHIVE | 2 |
| logs | STANDARD | 120 |
list-objects-v2 で見た結果と一致しました。
移行の前に使ったのと同じ SQL で、移行後の確認もできます。
分かったことは2つです。
- 境界は **131,072 バイト(128KiB)**でした。131,071 バイトは移らないので、128,000 バイトではありません
- ちょうど 128KiB は移ります。 移らないのは「128KiB 未満」だけです
最初は、移行費用の SQL を size > 131072 で書いていました。
これだと、ちょうど 128KiB の分だけ件数が少なく出ます。
実測に合わせて >= に直しました。
AND size >= 131072 -- ちょうど 128KiB も移る
128KiB は損益分岐点ではない
サイズ別の、元が取れるまでの月数
1件あたりで、移行費用と毎月の削減額を比べました。
比べる相手は、移さずに Standard に置き続けた場合です。
取り出し(復元)はしない前提です。
| 1件のサイズ | 元が取れるまで | 186日置いたときの損益(100万件あたり) |
|---|---|---|
| 128KiB | 約 25か月 | 約 -49 USD |
| 256KiB | 約 12か月 | 約 -32 USD |
| 512KiB | 約 6か月 | 約 +3 USD |
| 1MiB | 約 3か月 | 約 +73 USD |
| 10MiB | 約 0.3か月 | 約 +1,326 USD |
試算の前提は次のとおりです。
| 項目 | 単価(東京、2026-10-22 時点) |
|---|---|
| Deep Archive への移行 | 1,000件あたり 0.065 USD |
| S3 Standard | GB 月あたり 0.025 USD |
| Deep Archive | GB 月あたり 0.002 USD |
| 1件ごとの上乗せ | 8KB を Standard、32KB を Deep Archive の単価で |
単価は AWS Price List API で取得しました。
1か月は 30日として計算しています。
上乗せは、Deep Archive に移したファイルごとに付く管理用のデータです。
名前などの 8KB には Standard の単価、復元に使う索引などの 32KB には Deep Archive の単価がかかります(ドキュメント)。
小さいファイルほど、この上乗せの割合が大きくなります。
186日しか置かないなら、約 490KiB が分かれ目
今回のルールでは、Deep Archive に置くのは 186日(約 6.2か月)です。
その間に元が取れないファイルは、移すと損になります。
サイズ別に、移してからの損益を線で追うと次のようになります。
計算すると、分かれ目は約 490KiB でした。
128KiB〜490KiB のファイルは、ライフサイクルでは移りますが、移すと損です。
検証のデータでいうと、backups/ の 200KiB がこれに当たります。
元を取るのに約 16か月かかるので、186日では届きません。
分かれ目は、1件あたり「毎月の削減額 × 置く月数」が移行費用と等しくなるサイズです。
前半の単価から、次の式で出しました。
分かれ目(KiB)
= (移行単価 × 1,048,576 ÷ 月数 + 8 × Standard 単価 + 32 × Deep Archive 単価)
÷ (Standard 単価 − Deep Archive 単価)
= (0.000065 × 1,048,576 ÷ 6.2 + 8 × 0.025 + 32 × 0.002) ÷ (0.025 − 0.002)
≈ 489
移行単価は1件あたりの値(0.065 USD ÷ 1,000件)で、ほかの単価は GB 月あたりの値です。
1,048,576 は、1GB を KiB に直した数です。
ルールの日数やリージョンが違う場合も、月数と単価を差し替えれば分かれ目を出せます。
移すファイルをサイズで絞るなら、ライフサイクルのフィルタで ObjectSizeGreaterThan を指定できます。既定の 128KiB より大きい値にすれば、損になるサイズ帯を外せます。ドキュメントでも、小さいファイルが混ざる場合はサイズのフィルタで外すことが勧められています。
自分のバケットで、移るものと消えるものを数える
単価から分かるのは、「何 KiB から得か」までです。
そのサイズ帯に何件・何 GiB あるかは、数えないと分かりません。
ここで Inventory が役に立ちます。
経過日数とサイズで、ルールを入れた日に各ファイルがどうなるかを分けます。
WITH aged AS (
SELECT
date_diff('day', last_modified_date, CAST(now() AS timestamp)) AS age_days,
size
FROM inventory
WHERE dt = (SELECT max(dt) FROM inventory)
AND storage_class = 'STANDARD'
)
SELECT
CASE
WHEN age_days < 180 THEN '1: <180d keep'
-- 128KiB 未満は移らない(ちょうど 128KiB は移る)
WHEN age_days < 366 AND size < 131072 THEN '2: 180-365d <128KiB keep'
WHEN age_days < 366 THEN '3: 180-365d to DA'
-- 削除はサイズに関係なく行われる
ELSE '4: >=366d delete'
END AS fate,
count(*) AS objects,
sum(size) AS bytes
FROM aged
GROUP BY 1
ORDER BY 1;
ルールを入れた日にまとめて移るのは、3 のファイルです。
その日の移行費用は 3 の件数で、下がる保管料金は 3 の容量で見積もれます。
ただし、Deep Archive に置く期間が 186日より短くなるので、後半の注意点で触れる早期削除の料金がかかる場合があります。
これから 180日を迎えるファイル(1)も、128KiB 以上なら 180日目から順に移り、そのたびに移行費用がかかります。
1 を 128KiB と約 490KiB で区切って数えれば、移すと損になる件数(128KiB〜490KiB)の見当がつきます。
検証環境のファイルは、どれも作成から 180日たっていません。そのため、この SQL は 1 の1行しか返さず、2〜4 の分け方は試せていません。
実際のバケットに適用するときの注意点
検証環境では起きないものの、実際のバケットでは確認が要る点です。
| 項目 | 注意点 |
|---|---|
| バージョニング | 有効なら旧バージョンも課金対象。Inventory は全バージョンを含め、ライフサイクルにも旧バージョン用のルールが要る |
| 既存のライフサイクル | 2024年9月より前に作った設定は、小さいファイルも移す以前の動きのまま。ルールを編集すると新しい既定(128KiB 未満は移さない)に変わる |
| 出力先の暗号化 | SSE-KMS の AWS 管理キー(aws/s3)は、Inventory の出力に使えない |
| ルールを入れた初日 | すでに 180日を過ぎたファイルは、すぐに移る。たとえば 300日目のファイルは、66日後に消えるので早期削除の料金が出る |
| Inventory の片付け | 毎日動き続ける。数え終わったら設定を消す |
おわりに
ライフサイクルの既定は、128KiB(131,072 バイト)未満を外すだけです。
ちょうど 128KiB は移るので、移る件数は size >= 131072 で数えます。
移して元が取れるかは、Deep Archive に置く日数とファイルのサイズで決まります。
ルールの日数を決めるときは、分かれ目のサイズも一緒に出しておくと安心です。
Inventory と Athena で、出力の構造や Parquet の読み方を手で確かめられたのは、良い学習になりました。
移行前の費用の見積もりにも、移行後の確認にも使えます。
今回は、検証環境の小さなデータでの確認です。
また、作成から 180日を過ぎたファイルが無いので、経過日数で分ける SQL も試せていません。
次に機会があれば、サイズの分布を実際に数えて、ObjectSizeGreaterThan の値を決めるところまでやってみたいです。


