
Observing How Billed Memory in Amazon Bedrock AgentCore Runtime V2 Shrinks After Peak Usage Using USAGE_LOGS
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 checked the improved cold start performance in V2.
The official AWS blog introduces the memory allocation optimization in V2. In this article, I compared observation points inside the agent process with billing-side telemetry to observe how that optimization actually shows up in memory consumption and billing.
Test Environment
In AgentCore Runtime V2 in ap-northeast-1, I prepared two runtimes: one for basic measurement and one for session measurement that delivers USAGE_LOGS. Both are the same V2, and both deploy the ECR image agentcore-cpuinfo:mem-probe-v2 (the cpuinfo in the image name is carried over from the previous article; this article measures memory). Note that MB in this article means MiB (1 MB = 1024 × 1024 bytes).
Measurement Design
Billing Telemetry (USAGE_LOGS)
I obtained the billing telemetry as USAGE_LOGS. When you associate the Runtime ARN with a delivery source, metrics such as agent.runtime.memory.gb_hours.used are delivered to the log group at 1-second granularity, tagged with session.id. The setup procedure is 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 the log group (destination)
aws logs create-log-group --log-group-name "${LOG_GROUP}" --region ${REGION}
# 2. Create the 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 the delivery source (associate USAGE_LOGS with the runtime ARN)
aws logs put-delivery-source \
--name "mem-probe-usage-source" \
--resource-arn "${RUNTIME_ARN}" \
--log-type "USAGE_LOGS" \
--region ${REGION}
# 4. Create the 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 setup, I confirmed that events containing resource_arn, event_timestamp, attributes, and metrics are output. attributes contained items such as session.id, and metrics contained items such as agent.runtime.memory.gb_hours.used. Log events appeared about 9 minutes after the measurement finished, and it took about 20 minutes until all 3 sessions' worth were available.
agent.runtime.memory.gb_hours.used is the usage for one second expressed in GB-hours, so it can be converted into the average billed memory equivalent for that second with billed memory (MB) ≈ mem_gb_hours_per_s × 3600 × 1024. The billed memory conversion values from here on are calculated with this formula.
Auxiliary Observation Inside the Container
To check how well the billed memory in USAGE_LOGS corresponds to the values visible from inside the container, that is, whether VmRSS or memory.current can be used as a proxy indicator for the billed value, I observed process and cgroup memory.
In the test /invocations, the heap size 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. When sleep_sec is specified, it records VmRSS and memory.current after release at fixed intervals.
agent_mem_v2.py
In this test code, the allocated region is actually paged in with bytearray(os.urandom(alloc_mb * 1024 * 1024)). During the conversion, bytes and bytearray exist at the same time, so roughly twice the specified amount of memory is temporarily allocated. However, once the conversion finishes, bytes is released, so the VmRSS increase after allocation (after) almost matches the specified amount.
"""
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)
Observation Results from Inside the Container
Within the same session, I invoked with alloc_mb set 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 increase in VmRSS almost matched the allocated amount (the difference is in units of page size), and the amount of memory actually used by the agent process could be read from VmRSS. The process baseline was about 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 about 22MB was lower than the roughly 33MB of VmRSS. The breakdown of memory.stat read at the same time was anon=21.5MB, file=0, shmem=0, so in this measurement only anonymous pages were counted. In any case, the billed memory seen later is about 1,292MB even when idle, an order of magnitude different, and did not correspond to memory.current (about 22MB). Within the scope of this observation, memory.current could not be used as a proxy indicator for billed memory.
Behavior After Release and Stepwise Decline on the Billing Side
As shown in the freed column of the VmRSS table in the previous section, VmRSS returned to the pre-allocation level immediately after del and gc.collect(). The following table shows VmRSS and memory.current obtained every 10 seconds over the following 150 seconds (the 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 in level. The reclamation behavior on the billing side was not visible from the observation points inside the container.
The billing side looked different. I ran the same sleep-test-a session (sleeping for 150 seconds after allocating alloc_mb=1000) concurrently with the other 2 sessions from 12:52 to 12:57 JST, and the following table shows its USAGE_LOGS converted to billed memory.
| elapsed (s) | Billed memory equivalent (MB) | State |
|---|---|---|
| 3–5 | about 3,267 | Peak |
| 12–76 | about 1,340→1,330 | After del+gc, gradual decline |
| 77–82 | 1,330→1,268 | Sharp drop. About 62MB decrease |
| 82–191 | about 1,268→1,237 | Gradual decline |
| 192–219 | 1,218→1,068 | Sharp drop. About 150MB decrease |
| 220–277 | about 1,067→1,023 | Gradual decline |
In this sleep-test-a session's measurement, the billed memory equivalent declined monotonically. Rather than dropping all at once at a particular milestone, it showed a stepwise decline, with intervals where the decline steepened around 77 seconds and around 192–219 seconds.
Cross-Checking with CloudWatch
The invoke response body did not include billing values. The billing-side values are obtained from CloudWatch and USAGE_LOGS. First, I checked MemoryUsed-GBHours at 1-minute granularity. The namespace was AWS/Bedrock-AgentCore, and the dimensions were Service=AgentCore.Runtime and Resource=<runtime ARN>. The values shown below were observed on the Runtime for basic measurement. This is a different time window and a different measurement from the 3 USAGE_LOGS sessions in the previous section, but I confirmed that the values for a V2 Runtime can also be retrieved from CloudWatch with 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 about 20 minutes after 12:18 JST, when I ended the measurement.
The USAGE_LOGS side had 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 given in the measurement design to the event above, 0.000350 was about 1,292MB.
For the seq session, which ran concurrently with the other 2 sessions from 12:46 to 12:58 JST, converting the values during the operation period in which alloc_mb was changed in sequence into billed memory gives the following.
| elapsed (s) | agent.runtime.memory.gb_hours.used/s | Billed memory equivalent (MB) | Corresponding operation |
|---|---|---|---|
| 0 | 0.000350 | about 1,292 | Immediately after session start, idle state |
| 5 | 0.000619 | about 2,281 | Allocating alloc_mb=1000 |
| 9 | 0.000770 | about 2,839 | Peak |
| 15 | 0.000373 | about 1,375 | After del+gc, idle state |
| 41–67 | about 0.000308–0.000315 | about 1,137–1,161 | Idle stable period |
Immediately after session start, even at the point where nothing had been allocated, the billed memory was about 1,292MB. The process RSS (VmRSS) at that point was about 33MB, so the billed memory was about 39 times that.
When waiting in the idle state, the billed memory kept declining.
| elapsed (s) | Billed memory equivalent (MB) |
|---|---|
| 0 | about 1,292 |
| 67 | about 1,138 |
| 100 | about 1,027 |
| 200 | about 930 |
| 300 | about 876 |
| 500 | about 843 |
| 667 | about 796 |
Even the roughly 796MB after 667 seconds was about 24 times the process RSS (about 33MB). The ratio ranged from 24 to 39 times depending on the session's elapsed time.
The totals for the 3 sessions (seq, which changes alloc_mb in sequence; sleep-ada21282, which is mostly sleeping; and sleep-test-a, which sleeps for 150 seconds) are as follows. "Observed duration" is the range for which USAGE_LOGS could be obtained, and total mem_gb_hours is the total for that period.
| session | Actual working time | Observed duration | total mem_gb_hours |
|---|---|---|---|
| seq b22a9854 | about 15s | 667s | 0.171585 |
| sleep-ada21282 | about 38s during invoke processing | 639s | 0.202034 |
| sleep-test-a 6144ba0b | about 155s including in-handler sleep | 277s | 0.095797 |
| Total | — | — | 0.469416 |
The heap was actually allocated and used for only a few tens of seconds or less in each session. Even so, billed memory continued to be counted in the idle state for as long as the session lasted, and in seq, 667 seconds' worth (equivalent to about 800–1,300MB) accumulated against about 15 seconds of actual work. Most of the billed memory came from the sustained idle portion rather than the allocation peak.
Summary
In V2, the memory billing rules changed and the unit price went up. On the other hand, when memory allocated at peak is released, the billable memory is also reclaimed during the session. AWS states that, depending on the amount of memory released, costs can be lower than with V1.
This time, I set up USAGE_LOGS on V2 and confirmed that billed memory keeps declining even after the peak when allocating 1000MB. By summing agent.runtime.memory.gb_hours.used per session or target period and multiplying by the unit price for each region and version, you can estimate memory charges before billing.
When using V2 at a certain scale or above, I recommend recording USAGE_LOGS during load tests that reproduce production operation from the evaluation stage, and carrying out a cost estimate for memory billing.

