Just last week, we faced a sudden production failure in one of our core serverless data ingestion pipelines.
The pipeline runs on AWS Lambda with the Python 3.12 runtime. Its responsibility is straightforward: securely fetch an encrypted file from an external source, decrypt it, parse its hierarchical data structures (specifically large Excel spreadsheets with nested object trees), and stream the extracted records to downstream stores.
We had provisioned the Lambda with 1 vCPU of compute power (~1,769 MB of RAM)—what we initially assumed was generous headroom, bordering on “overkill.” Yet, during a batch run, the function abruptly died with an unceremonious Out-of-Memory (OOM) crash, leaving files half-processed and transactions incomplete.
Here is the breakdown of why our memory ballooned, how we isolated the bottleneck, and why switching to disk-backed ephemeral storage resolved the issue.
1. The Architecture & The Pipeline
The ingestion flow handles sensitive files that must be decrypted and validated on the fly:
|
|
Before processing, the function executes peripheral tasks:
- Assumes roles via AWS STS and fetches encryption keys.
- Instruments spans using OpenTelemetry for end-to-end tracing.
- Interacts with Amazon S3 using Boto3.
Each of these peripheral tasks consumes negligible CPU and memory (typically under 20–30 MB). So why were we exhausting over 1.7 GB of memory?
2. The Symptoms: Sudden Death in Production
During execution with larger files (30MB–80MB .xlsx spreadsheets), the Lambda invocation did not fail gracefully with an application-level exception. Instead, AWS CloudWatch reported:
|
|
The Linux kernel’s Out-Of-Memory killer had sent SIGKILL to the Python process. The file execution was aborted mid-stream, resulting in missing records and inconsistent state downstream.
3. The Investigation & Root Cause
Ruling Out the Red Herrings
We initially checked:
- OpenTelemetry Telemetry Buffers: Was the OTel SDK buffering too many spans? No—span batching was flushed frequently and used less than 15 MB.
- Boto3 & STS Client Leaks: Were connection pools leaking memory? No—connections were properly reused.
The Real Culprit: In-Memory Buffering + DOM Tree Amplification
Looking closely at the ingestion code, we discovered that the entire pipeline was being operated strictly in RAM:
|
|
Notice what happened here:
-
Double Buffering: At any given moment, the ciphertext byte string, the decryption output buffer, and the plaintext byte string were coexisting simultaneously in Python’s heap.
-
The “Excel XML Expansion” Multiplier: An
.xlsxfile is actually a compressed ZIP archive containing raw XML files. A 50 MB compressed spreadsheet can expand to 400 MB–800 MB of raw XML. -
Python Object Overhead: When a library like
openpyxlparses an XML document into Python objects (rows, cells, styles, formulas), each cell is represented as a full PythonPyObject. In Python, a single integer or string has significant memory overhead compared to raw C primitives.In this workload, the combined buffers, decompressed XML and Python object overhead pushed the process beyond the configured Lambda memory limit, triggering an OOM kill.
|
|
4. The Fix: Offloading from RAM to Ephemeral /tmp Storage
Instead of treating Lambda’s expensive memory as a hard drive, we realized that AWS Lambda provides ephemeral disk storage (/tmp)—configurable from 512 MB up to 10,240 MB per function. It is local ephemeral storage, not a replacement for durable object storage.
Rather than retaining multiple full-file buffers in memory, we redesigned the pipeline to write intermediate data to disk and use the spreadsheet library’s read-only worksheet mode.
Step 1: Stream Decryption Directly to Disk
We replaced io.BytesIO with tempfile.NamedTemporaryFile backed by Lambda’s /tmp directory:
|
|
Step 2: Read-Only Streaming Mode
By passing read_only=True to load_workbook, openpyxl switches from building a massive in-memory DOM tree to an iterative XML parser (iterparse). It exposes rows through an iterator instead of eagerly materializing the entire worksheet. Memory use still depends on workbook structure, shared strings, styles and the application’s row-processing code.
5. Results & Metrics
| Metric | In-Memory (Before) | Disk-Backed Streaming (After) |
|---|---|---|
| Peak memory in this workload | 1,769 MB limit reached (OOM crash) | ~140 MB observed after the change |
| File sizes tested in this workload | ~40 MB | up to 500 MB |
| Observed failures in tested batches | ~18% on large batches | 0% during the reported test window |
| Lambda memory configuration | Required more memory than the configured limit | Tested at 512 MB for this workload |
6. Key Takeaways
- Don’t Treat RAM as a File System:
In serverless environments, RAM is the most expensive resource. If you’re holding full payloads in
BytesIObuffers, you’re paying for memory you don’t need. - Beware the Spreadsheet Amplification Factor: Spreadsheets and XML documents are deceptively compressed on disk. In-memory object trees can expand by 10x to 30x the file’s raw size.
- Lambda
/tmpStorage Is Useful for Temporary Files: Lambda provides configurable local ephemeral storage from 512 MB up to 10,240 MB. Use it for temporary intermediate files when the workflow benefits from disk-backed processing; size it for the encrypted and decrypted data you need to hold. - Use Iterative Stream Parsers:
Whenever parsing tabular or structured files, prefer streaming APIs (
read_only=True, SAX, oriterparse) over loading the full DOM tree into memory.
The memory and throughput numbers above describe this particular workload and test window. They are not general Lambda or openpyxl guarantees. Validate with representative files, and ensure the configured
/tmpcapacity is larger than the maximum temporary data you expect to hold.