In the previous lesson you found a performance problem whose cause was a badly set configuration value: max_connections=200 against a max_connections=100. You found it by measuring, and you fixed it by checking the effect. This lesson does the same thing one level down: the values you are going to adjust are the kernel's.
And it starts with a warning, because kernel tuning is the area of systems administration with the most folklore per square metre. The Internet is full of lists of parameters "to optimise Linux" that get copied without being understood, that contradict the defaults for no reason, and that in the best case do nothing at all. There is a single rule that separates the work from the ritual:
You do not tune what you have not measured.
And the procedure that applies it, which is the same one you have already used twice — in the slow-website incident in 05-07 and in the latency one in 07-02:
- Measure the current state and write it down. Without a baseline there is no "after".
- Formulate a specific hypothesis: which parameter, why you think it matters, and what you expect to change.
- Change one single thing. Two simultaneous changes make it impossible to attribute the result.
- Measure again with the same method.
- Document the change with its reason, or revert it. A parameter with no written justification is a parameter nobody will be able to remove in a year's time.
Ubuntu 24.04's defaults are reasonable for most workloads. Changing them without a measurement to back it up is, in all probability, making the system worse.
Contents
- sysctl: the kernel's parameter interface
- Virtual memory
- The network and the TCP stack
- File and process limits
- The I/O scheduler
- Transparent huge pages
- Per-process limits and which one wins
- Frequency governors and tuned
- Kernel modules
- Compiling the kernel: when it makes sense and when it does not
- The Tramontana case: tuning the network stack with measurement
sysctl: the kernel's parameter interface
The kernel exposes its tunable parameters as files under /proc/sys/. It is Module 1's "everything is a file" applied to the kernel's own configuration: reading the parameter means reading a file, changing it means writing to it.
$ cat /proc/sys/vm/swappiness
60
$ sudo sh -c 'echo 10 > /proc/sys/vm/swappiness'
$ cat /proc/sys/vm/swappiness
10sysctl is the tool that does the same thing with a dotted syntax, where every dot is a slash in the path:
$ sysctl vm.swappiness # equivalent to /proc/sys/vm/swappiness
vm.swappiness = 10
$ sysctl -a 2>/dev/null | wc -l
1247
$ sysctl -a 2>/dev/null | grep -c '^net\.'
684Almost thirteen hundred parameters, two thirds of which are network ones. The vast majority are never touched.
The three modes of operation, and which to use when:
| Operation | Command | Survives a reboot |
|---|---|---|
| Query | sysctl <parameter> |
— |
| Change now, to test | sudo sysctl -w <parameter>=<value> |
No |
| Change permanently | A file in /etc/sysctl.d/ + sysctl --system |
Yes |
That ephemeral -w is the tool for step 3 of the procedure: it lets you try a change, measure, and if things get worse a reboot — or rewriting the old value — is enough to undo it. You never put a parameter straight into /etc/sysctl.d/ without having tested it live first.
Persistence and its precedence:
$ ls /etc/sysctl.d/
10-console-messages.conf 10-network-security.conf 60-hardening.conf
10-ipv6-privacy.conf 10-ptrace.conf 99-sysctl.conf
$ sudo tee /etc/sysctl.d/70-performance.conf >/dev/null <<'EOF'
# Performance settings. Every line with its reason and its measurement.
vm.swappiness = 10
EOF
$ sudo sysctl --system 2>&1 | grep -A1 70-performance
* Applying /etc/sysctl.d/70-performance.conf ...
vm.swappiness = 10The files are applied in alphabetical order, and the last one to write a parameter wins. Hence the convention of numbering them: 10- for whatever comes from the distribution, 60- to 90- for your own, and 99- for anything that must override everything. And an important note: /etc/sysctl.conf still works, but it is deprecated; use /etc/sysctl.d/ with separate files by purpose.
Notice that you already have 60-hardening.conf, from 06-06, with the security parameters. The performance ones go in a separate file, and that separation is not cosmetic: when somebody has to review the security posture, or when a performance setting has to be reverted, only one file is touched and what can be removed does not get mixed up with what cannot.
Virtual memory
The virtual memory subsystem is where settings have the greatest impact and where there is the most mythology. The parameters that matter:
vm.swappiness
The most misunderstood of the lot. It is not "how much swap to use", nor a percentage of memory. It is the kernel's relative preference between two ways of freeing memory when it needs to: discarding file cache pages, or moving anonymous pages (process memory) to swap.
| Value | Behaviour | When |
|---|---|---|
0 |
Only uses swap to avoid the OOM killer | Almost never; it can trigger an OOM prematurely |
1 |
The minimum possible without disabling it | Databases with all the RAM assigned |
10 |
Prefers discarding cache | Servers with enough RAM |
60 |
The default | Desktop and general use |
100 |
Treats cache and anonymous pages equally | Workloads where the cache is more valuable |
And the fact to internalise before touching it: if the system is not using swap, swappiness does nothing. It is a parameter that only manifests itself under memory pressure. Before changing it, measure whether there is any pressure:
# The prior measurement: is there any swap activity? (the si/so columns)
$ vmstat 5 4
procs -----------memory---------- ---swap-- -----io---- -system-- ------cpu-----
r b swpd free buff cache si so bi bo in cs us sy id wa st
0 0 0 1284412 104882 1841204 0 0 0 12 412 882 3 2 95 0 0
0 0 0 1284188 104882 1841204 0 0 0 8 388 841 2 2 96 0 0
# And the total since boot
$ grep -E 'pswpin|pswpout' /proc/vmstat
pswpin 0
pswpout 0Zero swap-ins and zero swap-outs since boot. On srv-tramontana, with 3.8 GB and 2.4 GB available, swappiness is irrelevant today. Lowering it would improve nothing.
Is it worth changing, then? Yes, but for a different reason than average performance: as insurance against degradation under pressure. If one day the application grows and pressure starts to appear, with swappiness=10 the kernel will prefer to discard cache rather than send the process's active pages to swap, which avoids the scenario of hundreds of milliseconds of latency. It is a defensible decision, and that is how it has to be documented: not "this speeds up the server", but "this limits the damage if memory ever runs short".
vm.dirty_ratio and vm.dirty_background_ratio
These do have a measurable effect, and they are the cause of a very characteristic latency pattern.
When a process writes to a file, the data goes to the page cache first and is marked as dirty; the kernel flushes it to disk later. These two parameters control when:
$ sysctl vm.dirty_background_ratio vm.dirty_ratio vm.dirty_expire_centisecs
vm.dirty_background_ratio = 10
vm.dirty_ratio = 20
vm.dirty_expire_centisecs = 3000| Parameter | What happens when it is reached |
|---|---|
dirty_background_ratio |
The kernel starts flushing in the background, blocking nobody |
dirty_ratio |
The writing process blocks until enough has been flushed |
dirty_expire_centisecs |
The maximum age of a dirty page before it is flushed (30 s) |
The pattern they produce: fast writes while there is headroom, and then suddenly a long pause when dirty_ratio is reached and the process is left blocked. With 3.8 GB of RAM, 20% is about 760 MB of dirty data that may have to go to disk all at once.
That is exactly the profile of the 05-07 incident: the backup writing in bursts, with a w_await of 22.85 ms and the application blocked. ionice mitigated the symptom; these parameters attack the mechanism.
# Prior measurement: how much dirty data there is at any given moment
$ grep -E '^Dirty|^Writeback' /proc/meminfo
Dirty: 1024 kB
Writeback: 0 kB
# And during the backup, with the encrypted volume as the destination
$ while true; do grep '^Dirty:' /proc/meminfo; sleep 2; done
Dirty: 412844 kB
Dirty: 688204 kB # approaching 20% of 3.8 GB
Dirty: 12408 kB # flushed all at once: here is the pauseSmaller values produce more frequent, smaller flushes, with more uniform latency:
And the honest warning: this does not make the system "faster" in total throughput — sometimes it makes it slightly slower — but more predictable. For an interactive service, uniform latency is worth more than peak throughput. For an isolated backup job, the opposite. It is a trade-off, not an improvement.
The rest of virtual memory
| Parameter | Default | What it does | When to touch it |
|---|---|---|---|
vm.vfs_cache_pressure |
100 | Aggressiveness in reclaiming inode and dentry cache | Lower it to 50 if there are many small files that get reread a lot |
vm.overcommit_memory |
0 | 0 heuristic, 1 allow everything, 2 strict | 1 for Redis; 2 only with a properly calculated overcommit_ratio |
vm.min_free_kbytes |
~45000 | The minimum free reserve | Raise it if there are allocation failures during network bursts |
vm.max_map_count |
65530 | Memory regions per process | Raise it for Elasticsearch and large databases |
vm.overcommit_memory=2 deserves a warning: it looks prudent ("do not promise memory you do not have") and in practice it makes perfectly normal applications fail on allocation, because many reserve far more than they use. It is not touched without a very specific reason.
The network and the TCP stack
This is where srv-tramontana has real headroom, and where the 07-02 incident left a clue unexplored.
Incoming connection queues
When a TCP connection arrives, it passes through two queues before the application accepts it:
$ sysctl net.core.somaxconn net.ipv4.tcp_max_syn_backlog
net.core.somaxconn = 4096
net.ipv4.tcp_max_syn_backlog = 1024| Parameter | Which queue it controls |
|---|---|
tcp_max_syn_backlog |
Half-open connections: the SYN arrived, the final ACK is missing |
somaxconn |
Established connections waiting for the application to accept them |
And the crucial detail that makes this parameter get tuned wrongly half the time: somaxconn is a ceiling, not an effective value. The real queue is the minimum of somaxconn and the backlog parameter the application passes to the listen() call. Raising somaxconn to 65535 achieves nothing if the application asks for 128.
The measurement that says whether the queue is filling up:
# The Recv-Q column on a LISTEN socket is the pending accept queue;
# Send-Q is the effective maximum (the minimum of somaxconn and the app backlog)
$ ss -ltn
State Recv-Q Send-Q Local Address:Port Peer Address:Port
LISTEN 0 128 127.0.0.1:8080 0.0.0.0:*
LISTEN 0 4096 10.0.2.15:5432 0.0.0.0:*
LISTEN 0 128 0.0.0.0:22 0.0.0.0:*
# The counter of connections dropped because the queue was full: THE metric that matters
$ nstat -az | grep -iE 'ListenOverflows|ListenDrops'
TcpExtListenOverflows 0 0.0
TcpExtListenDrops 0 0.0That Send-Q 128 on port 8080 is the valuable piece of information: the application asks for a backlog of 128, so the somaxconn of 4096 does not affect it at all. And ListenOverflows at zero means no connection has ever been dropped for a full queue. Conclusion: raising somaxconn on this machine would do nothing, and whoever put it in their sysctl.d would be adding folklore.
This is the procedure working: the reasonable hypothesis ("the connection queues are the bottleneck") is refuted by the measurement before anything is touched. Refuting hypotheses is as valuable as confirming them, and considerably cheaper.
Ephemeral ports and connections in wait
Here there really is something the 07-02 incident left in plain view. When the application was opening 300 connections every ten seconds, each one consumed an ephemeral port that stayed in the TIME_WAIT state for 60 seconds:
$ sysctl net.ipv4.ip_local_port_range net.ipv4.tcp_fin_timeout net.ipv4.tcp_tw_reuse
net.ipv4.ip_local_port_range = 32768 60999
net.ipv4.tcp_fin_timeout = 60
net.ipv4.tcp_tw_reuse = 2
$ ss -s
Total: 284
TCP: 112 (estab 24, closed 74, orphaned 0, timewait 71)Seventy-one sockets in TIME_WAIT. With 28,231 ports available that is not a problem, but during the incident, at 30 connections a second, some 1,800 built up — and an application opening ten times more would exhaust them.
TIME_WAIT is not a defect: it guarantees that delayed packets from a closed connection do not get confused with a new one reusing the same pair of ports. The possible adjustments:
| Adjustment | Effect | Risk |
|---|---|---|
Widen ip_local_port_range to 1024 65535 |
More ports available | It can clash with services listening on high ports |
tcp_tw_reuse = 1 |
Reuse sockets in TIME_WAIT for outbound connections |
Nothing relevant today; it requires TCP timestamps |
Lower tcp_fin_timeout |
Less time in TIME_WAIT |
It reintroduces the risk TIME_WAIT avoids |
tcp_tw_recycle |
— | Removed from the kernel in 4.12. It does not exist. |
That last one is the perfect example of folklore: tcp_tw_recycle appears in hundreds of guides "to optimise TCP", it broke connections from NATed networks subtly and intermittently, and it was removed from the kernel years ago. If you find a guide recommending it, you know its author has not tested it this decade.
On Ubuntu 24.04, tcp_tw_reuse already comes set to 2 (enabled only for loopback and link-local addresses), which is a prudent value. The real fix for the 07-02 problem was not a sysctl at all: it was fixing the connection pool, which is what you did.
Socket buffers
$ sysctl net.core.rmem_max net.core.wmem_max net.ipv4.tcp_rmem net.ipv4.tcp_wmem
net.core.rmem_max = 212992
net.core.wmem_max = 212992
net.ipv4.tcp_rmem = 4096 131072 6291456
net.ipv4.tcp_wmem = 4096 16384 4194304The three values of tcp_rmem are minimum, default and maximum, and the kernel adjusts automatically within that range. Widening the maximum matters when the bandwidth × latency product is large: a 1 Gbit/s link with a 100 ms round trip needs about 12 MB of window to saturate, and with a 6 MB maximum it stops halfway.
On a local network with 0.4 ms of latency, like Tramontana's, this is completely irrelevant. It is a setting for long-distance transfers, and putting it in "just in case" only consumes memory.
BBR: the network setting that is worth it
Congestion control decides how fast TCP sends when it detects problems on the network. The classic algorithm, CUBIC, interprets packet loss as a congestion signal. BBR, developed by Google, instead models the path's real bandwidth and latency.
The practical difference is large on links with loss or with large buffers (the bufferbloat problem), which is the case for nearly any connection over the Internet:
$ sysctl net.ipv4.tcp_available_congestion_control net.ipv4.tcp_congestion_control
net.ipv4.tcp_available_congestion_control = reno cubic
net.ipv4.tcp_congestion_control = cubic
# Load the BBR module
$ sudo modprobe tcp_bbr
$ sysctl net.ipv4.tcp_available_congestion_control
net.ipv4.tcp_available_congestion_control = reno cubic bbr
# Measure BEFORE, with the client on the other side of the network
$ iperf3 -c 198.51.100.20 -t 20 -R | tail -3
[ 5] 0.00-20.00 sec 184 MBytes 77.2 Mbits/sec receiver
# Change (ephemerally) and measure AFTER
$ sudo sysctl -w net.ipv4.tcp_congestion_control=bbr
$ sudo sysctl -w net.core.default_qdisc=fq
$ iperf3 -c 198.51.100.20 -t 20 -R | tail -3
[ 5] 0.00-20.00 sec 441 MBytes 185 Mbits/sec receiverFrom 77 to 185 Mbit/s on the same link, and with lower latency under load. default_qdisc=fq is not optional: BBR needs fair queue queueing to work as designed, and without it the result is worse.
With the measurement done, the change is made persistent with its reason written down:
$ sudo tee -a /etc/sysctl.d/70-performance.conf >/dev/null <<'EOF'
# BBR congestion control. Measured with iperf3 against a remote client:
# 77 -> 185 Mbit/s downstream, and lower latency under load. Requires fq.
# Measurement: 2026-08-18. Revert to cubic if anomalies appear.
net.core.default_qdisc = fq
net.ipv4.tcp_congestion_control = bbr
EOF
$ echo 'tcp_bbr' | sudo tee /etc/modules-load.d/bbr.conf
$ sudo sysctl --system >/dev/null && sysctl net.ipv4.tcp_congestion_control
net.ipv4.tcp_congestion_control = bbrNotice the comment: what was measured, with what, the result, the date and how to revert it. That is the difference between a tuning decision and a copied line.
File and process limits
$ sysctl fs.file-max fs.file-nr kernel.pid_max fs.inotify.max_user_watches
fs.file-max = 9223372036854775807
fs.file-nr = 2848 0 9223372036854775807
kernel.pid_max = 4194304
fs.inotify.max_user_watches = 65536fs.file-max on modern kernels is practically unlimited, so raising it — another classic of the guides — does nothing. The real limit reached in practice is the per-process one, covered in the next section.
fs.inotify.max_user_watches does genuinely run out, and with a baffling error. Every inotify watch consumes an entry, and development tools, file synchronisers and logrotate use them heavily:
# The symptom: "No space left on device" with no shortage of disk space
$ df -h / | tail -1
/dev/mapper/vg-root 23G 7.1G 15G 33% /
# The real cause
$ find /proc/*/fd -lname anon_inode:inotify 2>/dev/null | wc -l
1284
$ sudo sysctl -w fs.inotify.max_user_watches=524288That ENOSPC which is not a shortage of disk is one of the most frequent confusions in Linux, and it deserves a place in the runbook.
The I/O scheduler
The scheduler decides in what order requests to the disk are served. Ubuntu 24.04 uses the multi-queue infrastructure (blk-mq):
$ cat /sys/block/sda/queue/scheduler
[none] mq-deadline kyber bfq
$ lsblk -d -o NAME,ROTA,SCHED
NAME ROTA SCHED
sda 1 none| Scheduler | How it works | For which device |
|---|---|---|
none |
No reordering; FIFO per queue | NVMe and fast SSDs |
mq-deadline |
Deadlines per request, avoids starvation | SATA SSDs and server mechanical disks |
kyber |
Adaptive latency targets | Mixed workloads with many queues |
bfq |
Fair sharing per process | Desktop; interactivity over throughput |
The none for NVMe rule has a specific reason behind it that you need to understand: a scheduler reorders requests to minimise the head movement of a mechanical disk. An NVMe has no head, serves tens of thousands of operations a second and has its own queues in hardware. Reordering only adds latency and CPU consumption. On a mechanical disk, by contrast, reordering can multiply the throughput.
# An ephemeral change, to test
$ echo mq-deadline | sudo tee /sys/block/sda/queue/scheduler
$ cat /sys/block/sda/queue/scheduler
none [mq-deadline] kyber bfqSince /sys is not persistent, the correct way to fix it is a udev rule that decides according to the device type:
$ sudo tee /etc/udev/rules.d/60-io-scheduler.rules >/dev/null <<'EOF'
# NVMe: no scheduler. The device has its own queues.
ACTION=="add|change", KERNEL=="nvme[0-9]*n[0-9]*", ATTR{queue/scheduler}="none"
# Rotational disks (ROTA=1): mq-deadline reorders and exploits locality.
ACTION=="add|change", KERNEL=="sd[a-z]", ATTR{queue/rotational}=="1", \
ATTR{queue/scheduler}="mq-deadline"
# SATA SSDs (ROTA=0): mq-deadline as well, for its deadlines.
ACTION=="add|change", KERNEL=="sd[a-z]", ATTR{queue/rotational}=="0", \
ATTR{queue/scheduler}="mq-deadline"
EOF
$ sudo udevadm control --reload && sudo udevadm trigger --subsystem-match=block
$ cat /sys/block/sda/queue/scheduler
none [mq-deadline] kyber bfqAnd two other queue parameters that sometimes matter:
read_ahead_kb is how much the kernel reads ahead. Raising it (to 512 or more) helps with large sequential reads — a backup, a database dump; lowering it helps with small random accesses, because it avoids reading data that will not be used. It is exactly the kind of trade-off to measure before touching.
An observation about Tramontana's stack: the backup volume is LUKS on LVM on sda, and each layer presents its own block device with its own queue. The scheduler that matters is the physical device's (sda); the dm-* devices pass requests downwards. It is the same stack you had to break apart in the first exercise of 07-02.
Transparent huge pages
The processor translates virtual addresses to physical ones in 4 KB pages, using a cache called the TLB. With a lot of memory, the TLB misses often and each miss costs extra memory accesses. Huge pages of 2 MB drastically reduce the number of entries needed.
THP (Transparent Huge Pages) does that automatically. And it is the parameter every database asks you to disable:
$ cat /sys/kernel/mm/transparent_hugepage/enabled
[always] madvise never
$ cat /sys/kernel/mm/transparent_hugepage/defrag
always defer [defer+madvise] madvise never| Value | Behaviour |
|---|---|
always |
THP for everything. The default on Ubuntu |
madvise |
Only for processes that ask for it with madvise(MADV_HUGEPAGE) |
never |
Disabled |
Why databases reject it, which is the interesting part: to hand out a 2 MB page, the kernel needs 2 MB that are physically contiguous. When memory is fragmented, it has to compact it, and that compaction happens synchronously in the context of the process asking for memory. The result is unpredictable pauses of tens or hundreds of milliseconds in exactly the process that tolerates them least. PostgreSQL, MongoDB, Redis, Oracle and Elasticsearch all document the recommendation of madvise or never.
madvise is the best compromise: the processes that benefit ask for it explicitly, and the rest do not pay the cost.
Since /sys does not persist, you fix it with a systemd unit — applying 05-05 — which is cleaner than an rc.local:
$ sudo tee /etc/systemd/system/thp-madvise.service >/dev/null <<'EOF'
[Unit]
Description=Set transparent hugepages to madvise (recommended by PostgreSQL)
Documentation=https://www.postgresql.org/docs/16/kernel-resources.html
DefaultDependencies=no
After=sysinit.target local-fs.target
Before=postgresql.service tramontana.service
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/bin/sh -c 'echo madvise > /sys/kernel/mm/transparent_hugepage/enabled'
[Install]
WantedBy=basic.target
EOF
$ sudo systemctl daemon-reload && sudo systemctl enable --now thp-madvise.service
$ cat /sys/kernel/mm/transparent_hugepage/enabled
always [madvise] neverThe Before=postgresql.service matters: the change has to be in place before the database allocates its shared memory.
Per-process limits and which one wins
A kernel limit is global; the ones reached in practice are the per-process ones, and here there are three mechanisms that tread on each other. Knowing which one wins saves hours of bewilderment.
| Mechanism | Where | Who it applies to |
|---|---|---|
ulimit |
A shell command | The current process and its children |
/etc/security/limits.conf |
PAM (pam_limits) |
Only sessions that go through PAM (login, SSH, su) |
LimitNOFILE= in the unit |
systemd | systemd services |
DefaultLimitNOFILE= |
/etc/systemd/system.conf |
Every service with no limit of its own |
And the answer to "which one wins", which is the reason for this section: for a systemd service, the unit wins, and limits.conf does not apply at all. systemd starts services directly, without going through PAM. Putting svc-tramontana hard nofile 16384 in limits.conf and expecting it to affect tramontana.service is a classic mistake that gives no warning whatsoever: it simply does not work.
# Check the REAL limit of a running service: the source of truth
$ pid=$(systemctl show tramontana.service -p MainPID --value)
$ grep -E 'Max open files|Max processes' /proc/$pid/limits
Max open files 1024 524288 files
Max processes 15370 15370 processesWith MemoryMax=512M and TasksMax=64 already set back in 05-05, the open file limit is adjusted the same way, by drop-in:
$ sudo tee /etc/systemd/system/tramontana.service.d/limits.conf >/dev/null <<'EOF'
[Service]
# 80 connections in the PostgreSQL pool + client sockets + logs + margin.
# Measured after the 07-02 adjustment: a peak of 214 descriptors.
LimitNOFILE=8192
EOF
$ sudo systemctl daemon-reload && sudo systemctl restart tramontana.service
$ pid=$(systemctl show tramontana.service -p MainPID --value)
$ grep 'Max open files' /proc/$pid/limits
Max open files 8192 8192 files
# And verify the real usage, to know whether 8192 is reasonable or excessive
$ ls /proc/$pid/fd | wc -l
214214 descriptors in use with a limit of 8192: plenty of margin without being absurd. That is the criterion — a limit is sized from measured usage, not from a round number somebody copied.
Frequency governors and tuned
$ cat /sys/devices/system/cpu/cpu0/cpufreq/scaling_governor 2>/dev/null \
|| echo "no cpufreq (usual on a VM)"
no cpufreq (usual on a VM)On a virtual machine the frequency management is done by the host, so this section does not apply to srv-tramontana. On physical hardware:
| Governor | Behaviour | When |
|---|---|---|
powersave |
Minimum frequency, rising with demand | The default; with intel_pstate it is reasonable |
performance |
Maximum frequency at all times | Latency-sensitive servers |
schedutil |
Guided by the scheduler | The modern one; a good balance |
The real use case for performance is tail latency: raising the frequency takes microseconds, and in a service with strict latency targets those microseconds show up in the 99th percentile. At the price of considerably more electricity.
tuned is the sensible way to apply coherent sets of settings rather than isolated parameters:
$ sudo apt install tuned
$ sudo tuned-adm list
Available profiles:
- balanced - General non-specialized tuned profile
- latency-performance - Optimize for deterministic performance
- network-latency - Optimize for deterministic performance, low latency
- network-throughput - Optimize for streaming network throughput
- throughput-performance - Broadly applicable tuning for throughput
- virtual-guest - Optimize for running inside a virtual guest
$ sudo tuned-adm active
Current active profile: virtual-guest
# See EXACTLY what a profile changes before applying it
$ cat /usr/lib/tuned/throughput-performance/tuned.conf | grep -A12 '\[sysctl\]'That last command is the important piece of advice: tuned is useful because it applies tested, consistent sets of settings, but reading what a profile does before activating it is compulsory. A profile can change twenty parameters, and if one of them conflicts with your sysctl.d, the outcome depends on the order of application and is hard to debug.
Kernel modules
A module is a piece of kernel that is loaded and unloaded on the fly: drivers, filesystems, protocols.
$ lsmod | head -5
Module Size Used by
tcp_bbr 24576 20
dm_crypt 65536 1
raid1 49152 0
vboxguest 57344 2
$ modinfo tcp_bbr | head -5
filename: /lib/modules/6.8.0-41-generic/kernel/net/ipv4/tcp_bbr.ko.zst
license: Dual BSD/GPL
description: TCP BBR (Bottleneck Bandwidth and RTT)
depends:
intree: Y| Operation | Command |
|---|---|
| List the loaded ones | lsmod |
| Information | modinfo <module> |
| Load (with dependencies) | sudo modprobe <module> |
| Unload | sudo modprobe -r <module> |
| See the current parameters | systool -v -m <module> |
The persistent configuration:
# Load a module at boot (you used this for BBR)
$ echo 'tcp_bbr' | sudo tee /etc/modules-load.d/bbr.conf
# Pass parameters to a module
$ sudo tee /etc/modprobe.d/tramontana.conf >/dev/null <<'EOF'
# Reduces the memory usage of the encryption module on a small VM
options dm_crypt max_read_size=131072
EOF
# Stop a module being loaded: attack surface reduction (06-06)
$ sudo tee /etc/modprobe.d/blacklist-tramontana.conf >/dev/null <<'EOF'
# Filesystems and protocols this server never uses.
# Every line reduces attack surface: these are modules with historical CVEs.
install cramfs /bin/true
install freevxfs /bin/true
install jffs2 /bin/true
install hfs /bin/true
install hfsplus /bin/true
install udf /bin/true
install dccp /bin/true
install sctp /bin/true
install rds /bin/true
install tipc /bin/true
EOF
$ sudo update-initramfs -u -k allTwo important details. The first: install <module> /bin/true is more effective than blacklist <module>, because blacklist only prevents automatic loading and does not stop another module loading it as a dependency. The second: after touching modprobe.d, you have to regenerate the initramfs, for the reason you learned in 07-01 — the initramfs carries its own copy of that configuration.
This list of blocked modules is in fact a CIS Benchmarks recommendation that was left outstanding on the 06-06 checklist. It is a good moment to close it.
DKMS (Dynamic Kernel Module Support) solves the problem of third-party modules: a module compiled for kernel 6.8.0-41 does not work on 6.8.0-45. DKMS recompiles it automatically at every kernel update:
$ dkms status
virtualbox-guest/7.0.16, 6.8.0-41-generic, x86_64: installed
virtualbox-guest/7.0.16, 6.8.0-39-generic, x86_64: installedWith Secure Boot active, a module recompiled by DKMS needs a signature, and that is where the mmx64.efi (MokManager) you saw in 07-01 comes in. It is the usual cause of a third-party driver ceasing to work after a kernel update on a machine with Secure Boot.
Compiling the kernel: when it makes sense and when it does not
Compiling the kernel is a rite of passage in learning Linux, and it is worth being honest about its usefulness on a production server: hardly ever.
| The reason claimed | Is it worth it? |
|---|---|
| "A smaller, faster kernel" | No. Modules that are not loaded consume nothing. The gain is undetectable |
| "I need an option that is not enabled" | Sometimes. First check whether an Ubuntu package already ships it |
| "I need a patch that is in no release" | Yes. This is the legitimate case |
| "I need a very recent version for a driver" | No. Use the HWE kernel or linux-generic-hwe-24.04 |
| "To learn" | Yes, and in the lab, not in production |
And the cost that gets underestimated: a hand-compiled kernel falls outside the package system. It receives no automatic security updates — goodbye to the unattended-upgrades of 05-03 — it is not signed for Secure Boot, and every kernel CVE demands recompiling by hand. On a machine handling personal data, that is a compliance problem, not just an inconvenience.
The packaged variants cover almost everything:
| Package | What for |
|---|---|
linux-image-generic |
General use. It is what you have |
linux-image-virtual |
Virtual machines: no physical hardware drivers, smaller |
linux-image-generic-hwe-24.04 |
A more recent kernel on the LTS, for new hardware |
linux-image-lowlatency |
Lower scheduling latency, lower throughput |
The procedure, for the lab:
$ sudo apt install build-essential libncurses-dev bison flex libssl-dev \
libelf-dev dwarves zstd
$ apt source linux-image-unsigned-$(uname -r)
$ cd linux-6.8.0
# Start from the CURRENT configuration, never from scratch
$ cp /boot/config-$(uname -r) .config
$ make olddefconfig # adapts the old config to the new options
$ make menuconfig # only the change you need
$ make -j"$(nproc)" 2>&1 | tail -3
$ sudo make modules_install
$ sudo make install # copies vmlinuz and runs update-initramfs
$ sudo update-grub
# And the safety net from 07-01: the previous kernel is still in the menu
$ awk -F"'" '/menuentry .Ubuntu, with Linux/ {print $2}' /boot/grub/grub.cfgmake olddefconfig starting from /boot/config-$(uname -r) is what avoids the beginner's mistake: make defconfig generates a generic configuration that probably does not include the driver for your disk controller, and the result is the (initramfs) prompt from 07-01.
The Tramontana case: tuning the network stack with measurement
The complete exercise, applying the five-step procedure. Starting point: the 05-07 baseline, the incident of the db_timeouts with active_connections=200, and the connection pool adjustment from 07-02.
Step 1: measure the current state
$ cat ~/scripts/kernel_baseline.sh
#!/usr/bin/env bash
# kernel_baseline.sh - Records the kernel parameters and counters relevant
# to performance, so that before and after can be compared.
# Usage: kernel_baseline.sh [label]
set -euo pipefail
readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=lib/common.sh
source "${SCRIPT_DIR}/lib/common.sh"
readonly LOG_TAG="kernel-baseline"
readonly DESTINATION="${TRAMONTANA_BASE_DIR:-/home/operator/data/baselines}"
main() {
local label="${1:-manual}"
local stamp; stamp="$(date +%Y%m%d-%H%M%S)"
local file="${DESTINATION}/kernel-${label}-${stamp}.txt"
install -d -m 750 "$DESTINATION"
{
printf '# Kernel baseline — %s — %s\n\n' "$label" "$(date -Is)"
printf '## Parameters\n'
sysctl vm.swappiness vm.dirty_ratio vm.dirty_background_ratio \
net.core.somaxconn net.ipv4.tcp_max_syn_backlog \
net.ipv4.tcp_congestion_control net.core.default_qdisc \
net.ipv4.ip_local_port_range 2>/dev/null
printf '\n## I/O scheduler\n'
for d in /sys/block/sd*/queue/scheduler; do
printf '%s: %s\n' "$d" "$(cat "$d")"
done
printf '\n## Network counters (the ones that matter)\n'
nstat -az 2>/dev/null | grep -iE 'ListenOverflows|ListenDrops|TCPSynRetrans|RetransSegs'
printf '\n## Sockets\n'
ss -s
printf '\n## Listen queues\n'
ss -ltn
printf '\n## Swap\n'
grep -E 'pswpin|pswpout' /proc/vmstat
printf '\n## Application latency (5 samples)\n'
for _ in {1..5}; do
curl -s -o /dev/null -w '%{time_total}\n' \
"http://127.0.0.1:${TRAMONTANA_PORT:-8080}/houses" || true
done
} >"$file"
chmod 640 "$file"
log "baseline written to $file"
printf '%s\n' "$file"
}
main "$@"$ chmod +x ~/scripts/kernel_baseline.sh
$ shellcheck ~/scripts/kernel_baseline.sh && ~/scripts/kernel_baseline.sh before
[2026-08-18 17:12:04] kernel-baseline: baseline written to /home/operator/data/baselines/kernel-before-20260818-171204.txtStep 2: the hypotheses, and the one that gets refuted
Three reasonable hypotheses drawn from the history, and what the measurement says about each:
| Hypothesis | Measurement | Verdict |
|---|---|---|
| The connection queues are overflowing | ListenOverflows = 0, Send-Q = 128 (the app's backlog) |
Refuted. somaxconn plays no part |
| There is memory pressure and swapping | pswpin = 0, pswpout = 0, 2.4 GB available |
Refuted today; set as insurance |
| Congestion control is limiting remote traffic | cubic, and 77 Mbit/s measured with iperf3 |
Confirmed. BBR gives 185 Mbit/s |
Two out of three refuted by measurement, and that is the normal result. If you had copied a guide on "network optimisation for Linux", you would have set somaxconn = 65535 — which does nothing here — and probably tcp_tw_recycle, which no longer exists.
Steps 3 and 4: one change, measure
# The only change confirmed by measurement, ephemerally first
$ sudo modprobe tcp_bbr
$ sudo sysctl -w net.core.default_qdisc=fq
$ sudo sysctl -w net.ipv4.tcp_congestion_control=bbr
$ iperf3 -c 198.51.100.20 -t 20 -R | tail -2
[ 5] 0.00-20.00 sec 441 MBytes 185 Mbits/sec receiver
# And verify that nothing else has got worse
$ ~/scripts/health_check.sh; echo "status: $?"
status: 0
$ ~/scripts/kernel_baseline.sh after
$ diff -u ~/data/baselines/kernel-before-*.txt \
~/data/baselines/kernel-after-*.txt | head -20Step 5: document the final file
$ sudo tee /etc/sysctl.d/70-performance.conf >/dev/null <<'EOF'
# Performance settings for srv-tramontana.
# RULE: every parameter carries its reason, its measurement and its date.
# The SECURITY parameters are in 60-hardening.conf, deliberately kept
# apart: these can be reverted, those cannot.
# --- Network -----------------------------------------------------------
# BBR models bandwidth and latency instead of reacting to loss.
# Measured with iperf3 against 198.51.100.20 on 2026-08-18:
# cubic: 77 Mbit/s -> bbr: 185 Mbit/s, and lower latency under load.
# Requires the fq queueing discipline: without it, BBR performs WORSE than cubic.
# Revert: net.ipv4.tcp_congestion_control = cubic
net.core.default_qdisc = fq
net.ipv4.tcp_congestion_control = bbr
# --- Virtual memory ----------------------------------------------------
# NOT an average performance improvement: there is no swap activity today
# (pswpin=0, pswpout=0). It is INSURANCE: if memory pressure ever appears,
# the kernel will prefer to discard cache rather than send the process's
# active pages to swap, avoiding hundreds of ms of latency.
vm.swappiness = 10
# Dirty page thresholds lower than the 10/20 defaults.
# Reason: with 3.8 GB, 20% is ~760 MB that can be flushed all at once and
# block the writing process. It is the mechanism behind the incident of
# 2026-08-18 (the nightly backup saturating I/O, w_await 22.85 ms).
# Effect: lower peak throughput, more uniform latency. Trade-off accepted.
vm.dirty_background_ratio = 5
vm.dirty_ratio = 10
# --- Files -------------------------------------------------------------
# This genuinely runs out and the error is misleading ("No space left on
# device" with no shortage of disk). Measured: 1284 watches, limit 65536.
fs.inotify.max_user_watches = 524288
# WE DO NOT TOUCH THESE, and here is why:
# net.core.somaxconn: the app asks for backlog=128, so somaxconn plays
# no part (ss -ltn: Send-Q=128). ListenOverflows=0. No effect.
# net.core.rmem_max/wmem_max: a local network with 0.4 ms RTT. The
# bandwidth x latency product does not justify larger buffers.
# net.ipv4.tcp_tw_recycle: DOES NOT EXIST. Removed from the kernel in 4.12.
EOF
$ sudo sysctl --system >/dev/null
$ sysctl net.ipv4.tcp_congestion_control vm.swappiness
net.ipv4.tcp_congestion_control = bbr
vm.swappiness = 10The final section — "WE DO NOT TOUCH THESE, and here is why" — is the part of the file with the most long-term value. It documents the refuted hypotheses, and that stops somebody in six months' time — you included — adding somaxconn = 65535 because they saw it in a guide. A setting discarded with its reason written down is knowledge; a setting that is simply absent is only an omission.
Common Mistakes and Tips
- Copying
sysctllists off the Internet. Most are ten years old, many contradict defaults that are already correct, and some recommend parameters that no longer exist (tcp_tw_recycleis the unmistakable sign of an untested guide). - Changing several things at once. If it improves, you do not know which; if it gets worse, you do not either. One change, one measurement.
- Putting a parameter in
/etc/sysctl.d/without testing it with-w. The ephemeral change is reversible with a reboot; the persistent one can leave you with a system that boots badly. - Believing
swappinessreduces memory usage. It only acts when there is pressure and the system resorts to swap. With no swap in use, it does nothing. - Raising
somaxconnwithout looking at the application'sbacklog. The effective limit is the minimum of the two.ss -ltnshows the real one inSend-Q. - Trusting
limits.conffor a systemd service. It does not apply: systemd does not go through PAM. You useLimitNOFILE=in the unit, and verify in/proc/<pid>/limits. - Leaving THP at
alwayswith a database. It causes unpredictable synchronous compaction pauses.madviseis the right compromise, and it has to be applied before the database starts. - Setting
mq-deadlineon an NVMe. Reordering requests makes no sense with no head to move: it only adds latency.nonefor NVMe. - Forgetting
update-initramfsafter touching/etc/modprobe.d/. The initramfs carries its own copy. It is the 07-01 lesson. - Compiling the kernel in production. It falls outside the package system: no automatic security updates, no signature for Secure Boot, and every CVE handled by hand. Try
linux-image-virtualor the HWE kernel first. - Tuning with no baseline. With no recorded "before", the "after" means nothing, and the feeling of improvement is notoriously unreliable.
- A tip on method. Each parameter's comment should answer four questions: what you measured, with which command, what the result was, and how to revert it. And document what you decided not to touch as well: that is what stops the folklore getting back in.
Exercises
Exercise 1
Luis sends you this list "to optimise the server", taken from a blog:
net.core.somaxconn = 65535
net.ipv4.tcp_tw_recycle = 1
net.ipv4.tcp_max_syn_backlog = 65535
fs.file-max = 2097152
vm.swappiness = 0
net.core.rmem_max = 134217728
net.core.wmem_max = 134217728Evaluate each line: what it would do, whether it makes sense on srv-tramontana, and with which command you would check it. Write the reply you would send him.
Exercise 2
After the 07-02 adjustment, the application keeps a pool of 80 connections to PostgreSQL. Check whether the file descriptor limits are adequate at the three levels involved (the application's service, PostgreSQL's, and the global one), and adjust whatever is needed, with its measurement.
Exercise 3
/srv/tramontana/backups is LUKS on LVM on sda, and you want to know whether the I/O scheduler affects the duration of the nightly backup. Design the experiment: what you would measure, how you would isolate the variable, and why the result might not be conclusive.
Solutions
Solution 1
Re: kernel optimisation list
Thanks, Luis. I have gone through it line by line against the server's real state. Summary: one makes sense, one is counterproductive, one does not exist, and four would do nothing. Here is the detail with the commands, so you can check it yourself.
1.
net.core.somaxconn = 65535— No effect
somaxconnis a ceiling. The real queue is the minimum of this value and thebacklogthe application asks for inlisten().$ ss -ltn | grep 8080 LISTEN 0 128 127.0.0.1:8080 0.0.0.0:* $ nstat -az | grep ListenOverflows TcpExtListenOverflows 0 0.0
Send-Q = 128: the application asks for 128, so the current value (4096) is already irrelevant, and 65535 would be just as much so. AndListenOverflows = 0means no connection has ever been dropped for a full queue. For this parameter to do anything we would have to change thebacklogin the application's code, and there is no evidence at all that it is needed.2.
net.ipv4.tcp_tw_recycle = 1— It does not exist$ sysctl net.ipv4.tcp_tw_recycle sysctl: cannot stat /proc/sys/net/ipv4/tcp_tw_recycle: No such file or directoryIt was removed from the kernel in version 4.12 (2017) because it broke connections from NATed networks intermittently and in a way that was very hard to diagnose. Its appearing in the guide tells us something useful: the author has not tested it this decade, so the rest is worth being sceptical about. If the aim was recycling
TIME_WAIT, the current parameter istcp_tw_reuse, and it is already at2, which is the prudent default on Ubuntu 24.04.3.
net.ipv4.tcp_max_syn_backlog = 65535— No measurable effect$ nstat -az | grep -iE 'TCPReqQFullDrop|ListenDrops' TcpExtListenDrops 0 0.0This is the queue of half-open connections, and its real use is surviving a SYN flood. Zero drops with the current value of 1024. Besides, against that attack we already have
tcp_syncookiesenabled since 06-03, which is the correct defence. Raising it would reserve kernel memory for no benefit.4.
fs.file-max = 2097152— No effect (and it would be a reduction)$ sysctl fs.file-max fs.file-max = 9223372036854775807 $ sysctl fs.file-nr fs.file-nr 2848 0 9223372036854775807On modern kernels it is already practically unlimited: setting 2,097,152 would be lowering it. And we use 2,848. The limit that is actually reached in practice is the per-process one, which on a systemd service is set with
LimitNOFILE=in the unit —limits.confdoes not apply to services, a detail that confuses a lot of people.5.
vm.swappiness = 0— CounterproductiveThis is the one that worries me.
swappiness = 0does not mean "do not use swap", it means "use swap only to avoid the OOM killer". The real effect is that the kernel exhausts the cache completely before considering swap, and in a memory spike it can invoke the OOM killer prematurely — killing the application's process or PostgreSQL's.$ grep -E 'pswpin|pswpout' /proc/vmstat pswpin 0 pswpout 0 $ free -h | awk 'NR==2 {print "available:", $7}' available: 2.4GiToday it makes no difference because there is no swap activity, but the day there is,
0is worse than the default. I have set10, which prefers discarding cache without giving up swap as a safety net, and I have documented it as insurance against degradation, not as a performance improvement.6 and 7.
rmem_max/wmem_max= 128 MB — No effect, and it consumes memoryLarge buffers matter when the bandwidth × latency product is large.
$ ping -c3 10.0.2.15 | tail -1 rtt min/avg/max/mdev = 0.312/0.398/0.482/0.071 msWith a 0.4 ms round trip on a local network, the window needed to saturate 1 Gbit/s is about 50 KB. The current maximum (208 KB of
rmem_max, and up to 6 MB intcp_rmem) is ample. Reserving 128 MB per socket on a machine with 3.8 GB of RAM is a real risk if many connections are opened.
What I have done, and why
Following the same criterion — measure first — the only network change that turned out to be justified is BBR congestion control, which was not on your list:
# cubic (the default) $ iperf3 -c 198.51.100.20 -t 20 -R | tail -2 [ 5] 0.00-20.00 sec 184 MBytes 77.2 Mbits/sec receiver # bbr + fq $ iperf3 -c 198.51.100.20 -t 20 -R | tail -2 [ 5] 0.00-20.00 sec 441 MBytes 185 Mbits/sec receiverFrom 77 to 185 Mbit/s towards remote clients. That is a setting with a measured effect, and it is in
/etc/sysctl.d/70-performance.confwith the measurement, the date and the way to revert it.What I propose for next time. Before applying a parameter, three questions: which counter tells me this resource is the bottleneck? have I tested it with
sysctl -wand measured before and after? is the reason written down? If any answer is no, the parameter does not go in. I have added a "WE DO NOT TOUCH THESE, and here is why" section to the file with these seven lines and their reasoning, precisely so that we do not have to discuss it again in six months.
Solution 2
The three levels, measured from the inside out.
# --- Level 1: the application's service ---
$ pid_app=$(systemctl show tramontana.service -p MainPID --value)
$ grep 'Max open files' /proc/$pid_app/limits
Max open files 8192 8192 files
$ ls /proc/$pid_app/fd | wc -l
214Real usage 214 against a limit of 8192: comfortable and correct. The breakdown confirms the number makes sense:
$ ls -l /proc/$pid_app/fd | awk '{print $NF}' | sed 's/[0-9]*$//' \
| sort | uniq -c | sort -rn | head -5
80 socket:[
94 /opt/tramontana/releases/3.2.1/templates/
14 /var/log/tramontana/
3 pipe:[
2 /dev/null80 sockets = the PostgreSQL connection pool, exactly the max_connections=80 value set in 07-02. The descriptors are where they should be.
# --- Level 2: PostgreSQL. Here is the problem. ---
$ pid_pg=$(systemctl show [email protected] -p MainPID --value)
$ grep 'Max open files' /proc/$pid_pg/limits
Max open files 1024 524288 files
$ sudo -u postgres psql -tAc "SHOW max_connections;"
100
$ sudo -u postgres psql -tAc "SHOW max_files_per_process;"
1000The soft limit is 1024, and max_files_per_process is 1000. And here is the calculation that reveals the risk: each PostgreSQL backend process opens descriptors for the table and index files it touches. With 100 possible connections and up to 1000 files per process, the main process's limit of 1024 is tight.
# The direct check: real aggregate usage
$ sudo ls /proc/$pid_pg/fd | wc -l
88
$ for p in $(pgrep -P $pid_pg); do sudo ls /proc/$p/fd 2>/dev/null | wc -l; done \
| awk '{s+=$1} END {print "descriptors in the backends:", s}'
descriptors in the backends: 412It is not being reached today, but the margin is slim and the failure, when it comes, shows up as intermittent connection errors under load — hard to diagnose.
# Fix it by drop-in, never by editing the package's unit
$ sudo mkdir -p /etc/systemd/system/[email protected]
$ sudo tee /etc/systemd/system/[email protected]/limits.conf >/dev/null <<'EOF'
[Service]
# max_connections=100 x max_files_per_process=1000 in the worst case.
# 65536 gives ample margin without being absurd. Measured on 2026-08-18:
# main process 88 fd, backends 412 fd in aggregate.
LimitNOFILE=65536
EOF
$ sudo systemctl daemon-reload && sudo systemctl restart [email protected]
$ pid_pg=$(systemctl show [email protected] -p MainPID --value)
$ grep 'Max open files' /proc/$pid_pg/limits
Max open files 65536 65536 files# --- Level 3: the global limit ---
$ sysctl fs.file-nr fs.file-max
fs.file-nr 2848 0 9223372036854775807
fs.file-max = 92233720368547758072,848 descriptors across the whole system against a practically infinite limit: there is nothing to adjust, and this confirms what Luis was told in the previous exercise about fs.file-max.
# And the default value for services that declare no limit of their own
$ grep -i defaultlimitnofile /etc/systemd/system.conf
#DefaultLimitNOFILE=1024:524288There is a judgement call here: the global default could be raised, but it is preferable not to. A high default limit hides descriptor leaks: a service that does not close what it opens fails early and visibly with a reasonable limit, and silently consumes kernel memory for months with a high one. Better explicit limits, sized per service.
The final verification:
$ ~/scripts/health_check.sh; echo "status: $?"
status: 0
$ sudo -u postgres psql -tAc "SELECT count(*) FROM pg_stat_activity;"
82
$ sudo timeout 10 strace -f -c -p $pid_app 2>&1 | grep -cE 'EMFILE|ENFILE'
0Zero EMFILE/ENFILE errors, which are the ones that would appear if descriptors ran out. And the lesson on method: the problem was in PostgreSQL, not in the application, even though the earlier symptom manifested in the application. Just as in 07-02, the cause was on the other side of the client-server relationship. It is worth adding the rule to the runbook: when adjusting a limit on one side, check the one on the other.
Solution 3
What I would measure, and why those metrics. The backup's total duration is the variable of interest, but it is far too coarse to attribute to the scheduler. You have to measure at three levels:
# 1. Total duration (what matters to Marta)
$ systemd-analyze --no-pager verify tramontana-backup.service
$ sudo systemctl show tramontana-backup.service \
-p ExecMainStartTimestamp -p ExecMainExitTimestamp
# 2. Latency by DEVICE. -D separates the layers of the stack, which is
# the reason for using biolatency instead of iostat's average.
$ sudo biolatency-bpfcc -D 300 1 > /tmp/lat-scheduler.txt
# 3. Where the process blocks: encryption (CPU) or I/O (waiting)
$ sudo timeout 60 offcputime-bpfcc -p $(pgrep -f 'restic backup') -f \
| sort -k2 -rn | head -5
$ sudo perf stat -p $(pgrep -f 'restic backup') -- sleep 30 2>&1 \
| grep -E 'CPUs utilized'How I would isolate the variable. This is the core of the exercise, and there are four sources of contamination to neutralise:
# a) Identical data in both runs. An incremental restic backup depends on
# what has changed: two different nights are NOT comparable.
# Solution: start from the same snapshot in both tests.
$ sudo restic -r "$REPO" snapshots --latest 1
$ sudo lvcreate -L 4G -s -n test-io /dev/vg-data/lv-backups
# b) An identical page cache state. A second run is faster purely because
# the data is already cached.
$ sync && echo 3 | sudo tee /proc/sys/vm/drop_caches
# c) No concurrent load. The real backup runs at 02:30 with ionice;
# for the experiment you have to remove both the load and the ionice,
# because ionice interacts with the scheduler and would confuse the result.
$ sudo systemctl stop tramontana.service # on the test VM only
$ sudo ss -tn state established | wc -l
# d) Several repetitions, not one. I/O variability on a VM is high.
$ for scheduler in none mq-deadline bfq; do
for repetition in 1 2 3; do
echo "$scheduler" | sudo tee /sys/block/sda/queue/scheduler >/dev/null
sync && echo 3 | sudo tee /proc/sys/vm/drop_caches >/dev/null
start=$(date +%s)
sudo restic -r /srv/tramontana/backups/restic backup \
--quiet /opt/tramontana/releases/3.2.1
printf '%s\t%s\t%s\n' "$scheduler" "$repetition" "$(( $(date +%s) - start ))"
done
done | tee /tmp/scheduler-experiment.tsvAnd an important design decision: the experiment is done on srv-tramontana-test, not in production. It is the VM you will create in the next lesson, and this is exactly the use case that justifies it: changing the I/O scheduler live on the real server, with drop_caches and without ionice, is asking for a service degradation.
Why the result might not be conclusive. Five reasons, and they are what makes this a good exercise:
-
The scheduler that matters is the physical device's, not the stack's.
/srv/tramontana/backupsisdm-1(LUKS) onlv-backups(LVM) onsda. Thedm-*devices have no scheduler of their own: they pass requests downwards. You check it:$ cat /sys/block/dm-1/queue/scheduler noneA fixed
none, with no alternatives in square brackets, indicates that device does no scheduling. So the experiment only makes sense onsda. -
We are on a virtual machine. The guest's scheduler operates on a disk that is really a file on the host, which has its own scheduler and its own cache. The guest's decisions can be completely overridden by the host. This is what will probably make the result inconclusive, and it is the reason
virtual-guestis the activetunedprofile. -
The bottleneck could be the encryption, not the I/O. If
perf statgivesCPUs utilizedclose to 1.00 andoffcputimepoints at AES functions, the process is CPU-bound and the I/O scheduler is irrelevant by definition. This check has to be done before the experiment: if the bottleneck is the encryption, the experiment is moot. -
The backup is mostly sequential writing, and there the differences between schedulers are small. Where they really diverge is on a mixture of random reads and writes with several processes competing.
-
resticdeduplicates and compresses, so the relationship between data read and blocks written is not fixed. Even with identical source data, the I/O load can vary between runs.
The design's conclusion. The experiment should be done, but with the right expectation: the most likely hypothesis is that the scheduler has no measurable influence on this machine, and step 3 — measuring whether the bottleneck is CPU or I/O — will probably demonstrate it before you even start. That is not a failure: it is refuting a hypothesis for 30 minutes' work, and it avoids adding a udev rule that does nothing.
And if step 3 shows the bottleneck is the encryption, the correct line of investigation is a different one: check whether the CPU exposes AES-NI to the guest (grep -m1 aes /proc/cpuinfo) and, if not, enable host-passthrough in the VM's definition. That can make encryption five or ten times faster, and it is a virtualisation change, not a kernel one. Precisely the territory of the next lesson.
Conclusion
You know how to tune the kernel, and — more importantly — you know when not to tune it. You have the map of the parameters that genuinely matter: virtual memory with swappiness and the dirty page thresholds, the network stack with its two connection queues and congestion control, the file limits with the limits.conf trap that does not apply to systemd services, the I/O scheduler with the none-for-NVMe rule and the reason behind it, and the transparent huge pages that every database asks to be set to madvise. You have seen modules, modprobe.d with install ... /bin/true — closing an outstanding row on the 06-06 checklist along the way — DKMS, and an honest answer on compiling the kernel: hardly ever in production, because it takes the machine out of the package system and with it out of security updates.
But what you take away from this lesson is not a list of parameters. It is the procedure: measure, formulate a hypothesis, change one thing with sysctl -w, measure again, and document with the measurement and the date or revert. Applying it, of three reasonable hypotheses about srv-tramontana's network two were refuted — somaxconn plays no part because the application asks for backlog=128, and swappiness does nothing with no swap activity — and only one, BBR, turned out to have a measurable effect: from 77 to 185 Mbit/s. That ratio is the normal one, and the "WE DO NOT TOUCH THESE, and here is why" section of your 70-performance.conf is what will stop the folklore getting back in six months from now.
Notice where the last exercise has left you. To test the I/O scheduler without degrading the service you needed a machine where you could drop the cache, remove the ionice and stop the application without anybody noticing. To measure whether LUKS encryption runs in software you needed to change the VM's CPU configuration. And the 3.3.0 deployment that would not start and forced a rollback in Module 4 was never diagnosed, because there was nowhere to reproduce it. Everything points the same way: a test environment is missing, and the course has been feeling its absence for four modules.
In lesson 07-04: Virtualization with Linux you build one. You will understand what virtualising really is — the three models, the exact role of KVM as the module that turns Linux into a hypervisor, and its relationship with QEMU and libvirt — you will manage machines with virsh and virt-install, provision them unattended with cloud-init, choose between raw and qcow2 knowing why, take snapshots while remembering that a snapshot is not a backup, and connect the network by NAT or by bridge as appropriate — including the explanation of that virbr0 with no carrier that cost 35 seconds of boot time in 07-01. At the end you will have srv-tramontana-test, cloned from the real server: the place to test this lesson's kernel changes, reproduce the deployment that failed, and rehearse the boot recovery of 07-01 without risking anything. And along the way you will see the virtualisation stack from the inside, which is what makes the following lesson — containers — comprehensible by contrast rather than by analogy.
Linux Course: From Beginner to System Administrator
Module 1: Introduction to Linux
- What Is Linux?
- History of Linux
- Linux Distributions
- Installing Linux
- First Contact with the System
- The Linux File System Structure
Module 2: Basic Linux Commands
- Introduction to the Command Line
- Getting Help and System Documentation
- Navigating the File System
- File and Directory Operations
- Viewing and Editing Files
- Hard and Symbolic Links
- File Permissions and Ownership
Module 3: Advanced Command-Line Skills
- The Shell Environment: Variables, Aliases and History
- Using Wildcards and Regular Expressions
- Searching Files and Content: find, locate and grep
- Pipes and Redirection
- Text Processing: cut, sort, uniq, sed and awk
- Process Management
- Scheduling Tasks with Cron
- Networking Commands
Module 4: Shell Scripting
- Introduction to Shell Scripting
- Variables and Data Types
- Script Input, Output and Arguments
- Control Structures
- Functions and Libraries
- Debugging and Error Handling
- Production Scripts: Best Practices
Module 5: System Administration
- User and Group Management
- sudo and Special Permissions
- Package Management
- Disk Management
- systemd and Service Management
- System Logs: journald and syslog
- System Monitoring and Performance Tuning
- Backup and Restore
Module 6: Networking and Security
- Network Configuration
- SSH and Remote Access
- Firewalls and Perimeter Security
- Intrusion Detection Systems
- Secrets Management and TLS Certificates
- Securing Linux Systems
Module 7: Advanced Topics
- The Boot Process and System Recovery
- Advanced Diagnostics: strace, perf and eBPF
- Linux Kernel Tuning
- Virtualization with Linux
- Linux Containers and Docker
- Automation with Ansible
- High Availability and Load Balancing
