Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion config/k3s/kubelet.conf
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,9 @@ kind: KubeletConfiguration
# Keep limited parallelism for faster cold starts without allowing a full-node
# restart to saturate containerd, disk I/O, and CRI request deadlines.
serializeImagePulls: false
maxParallelImagePulls: 4
maxParallelImagePulls: 2
# Make the single-node disk contract explicit. The ext4 root reserve is managed
# by the Kyber activation script, keeping ordinary usage below the low watermark
# while kubelet remains the sole owner of image and container garbage collection.
imageGCHighThresholdPercent: 85
imageGCLowThresholdPercent: 80

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit — explicit defaults: 85 / 80 are already the kubelet defaults per the KubeletConfiguration reference, so these two lines don't change runtime GC behavior; they only pin the values in code. The comment above ("Make the single-node disk contract explicit") makes the intent clear, but it may be worth adding # (kubelet defaults, restated for clarity) so future readers don't infer this was the fix for the July 2026 incident.

38 changes: 37 additions & 1 deletion home-manager/services/k3s/activate.sh
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
#!/usr/bin/env bash
# Install the generated k3s service and sync kubeconfig for the current user.
# @diff@ and @systemctl@ are substituted by pkgs.replaceVars.
# Command placeholders are substituted by pkgs.replaceVars.
set -euo pipefail

SERVICE_FILE="$1"
Expand Down Expand Up @@ -37,6 +37,42 @@ require_sudo() {
fi
}

configure_root_ext4_reserve() {
local root_source root_fs_type block_count reserved_blocks target_reserved_blocks
local target_reserved_percent=1

root_source="$(@findmnt@ --noheadings --output SOURCE --target /)"
root_fs_type="$(@findmnt@ --noheadings --output FSTYPE --target /)"

if [ "$root_fs_type" != "ext4" ]; then
return 0
fi
if [ ! -b "$root_source" ]; then
echo "Warning: ext4 root source is not a block device: $root_source" >&2
return 0
fi

require_sudo || return 0
# shellcheck disable=SC2016
block_count="$(run_sudo @tune2fs@ -l "$root_source" 2>/dev/null | @awk@ -F: '/^Block count:/ { gsub(/[[:space:]]/, "", $2); print $2 }')"
# shellcheck disable=SC2016
reserved_blocks="$(run_sudo @tune2fs@ -l "$root_source" 2>/dev/null | @awk@ -F: '/^Reserved block count:/ { gsub(/[[:space:]]/, "", $2); print $2 }')"
Comment on lines +57 to +59

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Since set -e and pipefail are enabled, if run_sudo @tune2fs@ fails (for example, if sudo requires a password in a non-interactive shell, or if the block device cannot be opened), the command substitution will return a non-zero exit status and prematurely abort the entire activation script.

To prevent this and allow the subsequent if [ -z "$block_count" ] check to handle the failure gracefully, append || true to the pipelines.

Suggested change
block_count="$(run_sudo @tune2fs@ -l "$root_source" 2>/dev/null | @awk@ -F: '/^Block count:/ { gsub(/[[:space:]]/, "", $2); print $2 }')"
# shellcheck disable=SC2016
reserved_blocks="$(run_sudo @tune2fs@ -l "$root_source" 2>/dev/null | @awk@ -F: '/^Reserved block count:/ { gsub(/[[:space:]]/, "", $2); print $2 }')"
block_count="$(run_sudo @tune2fs@ -l "$root_source" 2>/dev/null | @awk@ -F: '/^Block count:/ { gsub(/[[:space:]]/, "", $2); print $2 }' || true)"
# shellcheck disable=SC2016
reserved_blocks="$(run_sudo @tune2fs@ -l "$root_source" 2>/dev/null | @awk@ -F: '/^Reserved block count:/ { gsub(/[[:space:]]/, "", $2); print $2 }' || true)"
References
  1. When 'set -e' is enabled in Bash scripts, ensure that individual command failures within a loop do not prematurely abort the entire script. Handle potential failures gracefully, for example by appending '|| true'.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit — duplicated tune2fs -l: Both block_count and reserved_blocks are extracted with independent sudo tune2fs -l "$root_source" invocations, each piped to awk. On every home-manager activation this runs tune2fs -l twice. Consider a single call whose output is parsed once, e.g.:

read -r block_count reserved_blocks < <(
  run_sudo @tune2fs@ -l "$root_source" 2>/dev/null \
    | @awk@ -F: '
        /^Block count:/          { gsub(/[[:space:]]/, "", $2); bc=$2 }
        /^Reserved block count:/ { gsub(/[[:space:]]/, "", $2); rc=$2 }
        END                       { print bc, rc }'
)

Purely cosmetic — behavior is identical.

if [ -z "$block_count" ] || [ -z "$reserved_blocks" ]; then
echo "Warning: unable to inspect ext4 reserve on $root_source" >&2
return 0
fi

target_reserved_blocks=$((block_count * target_reserved_percent / 100))
if [ "$reserved_blocks" -eq "$target_reserved_blocks" ]; then
return 0
fi

run_sudo @tune2fs@ -m "$target_reserved_percent" "$root_source"
echo "Configured $root_source ext4 reserved blocks to ${target_reserved_percent}%"
}

