
Amazon Bedrock AgentCore Runtime V2 の課金メモリがピーク後に縮小する挙動を USAGE_LOGS で観測してみた
はじめに
2026年9月18日、Amazon Bedrock AgentCore Runtime に、新しいプラットフォームバージョン V2 が追加されました。前記事では、V2 で改善されたコールドスタート性能を確認しました。
AWS の公式ブログでは、V2 におけるメモリ割り当ての最適化が紹介されています。本記事では、その最適化が実際のメモリ消費と課金にどのように表れるのかを、エージェントプロセス内の観測点と課金側テレメトリを突き合わせて観測しました。
検証環境
ap-northeast-1 の AgentCore Runtime V2 に、基本計測用と、USAGE_LOGS を配信するセッション計測用の 2 本を用意しました。いずれも同じ V2 で、ECR イメージ agentcore-cpuinfo:mem-probe-v2 をデプロイしています(イメージ名の cpuinfo は前記事からの流用で、本記事ではメモリを計測しています)。なお本記事の MB は MiB(1 MB = 1024 × 1024 バイト)を指します。
計測設計
課金テレメトリ(USAGE_LOGS)
課金テレメトリは USAGE_LOGS として取得しました。Runtime ARN を delivery source に紐付けると、agent.runtime.memory.gb_hours.used などのメトリクスが session.id 付きで 1 秒粒度でロググループへ配信されます。設定手順は次のとおりです。
REGION="ap-northeast-1"
ACCOUNT_ID="$(aws sts get-caller-identity --query Account --output text)"
RUNTIME_ARN="arn:aws:bedrock-agentcore:${REGION}:${ACCOUNT_ID}:runtime/<RUNTIME_ID>"
LOG_GROUP="/aws/vendedlogs/bedrock-agentcore/mem-probe-usage"
# 1. ロググループ作成(宛先)
aws logs create-log-group --log-group-name "${LOG_GROUP}" --region ${REGION}
# 2. delivery destination 作成
aws logs put-delivery-destination \
--name "mem-probe-usage-dest" \
--delivery-destination-configuration \
"{\"destinationResourceArn\":\"arn:aws:logs:${REGION}:${ACCOUNT_ID}:log-group:${LOG_GROUP}:*\"}" \
--output-format "json" --region ${REGION}
# 3. delivery source 作成(runtime ARN に USAGE_LOGS を紐付け)
aws logs put-delivery-source \
--name "mem-probe-usage-source" \
--resource-arn "${RUNTIME_ARN}" \
--log-type "USAGE_LOGS" \
--region ${REGION}
# 4. delivery 作成
aws logs create-delivery \
--delivery-source-name "mem-probe-usage-source" \
--delivery-destination-arn \
"arn:aws:logs:${REGION}:${ACCOUNT_ID}:delivery-destination:mem-probe-usage-dest" \
--region ${REGION}
この設定で、resource_arn、event_timestamp、attributes、metrics を含むイベントが出力されることを確認しました。attributes には session.id などが、metrics には agent.runtime.memory.gb_hours.used などが含まれていました。ログイベントは計測完了から約 9 分後に出現し、3 セッション分が揃うまで約 20 分かかりました。
agent.runtime.memory.gb_hours.used は 1 秒間の使用量を GB-hours 単位で表した値なので、課金メモリ(MB) ≈ mem_gb_hours_per_s × 3600 × 1024 で、その 1 秒間の平均課金メモリ相当に換算できます。以降の課金メモリ換算値はこの式で求めています。
コンテナ内の補助観測
USAGE_LOGS の課金メモリと、コンテナ内から見える値がどの程度対応するか、すなわち VmRSS や memory.current を課金値の代理指標として使えるかを確かめるため、プロセスと cgroup のメモリを観測しました。
検証用の /invocations では、alloc_mb で確保するヒープ量を指定します。確保前・確保後・解放後の 3 時点で /proc/self/status と /sys/fs/cgroup/memory.current を返し、sleep_sec を指定した場合は解放後の VmRSS と memory.current を一定間隔で記録します。
agent_mem_v2.py
この検証コードでは、bytearray(os.urandom(alloc_mb * 1024 * 1024)) で割り当て領域を実際にページインしています。変換中は bytes と bytearray が同時に存在するため、一時的に指定量のおよそ 2 倍のメモリを確保します。ただし変換が終わると bytes は解放されるため、確保後(after)の VmRSS 増分は指定量とほぼ一致します。
"""
AgentCore Runtime V2 課金メモリ観測エージェント
/invocations に渡せるフィールド:
alloc_mb : ヒープ確保量 (MiB, default=0)
sleep_sec : 解放後にスリープする秒数 (default=0)
interval_sec: sleep_sec > 0 の場合、interval_sec ごとに VmRSS と memory.current を記録 (default=10)
返却フィールド:
vmrss : /proc/self/status の VmRSS 増分(確保前後・解放後)
cgroup_memory : /sys/fs/cgroup/memory.current (存在しない場合は null)
sleep_snapshots: sleep_sec > 0 の場合、interval_sec ごとの VmRSS と memory.current のスナップショット列
"""
import gc
import json
import os
import time
from flask import Flask, request, jsonify, Response
app = Flask(__name__)
CGROUP_MEMORY_CURRENT = "/sys/fs/cgroup/memory.current"
CGROUP_MEMORY_STAT = "/sys/fs/cgroup/memory.stat"
def read_proc_status():
"""
/proc/self/status からメモリ関連行を dict で返す。
単位は kB のまま返す。
"""
mem = {}
try:
with open("/proc/self/status") as f:
for line in f:
if ":" not in line:
continue
key, _, val = line.partition(":")
key = key.strip()
if key.startswith("Vm") or key.startswith("Rss"):
mem[key] = val.strip()
except Exception as e:
mem["error"] = str(e)
return mem
def read_cgroup_memory():
"""
cgroup v2 の memory.current(コンテナ全体の現在メモリ使用量 bytes)を取得。
存在しない場合は None。memory.stat の anon / file / shmem / kernel も補足する。
"""
result = {}
for path, key in [
(CGROUP_MEMORY_CURRENT, "memory_current_bytes"),
]:
try:
with open(path) as f:
raw = f.read().strip()
result[key] = int(raw)
except FileNotFoundError:
result[key] = None
result[f"{key}_note"] = f"not found: {path}"
except Exception as e:
result[key] = None
result[f"{key}_error"] = str(e)
# memory.stat から anon / file を補足
try:
stat = {}
with open(CGROUP_MEMORY_STAT) as f:
for line in f:
parts = line.strip().split()
if len(parts) == 2 and parts[0] in {"anon", "file", "shmem", "kernel"}:
stat[parts[0]] = int(parts[1])
result["memory_stat"] = stat
except FileNotFoundError:
result["memory_stat"] = None
except Exception as e:
result["memory_stat_error"] = str(e)
return result
def vmrss_kb():
"""VmRSS の現在値 (kB) だけを高速に取得。"""
try:
with open("/proc/self/status") as f:
for line in f:
if line.startswith("VmRSS:"):
return int(line.split()[1])
except Exception:
pass
return 0
@app.route("/ping")
def ping():
return Response("OK", status=200)
@app.route("/invocations", methods=["POST"])
def invocations():
try:
raw = request.data or b"{}"
body = json.loads(raw)
except Exception:
body = {}
alloc_mb = int(body.get("alloc_mb", 0))
sleep_sec = int(body.get("sleep_sec", 0))
interval_sec = int(body.get("interval_sec", 10))
# --- 確保前 ---
before = read_proc_status()
cgroup_before = read_cgroup_memory()
# --- ヒープ確保(ページイン保証) ---
data = None
alloc_error = None
alloc_actual_bytes = 0
t_alloc_start = time.monotonic()
if alloc_mb > 0:
try:
data = bytearray(os.urandom(alloc_mb * 1024 * 1024))
alloc_actual_bytes = len(data)
_ = data[0]
_ = data[-1]
except Exception as e:
alloc_error = str(e)
t_alloc_ms = round((time.monotonic() - t_alloc_start) * 1000, 1)
# --- 確保後 ---
after = read_proc_status()
cgroup_after = read_cgroup_memory()
# --- 解放 ---
del data
gc.collect()
# --- 解放後 ---
freed = read_proc_status()
cgroup_freed = read_cgroup_memory()
# --- 解放後スリープ ---
sleep_snapshots = []
if sleep_sec > 0:
t_sleep_start = time.monotonic()
next_snap = interval_sec
while True:
elapsed = time.monotonic() - t_sleep_start
if elapsed >= sleep_sec:
break
if elapsed >= next_snap:
snap_rss = vmrss_kb()
snap_cgroup = read_cgroup_memory()
sleep_snapshots.append({
"elapsed_sec": round(elapsed, 1),
"vmrss_kb": snap_rss,
"cgroup_memory_current_bytes": snap_cgroup.get("memory_current_bytes"),
})
next_snap += interval_sec
time.sleep(0.5)
# 最終スナップ
sleep_snapshots.append({
"elapsed_sec": round(time.monotonic() - t_sleep_start, 1),
"vmrss_kb": vmrss_kb(),
"cgroup_memory_current_bytes": read_cgroup_memory().get("memory_current_bytes"),
})
def kb_val(d, key):
v = d.get(key, "0 kB")
try:
return int(v.split()[0])
except Exception:
return 0
rss_before_kb = kb_val(before, "VmRSS")
rss_after_kb = kb_val(after, "VmRSS")
rss_freed_kb = kb_val(freed, "VmRSS")
return jsonify({
"alloc_mb_requested": alloc_mb,
"alloc_actual_bytes": alloc_actual_bytes,
"alloc_time_ms": t_alloc_ms,
"alloc_error": alloc_error,
"vmrss": {
"before_kb": rss_before_kb,
"after_kb": rss_after_kb,
"freed_kb": rss_freed_kb,
"delta_alloc_kb": rss_after_kb - rss_before_kb,
"delta_freed_kb": rss_freed_kb - rss_after_kb,
},
"proc_status": {
"before": before,
"after": after,
"freed": freed,
},
# --- cgroup ---
"cgroup_memory": {
"before": cgroup_before,
"after": cgroup_after,
"freed": cgroup_freed,
},
# --- スリープ中の推移 ---
"sleep_snapshots": sleep_snapshots,
})
if __name__ == "__main__":
app.run(host="0.0.0.0", port=8080)
コンテナ内からの観測結果
同一セッション内で alloc_mb を 0、100、500、1000 と変えて呼び出しました。
| alloc_mb | VmRSS before (kB) | VmRSS after (kB) | delta (kB) | VmRSS freed (kB) |
|---|---|---|---|---|
| 0 | 32,940 | 32,940 | 0 | 32,940 |
| 100 | 32,972 | 135,384 | 102,412 | 32,980 |
| 500 | 32,972 | 545,008 | 512,036 | 33,004 |
| 1000 | 32,996 | 1,057,008 | 1,024,012 | 33,004 |
VmRSS の増分は確保量とほぼ一致し(差はページサイズ単位)、エージェントプロセスが実際に使用したメモリ量を VmRSS から読み取れました。プロセスのベースラインは約 33MB でした。
cgroup の /sys/fs/cgroup/memory.current も確保量に追従しました。
| alloc_mb | cgroup before (bytes) | cgroup after (bytes) | delta (MiB) |
|---|---|---|---|
| 0 | 22,478,848 | 22,478,848 | 0.0 |
| 100 | 22,495,232 | 127,471,616 | 100.1 |
| 500 | 22,495,232 | 548,605,952 | 501.7 |
| 1000 | 22,532,096 | 1,075,105,792 | 1003.8 |
ただしベースラインの約 22MB は VmRSS の約 33MB を下回りました。同時に読んだ memory.stat の内訳は anon=21.5MB、file=0、shmem=0 で、この計測では匿名ページだけが計上されていました。いずれにせよ、この後で見る課金メモリはアイドル時でも約 1,292MB と桁が違い、memory.current(約 22MB)とは対応しませんでした。この観測の範囲では、memory.current を課金メモリの代理指標として使うことはできませんでした。
解放後の挙動と課金側の段階低下
前節の VmRSS 表の freed 列のとおり、del と gc.collect() の直後に VmRSS は確保前の水準へ戻りました。続けて 150 秒間にわたり 10 秒ごとに VmRSS と memory.current を取得したものが次の表です(alloc_mb=1000 を確保・解放した後の sleep-test-a セッション)。
| elapsed (s) | VmRSS (kB) | cgroup memory.current (bytes) |
|---|---|---|
| 10 | 33,000 | 22,302,720 |
| 50 | 33,012 | 22,077,440 |
| 100 | 33,028 | 22,331,392 |
| 120 | 33,040 | 22,343,680 |
| 150 | 33,044 | 22,351,872 |
150 秒間を通じて、VmRSS も memory.current も水準は変わりませんでした。コンテナ内の観測点からは、課金側の回収挙動は見えませんでした。
課金側は別の様相でした。同じ sleep-test-a セッション(alloc_mb=1000 確保後に 150 秒スリープ)を 12:52〜12:57 JST に他の 2 セッションと同時に実行し、その USAGE_LOGS を課金メモリに換算したものが次の表です。
| elapsed (s) | 課金メモリ換算(MB) | 状態 |
|---|---|---|
| 3〜5 | 約 3,267 | ピーク |
| 12〜76 | 約 1,340→1,330 | del+gc 後、緩やかに低下 |
| 77〜82 | 1,330→1,268 | 急低下。約 62MB 減 |
| 82〜191 | 約 1,268→1,237 | 緩やかに低下 |
| 192〜219 | 1,218→1,068 | 急低下。約 150MB 減 |
| 220〜277 | 約 1,067→1,023 | 緩やかに低下 |
この sleep-test-a セッションの計測では、課金メモリ換算値は単調に低下し、特定の節目で一括して外れるのではなく、下がり方が急になる区間が 77 秒付近と 192〜219 秒付近に現れる段階的な低下を示しました。
CloudWatch との突合
invoke のレスポンス本文に課金値は含まれていませんでした。課金側の値は CloudWatch と USAGE_LOGS から取得します。まず、1 分粒度の MemoryUsed-GBHours を確認しました。名前空間は AWS/Bedrock-AgentCore、ディメンションは Service=AgentCore.Runtime と Resource=<runtime ARN> でした。次に示す値は、基本計測用の Runtime で観測したものです。前節の USAGE_LOGS の 3 セッションとは別の時間帯・別の計測ですが、V2 の Runtime の値を、同じ名前空間・ディメンションで CloudWatch からも取得できることを確認しました。
| 時刻(JST) | MemoryUsed-GBHours | CPUUsed-vCPUHours |
|---|---|---|
| 12:14 | 0.022715 | 0.003844 |
| 12:15 | 0.039315 | 0.000329 |
| 12:16 | 0.036524 | 0.000290 |
| 12:17 | 0.007759 | 0.000057 |
| 合計 | 0.106313 | 0.004520 |
これらのメトリクスは、計測を終了した 12:18 JST から約 20 分後に出現しました。
USAGE_LOGS 側は 1 秒粒度でした。イベントは次の構造で届きました。
{
"attributes": {
"time_elapsed_seconds": 1.00,
"session.id": "mem-probe-ext-seq-b22a9854-..."
},
"metrics": {
"agent.runtime.memory.gb_hours.used": 0.000350483296511,
"agent.runtime.vcpu.hours.used": 0.000556712777778
}
}
計測設計で示した換算式を上記のイベントに当てはめると、0.000350 は約 1,292MB でした。
12:46〜12:58 JST に他の 2 セッションと同時に実行した seq セッションについて、alloc_mb を順に変えた操作期間中の値を課金メモリに換算すると、次のようになりました。
| elapsed (s) | agent.runtime.memory.gb_hours.used/s | 課金メモリ換算(MB) | 対応操作 |
|---|---|---|---|
| 0 | 0.000350 | 約 1,292 | セッション開始直後、アイドル状態 |
| 5 | 0.000619 | 約 2,281 | alloc_mb=1000 確保中 |
| 9 | 0.000770 | 約 2,839 | ピーク |
| 15 | 0.000373 | 約 1,375 | del+gc 後、アイドル状態 |
| 41〜67 | 約 0.000308〜0.000315 | 約 1,137〜1,161 | アイドル安定期 |
セッション開始直後で、何も確保していない時点でも課金メモリは約 1,292MB でした。その時点のプロセス RSS(VmRSS)は約 33MB で、課金メモリはその約 39 倍にあたります。
アイドル状態のまま待機すると、課金メモリは下がり続けました。
| elapsed (s) | 課金メモリ換算(MB) |
|---|---|
| 0 | 約 1,292 |
| 67 | 約 1,138 |
| 100 | 約 1,027 |
| 200 | 約 930 |
| 300 | 約 876 |
| 500 | 約 843 |
| 667 | 約 796 |
667 秒後の約 796MB でも、プロセス RSS(約 33MB)の約 24 倍でした。倍率はセッション経過に応じて 24〜39 倍の範囲で動きました。
3 セッション(alloc_mb を順に変える seq、スリープ中心の sleep-ada21282、150 秒スリープの sleep-test-a)の合計は次のとおりです。「観測 duration」は USAGE_LOGS を取得できた範囲、total mem_gb_hours はその期間の合計です。
| session | 実作業時間 | 観測 duration | total mem_gb_hours |
|---|---|---|---|
| seq b22a9854 | 約 15s | 667s | 0.171585 |
| sleep-ada21282 | invoke 処理中の約 38s | 639s | 0.202034 |
| sleep-test-a 6144ba0b | ハンドラ内スリープを含む約 155s | 277s | 0.095797 |
| 合計 | — | — | 0.469416 |
実際にヒープを確保・使用していたのは各セッションとも数十秒以内です。それでも課金メモリはセッションが続く限りアイドル状態のまま計上され続け、seq では実作業約 15 秒に対して 667 秒分(約 800〜1,300MB 相当)が積み上がりました。課金メモリの大半は、確保のピークよりもアイドル中の継続分でした。
まとめ
V2 ではメモリ課金のルールが変わり、単価は上がりました。一方で、ピーク時に確保したメモリを解放すると、課金対象のメモリもセッション中に回収されます。AWS は、解放されるメモリの量によっては V1 より費用を抑えられると案内しています。
今回、V2 で USAGE_LOGS を設定し、1000MB 確保時のピーク後も課金メモリが低下する挙動を確認しました。agent.runtime.memory.gb_hours.used をセッションまたは対象期間で合計し、リージョン・バージョンごとの単価を掛ければ、請求前にメモリ課金を試算できます。
一定規模以上で V2 を利用する場合は、評価段階から実稼働を再現した負荷テストで USAGE_LOGS を記録し、メモリ課金のコスト試算を実施することをおすすめします。











