Optimizing kernel parameters with sysctl on CentOS Stream 9: TCP/IP stack, virtual memory, and file descriptors for production

CentOS tutorial - IT technology blog
CentOS tutorial - IT technology blog

Get it done in 5 minutes: A baseline optimized configuration

If you have a CentOS Stream 9 server running and want to improve performance right now, run the following commands:

# Check the current value of any parameter
sysctl net.core.somaxconn

# Apply immediately (no reboot needed, but changes are lost after restart)
sudo sysctl -w net.core.somaxconn=65535
sudo sysctl -w net.ipv4.tcp_fin_timeout=30
sudo sysctl -w vm.swappiness=10

# Verify the changes were applied
sysctl net.core.somaxconn vm.swappiness net.ipv4.tcp_fin_timeout

Note: sysctl -w only applies to the current session. To make changes permanent, you need to create a config file — covered in the next section.

Why tune sysctl on a production server?

sysctl is a tool for reading and writing kernel parameters directly through the virtual filesystem at /proc/sys/. Each parameter maps to a specific behavior — net.core.somaxconn determines how many TCP connections can be queued waiting for accept, while vm.swappiness controls how aggressively the kernel uses swap.

The problem is that CentOS Stream 9 ships with default values designed for general-purpose servers, not production workloads. Running Nginx at 1,000 req/s with somaxconn=128? Connections start dropping the moment there’s even a minor traffic spike.

When CentOS 8 hit EOL, I rushed to migrate 5 servers to Rocky Linux in a week. That’s when it hit me: every single one was running with TCP defaults from over a decade ago. After applying the right set of parameters, average latency dropped noticeably — especially for short-lived connections in a microservice environment.

Three parameter groups have the biggest impact on a production server:

  • net.* — TCP/IP stack, connection queues, port range
  • vm.* — Virtual memory, swap behavior, dirty pages
  • fs.* — File descriptors, inotify limits

A detailed breakdown of each parameter group

TCP/IP Stack: Tuning for web servers and reverse proxies

Create a dedicated config file in /etc/sysctl.d/ — don’t edit /etc/sysctl.conf directly, as it can get overwritten and is harder to track:

sudo nano /etc/sysctl.d/99-production.conf

Paste the following into the file:

# --- TCP/IP Stack ---
# Increase the socket backlog queue (default is 128 — far too low for a web server)
net.core.somaxconn = 65535
net.core.netdev_max_backlog = 65535

# Allow reuse of sockets in TIME_WAIT state (reduces port exhaustion)
net.ipv4.tcp_tw_reuse = 1

# Shorten the connection close wait time (default is 60s)
net.ipv4.tcp_fin_timeout = 30

# Expand the port range for outbound connections (important for reverse proxies)
net.ipv4.ip_local_port_range = 1024 65535

# Socket buffer sizes — improves throughput under high latency
net.core.rmem_max = 16777216
net.core.wmem_max = 16777216
net.ipv4.tcp_rmem = 4096 87380 16777216
net.ipv4.tcp_wmem = 4096 65536 16777216

# SYN backlog — basic SYN flood protection
net.ipv4.tcp_max_syn_backlog = 65535
net.ipv4.tcp_syncookies = 1

somaxconn = 65535: The maximum number of connections queued waiting for accept(). At the default value of 128, a web server handling 500 req/s will start dropping connections at the slightest spike.

tcp_tw_reuse = 1: Allows the kernel to reuse sockets in TIME_WAIT state for new connections. For microservices that create thousands of short-lived connections, this is one of the most impactful parameters you can set.

ip_local_port_range: The port range for outbound connections. If Nginx connects to 10 backend upstreams, each request consumes a port. The default gives you roughly 28,000 ports — at 300 req/s, that’s exhausted in under 2 minutes if TIME_WAIT connections linger.

Virtual Memory (vm.*): Avoiding OOM kills during peak load

# --- Virtual Memory ---
# Reduce swap tendency (10 = only swap when truly necessary)
vm.swappiness = 10

# Pressure to reclaim page cache when RAM is low
vm.vfs_cache_pressure = 50

# Flush dirty pages to disk more frequently (avoids sudden I/O spikes)
vm.dirty_ratio = 15
vm.dirty_background_ratio = 5

