I observed in USAGE_LOGS the behavior of Amazon Bedrock AgentCore Runtime V2 billing memory shrinking after peak

I observed in USAGE_LOGS the behavior of Amazon Bedrock AgentCore Runtime V2 billing memory shrinking after peak

AgentCore Runtime V2 is billed based on actual memory consumption. We observed the billed memory at one-second granularity in USAGE_LOGS and confirmed through measured values that while it gradually shrinks after the peak allocation, it continues to be billed even during idle periods.
2026.09.19

This page has been translated by machine translation. View original

Introduction

On September 18, 2026, a new platform version V2 was added to Amazon Bedrock AgentCore Runtime. In the previous article, we confirmed the improved cold start performance in V2.

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

The official AWS blog introduces memory allocation optimizations in V2. In this article, we observed how those optimizations manifest in actual memory consumption and billing, by cross-referencing observation points inside the agent process with billing-side telemetry.

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

Verification Environment

We prepared two setups for AgentCore Runtime V2 in ap-northeast-1: one for basic measurement and one for session measurement delivering USAGE_LOGS. Both use the same V2, deploying ECR image agentcore-cpuinfo:mem-probe-v2 (the cpuinfo in the image name is carried over from the previous article; in this article we are measuring memory). Note that MB in this article refers to MiB (1 MB = 1024 × 1024 bytes).

Measurement Design

Billing Telemetry (USAGE_LOGS)

Billing telemetry was obtained as USAGE_LOGS. By linking a Runtime ARN to a delivery source, metrics such as agent.runtime.memory.gb_hours.used are delivered to a log group at 1-second granularity with session.id attached. The setup steps are as follows.

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. Create log group (destination)
aws logs create-log-group --log-group-name "${LOG_GROUP}" --region ${REGION}

# 2. Create 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. Create delivery source (link USAGE_LOGS to runtime ARN)
aws logs put-delivery-source \
  --name "mem-probe-usage-source" \
  --resource-arn "${RUNTIME_ARN}" \
  --log-type "USAGE_LOGS" \
  --region ${REGION}

# 4. Create 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}

With this configuration, we confirmed that events containing resource_arn, event_timestamp, attributes, and metrics are output. attributes includes items such as session.id, and metrics includes items such as agent.runtime.memory.gb_hours.used. Log events appeared approximately 9 minutes after measurement completion, and it took about 20 minutes for all 3 sessions to be available.

Since agent.runtime.memory.gb_hours.used represents usage over 1 second in GB-hours units, the billable memory for that 1-second average can be converted using billed memory (MB) ≈ mem_gb_hours_per_s × 3600 × 1024. All billed memory conversion values going forward are calculated using this formula.

Supplementary Observation Inside the Container

To verify how well the billed memory from USAGE_LOGS corresponds to values visible from inside the container—that is, whether VmRSS or memory.current can be used as proxy metrics for billing values—we observed the memory of the process and cgroup.

In the /invocations endpoint for verification, the amount of heap to allocate is specified with alloc_mb. It returns /proc/self/status and /sys/fs/cgroup/memory.current at three points: before allocation, after allocation, and after release. If sleep_sec is specified, VmRSS and memory.current are recorded at fixed intervals after release.

agent_mem_v2.py

In this verification code, bytearray(os.urandom(alloc_mb * 1024 * 1024)) is used to actually page in the allocated region. During conversion, both bytes and bytearray exist simultaneously, so approximately twice the specified amount of memory is temporarily allocated. However, since the bytes is released once the conversion is complete, the VmRSS increase after allocation (after) closely matches the specified amount.

"""
AgentCore Runtime V2 Billed Memory Observation Agent

Fields that can be passed to /invocations:
  alloc_mb    : Heap allocation amount (MiB, default=0)
  sleep_sec   : Number of seconds to sleep after release (default=0)
  interval_sec: If sleep_sec > 0, record VmRSS and memory.current every interval_sec seconds (default=10)

Return fields:
  vmrss          : VmRSS delta from /proc/self/status (before/after allocation, after release)
  cgroup_memory  : /sys/fs/cgroup/memory.current (null if not present)
  sleep_snapshots: If sleep_sec > 0, sequence of VmRSS and memory.current snapshots every interval_sec seconds
"""
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():
    """
    Return memory-related lines from /proc/self/status as a dict.
    Units are returned as-is in 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():
    """
    Get memory.current from cgroup v2 (current memory usage in bytes for the entire container).
    Returns None if not found. Also supplements anon / file / shmem / kernel from memory.stat.
    """
    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)
    # Supplement anon / file from memory.stat
    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():
    """Quickly retrieve only the current value of 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 allocation ---
    before        = read_proc_status()
    cgroup_before  = read_cgroup_memory()

    # --- Heap allocation (page-in guaranteed) ---
    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 allocation ---
    after         = read_proc_status()
    cgroup_after  = read_cgroup_memory()

    # --- Release ---
    del data
    gc.collect()

    # --- After release ---
    freed         = read_proc_status()
    cgroup_freed  = read_cgroup_memory()

    # --- Post-release sleep ---
    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)
        # Final snapshot
        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,
        },
        # --- Transition during sleep ---
        "sleep_snapshots": sleep_snapshots,
    })

if __name__ == "__main__":
    app.run(host="0.0.0.0", port=8080)

Observation Results from Inside the Container

We invoked within the same session while changing alloc_mb to 0, 100, 500, and 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

The VmRSS increase closely matched the allocated amount (differences are within page size units), and the actual memory used by the agent process could be read from VmRSS. The process baseline was approximately 33MB.

The cgroup's /sys/fs/cgroup/memory.current also tracked the allocated amount.

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

