Debugging Nginx HTTP/2 HPACK dynamic table bloat
RSS growth in Nginx workers via fragmented memory pools
Environment: Debian 12.1, Kernel 6.1.0-11-amd64. Hardware: Bare metal, Dual Xeon Silver 4214, 64GB ECC RAM. Application Layer: Nginx 1.25.1 (compiled from source with jemalloc), PHP-FPM 8.2, Redis 7.0.
A linear increase in Resident Set Size (RSS) across all Nginx worker processes was observed via Prometheus node exporter metrics. Upon initialization, each worker consumed approximately 35MB of memory. Over a rolling 14-day uptime window, this baseline grew to 1.2GB per worker. CPU utilization remained stable at 8%, and the active connection count hovered consistently around 3,200. There were no OOM killer invocations or swap activity.
The upstream application is a high-traffic digital publication running the Lavander - A Lifestyle Responsive WordPress Blog Theme. The architecture relies heavily on HTTP/2 multiplexing to deliver a dense payload of static assets and asynchronous tracking beacons.
I bypassed standard error logs, as steady-state memory accumulation rarely generates application-level exceptions, and initiated a memory mapping analysis on a specific bloated worker process (PID 41920) using pmap.
# pmap -X 41920 | sort -k 2 -n -r | head -n 15
41920: nginx: worker process
Address Perm Offset Device Inode Size Rss Pss Referenced Anonymous
000055f9a2b4c000 rw-p 00000000 00:00 0 128456 128456 128456 128456 128456
000055f9ab120000 rw-p 00000000 00:00 0 64228 64228 64228 64228 64228
000055f9b4331000 rw-p 00000000 00:00 0 64228 64228 64228 64228 64228
...
The output indicated the memory consumption was not concentrated in a single large allocation, but rather distributed across thousands of anonymous memory mappings, specifically in 64KB and 128KB segments.
To inspect the contents of these anonymous regions, I extracted a core dump of the worker process and loaded it into gdb.
# gcore 41920
# gdb /usr/sbin/nginx core.41920
Nginx utilizes an arena-based memory management system. Instead of calling malloc() and free() for individual variables, it allocates memory in contiguous pools (ngx_pool_t). When a request or connection terminates, the entire pool is destroyed, mitigating standard memory leaks.
The fundamental structure of an Nginx memory pool is defined in src/core/ngx_palloc.h:
typedef struct {
u_char *last;
u_char *end;
ngx_pool_t *next;
ngx_uint_t failed;
} ngx_pool_data_t;
struct ngx_pool_s {
ngx_pool_data_t d;
size_t max;
ngx_pool_t *current;
ngx_chain_t *chain;
ngx_pool_large_t *large;
ngx_pool_cleanup_t *cleanup;
ngx_log_t *log;
};
When an allocation request exceeds the remaining space between d.last and d.end, Nginx allocates a new block, links it via the next pointer, and updates current.
I wrote a brief Python script within the gdb environment to walk the ngx_cycle->connections array, locate active HTTP/2 connections, and traverse their associated pool->d.next linked lists to count the number of memory blocks allocated per connection pool.
# gdb-python
import gdb
def analyze_nginx_pools():
cycle = gdb.parse_and_eval("ngx_cycle")
connections = cycle['connections']
connection_n = cycle['connection_n']
for i in range(connection_n):
conn = connections[i]
if conn['fd'] != -1 and conn['pool'] != 0:
pool = conn['pool']
block_count = 1
current_block = pool['d']['next']
while current_block != 0:
block_count += 1
current_block = current_block.dereference()['d']['next']
if block_count > 50:
print(f"Connection FD {conn['fd']} has {block_count} pool blocks.")
analyze_nginx_pools()
Execution yielded hundreds of lines similar to:
Connection FD 412 has 1845 pool blocks.
A standard, healthy HTTP/2 connection pool should contain fewer than 5 blocks. 1,845 blocks indicates continuous allocations against a long-lived TCP connection where the memory is never released because the connection never closes.
I examined the memory within one of these bloated pools using gdb's memory inspection (x/s).
(gdb) p c->pool->d.next
$1 = (ngx_pool_t *) 0x55f9a2b4c000
(gdb) x/128s 0x55f9a2b4c000 + 40
...
0x55f9a2b4c100: "x-reader-heatmap-x: 1442"
0x55f9a2b4c120: "x-reader-heatmap-y: 890"
0x55f9a2b4c140: "x-session-tick: 4192"
0x55f9a2b4c160: "x-reader-heatmap-x: 1445"
0x55f9a2b4c180: "x-reader-heatmap-y: 891"
...
The memory pool was saturated with custom HTTP headers representing mouse coordinates and scroll depths.
This behavior traces back to how Nginx handles HTTP/2 Header Compression (HPACK) as defined in RFC 7541. HTTP/2 maintains a stateful compression context between the client and the server. This consists of a Static Table (pre-defined common headers like :method: GET) and a Dynamic Table.
When a client sends a header not in the Static Table, it is added to the Dynamic Table. Future requests on the same connection can reference this header via an integer index, saving bandwidth. The default maximum size of the Dynamic Table is 4096 bytes.
A standard architectural pattern when administrators [Download WordPress Theme] files geared towards lifestyle or content-heavy sites is the inclusion of aggressive frontend analytics. The theme's Javascript payload was configured to send reader viewport data back to the server every 5 seconds. Instead of placing this data in a POST body, the developer opted to send it via custom HTTP headers (X-Reader-Heatmap-X, etc.) utilizing fetch() API calls over the established HTTP/2 connection.
Because the coordinates changed every 5 seconds, these headers were unique. The HTTP/2 client continuously instructed the server to add these new literal header representations to the HPACK Dynamic Table.
When Nginx receives an HPACK header literal requiring incremental indexing, it processes it in src/http/v2/ngx_http_v2_filter_module.c.
// Nginx HPACK decompression allocation logic
header = ngx_palloc(h2c->connection->pool, sizeof(ngx_http_v2_header_t));
if (header == NULL) {
return NGX_ERROR;
}
header->name.len = name_len;
header->name.data = ngx_palloc(h2c->connection->pool, name_len + 1);
// ... string copy ...
Nginx enforces the 4096-byte limit of the Dynamic Table logically. When the table exceeds this limit, older headers are evicted from the HPACK state matrix.
However, Nginx allocates the memory for the header strings from the connection pool (h2c->connection->pool). Nginx's ngx_pool_t design does not allow for releasing individual microscopic string allocations back to the operating system. ngx_pfree is only functional for large allocations attached to the pool->large chain. Memory allocated within the standard pool blocks remains there until the entire ngx_pool_t is destroyed.
Every 5 seconds, the client sent a new heatmap coordinate. Nginx allocated approximately 60 bytes from the connection pool to decompress and index this header. The HPACK table correctly evicted the logical reference to the old coordinate, but the 60 bytes of physical memory remained trapped in the connection pool.
At 12 requests per minute, a single TCP connection accumulated ~43KB of trapped pool memory per hour. With a default keepalive_timeout of 75 seconds, and continuous pings every 5 seconds, the TCP connection never idled. Multiplexed across 3,200 active, long-duration reader sessions, the worker RSS grew linearly.
The fundamental issue is the impedance mismatch between the HTTP/2 HPACK design (which assumes long-lived stateful connections) and the Nginx memory pool allocator (which assumes short-lived requests and clears memory only upon connection termination).
To rectify this, the lifespan of HTTP/2 connections must be strictly bounded at the server level, forcing clients to drop the TCP socket, allowing Nginx to destroy the bloated connection pool and re-establish a fresh HPACK compression context.
http2_max_requests 1000;
keepalive_requests 1000;
keepalive_timeout 60s;
评论 0