vm.swappiness = 10: At the default value of 60, the kernel is quite aggressive about swapping — even when RAM isn’t close to exhausted. This causes significant latency spikes because disk reads and writes are hundreds to thousands of times slower than RAM (depending on whether you’re on HDD or NVMe). Setting it to 10 keeps applications in RAM longer, only swapping when memory is genuinely tight.

If you’re running MySQL or PostgreSQL, many DBAs recommend setting vm.swappiness = 1 rather than 0. Setting it to 0 doesn’t completely disable swap — it just makes the kernel more hesitant in certain edge cases; setting it to 1 causes fewer side effects on newer kernels.

vm.dirty_ratio = 15: The maximum percentage of RAM that can hold dirty pages before the kernel forces a flush to disk. Lowering it from the default of 20 to 15 distributes I/O more evenly, preventing the sudden I/O wait spikes that happen when too many dirty pages are flushed all at once.

File Descriptor Limits: Essential for Nginx, Node.js, and Elasticsearch

File descriptor limits tend to get ignored until Too many open files errors start showing up in logs — usually right when the server is under load. CentOS Stream 9 defaults to 1024 files per process, which is far too low for any serious production service.

# --- File System ---
# Maximum number of file descriptors system-wide
fs.file-max = 2097152

# Inotify watches — required for file monitoring tools (Webpack, Vite, Elasticsearch)
fs.inotify.max_user_watches = 524288
fs.inotify.max_user_instances = 512

In addition to sysctl, you also need to configure per-process limits via PAM:

sudo nano /etc/security/limits.d/99-nofile.conf
# Apply to all users
*       soft    nofile  65536
*       hard    nofile  65536

# Specific limit for the Nginx user
nginx   soft    nofile  200000
nginx   hard    nofile  200000

For systemd services, you also need to add an override to the unit file:

sudo systemctl edit nginx
[Service]
LimitNOFILE=200000

Going further: Applying and verifying your configuration

# Apply all files in /etc/sysctl.d/ — no reboot required
sudo sysctl --system

# Check a specific parameter
sysctl net.core.somaxconn

# Search for a parameter (useful when you can't remember the full name)
sysctl -a 2>/dev/null | grep tcp_fin

# Check the file descriptor limit of a running process
cat /proc/$(pgrep nginx | head -1)/limits | grep "open files"

# Count TIME_WAIT sockets — should decrease after enabling tcp_tw_reuse
ss -ant | grep TIME-WAIT | wc -l

Elasticsearch has its own mandatory parameter — without it, the service refuses to start and throws an error immediately:

# Required for Elasticsearch
echo 'vm.max_map_count = 262144' | sudo tee /etc/sysctl.d/99-elasticsearch.conf
sudo sysctl --system

Practical tips

1. Benchmark before and after making changes

Use ss -s to view socket statistics before and after your changes. I usually capture the output and compare it after 24 hours of production traffic:

# Snapshot TCP connection statistics
ss -s

# View a breakdown by connection state
ss -ant | awk '{print $1}' | sort | uniq -c | sort -rn

2. Don’t blindly copy-paste — check current values first

Before changing any parameter, check the current value and read the kernel docs. On kernel 5.x and later, many TCP buffer parameters are already auto-tuned — manually overriding them can sometimes make things slower, not faster.

3. Test on staging before applying to production

Most parameters are safe to experiment with, but vm.overcommit_memory = 2 is an exception — it can cause the OOM killer to become more aggressive and kill processes if memory accounting is miscalculated. I once watched a MySQL server get killed immediately after applying this — fortunately it was staging, not production.

4. Verify after every kernel update

Files in /etc/sysctl.d/ load automatically at boot — but kernel updates sometimes change defaults. A value you’re overriding today might become unnecessary after a few kernel versions. Run the following to verify:

# Compare live values against config file values
sysctl -a 2>/dev/null | grep -E 'somaxconn|swappiness|file-max|tcp_fin_timeout'

# See all sysctl files currently being loaded
sysctl --system --dry-run 2>&1 | head -20

After applying network parameters, also run dmesg | tail -20 to check for any kernel warnings — parameters that exceed hardware limits will show up here before they cause real problems.

Share: