はじめに
こんばんは、mirukyです。
2026年6月、Amazon S3へS3 Annotationsという機能が追加されました。S3オブジェクトへ業務上の情報を持たせる機能ですが、オブジェクトタグやユーザー定義メタデータとは役割が少し違います。
今回は3つの違いを確認した後、AWS CLIで注釈の追加、取得、更新、削除まで試します。なるべく短くまとめますので、気軽に触ってみましょう!
目次
1. S3 Annotationsとは
S3 Annotationsは、S3オブジェクトへ名前付きのUTF-8テキストを添付する機能です。JSON、XML、YAML、プレーンテキストなどを扱え、オブジェクト本体を再アップロードせずに追加、取得、更新、削除できます。
1つの注釈は1バイトから1MiBまでで、1つのオブジェクトバージョンへ最大1,000件を添付できます。合計容量の上限は1GiBです。
同じ名前で書き込むと、その注釈のペイロードが置き換わります。注釈は独立したバージョンを持たないため、上書き前の値や削除した値は復元できません。
2. タグやメタデータとの違い
2-1. 3つの機能の役割
3つの機能は、どれもオブジェクトへ情報を付けます。ただし、向いている用途は次のように分かれます。
| 観点 | ユーザー定義メタデータ | オブジェクトタグ | Annotations |
|---|---|---|---|
| データ形式 | ヘッダーのキーと値 | 文字列のキーと値 | UTF-8テキスト |
| 上限 | キーと値の合計2KB | 1バージョン10個 | 1バージョン1,000件、各1MiB |
| 付与するタイミング | アップロード時 | アップロード時または後から | アップロード後 |
| 後からの変更 | オブジェクトのコピーが必要 | 変更できる | 注釈ごとに変更できる |
| 主な用途 | 作成元や文書種別など | IAM、Lifecycle、コスト配分 | AIの出力、文書情報、処理履歴など |
アクセス制御やLifecycleの条件に使うならタグ、JSONのようなまとまった情報を持たせるならAnnotations、という分け方がわかりやすいかと思います。Annotationsはタグの上位互換ではなく、IAMポリシーやLifecycleの条件には使えません。
2-2. 1枚の請求書PDFで使い分ける
請求書PDFをS3へ保存し、その後にAIで文書を分類し、経理担当者が承認する流れを考えてみます。同じPDFでも、付けたい情報の目的によって選ぶ機能が変わります。
| 行いたいこと | 選ぶ機能 | 保存する内容の例 | 選ぶ理由 |
|---|---|---|---|
| アップロード元を記録する | ユーザー定義メタデータ | source-system=scan-app |
アップロード時に決まる小さな情報だから |
| Lifecycleルールで7年後に削除する | オブジェクトタグ | retention=7years |
S3 Lifecycleの条件に使えるから |
| AIの解析結果を保存する | Annotations | 文書種別、請求書番号、抽出結果 | JSONを後から追加できるから |
| 承認状態を更新する | Annotations |
pendingからapprovedへの変更 |
PDF本体をコピーせず更新できるから |
3章では、この中の承認状態をdocument.contextという注釈へ保存します。AIの解析が終わった時点ではpending、経理担当者が確認した後はapprovedへ変える、という流れです。まさしく、元のPDFはそのままで、周辺の情報だけが更新される場面ですね。
ほかにも、動画へ文字起こしや字幕を付ける、画像へコンテンツ判定結果を残す、データ処理へETLの状態や来歴を添える、といった用途があります。オブジェクトを後から調べたときに必要になる、まとまった文脈を持たせたい場面が候補です。
3. AWS CLIで試してみる
ここからは、東京リージョンに検証用バケットを作り、注釈の追加、取得、更新、削除まで進めます。コマンドはLinux、macOS、WSLのBashまたはzshを想定しています。最後にオブジェクトとバケットも削除するため、この章のコードを同じターミナルで上から順番に実行してください。
この章で行う操作は5段階です。
- 検証用バケットとオブジェクトを1つずつ作る
-
pendingの注釈を追加する - 同じ注釈を
approvedへ更新する - 注釈だけを削除し、オブジェクトが残っている状態に戻す
- オブジェクト、バケット、ローカルファイルを削除する
AWSアカウントとAWS CLI v2が必要です。操作するIAMユーザーまたはIAMロールには、次の権限を用意します。
s3:CreateBuckets3:DeleteBuckets3:GetBucketLocations3:ListBuckets3:PutObjects3:GetObjects3:DeleteObjects3:PutObjectAnnotations3:GetObjectAnnotations3:ListObjectAnnotationss3:DeleteObjectAnnotation
s3:ListBucketは、オブジェクトの削除後にhead-objectで404を確認するためにも使います。権限がない場合、存在しないオブジェクトでも403になることがあります。
3-1. AWS CLIと認証状態を確認する
まだS3リソースは作りません。最初に、このターミナルからAWS CLIを実行できるか確認します。
AWS_REGIONは、この後の全コマンドで使う東京リージョンの識別子です。aws --versionはAWS CLIの導入状況、get-caller-identityは認証情報の有効性を確認します。クエリにはlength(Account)を指定しているため、アカウントIDそのものではなく文字数だけが表示されます。
AWS_REGION="ap-northeast-1"
aws --version
aws sts get-caller-identity \
--region "${AWS_REGION}" \
--query 'length(Account)' \
--output text \
--no-cli-pager
最後に12と表示されていれば、認証情報から12桁のAWSアカウントIDを取得できています。ExpiredTokenやUnable to locate credentialsが表示された場合は、認証を済ませてから進んでください。
次は、現在のAWS CLIがS3 Annotationsのコマンドを持っているかを確認します。--generate-cli-skeleton inputは入力例のJSONをローカルで生成するオプションで、AWS APIは呼び出しません。生成されるJSONは不要なので、> /dev/nullで標準出力だけを捨てています。エラーは画面へ残ります。
aws s3api put-object-annotation \
--generate-cli-skeleton input \
--no-cli-pager \
> /dev/null
何も表示されず次のプロンプトへ戻れば、この確認は完了です。Invalid choiceと表示された場合は、AWS CLI v2を最新版へ更新してください。
続いて、ローカルに一時フォルダを作ります。S3_ANNOTATIONS_START_DIRには開始時のフォルダ、S3_ANNOTATIONS_WORK_DIRにはmktemp -dで作る専用フォルダを保存します。最後に元の場所へ戻るため、どちらも3-8まで変更しません。
3つのファイルパスも絶対パスで用意します。これにより、途中でカレントディレクトリを確認し直しても、AWS CLIへ渡すファイルの場所が明確になります。
S3_ANNOTATIONS_START_DIR="$(pwd)"
S3_ANNOTATIONS_WORK_DIR="$(mktemp -d "${TMPDIR:-/tmp}/s3-annotations-demo.XXXXXX")"
cd "${S3_ANNOTATIONS_WORK_DIR}"
INVOICE_FILE="${S3_ANNOTATIONS_WORK_DIR}/invoice-sample.txt"
ANNOTATION_PAYLOAD_FILE="${S3_ANNOTATIONS_WORK_DIR}/document-context.json"
DOWNLOADED_ANNOTATION_FILE="${S3_ANNOTATIONS_WORK_DIR}/downloaded-context.json"
printf 'Working directory: %s\n' "${S3_ANNOTATIONS_WORK_DIR}"
3-2. 検証用バケットを作成する
ここでは、これからAWS上に作るリソースの名前を決めます。このコードだけではAWS側に何も作成されません。
S3のバケット名は、同じAWSパーティション内で一意である必要があります。つまり、別のAWSアカウントがすでに使っている名前も選べません。現在時刻とランダムな数値を付け、ほかのバケットと同じ名前になる可能性を下げています。
OBJECT_KEYはS3上のオブジェクト名です。documents/はフォルダではなく、キーに含まれる文字列ですね。ANNOTATION_NAMEは、追加、取得、更新、削除の全操作で同じ値を使います。
BUCKET_NAME="s3-annotations-demo-$(date +%s)-${RANDOM}"
OBJECT_KEY="documents/invoice-sample.txt"
ANNOTATION_NAME="document.context"
printf 'Bucket name: %s\n' "${BUCKET_NAME}"
create-bucketで、ここまでで初めてAWSリソースを作ります。東京リージョンはus-east-1以外なので、LocationConstraintの指定が必要です。
続くget-bucket-locationは作成したバケットの配置先を読み取るコマンドです。--query LocationConstraintで必要な値だけを取り出します。
aws s3api create-bucket \
--bucket "${BUCKET_NAME}" \
--region "${AWS_REGION}" \
--create-bucket-configuration "LocationConstraint=${AWS_REGION}" \
--no-cli-pager
aws s3api get-bucket-location \
--bucket "${BUCKET_NAME}" \
--region "${AWS_REGION}" \
--query LocationConstraint \
--output text \
--no-cli-pager
最後にap-northeast-1と表示されれば、東京リージョンへバケットを作成できています。BucketAlreadyExistsが表示された場合は、バケット名を設定するコードからもう一度実行してください。
3-3. 検証用オブジェクトを配置する
S3 Annotationsは、すでに存在するオブジェクトへ後から付けます。そこで、先に注釈の親となるオブジェクトを用意しましょう。
printfで請求書に見立てた20バイトのテキストファイルを作り、put-objectでS3へアップロードします。--bodyはローカルファイル、--keyはS3へ保存するときの名前です。
printf 'sample invoice body\n' > "${INVOICE_FILE}"
aws s3api put-object \
--bucket "${BUCKET_NAME}" \
--key "${OBJECT_KEY}" \
--body "${INVOICE_FILE}" \
--region "${AWS_REGION}" \
--no-cli-pager
アップロード後はhead-objectでオブジェクトの属性を読み取ります。このコマンドはオブジェクト本体をダウンロードしません。
最初の呼び出しではContentLengthだけを取得します。20と表示されれば、改行を含む検証ファイルがS3にあります。2回目はETagを取得し、ETAG_BEFOREへ保存します。ETagは後ほど、注釈を変更してもオブジェクト本体が変わっていないことを確かめる比較値として使います。
aws s3api head-object \
--bucket "${BUCKET_NAME}" \
--key "${OBJECT_KEY}" \
--region "${AWS_REGION}" \
--query ContentLength \
--output text \
--no-cli-pager
ETAG_BEFORE="$(aws s3api head-object \
--bucket "${BUCKET_NAME}" \
--key "${OBJECT_KEY}" \
--region "${AWS_REGION}" \
--query ETag \
--output text \
--no-cli-pager)"
画面へ表示されるのは最初のコマンドの20だけです。ETAG_BEFORE="$(...)"はコマンドの出力を変数へ格納する書き方なので、ETag自体は表示されません。
3-4. 注釈のJSONを作成する
S3へ置いたオブジェクトは変えず、業務上の状態を別のJSONとして用意します。2章の請求書を想定し、invoiceIdへ請求書番号、reviewerへ担当チームを入れました。この時点では未承認なので、statusはpendingです。
printfでJSONを作成し、catで内容を表示します。続くtest -sは、ファイルが存在し、サイズが0バイトではないことを確認するコマンドです。
printf '%s\n' \
'{"status":"pending","invoiceId":"INV-2026-001","reviewer":"accounting-team"}' \
> "${ANNOTATION_PAYLOAD_FILE}"
cat "${ANNOTATION_PAYLOAD_FILE}"
test -s "${ANNOTATION_PAYLOAD_FILE}" \
&& printf 'Annotation payload ready\n'
JSONとAnnotation payload readyが表示されれば、注釈へ渡すローカルファイルの準備は完了です。この表示がない場合は、次の操作へ進む前にファイルパスを確認してください。
3-5. 注釈を追加して取得する
いよいよ、S3オブジェクトへ最初の注釈を付けます。注釈はput-objectと同時に指定できないため、オブジェクトを配置した後にput-object-annotationを呼び出します。
--bucketと--keyで親オブジェクト、--annotation-nameで注釈名、--annotation-payloadで先ほど作成したJSONを指定します。ペイロードはストリーミング形式で送られるため、file://やfileb://を付けず、ファイルパスをそのまま渡してください。
aws s3api put-object-annotation \
--bucket "${BUCKET_NAME}" \
--key "${OBJECT_KEY}" \
--annotation-name "${ANNOTATION_NAME}" \
--annotation-payload "${ANNOTATION_PAYLOAD_FILE}" \
--region "${AWS_REGION}" \
--no-cli-pager
成功時は何も表示されません。Blob values must be a path to a fileが表示された場合は、3-4のtest -sを再実行し、同じターミナルでANNOTATION_PAYLOAD_FILEが設定されていることを確認してください。
応答が空のままでは追加結果がわからないため、list-object-annotationsで親オブジェクトに付いている注釈を取得します。
通常の応答にはサイズや更新日時なども含まれますが、ここでは--queryを使い、注釈の総数と名前だけに絞ります。Countが1、Namesにdocument.contextが表示されれば追加できています。
aws s3api list-object-annotations \
--bucket "${BUCKET_NAME}" \
--key "${OBJECT_KEY}" \
--region "${AWS_REGION}" \
--query '{Count:AnnotationCount,Names:Annotations[].AnnotationName}' \
--output json \
--no-cli-pager
この時点のAWS上には、オブジェクト本体が1つ、そのオブジェクトに紐づく注釈が1つあります。
次は、注釈を付ける前後のETagを比べます。ETAG_AFTER_CREATEへ現在のETagを保存し、[ ... = ... ]で3-3のETAG_BEFOREと比較します。&&の右側は、2つが同じ場合だけ実行されます。
ETAG_AFTER_CREATE="$(aws s3api head-object \
--bucket "${BUCKET_NAME}" \
--key "${OBJECT_KEY}" \
--region "${AWS_REGION}" \
--query ETag \
--output text \
--no-cli-pager)"
[ "${ETAG_BEFORE}" = "${ETAG_AFTER_CREATE}" ] \
&& printf 'ETag unchanged after annotation create\n'
ETag unchanged after annotation createと表示されれば、注釈だけが追加され、オブジェクト本体は変わっていません。何も表示されない場合はETagが一致していないため、ここで手を止めて対象のバケット名とキーを確認してください。
一覧APIからわかるのは注釈名やサイズまでで、JSONの中身は返りません。get-object-annotationを使い、特定の注釈をローカルファイルへダウンロードします。
最後のDOWNLOADED_ANNOTATION_FILEはオプションではなく、保存先として必須の引数です。続くcatでJSONを表示し、statusがpendingであることを確認します。
aws s3api get-object-annotation \
--bucket "${BUCKET_NAME}" \
--key "${OBJECT_KEY}" \
--annotation-name "${ANNOTATION_NAME}" \
--region "${AWS_REGION}" \
--no-cli-pager \
"${DOWNLOADED_ANNOTATION_FILE}"
cat "${DOWNLOADED_ANNOTATION_FILE}"
ここまでで、注釈の追加、一覧、内容の取得まで進みました。次はオブジェクト本体を触らず、注釈だけを更新します。
3-6. 注釈を更新して差分を確認する
経理担当者が請求書を承認した想定で、ローカルのJSONを更新します。請求書番号と担当チームはそのままにして、statusだけをpendingからapprovedへ変えます。
同じファイルへ書き込むため、3-4で作成したpendingの内容はローカル上で置き換わります。まずcatで、送信前のJSONがapprovedになっていることを確認してください。
printf '%s\n' \
'{"status":"approved","invoiceId":"INV-2026-001","reviewer":"accounting-team"}' \
> "${ANNOTATION_PAYLOAD_FILE}"
cat "${ANNOTATION_PAYLOAD_FILE}"
AWS上の注釈も同じ方法で更新します。put-object-annotationへ既存のdocument.contextを指定すると、新しい注釈が増えるのではなく、その名前のペイロードが置き換わります。Annotationsには独立した変更履歴がないため、更新前のpendingは復元できません。
aws s3api put-object-annotation \
--bucket "${BUCKET_NAME}" \
--key "${OBJECT_KEY}" \
--annotation-name "${ANNOTATION_NAME}" \
--annotation-payload "${ANNOTATION_PAYLOAD_FILE}" \
--region "${AWS_REGION}" \
--no-cli-pager
更新コマンドも成功時には何も表示されません。実際にAWS上の値が変わったか、get-object-annotationでもう一度ダウンロードします。
保存先には先ほどと同じDOWNLOADED_ANNOTATION_FILEを指定します。catの出力でstatusがapprovedへ変わり、請求書番号と担当チームが残っていることを確認してください。
aws s3api get-object-annotation \
--bucket "${BUCKET_NAME}" \
--key "${OBJECT_KEY}" \
--annotation-name "${ANNOTATION_NAME}" \
--region "${AWS_REGION}" \
--no-cli-pager \
"${DOWNLOADED_ANNOTATION_FILE}"
cat "${DOWNLOADED_ANNOTATION_FILE}"
最後に、更新後のオブジェクトETagをETAG_AFTER_UPDATEへ保存します。3-3のETAG_BEFOREと同じなら、請求書本体を再アップロードせず、注釈のJSONだけをapprovedへ変えられたということです。
ETAG_AFTER_UPDATE="$(aws s3api head-object \
--bucket "${BUCKET_NAME}" \
--key "${OBJECT_KEY}" \
--region "${AWS_REGION}" \
--query ETag \
--output text \
--no-cli-pager)"
[ "${ETAG_BEFORE}" = "${ETAG_AFTER_UPDATE}" ] \
&& printf 'ETag unchanged after annotation update\n'
ETag unchanged after annotation updateと表示されています。元のオブジェクトは同じまま、注釈の内容だけが更新されました。
3-7. 注釈を削除して結果を確認する
更新まで確認できたので、document.contextだけを削除します。delete-object-annotationが削除するのは--annotation-nameで指定した注釈で、親オブジェクトは残ります。
成功時に出力はありません。注釈は独立したバージョンや削除マーカーを持たず、削除後は復元できないため、本番データではバケット、キー、注釈名を確認してから実行してください。
aws s3api delete-object-annotation \
--bucket "${BUCKET_NAME}" \
--key "${OBJECT_KEY}" \
--annotation-name "${ANNOTATION_NAME}" \
--region "${AWS_REGION}" \
--no-cli-pager
削除結果は、同じオブジェクトへlist-object-annotationsを実行して確認します。ここで一覧APIが応答することから親オブジェクトは残っており、AnnotationCountが0なら注釈だけがなくなったと判断できます。
aws s3api list-object-annotations \
--bucket "${BUCKET_NAME}" \
--key "${OBJECT_KEY}" \
--region "${AWS_REGION}" \
--query AnnotationCount \
--output text \
--no-cli-pager
0と表示されました。AWS上には注釈のないオブジェクトと、そのオブジェクトを格納するバケットが残っています。
3-8. オブジェクトとバケットを削除する
注釈の操作は終わりました。ここからは料金の発生源を残さないように、オブジェクト、バケット、ローカルファイルの順で削除します。S3バケットは中身が残っていると削除できないため、オブジェクトが先です。
delete-objectでオブジェクトを削除した後、head-objectで同じキーを読み取ります。削除済みなら読み取りは失敗するため、2>&1でエラー出力をOBJECT_DELETE_RESULTへ取り込みます。|| trueは、この期待どおりの失敗でコマンドの流れを止めないための指定です。最後にgrepで404の行だけを表示します。
aws s3api delete-object \
--bucket "${BUCKET_NAME}" \
--key "${OBJECT_KEY}" \
--region "${AWS_REGION}" \
--no-cli-pager
OBJECT_DELETE_RESULT="$(aws s3api head-object \
--bucket "${BUCKET_NAME}" \
--key "${OBJECT_KEY}" \
--region "${AWS_REGION}" \
--no-cli-pager 2>&1 || true)"
printf '%s\n' "${OBJECT_DELETE_RESULT}" | grep '(404)'
画面にはaws: [ERROR]と表示されますが、ここでは想定した結果です。HeadObject operation: Not Foundと(404)が、指定したキーを取得できなくなったことを示しています。
オブジェクトを削除するとバケットは空になります。続いてdelete-bucketでバケットを削除し、head-bucketで同じ名前を読み取ります。こちらもエラーを変数へ取り込み、404だけを表示する流れです。
aws s3api delete-bucket \
--bucket "${BUCKET_NAME}" \
--region "${AWS_REGION}" \
--no-cli-pager
BUCKET_DELETE_RESULT="$(aws s3api head-bucket \
--bucket "${BUCKET_NAME}" \
--region "${AWS_REGION}" \
--no-cli-pager 2>&1 || true)"
printf '%s\n' "${BUCKET_DELETE_RESULT}" | grep '(404)'
HeadBucket operation: Not Foundと(404)が表示されれば、AWS上の検証用リソースは削除されています。この画像のaws: [ERROR]も削除確認のために発生させた出力です。
AWS側の後片付けは終わりました。最後にローカルへ作成した3つのファイルと一時フォルダを削除します。
最初のcdで作業開始時のフォルダへ戻ります。unlinkは1回につき1ファイルを削除するため、forで3つのパスを順番に渡しています。rmdirは空のフォルダだけを削除するコマンドです。想定外のファイルが残っていれば失敗するため、一時フォルダの中身をまとめて消してしまうこともありません。
cd "${S3_ANNOTATIONS_START_DIR}"
for FILE_PATH in \
"${S3_ANNOTATIONS_WORK_DIR}/invoice-sample.txt" \
"${S3_ANNOTATIONS_WORK_DIR}/document-context.json" \
"${S3_ANNOTATIONS_WORK_DIR}/downloaded-context.json"
do
unlink "${FILE_PATH}"
done
rmdir "${S3_ANNOTATIONS_WORK_DIR}"
[ ! -e "${S3_ANNOTATIONS_WORK_DIR}" ] \
&& printf 'Local cleanup complete\n'
最後の存在確認に通るとLocal cleanup completeと表示されます。これでAWS上とローカルの後片付けまで完了です!
4. 制限と料金
2026年8月時点で、S3 Annotationsは東京リージョンで利用できます。ただし、S3 Express One Zoneのディレクトリバケット、S3 on Outposts、Amazon S3 Files、Amazon S3 File Gateway、S3 Inventory Reports、S3 Storage Lens、Amazon FSx、API Gateway、SSE-Cで暗号化したオブジェクトなどには対応していません。
注釈は特定のオブジェクトバージョンに紐づき、新しいバージョンへは引き継がれません。バージョニングを使う場合は--version-idの指定も確認してください。
料金は、注釈の保存容量と読み書きのリクエストに発生します。保存容量は親オブジェクトのストレージクラスに関係なくS3 Standard料金、PUTやGETなどのリクエストもS3 Standardと同じ料金です。各注釈を1リクエストとして数えるため、10件の注釈を含めてオブジェクトをコピーすると、注釈分だけで10回のPUTが加算されます。
大量のオブジェクトから注釈を検索したい場合は、S3 Metadataのannotation tableへ連携し、Athenaなどからクエリできます。その場合はS3 Metadata、S3 Tables、Athenaの料金も別途確認してください。
おわりに
ここまでお付き合いいただきありがとうございます。
S3 Annotationsを使うと、JSONなどの業務情報をオブジェクト本体と分けたまま、後から付け替えられます。タグはIAMやLifecycle、Annotationsは大きめの文脈データ、と覚えておけば使い分けやすいですね。
ではまた、お会いしましょう。
参考リンク
S3 Annotations
- Amazon S3 adds annotations to provide AI agents and analytics tools with context for data discovery - AWS
- Annotating your objects - AWS
- Managing annotations - AWS
- put-object-annotation command reference - AWS









