Skip to content

Commit 9d2045a

Browse files
committed
fix(restore): bound kopia's content cache and rotate CLI logs
Kopia's content cache grows without an explicit cap and its CLI log files accumulate indefinitely. The per-replica cache PVC eventually fills up, after which kopia can't even write its config and every restore Job pod exits in 1-2 minutes with "no space left on device". Observed in production across multiple replicas: the only path back to working was manually wiping the PVC contents. Pass content/metadata cache size limits on `kopia repository connect` in the restore Job so kopia evicts its own data once it reaches the cap. Content cache is sized to the cache PVC minus a small reserve (for metadata cache, logs, and slop); metadata cache is a fixed small value. Both limits are persisted to the local kopia config on connect, so subsequent operations inherit them. Also apply global log-rotation flags to every kopia invocation in both the restore script and the snapshot-list script: `--log-dir-max-files=20 --log-dir-max-age=24h`. Without this the CLI logs alone reached hundreds of MB across hundreds of files (one new file per snapshot-list reconcile). These together turn the cache PVC's role into kopia's own LRU eviction rather than a one-shot fill-then-fail.
1 parent fe4c6c7 commit 9d2045a

4 files changed

Lines changed: 207 additions & 11 deletions

File tree

src/controllers/replica/resources.rs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,11 @@ if [ "$KOPIA_DISABLE_TLS" = "true" ]; then
8484
ENDPOINT_ARGS="$ENDPOINT_ARGS --disable-tls --disable-tls-verification"
8585
fi
8686
87-
kopia repository connect s3 \
87+
# Global kopia flags: rotate CLI logs so they don't accumulate over
88+
# many snapshot-list invocations.
89+
KOPIA_GLOBAL_FLAGS="--log-dir-max-files=20 --log-dir-max-age=24h"
90+
91+
kopia $KOPIA_GLOBAL_FLAGS repository connect s3 \
8892
--bucket="$KOPIA_BUCKET" \
8993
--region="$KOPIA_REGION" \
9094
--access-key="$AWS_ACCESS_KEY_ID" \
@@ -95,7 +99,7 @@ kopia repository connect s3 \
9599
96100
SNAP_FILE=$(mktemp)
97101
trap 'rm -f "$SNAP_FILE"' EXIT
98-
kopia snapshot list --json --all > "$SNAP_FILE" || echo "[]" > "$SNAP_FILE"
102+
kopia $KOPIA_GLOBAL_FLAGS snapshot list --json --all > "$SNAP_FILE" || echo "[]" > "$SNAP_FILE"
99103
cat "$SNAP_FILE"
100104
if [ -n "$SNAPSHOT_CALLBACK_URL" ]; then
101105
SNAP_SIZE=$(wc -c < "$SNAP_FILE")

src/controllers/replica/tests.rs

Lines changed: 65 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
1-
use crate::kopia::Snapshot;
1+
use k8s_openapi::api::core::v1::SecretReference;
2+
use kube::api::ObjectMeta;
23

3-
use super::generate_password;
4+
use crate::{kopia::Snapshot, types::*, util::TimeSpan};
5+
6+
use super::{generate_password, resources::build_snapshot_list_job};
47