However, the baseline of approximately 22MB was below VmRSS's approximately 33MB. The breakdown of memory.stat read at the same time showed anon=21.5MB, file=0, shmem=0, meaning only anonymous pages were counted in this measurement. Regardless, the billed memory seen later was approximately 1,292MB even during idle—a completely different order of magnitude—and did not correspond to memory.current (approximately 22MB). Within the scope of this observation, memory.current could not be used as a proxy metric for billed memory.

Post-Release Behavior and Stepwise Decrease on the Billing Side

As shown in the freed column of the VmRSS table in the previous section, VmRSS immediately returned to the pre-allocation level right after del and gc.collect(). The following table shows VmRSS and memory.current values captured every 10 seconds over 150 seconds afterward (sleep-test-a session after allocating and releasing alloc_mb=1000).

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

Throughout the 150 seconds, neither VmRSS nor memory.current changed their levels. The billing-side reclamation behavior was not visible from the observation points inside the container.

The billing side showed a different picture. The same sleep-test-a session (150-second sleep after allocating alloc_mb=1000) was run simultaneously with 2 other sessions from 12:52 to 12:57 JST, and the following table shows those USAGE_LOGS converted to billed memory.

elapsed (s) Billed Memory Equivalent (MB) State
3〜5 Approx. 3,267 Peak
12〜76 Approx. 1,340→1,330 After del+gc, gradual decrease
77〜82 1,330→1,268 Rapid decrease. Approx. 62MB drop
82〜191 Approx. 1,268→1,237 Gradual decrease
192〜219 1,218→1,068 Rapid decrease. Approx. 150MB drop
220〜277 Approx. 1,067→1,023 Gradual decrease

In this sleep-test-a session measurement, the billed memory equivalent value decreased monotonically and did not drop off at specific milestones all at once; instead, it showed a stepwise decrease with intervals of more rapid decline appearing around the 77-second mark and around the 192–219 second mark.

Cross-Referencing with CloudWatch

The billed values were not included in the invoke response body. Billing-side values are obtained from CloudWatch and USAGE_LOGS. First, we checked MemoryUsed-GBHours at 1-minute granularity. The namespace was AWS/Bedrock-AgentCore, with dimensions Service=AgentCore.Runtime and Resource=<runtime ARN>. The values shown below were observed from the Runtime used for basic measurement. While these are from a different time period and different measurement than the 3 sessions in USAGE_LOGS in the previous section, we confirmed that V2 Runtime values can also be obtained from CloudWatch using the same namespace and dimensions.

Time (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
Total 0.106313 0.004520

These metrics appeared approximately 20 minutes after measurement ended at 12:18 JST.

The USAGE_LOGS side was at 1-second granularity. Events arrived in the following structure.

{
  "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
  }
}

Applying the conversion formula shown in the measurement design section to the above event, 0.000350 was approximately 1,292MB.

For the seq session run simultaneously with 2 other sessions from 12:46 to 12:58 JST, converting the values during the operation period where alloc_mb was changed sequentially to billed memory yields the following.

elapsed (s) agent.runtime.memory.gb_hours.used/s Billed Memory Equivalent (MB) Corresponding Operation
0 0.000350 Approx. 1,292 Immediately after session start, idle state
5 0.000619 Approx. 2,281 Allocating alloc_mb=1000
9 0.000770 Approx. 2,839 Peak
15 0.000373 Approx. 1,375 After del+gc, idle state
41〜67 Approx. 0.000308〜0.000315 Approx. 1,137〜1,161 Stable idle period

Even immediately after session start with nothing allocated, the billed memory was approximately 1,292MB. At that point, the process RSS (VmRSS) was approximately 33MB, meaning billed memory was approximately 39 times that amount.

Remaining in idle state, the billed memory continued to decrease.

elapsed (s) Billed Memory Equivalent (MB)
0 Approx. 1,292
67 Approx. 1,138
100 Approx. 1,027
200 Approx. 930
300 Approx. 876
500 Approx. 843
667 Approx. 796

Even at approximately 796MB after 667 seconds, this was approximately 24 times the process RSS (approximately 33MB). The multiplier ranged between 24 and 39 times depending on session elapsed time.

The totals for the 3 sessions (seq changing alloc_mb sequentially, sleep-ada21282 focused on sleeping, and sleep-test-a with a 150-second sleep) are as follows. "Observed duration" refers to the range over which USAGE_LOGS could be obtained, and total mem_gb_hours is the total for that period.

session Actual work time Observed duration total mem_gb_hours
seq b22a9854 Approx. 15s 667s 0.171585
sleep-ada21282 Approx. 38s during invoke processing 639s 0.202034
sleep-test-a 6144ba0b Approx. 155s including in-handler sleep 277s 0.095797
Total 0.469416

The actual heap allocation and usage was within a few tens of seconds for each session. Nevertheless, billed memory continued to accumulate in an idle state for as long as the session lasted, and for seq, approximately 667 seconds' worth (equivalent to approximately 800–1,300MB) accumulated relative to approximately 15 seconds of actual work. The majority of billed memory came from the idle continuation period rather than the allocation peak.

Summary

In V2, the memory billing rules changed and the unit price increased. On the other hand, when memory allocated at peak is released, the billable memory is also reclaimed during the session. AWS advises that depending on the amount of memory released, costs can be kept lower than V1.

This time, we configured USAGE_LOGS in V2 and confirmed the behavior of billed memory decreasing even after the peak when allocating 1000MB. By summing agent.runtime.memory.gb_hours.used over a session or target period and multiplying by the unit price for each region and version, memory billing costs can be estimated before charges are incurred.

When using V2 at a certain scale or above, we recommend running load tests that replicate production workloads from the evaluation stage, recording USAGE_LOGS, and performing cost estimates for memory billing.


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

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

CCoE総合支援

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

Share this article

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