Amazon Bedrock AgentCore Runtime V2 の課金メモリがピーク後に縮小する挙動を USAGE_LOGS で観測してみた

Amazon Bedrock AgentCore Runtime V2 の課金メモリがピーク後に縮小する挙動を USAGE_LOGS で観測してみた

AgentCore Runtime V2 は実消費メモリで課金されます。USAGE_LOGS で課金メモリを 1 秒粒度で観測し、確保のピーク後に段階的に縮小する一方、アイドル中も課金され続ける挙動を実測値で確認しました。
2026.09.19

はじめに

2026年9月18日、Amazon Bedrock AgentCore Runtime に、新しいプラットフォームバージョン V2 が追加されました。前記事では、V2 で改善されたコールドスタート性能を確認しました。

https://dev.classmethod.jp/articles/amazon-bedrock-agentcore-runtime-v2-tried/

AWS の公式ブログでは、V2 におけるメモリ割り当ての最適化が紹介されています。本記事では、その最適化が実際のメモリ消費と課金にどのように表れるのかを、エージェントプロセス内の観測点と課金側テレメトリを突き合わせて観測しました。

https://aws.amazon.com/jp/blogs/machine-learning/the-new-agentcore-runtime-elastic-optimized-and-consistently-fast-starts/

検証環境

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_arnevent_timestampattributesmetrics を含むイベントが出力されることを確認しました。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 の課金メモリと、コンテナ内から見える値がどの程度対応するか、すなわち VmRSSmemory.current を課金値の代理指標として使えるかを確かめるため、プロセスと cgroup のメモリを観測しました。

検証用の /invocations では、alloc_mb で確保するヒープ量を指定します。確保前・確保後・解放後の 3 時点で /proc/self/status/sys/fs/cgroup/memory.current を返し、sleep_sec を指定した場合は解放後の VmRSSmemory.current を一定間隔で記録します。

agent_mem_v2.py

この検証コードでは、bytearray(os.urandom(alloc_mb * 1024 * 1024)) で割り当て領域を実際にページインしています。変換中は bytesbytearray が同時に存在するため、一時的に指定量のおよそ 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 列のとおり、delgc.collect() の直後に VmRSS は確保前の水準へ戻りました。続けて 150 秒間にわたり 10 秒ごとに VmRSSmemory.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 秒間を通じて、VmRSSmemory.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.RuntimeResource=<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 を記録し、メモリ課金のコスト試算を実施することをおすすめします。


コスト最適化、打ちっぱなしで元通りになっていませんか

タグ付けも不要リソースの棚卸しも、施策は打てる。でも続ける仕組みがなければ、コストは数か月でじわじわ戻る。一度きりで終わらせず、FinOpsを組織に定着させる=CCoEの役割。最適化を回し続ける進め方を、無料資料にまとめました。

CCoE総合支援

FinOpsを定着させる資料をもらう

この記事をシェアする

AWSのお困り事はクラスメソッドへ

関連記事