Skip to content

Commit 89ecfb6

Browse files
feat: streaming framed part encryption (O(frame) UploadPart memory) (#79)
1 parent 7b5d434 commit 89ecfb6

9 files changed

Lines changed: 690 additions & 58 deletions

File tree

e2e/postgres/templates/postgres-cluster.yaml

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,5 +36,11 @@ spec:
3636
wal:
3737
compression: gzip
3838
data:
39-
compression: gzip
39+
# Compression intentionally OFF: pgbench data is highly compressible, so
40+
# gzip would shrink the backup below one 512MB chunk and we'd never get
41+
# concurrent large uploads. Uncompressed, the dataset spans several 512MB
42+
# chunks which (with jobs=4) reproduces the multipart-upload OOM.
43+
jobs: 4
44+
additionalCommandArgs:
45+
- "--min-chunk-size=512MB"
4046
retentionPolicy: "7d"

e2e/postgres/test.sh

Lines changed: 79 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ fi
2020

2121
NAMESPACE="postgres-test"
2222
export CLUSTER_NAME="pg-cluster"
23-
DATA_SIZE_GB=2
23+
DATA_SIZE_GB=3
2424
SCALE_FACTOR=$((DATA_SIZE_GB * 70)) # pgbench scale: ~15MB per scale factor
2525

2626
# Colors for output
@@ -33,6 +33,44 @@ log_info() { echo -e "${GREEN}[INFO]${NC} $1"; }
3333
log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
3434
log_error() { echo -e "${RED}[ERROR]${NC} $1"; }
3535

36+
S3_ENDPOINT="http://s3proxy-python-frontproxy.s3proxy:80"
37+
S3_WAL_CHECK_POD="s3-wal-check"
38+
39+
ensure_s3_wal_checker() {
40+
if kubectl get pod -n "$NAMESPACE" "$S3_WAL_CHECK_POD" >/dev/null 2>&1; then
41+
kubectl wait -n "$NAMESPACE" --for=condition=Ready "pod/${S3_WAL_CHECK_POD}" --timeout=120s
42+
return
43+
fi
44+
kubectl run "$S3_WAL_CHECK_POD" --restart=Never -n "$NAMESPACE" \
45+
--image=amazon/aws-cli:2.15.0 \
46+
--overrides='{"spec":{"containers":[{"name":"'"$S3_WAL_CHECK_POD"'","image":"amazon/aws-cli:2.15.0","command":["sleep","3600"],"envFrom":[{"secretRef":{"name":"s3-credentials"}}]}]}}'
47+
kubectl wait -n "$NAMESPACE" --for=condition=Ready "pod/${S3_WAL_CHECK_POD}" --timeout=120s
48+
}
49+
50+
wait_for_end_wal_in_s3() {
51+
local end_wal="$1"
52+
local timeline_prefix="${end_wal:0:16}"
53+
local wal_object="pg-cluster/wals/${timeline_prefix}/${end_wal}.gz"
54+
55+
log_info "Waiting for backup end WAL in S3: ${wal_object}"
56+
ensure_s3_wal_checker
57+
58+
local deadline=$((SECONDS + 600))
59+
while [ "$SECONDS" -lt "$deadline" ]; do
60+
if kubectl exec -n "$NAMESPACE" "$S3_WAL_CHECK_POD" -- sh -c "
61+
export AWS_ACCESS_KEY_ID=\$ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY=\$ACCESS_SECRET_KEY
62+
aws --endpoint-url ${S3_ENDPOINT} s3 ls s3://postgres-backups/${wal_object} >/dev/null 2>&1
63+
"; then
64+
log_info "✓ End WAL archived: ${end_wal}.gz"
65+
return 0
66+
fi
67+
sleep 5
68+
done
69+
70+
log_error "Timeout waiting for end WAL ${end_wal}.gz in S3"
71+
return 1
72+
}
73+
3674
cleanup() {
3775
log_info "Cleaning up..."
3876
kubectl delete namespace "$NAMESPACE" --ignore-not-found --wait=false || true
@@ -218,41 +256,63 @@ log_info "Backup completed!"
218256
kubectl get backup -n "$NAMESPACE" ${CLUSTER_NAME}-backup-1 -o yaml | grep -A5 "status:"
219257

220258
# ============================================================================
221-
# STEP 5: Verify encryption + Delete cluster + Create new cluster (ALL PARALLEL)
259+
# STEP 4b: Assert the s3proxy pods survived (the OOM we are gating against)
260+
# ============================================================================
261+
log_info "=== Checking s3proxy pods were not OOM-killed during backup ==="
262+
oom_found=0
263+
for p in $(kubectl get pods -n s3proxy -l app.kubernetes.io/name=s3proxy-python,app.kubernetes.io/component=server -o name); do
264+
rc=$(kubectl get -n s3proxy "$p" -o jsonpath='{.status.containerStatuses[0].restartCount}' 2>/dev/null || echo 0)
265+
reason=$(kubectl get -n s3proxy "$p" -o jsonpath='{.status.containerStatuses[0].lastState.terminated.reason}' 2>/dev/null || echo "")
266+
log_info " $p restarts=${rc:-0} lastTerminated=${reason:-none}"
267+
if [ "${reason}" = "OOMKilled" ] || [ "${rc:-0}" -gt 0 ]; then oom_found=1; fi
268+
done
269+
if [ "$oom_found" -eq 1 ]; then
270+
log_error "s3proxy was OOM-killed/restarted during backup"
271+
exit 1
272+
fi
273+
log_info "✓ s3proxy survived the backup"
274+
275+
# ============================================================================
276+
# STEP 4c: Wait for backup end WAL to reach S3 (restore needs it)
277+
# CNPG may mark backup completed before the archiver uploads the final segment.
222278
# ============================================================================
223-
log_info "=== Step 5: Parallel - verify encryption, delete old, create new ==="
279+
END_WAL=$(kubectl get backup -n "$NAMESPACE" "${CLUSTER_NAME}-backup-1" -o jsonpath='{.status.endWal}')
280+
if [ -z "$END_WAL" ]; then
281+
log_error "Backup status.endWal is empty"
282+
exit 1
283+
fi
284+
log_info "=== Step 4c: Waiting for end WAL ${END_WAL} in S3 ==="
285+
wait_for_end_wal_in_s3 "$END_WAL"
286+
287+
# ============================================================================
288+
# STEP 5: Verify encryption, restore, then delete source cluster
289+
# Keep the source cluster alive until end WAL is archived and restore succeeds.
290+
# ============================================================================
291+
log_info "=== Step 5: Verify encryption and restore from backup ==="
224292

225-
# 1. Start encryption verification in background
226293
verify_encryption "postgres-backups" "" "$NAMESPACE" ".gz|.tar|.backup|.data" &
227294
VERIFY_PID=$!
228295

229-
# 2. Delete old cluster in background
230-
(
231-
kubectl delete cluster -n "$NAMESPACE" ${CLUSTER_NAME} --wait
232-
kubectl wait --namespace "$NAMESPACE" \
233-
--for=delete pod -l cnpg.io/cluster=${CLUSTER_NAME} \
234-
--timeout=300s || true
235-
log_info "✓ Old cluster deleted"
236-
) &
237-
DELETE_PID=$!
238-
239-
# 3. Create new cluster immediately (different name, can coexist)
240-
log_info "Creating restored cluster (parallel with deletion)..."
296+
log_info "Creating restored cluster..."
241297
envsubst < "${SCRIPT_DIR}/templates/postgres-cluster-restore.yaml" | kubectl apply -n "$NAMESPACE" -f -
242298

243-
# Wait for all parallel operations
244299
wait $VERIFY_PID || { log_error "Encryption verification failed"; exit 1; }
245300
log_info "✓ Encryption verified"
246301

247-
wait $DELETE_PID || { log_error "Old cluster deletion failed"; exit 1; }
248-
249302
log_info "Waiting for restored cluster to be ready..."
250303
kubectl wait --namespace "$NAMESPACE" \
251304
--for=condition=Ready cluster/${CLUSTER_NAME}-restored \
252305
--timeout=1800s
253306

254307
log_info "Restored cluster is ready!"
255308

309+
log_info "Deleting source cluster (no longer needed)..."
310+
kubectl delete cluster -n "$NAMESPACE" "${CLUSTER_NAME}" --wait
311+
kubectl wait --namespace "$NAMESPACE" \
312+
--for=delete pod -l "cnpg.io/cluster=${CLUSTER_NAME}" \
313+
--timeout=300s || true
314+
log_info "✓ Old cluster deleted"
315+
256316
# ============================================================================
257317
# STEP 6: Validate restored data
258318
# ============================================================================

s3proxy/client/s3.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -195,7 +195,8 @@ async def upload_part(
195195
Body=body,
196196
)
197197
duration = time.monotonic() - start
198-
size_mb = len(body) / 1024 / 1024
198+
size = len(body)
199+
size_mb = size / 1024 / 1024
199200
logger.debug(
200201
"S3 upload_part completed",
201202
bucket=bucket,

s3proxy/crypto.py

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,28 @@
3737
# Sweet spot: 8MB balances part count vs per-part overhead
3838
MAX_BUFFER_SIZE = 8 * 1024 * 1024 # 8 MB per internal part
3939

40+
# Framed internal-part format.
41+
# S3's 10,000-part limit forces large internal parts for big objects, but we do
42+
# not want to hold a whole part (plaintext + ciphertext) in memory. So a part is
43+
# encrypted as a sequence of independent AES-GCM *frames*, each sealing up to
44+
# FRAME_PLAINTEXT_SIZE bytes:
45+
#
46+
# part = frame[0] || frame[1] || ...
47+
# frame[i] = nonce(12) || ciphertext || tag(16)
48+
#
49+
# Both writer and reader stream one frame at a time, so peak memory is O(frame),
50+
# independent of part size. The reader recovers the frame count purely from the
51+
# stored sizes:
52+
#
53+
# num_frames = (ciphertext_size - plaintext_size) / ENCRYPTION_OVERHEAD
54+
#
55+
# A legacy single-seal part is exactly the num_frames == 1 case, so it is read by
56+
# the same code path with no migration of stored data.
57+
#
58+
# FROZEN: never change FRAME_PLAINTEXT_SIZE. Parts already written were framed at
59+
# this boundary and the reader splits ciphertext on (FRAME_PLAINTEXT_SIZE + overhead).
60+
FRAME_PLAINTEXT_SIZE = 8 * 1024 * 1024 # 8 MB plaintext per frame
61+
4062

4163
def calculate_optimal_part_size(content_length: int) -> int:
4264
"""Calculate optimal part size to avoid creating parts < 5MB that aren't the final part."""
@@ -140,6 +162,78 @@ def derive_part_nonce(upload_id: str, part_number: int) -> bytes:
140162
return hashlib.sha256(data).digest()[:NONCE_SIZE]
141163

142164

165+
def derive_frame_nonce(upload_id: str, part_number: int, frame_index: int) -> bytes:
166+
"""Deterministic, unique nonce for one frame of an internal part.
167+
168+
Within an upload the DEK is fixed, so the (part_number, frame_index) pair must
169+
be globally unique to never reuse an AES-GCM nonce. Internal part numbers are
170+
unique per upload and frame indexes are unique within a part, so this holds.
171+
"""
172+
data = f"{upload_id}:{part_number}:{frame_index}".encode()
173+
return hashlib.sha256(data).digest()[:NONCE_SIZE]
174+
175+
176+
def frame_count(plaintext_size: int) -> int:
177+
"""Number of frames a plaintext of this size is encrypted into."""
178+
if plaintext_size <= FRAME_PLAINTEXT_SIZE:
179+
return 1
180+
return (plaintext_size + FRAME_PLAINTEXT_SIZE - 1) // FRAME_PLAINTEXT_SIZE
181+
182+
183+
def framed_ciphertext_size(plaintext_size: int) -> int:
184+
"""Total stored size of a framed part: plaintext + per-frame GCM overhead."""
185+
return plaintext_size + frame_count(plaintext_size) * ENCRYPTION_OVERHEAD
186+
187+
188+
def encrypt_frame(
189+
plaintext: bytes, dek: bytes, upload_id: str, part_number: int, frame_index: int
190+
) -> bytes:
191+
"""Encrypt a single frame (nonce || ciphertext || tag) with its derived nonce."""
192+
return encrypt(plaintext, dek, derive_frame_nonce(upload_id, part_number, frame_index))
193+
194+
195+
def ciphertext_frame_byte_sizes(plaintext_size: int, ciphertext_size: int) -> list[int]:
196+
"""Ciphertext byte length of each frame in a (possibly framed) internal part."""
197+
return _ciphertext_frame_sizes(plaintext_size, ciphertext_size - plaintext_size)
198+
199+
200+
def _ciphertext_frame_sizes(plaintext_size: int, stored_overhead: int) -> list[int]:
201+
"""Ciphertext byte length of each frame, derived from the stored sizes.
202+
203+
`stored_overhead = ciphertext_size - plaintext_size` tells us the real frame
204+
count for the part as it was written, which is authoritative even if
205+
FRAME_PLAINTEXT_SIZE were ever reinterpreted: a legacy single-seal part has
206+
overhead == ENCRYPTION_OVERHEAD (one frame) regardless of its plaintext size.
207+
"""
208+
num_frames = stored_overhead // ENCRYPTION_OVERHEAD
209+
if num_frames <= 1:
210+
return [plaintext_size + ENCRYPTION_OVERHEAD]
211+
sizes = []
212+
remaining = plaintext_size
213+
for _ in range(num_frames):
214+
pt = min(FRAME_PLAINTEXT_SIZE, remaining)
215+
sizes.append(pt + ENCRYPTION_OVERHEAD)
216+
remaining -= pt
217+
return sizes
218+
219+
220+
def decrypt_framed(ciphertext: bytes, dek: bytes, plaintext_size: int) -> bytes:
221+
"""Decrypt a (possibly framed) internal part held whole in memory.
222+
223+
Backward compatible: a legacy single-seal part has only one frame's worth of
224+
overhead, so it is decrypted in a single call via decrypt().
225+
"""
226+
sizes = _ciphertext_frame_sizes(plaintext_size, len(ciphertext) - plaintext_size)
227+
if len(sizes) == 1:
228+
return decrypt(ciphertext, dek)
229+
out = bytearray()
230+
offset = 0
231+
for fsize in sizes:
232+
out += decrypt(ciphertext[offset : offset + fsize], dek)
233+
offset += fsize
234+
return bytes(out)
235+
236+
143237
def wrap_key(dek: bytes, kek: bytes) -> bytes:
144238
"""Wrap DEK using AES-KWP (Key Wrap with Padding)."""
145239
try:

0 commit comments

Comments
 (0)