configure_root_ext4_reserve

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: If the tune2fs -l pipeline fails (e.g., sudo permission issue or transient device error), set -euo pipefail exits the subshell, block_count/reserved_blocks stay empty, and the script exits at line ~65 before the [ -z "$block_count" ] guard on line ~74 is reached. The 2>/dev/null on the tune2fs call hides the original error, making the failure hard to diagnose. The same risk applies to the final run_sudo @tune2fs@ -m command on the last changed line — if that fails, the function returns non-zero and set -e aborts the entire activation script, skipping k3s service installation.

The ext4 reserve adjustment is a best-effort optimization; it shouldn't block the service file installation and k3s enable/start that follow. Consider guarding the function call with || true so a disk-configuration failure doesn't prevent k3s from being installed. As a secondary improvement, removing 2>/dev/null (or keeping it but adding || true after each subshell) lets the existing [ -z "$block_count" ] warning actually fire when tune2fs fails.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At home-manager/services/k3s/activate.sh, line 74:

<comment>If the `tune2fs -l` pipeline fails (e.g., sudo permission issue or transient device error), `set -euo pipefail` exits the subshell, `block_count`/`reserved_blocks` stay empty, and the script exits at line ~65 before the `[ -z "$block_count" ]` guard on line ~74 is reached. The `2>/dev/null` on the tune2fs call hides the original error, making the failure hard to diagnose. The same risk applies to the final `run_sudo @tune2fs@ -m` command on the last changed line — if that fails, the function returns non-zero and `set -e` aborts the entire activation script, skipping k3s service installation.

The ext4 reserve adjustment is a best-effort optimization; it shouldn't block the service file installation and k3s enable/start that follow. Consider guarding the function call with `|| true` so a disk-configuration failure doesn't prevent k3s from being installed. As a secondary improvement, removing `2>/dev/null` (or keeping it but adding `|| true` after each subshell) lets the existing `[ -z "$block_count" ]` warning actually fire when tune2fs fails.</comment>

<file context>
@@ -37,6 +37,42 @@ require_sudo() {
+  echo "Configured $root_source ext4 reserved blocks to ${target_reserved_percent}%"
+}
+
+configure_root_ext4_reserve
+
 if [ -f "$SERVICE_FILE" ] && ! @diff@ -q "$SERVICE_FILE" "$SYSTEM_SERVICE" >/dev/null 2>&1; then
</file context>