58
#[test]
69
fn generate_password_length_and_charset() {
@@ -74,3 +77,63 @@ fn parse_kopia_snapshot_with_backslash_paths() {
7477
let snaps: Vec<Snapshot> = serde_json::from_str(raw).unwrap();
7578
assert_eq!(snaps[0].source.path, r"C:\Users\backup\data");
7679
}
80+
81+
#[test]
82+
fn snapshot_list_job_rotates_kopia_logs() {
83+
// Snapshot-list jobs run on every scheduled reconcile (many times
84+
// per day per replica). Without log rotation kopia's CLI logs
85+
// accumulate in the pod's writable layer / cache PVC over time and
86+
// eventually contribute to filling it. Confirm the script applies
87+
// the global log-rotation flags to every kopia invocation.
88+
let replica = PostgresPhysicalReplica {
89+
metadata: ObjectMeta {
90+
name: Some("test".into()),
91+
namespace: Some("default".into()),
92+
uid: Some("uid".into()),
93+
..Default::default()
94+
},
95+
spec: PostgresPhysicalReplicaSpec {
96+
kopia_secret_ref: SecretReference {
97+
name: Some("creds".into()),
98+
namespace: None,
99+
},
100+
snapshot_filter: None,
101+
schedule: "0 * * * *".into(),
102+
schedule_jitter: TimeSpan(jiff::Span::new()),
103+
minimum_ttl: None,
104+
switchover_grace_period: TimeSpan(jiff::Span::new()),
105+
analytics_username: "analytics".into(),
106+
storage_class: None,
107+
storage_size_override: None,
108+
resources: None,
109+
service_annotations: None,
110+
pod_annotations: None,
111+
affinity: None,
112+
tolerations: vec![],
113+
read_only: true,
114+
postgres_extra_config: None,
115+
notifications: vec![],
116+
persistent_schemas: None,
117+
storage_size_maximum: k8s_openapi::apimachinery::pkg::api::resource::Quantity(
118+
"2Ti".to_string(),
119+
),
120+
},
121+
status: None,
122+
};
123+
124+
let job = build_snapshot_list_job(&replica, "test-snap", "default", "kopia:latest", "http://x")
125+
.expect("job builds");
126+
let script = job.spec.unwrap().template.spec.unwrap().containers[0]
127+
.args
128+
.as_ref()
129+
.unwrap()[0]
130+
.clone();
131+
assert!(
132+
script.contains("--log-dir-max-files=20"),
133+
"snapshot-list script must rotate kopia logs by file count"
134+
);
135+
assert!(
136+
script.contains("--log-dir-max-age=24h"),
137+
"snapshot-list script must rotate kopia logs by age"
138+
);
139+
}

src/controllers/restore/builders.rs

Lines changed: 65 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -387,6 +387,37 @@ pub fn kopia_cache_pvc_size(snapshot_size: &Quantity) -> Quantity {
387387
chosen.into()
388388
}
389389

390+
/// MB to leave free on the cache PVC for kopia's metadata cache, its
391+
/// rolling CLI logs, and slop. Subtracted from the PVC capacity to
392+
/// derive the content-cache hard cap.
393+
pub const KOPIA_CACHE_RESERVE_MB: u64 = 2048;
394+
/// Hardcoded metadata-cache cap. Kopia's metadata cache is small
395+
/// (indices and manifest data) so a fixed allocation is fine.
396+
pub const KOPIA_METADATA_CACHE_MB: u64 = 512;
397+
/// Floor for the content-cache cap in MB, so degenerate-small PVCs
398+
/// still get a useful cache.
399+
pub const KOPIA_CONTENT_CACHE_FLOOR_MB: u64 = 1024;
400+
401+
/// Compute the content-cache cap (MB) passed to `kopia repository
402+
/// connect --content-cache-size-mb`. Sized to the cache PVC minus a
403+
/// fixed reserve for metadata cache + logs + slop.
404+
///
405+
/// Without a cap kopia's content cache grows unbounded and eventually
406+
/// fills the PVC, after which kopia can't even write its config and
407+
/// every restore Job pod exits in 1–2 minutes. The cap turns that into
408+
/// kopia's own LRU eviction, which is what we want.
409+
pub fn kopia_content_cache_mb(snapshot_size: &Quantity) -> u64 {
410+
let pvc_size = kopia_cache_pvc_size(snapshot_size);
411+
let pvc_bytes = ParsedQuantity::try_from(pvc_size)
412+
.ok()
413+
.and_then(|q| q.to_bytes_f64())
414+
.unwrap_or(0.0);
415+
let pvc_mb = (pvc_bytes / 1024.0 / 1024.0) as u64;
416+
pvc_mb
417+
.saturating_sub(KOPIA_CACHE_RESERVE_MB)
418+
.max(KOPIA_CONTENT_CACHE_FLOOR_MB)
419+
}
420+
390421
pub fn build_kopia_cache_pvc(
391422
replica: &PostgresPhysicalReplica,
392423
snapshot_size: &Quantity,
@@ -446,17 +477,29 @@ if [ "$KOPIA_DISABLE_TLS" = "true" ]; then
446477
ENDPOINT_ARGS="$ENDPOINT_ARGS --disable-tls --disable-tls-verification"
447478
fi
448479
480+
# Global kopia flags applied to every invocation: rotate CLI logs so
481+
# they don't fill the cache PVC. Cap at 20 most-recent files and
482+
# 24 hours, plenty for debugging a current restore without growing
483+
# without bound.
484+
KOPIA_GLOBAL_FLAGS="--log-dir-max-files=20 --log-dir-max-age=24h"
485+
449486
echo "Connecting to kopia repository..."
450-
kopia repository connect s3 \
487+
# --content-cache-size-mb / --metadata-cache-size-mb are persisted to
488+
# the local config on connect, so subsequent operations inherit the
489+
# bound. Without them kopia's content cache grows unbounded and
490+
# eventually fills the cache PVC (observed across multiple replicas).
491+
kopia $KOPIA_GLOBAL_FLAGS repository connect s3 \
451492
--bucket="$KOPIA_BUCKET" \
452493
--region="$KOPIA_REGION" \
453494
--access-key="$AWS_ACCESS_KEY_ID" \
454495
--secret-access-key="$AWS_SECRET_ACCESS_KEY" \
455496
--password="$KOPIA_PASSWORD" \
497+
--content-cache-size-mb="$KOPIA_CONTENT_CACHE_MB" \
498+
--metadata-cache-size-mb="$KOPIA_METADATA_CACHE_MB" \
456499
$ENDPOINT_ARGS
457500
458501
echo "Starting restore..."
459-
kopia snapshot restore "$SNAPSHOT_ID" /pgdata/postgres
502+
kopia $KOPIA_GLOBAL_FLAGS snapshot restore "$SNAPSHOT_ID" /pgdata/postgres
460503
461504
echo "Restore complete"
462505
ls -la /pgdata/
@@ -544,11 +587,26 @@ echo -n "$VERSION" > /dev/termination-log
544587
args: Some(vec![restore_script.to_string()]),
545588
env: Some(
546589
[
547-
vec![EnvVar {
548-
name: "SNAPSHOT_ID".to_string(),
549-
value: Some(restore.spec.snapshot.clone()),
550-
..Default::default()
551-
}],
590+
vec![
591+
EnvVar {
592+
name: "SNAPSHOT_ID".to_string(),
593+
value: Some(restore.spec.snapshot.clone()),
594+
..Default::default()
595+
},
596+
EnvVar {
597+
name: "KOPIA_CONTENT_CACHE_MB".to_string(),
598+
value: Some(
599+
kopia_content_cache_mb(&restore.spec.snapshot_size)
600+
.to_string(),
601+
),
602+
..Default::default()
603+
},
604+
EnvVar {
605+
name: "KOPIA_METADATA_CACHE_MB".to_string(),
606+
value: Some(KOPIA_METADATA_CACHE_MB.to_string()),
607+
..Default::default()
608+
},
609+
],
552610
kopia_writable_env(),
553611
vec![
554612
env_from_secret("KOPIA_BUCKET", kopia_secret, "bucket"),

src/controllers/restore/tests.rs

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,77 @@ fn kopia_cache_pvc_owned_by_replica() {
236236
assert_eq!(access_modes, &vec!["ReadWriteOnce".to_string()]);
237237
}
238238

239+
#[test]
240+
fn kopia_content_cache_mb_floor_for_small_pvc() {
241+
// 10Gi PVC (the floor for small snapshots) minus the 2Gi reserve =
242+
// 8 Gi content cache, expressed in MiB.
243+
let small = Quantity("1Gi".to_string());
244+
let mb = super::builders::kopia_content_cache_mb(&small);
245+
let expected = 10 * 1024 - super::builders::KOPIA_CACHE_RESERVE_MB;
246+
assert_eq!(mb, expected);
247+
assert!(
248+
mb >= super::builders::KOPIA_CONTENT_CACHE_FLOOR_MB,
249+
"content cache must always be at least the floor"
250+
);
251+
}
252+
253+
#[test]
254+
fn kopia_content_cache_mb_scales_with_snapshot() {
255+
// 100Gi snapshot → 20Gi PVC → 20Gi - 2Gi reserve = 18Gi cache.
256+
let big = Quantity("100Gi".to_string());
257+
let mb = super::builders::kopia_content_cache_mb(&big);
258+
let expected = 20 * 1024 - super::builders::KOPIA_CACHE_RESERVE_MB;
259+
assert_eq!(mb, expected);
260+
}
261+
262+
#[test]
263+
fn restore_job_passes_cache_caps_and_log_rotation() {
264+
// The restore Job's pod spec must set KOPIA_CONTENT_CACHE_MB and
265+
// KOPIA_METADATA_CACHE_MB so the embedded script can cap kopia's
266+
// caches, and the script must rotate CLI logs. Without these the
267+
// cache PVC fills up and every subsequent restore Job pod exits
268+
// in 1–2 minutes ("no space left on device").
269+
let (restore, replica) = test_restore_and_replica();
270+
let job = build_restore_job(
271+
&restore,
272+
"test-restore-restore",
273+
"default",
274+
&replica,
275+
"kopia:latest",
276+
)
277+
.unwrap();
278+
let pod_spec = job.spec.unwrap().template.spec.unwrap();
279+
let container = &pod_spec.containers[0];
280+
let env = container.env.as_ref().expect("container must declare env");
281+
let names: Vec<&str> = env.iter().map(|e| e.name.as_str()).collect();
282+
assert!(
283+
names.contains(&"KOPIA_CONTENT_CACHE_MB"),
284+
"restore Job env must include KOPIA_CONTENT_CACHE_MB"
285+
);
286+
assert!(
287+
names.contains(&"KOPIA_METADATA_CACHE_MB"),
288+
"restore Job env must include KOPIA_METADATA_CACHE_MB"
289+
);
290+
291+
let script = &container.args.as_ref().unwrap()[0];
292+
assert!(
293+
script.contains("--content-cache-size-mb=\"$KOPIA_CONTENT_CACHE_MB\""),
294+
"connect command must cap content cache via env var"
295+
);
296+
assert!(
297+
script.contains("--metadata-cache-size-mb=\"$KOPIA_METADATA_CACHE_MB\""),
298+
"connect command must cap metadata cache"
299+
);
300+
assert!(
301+
script.contains("--log-dir-max-files=20"),
302+
"kopia invocations must rotate CLI logs by file count"
303+
);
304+
assert!(
305+
script.contains("--log-dir-max-age=24h"),
306+
"kopia invocations must rotate CLI logs by age"
307+
);
308+
}
309+
239310
#[test]
240311
fn cache_size_needs_grow_ratchet() {
241312
let small = Quantity("10Gi".to_string());

0 commit comments

Comments
 (0)