diff --git a/scripts/setup/machine-setup/lib/system.sh b/scripts/setup/machine-setup/lib/system.sh index 5c819f12..dd2654ba 100644 --- a/scripts/setup/machine-setup/lib/system.sh +++ b/scripts/setup/machine-setup/lib/system.sh @@ -89,6 +89,90 @@ locale_set() { esac } +# ----------------------------------------------------------------------------- +# Swap +# ----------------------------------------------------------------------------- + +SWAPFILE=/swapfile + +# Rounded to nearest, not floored: a 4 GiB swapfile is 4194300 kB, which floors +# to 3 and reads as though a gigabyte went missing. Same for RAM, where 3.7 GiB +# reporting as "3G" makes the sizing tiers look wrong. +kb_to_gb_rounded() { echo $((($1 + 524288) / 1048576)); } + +# Total active swap in GiB, 0 if there is none. +# +# From /proc/meminfo rather than by grepping swapon's output for a slash, which +# is what the original did to spot a swap FILE — that test reports no swap at all +# on a machine using zram or a swap partition, and the step would then add a +# swapfile beside perfectly good swap. +swap_active_gb() { kb_to_gb_rounded "$(awk '/^SwapTotal:/ { print $2 }' /proc/meminfo)"; } + +ram_gb() { kb_to_gb_rounded "$(awk '/^MemTotal:/ { print $2 }' /proc/meminfo)"; } + +# Free space on the filesystem that would hold the swapfile, in GiB. Floored +# rather than rounded, deliberately: this one decides how much to allocate, and +# rounding up invents space that is not there. +disk_free_gb() { echo $(($(df -Pk "$(dirname "$SWAPFILE")" | awk 'NR == 2 { print $4 }') / 1024 / 1024)); } + +# How much swap this machine should have. +# +# The tiers are the original's. What is new is that the answer is capped by what +# is actually on the disk — the original would try to fallocate 8G on a VPS with +# 4G free, fail, and take the run down with it. +swap_recommended_gb() { + local ram size + ram="$(ram_gb)" + if ((ram <= 2)); then + size=2 + elif ((ram <= 8)); then + size=4 + else + size=8 + fi + + # Leave a few gigabytes behind. A swapfile that fills the disk is a worse + # problem than no swapfile. + local room=$(($(disk_free_gb) - 5)) + ((room < size)) && size="$room" + ((size < 1)) && size=0 + echo "$size" +} + +# How eagerly the kernel swaps, by role. +# +# 10 on a server: swapping is the emergency valve, not a routine, and the cost of +# a page fault on a request path is latency somebody is waiting for. A desktop is +# the opposite case — swapping out an application nobody has touched in an hour +# is exactly what you want — so dev keeps the kernel default of 60. +swappiness_for_role() { if is_server; then echo 10; else echo 60; fi; } + +swap_create() { + local gb="$1" + + # fallocate is instant but produces a file some filesystems refuse to swap on + # (btrfs without the right attributes, zfs at all). dd is slow and always + # works, so it is the fallback rather than the default. + if ! fallocate -l "${gb}G" "$SWAPFILE" 2>/dev/null; then + info " fallocate is not usable here — writing the file with dd, which is slower" + dd if=/dev/zero of="$SWAPFILE" bs=1M count=$((gb * 1024)) status=none + fi + + chmod 600 "$SWAPFILE" + mkswap "$SWAPFILE" >/dev/null + swapon "$SWAPFILE" + + grep -qs "^${SWAPFILE}[[:space:]]" /etc/fstab || echo "${SWAPFILE} none swap sw 0 0" >>/etc/fstab +} + +# Written as a drop-in rather than by rewriting /etc/sysctl.conf in place. The +# original sed'd that file, which means the setting is tangled up with whatever +# else lives there and is invisible to anyone looking for what this script did. +swappiness_set() { + echo "vm.swappiness=$1" >/etc/sysctl.d/99-machine-setup-swappiness.conf + sysctl -q -w "vm.swappiness=$1" +} + # ----------------------------------------------------------------------------- # Timezone # ----------------------------------------------------------------------------- diff --git a/scripts/setup/machine-setup/machine-setup.sh b/scripts/setup/machine-setup/machine-setup.sh index 407b9169..d2c548f9 100755 --- a/scripts/setup/machine-setup/machine-setup.sh +++ b/scripts/setup/machine-setup/machine-setup.sh @@ -261,13 +261,67 @@ if ! skip; then step_ok fi +# ============================================================================= +# 7. Swap +# ============================================================================= +# +# Disk the kernel can park cold pages on when RAM fills, so a spike costs +# latency instead of a process. A `bun install` or a Docker build on a small +# machine is exactly the spike this is for. + +step "Swap" +if ! skip; then + ACTIVE_SWAP_GB="$(swap_active_gb)" + WANT_SWAP_GB="$(swap_recommended_gb)" + SWAPPINESS="$(swappiness_for_role)" + CURRENT_SWAPPINESS="$(sysctl -n vm.swappiness 2>/dev/null || echo unknown)" + + echo "" + info "Swap — overflow space so a memory spike costs speed rather than a process" + echo " RAM: $(ram_gb)G" + echo " active swap: ${ACTIVE_SWAP_GB}G" + echo " swappiness: ${CURRENT_SWAPPINESS} -> ${SWAPPINESS} (${MACHINE_ROLE})" + + if [[ "$IS_WSL" == true ]]; then + # WSL2 runs its own managed swap inside the VM; a swapfile here is wasted + # disk and is not what the kernel would use anyway. + echo " WSL manages its own swap — leaving it alone" + SUMMARY+=("Swap: left to WSL") + elif ((ACTIVE_SWAP_GB > 0)); then + echo " already has ${ACTIVE_SWAP_GB}G of swap, leaving it alone" + if [[ "$CURRENT_SWAPPINESS" != "$SWAPPINESS" ]] && confirm "Set swappiness to ${SWAPPINESS}?"; then + swappiness_set "$SWAPPINESS" + ok "swappiness set to ${SWAPPINESS}" + SUMMARY+=("Swap: kept ${ACTIVE_SWAP_GB}G, swappiness ${SWAPPINESS}") + else + SUMMARY+=("Swap: kept ${ACTIVE_SWAP_GB}G") + fi + elif ((WANT_SWAP_GB == 0)); then + # Capped to nothing by the disk check rather than by choice. + warn "not enough free disk to add swap safely — $(disk_free_gb)G free" + SUMMARY+=("Swap: none added, disk too full") + else + echo " to create: ${WANT_SWAP_GB}G at ${SWAPFILE} ($(disk_free_gb)G free now)" + if confirm "Proceed?"; then + swap_create "$WANT_SWAP_GB" + swappiness_set "$SWAPPINESS" + ok "${WANT_SWAP_GB}G swap active, swappiness ${SWAPPINESS}" + SUMMARY+=("Swap: ${WANT_SWAP_GB}G created, swappiness ${SWAPPINESS}") + else + warn "skipped by request" + SUMMARY+=("Swap: SKIPPED by request") + fi + fi + step_ok +fi + # ============================================================================= # NOT PORTED YET # ============================================================================= # # Sections still to move across from scripts/setup-old/setup-ubuntu.sh, in order: # -# swap · auto-suspend · boot-hang fix · user creation · +# auto-suspend · boot-hang fix · user creation · # ssh keys · ssh hardening · dns · static ip · fail2ban · unattended-upgrades · # git config · docker · zsh + prompt · tailscale · neovim · js runtimes · # dev tools · ufw · zshrc · disk ballast