if [ -f "$SERVICE_FILE" ] && ! @diff@ -q "$SERVICE_FILE" "$SYSTEM_SERVICE" >/dev/null 2>&1; then
require_sudo || exit 0
run_sudo cp "$SERVICE_FILE" "$SYSTEM_SERVICE"
Expand Down
3 changes: 3 additions & 0 deletions home-manager/services/k3s/default.nix
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,11 @@ let
inherit (inputs) host;
homeDir = config.home.homeDirectory;
setupScript = pkgs.replaceVars ./activate.sh {
awk = "${pkgs.gawk}/bin/awk";
diff = "${pkgs.diffutils}/bin/diff";
findmnt = "${pkgs.util-linux}/bin/findmnt";
systemctl = "${pkgs.systemd}/bin/systemctl";
tune2fs = "${pkgs.e2fsprogs}/bin/tune2fs";
};
serviceFile = "${homeDir}/.config/k3s/k3s.service";
in
Expand Down
36 changes: 36 additions & 0 deletions named-hosts/kyber/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,42 @@ Once Tailscale is set up:
kyber # Fish abbreviation that runs: ssh ubuntu@kyber
```

## k3s Disk Headroom

Kyber runs k3s and its embedded containerd on the root ext4 filesystem. The
host activation keeps ext4 reserved blocks at 1% and limits kubelet to two
parallel image pulls. On this 916 GiB volume, Ubuntu's default 5% reserve hid
about 46 GiB from kubelet and left too little usable headroom during overlapping
application rollouts.

Kubelet owns image, container, and pod-sandbox garbage collection. Do not add a
separate `crictl` cleanup timer: deleting CRI objects behind kubelet can race
active pod lifecycle operations and leave container names or cgroups stuck.

The July 2026 incident was a disk-pressure feedback loop, not a slow Temporal
queue. Root usage crossed kubelet's 85% image-GC threshold during concurrent
image pulls. Kubelet attempted to reclaim tens of GiB from a much smaller
logical image cache while containerd and Kine were already I/O-bound. CRI calls
timed out, stale tasks accumulated, and Temporal workers could not start new
chat turns. Reducing the ext4 reserve, limiting pull parallelism, and preserving
free space prevent that loop.

For diagnosis, check filesystem headroom, I/O pressure, kubelet GC messages,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The diagnosis checklist says to check I/O pressure, but the commands below do not inspect it, so an operator following this recovery procedure can miss the I/O-bound condition described above. Adding an explicit PSI or iostat check would make the checklist complete.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At named-hosts/kyber/README.md, line 72:

<comment>The diagnosis checklist says to check I/O pressure, but the commands below do not inspect it, so an operator following this recovery procedure can miss the I/O-bound condition described above. Adding an explicit PSI or `iostat` check would make the checklist complete.</comment>

<file context>
@@ -49,6 +49,42 @@ Once Tailscale is set up:
+chat turns. Reducing the ext4 reserve, limiting pull parallelism, and preserving
+free space prevent that loop.
+
+For diagnosis, check filesystem headroom, I/O pressure, kubelet GC messages,
+and CRI health before restarting services:
+
</file context>

and CRI health before restarting services:

```bash
df -h /
sudo tune2fs -l "$(findmnt -n -o SOURCE /)" | grep -E 'Block count|Reserved block count'
sudo journalctl -u k3s --since '30 minutes ago' | grep -E 'image garbage collection|DiskPressure|deadline exceeded'
sudo k3s crictl info
```

An ordinary `systemctl restart k3s` intentionally preserves running containers
because the upstream unit uses `KillMode=process`. If containerd itself is
wedged, use the installed `k3s-killall.sh` once during an attended recovery,
then start k3s again. The helper preserves cluster data but terminates every
running workload, so it is not a timer or routine cleanup mechanism.

## SSH Key Management

### Automated Setup
Expand Down
10 changes: 10 additions & 0 deletions spec/k3s_service_activate_spec.sh
Original file line number Diff line number Diff line change
Expand Up @@ -75,5 +75,15 @@ It 'preserves dry-run command handling'
When run bash -c "grep 'DRY_RUN_CMD' '$SCRIPT'"
The output should include 'DRY_RUN_CMD'
End

It 'keeps one percent of the ext4 root volume reserved'
When run bash -c "grep 'target_reserved_percent=1' '$SCRIPT'"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The grep pattern target_reserved_percent=1 is a substring match — it also matches target_reserved_percent=10, target_reserved_percent=12, etc. If the reserved percent is ever changed to a multi-digit value, this test will silently pass even after the script loses the intended value. Adding -w isolates the value boundary correctly.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At spec/k3s_service_activate_spec.sh, line 80:

<comment>The grep pattern `target_reserved_percent=1` is a substring match — it also matches `target_reserved_percent=10`, `target_reserved_percent=12`, etc. If the reserved percent is ever changed to a multi-digit value, this test will silently pass even after the script loses the intended value. Adding `-w` isolates the value boundary correctly.</comment>

<file context>
@@ -75,5 +75,15 @@ It 'preserves dry-run command handling'
 End
+
+It 'keeps one percent of the ext4 root volume reserved'
+When run bash -c "grep 'target_reserved_percent=1' '$SCRIPT'"
+The output should include 'target_reserved_percent=1'
+End
</file context>
Suggested change
When run bash -c "grep 'target_reserved_percent=1' '$SCRIPT'"
When run bash -c "grep -w 'target_reserved_percent=1' '$SCRIPT'"

The output should include 'target_reserved_percent=1'
End

It 'resolves the mounted root block device instead of hard-coding it'
When run bash -c "grep '@findmnt@ --noheadings --output SOURCE --target /' '$SCRIPT'"
The output should include '@findmnt@ --noheadings --output SOURCE --target /'
End
End
End
Loading