A Linux server can be running without any obvious errors and still feel painfully slow.
Applications may take longer to respond, SSH sessions may become sluggish, users may suddenly experience high latency. The difficult part is that the root cause is not always obvious.
A slow Linux server does not necessarily mean that the CPU is overloaded.
It could be memory pressure, excessive disk I/O, network congestion, too many open file descriptors, or simply a process consuming resources unexpectedly.
The challenge for a DevOps Engineer is not just finding that the server is slow, but identifying why it is slow.
Instead of immediately restarting services or killing processes, we need to investigate the system, layer by layer:
Is the CPU under pressure?
Is the system running out of memory?
Is disk I/O becoming a bottleneck?
Are processes exhausting file descriptors?
Which process is actually responsible for the problem?
This article is a practical introduction to Linux performance troubleshooting using real reproducible scenarios.
Rather than looking at isolated commands, we will build a troubleshooting mindset: observe the symptom, collect evidence, identify the bottleneck, apply the appropriate solution, and verify the result.
To make the investigation reproducible, I created a companion lab repository containing scenarios that intentionally introduce problems:
High CPU
High CPU usage is one of the most common Linux performance problems.
In this scenario, we intentionally create a process that continuously executes an infinite loop.
The reproduction script is available in the github repository repository.
Run the reproduction script:
chmod +x ./high-cpu/reproduce.sh
./high-cpu/reproduce.sh
The script will display a warning before starting the scenario.
While it is running, open another terminal to investigate the system.
Identify High CPU Usage
The first step is to confirm whether the system is actually experiencing high CPU utilization.
Use top:
top
Look at the CPU summary:
%Cpu(s)
Example result:
%Cpu(s): 95.2 us, 1.8 sy, 0.0 ni, 0.0 id, 2.5 wa, ...
The most important fields are:
us— CPU time spent running user-space processessy— CPU time spent running kernel-space processesid— percentage of CPU time that is idlewa— CPU time waiting for I/O
If id is consistently very low while us is high, the CPU is heavily utilized by processes.
Identify the Process
The next step is to find the process that consume the most CPU.
Use:
ps aux --sort=-%cpu | head
Example result:
USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND
root 38690 90.5 0.0 7740 3512 pts/4 R+ 02:39 0:16 bash ./reproduce.sh
The most important fields are:
PID— Process ID%CPU— CPU consumption%MEM— Memory consumptionSTAT— Current process stateCOMMAND— Command used to start the process
Here bash ./reproduce.sh process is clearly consuming a significant amount of CPU.
We now have a candidate process, but before terminating it, we should inspect it further.
Inspect the Process
Use the PID identified in the previous step:
ps -fp 38690
Output:
UID PID PPID C STIME TTY TIME CMD
root 38690 37782 96 02:39 pts/4 00:00:45 bash ./reproduce.sh
This provides additional information about the process.
The most important fields are:
PID— Process IDPPID— Parent Process IDSTIME— Process start timeTIME— Total CPU time consumedCMD— Command used to start the process
Understand the Cause
Inspect the process script:
cat reproduce.sh
The important part is:
while true; do
:
done
This creates an infinite loop.
The loop continuously executes without any sleep, blocking operation, or other mechanism that would allow the process to stop consuming CPU therefore keeps the CPU busy.
This is the root cause of the high CPU usage in this scenario.
Fix and Verify
Once the responsible process and its cause have been identified, terminate the process using its PID:
kill 38690
Then check the CPU usage again:
top
The process should no longer appear in the process list, and CPU utilization should return to its previous level.
Disk I/O
High disk I/O is another common Linux performance problem.
In this scenario, we intentionally create a process that continuously writes data to disk.
The reproduction script is available in the github repository repository.
Run the reproduction script:
chmod +x ./disk/reproduce.sh
./disk/reproduce.sh
Identify Disk I/O
The first step is to confirm whether the system is actually experiencing high Disk I/O activity.
Use iostat:
iostat -xz 1
Example result:
Device r/s w/s rkB/s wkB/s %util
sda 0.00 125.00 0.00 51200.00 98.50
The most important fields are:
r/s— Number of read requests per secondw/s— Number of write requests per secondrkB/s— Data read per secondwkB/s— Data written per second%util— Percentage of time the device was busy
If %util is consistently close to 100%, the storage device is heavily utilized.
Identify the Process
The next step is to find which process is generating the disk activity.
Use:
pidstat -d 1
Example result:
UID PID kB_rd/s kB_wr/s kB_ccwr/s iodelay Command
0 41043 0.00 259548.51 0.00 0 bash
The most important fields are:
PID— Process IDCOMMAND— Command generating the I/O
Here the PID 41043 process is clearly generating significant disk write activity.
Inspect the Process
Use the PID identified from the previous step:
ps -fp 41043
Output:
UID PID PPID C STIME TTY TIME CMD
root 41043 37782 0 03:03 pts/4 00:00:00 bash ./reproduce.sh
This provides additional information about the process.
The most important fields are:
PID— Process IDPPID— Parent Process IDSTIME— Process start timeTIME— Total CPU time consumedCMD— Command used to start the process
The command shows that the process is continuously writing data to testfile.
Understand the Cause
Inspect the reproduction script:
cat reproduce.sh
The important part is:
while true; do
dd if=/dev/zero of=testfile bs=1M count=1024 conv=fsync
rm -f testfile
done
The script continuously creates a file and writes data to it.
The dd command reads zero-filled data from /dev/zero and writes it to testfile.
The conv=fsync option forces dd to flush the written data to the storage device before completing the operation. This makes the scenario generate actual disk write activity instead of relying entirely on the filesystem page cache.
The file is then removed and the process repeats the operation.
Fix and Verify
Once the responsible process and its cause have been identified, terminate the process using its PID:
kill 41043
Then check the disk activity again:
iostat -xz 1
You can also verify the processes performing I/O:
iotop
The dd process should no longer appear, disk write throughput should drop significantly, and %util should return to its previous level.
Finally, verify that the test file has been removed:
ls -lh testfile
Memory Pressure
High memory usage is another common Linux performance problem.
In this scenario, we create a process that allocates a large amount of memory and keeps it allocated.
The reproduction script is available in the github repository repository.
Run the reproduction script with a memory target:
chmod +x ./memory-pressure/reproduce.sh
./memory-pressure/reproduce.sh 2G
Replace 2G with the amount of memory you want to allocate.
Identify Memory Pressure
The first step is to confirm whether the system is actually experiencing high memory utilization.
Use:
free -h
Example result:
total used free shared buff/cache available
Mem: 3.8Gi 3.1Gi 141Mi 1.0Mi 822Mi 692Mi
Swap: 0B 0B 0B
The most important fields are:
total— Total physical memory availableused— Memory currently used by the systemfree— Completely unused memorybuff/cache— Memory used by the kernel for buffers and filesystem cacheavailable— Memory available for new applications
The available value is particularly important when diagnosing memory pressure. If available memory is consistently very low while used memory is high, the system is under significant memory pressure.
If the system has swap enabled, also pay attention to swap usage. Significant swap usage can indicate that the system does not have enough physical memory to satisfy current workloads.
Identify the Process
The next step is to find which process is consuming the most memory.
Use:
ps aux --sort=-%mem | head
Example result:
USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND
root 39937 0.1 52.7 2123808 2116080 pts/4 S+ 02:56 0:03 python3 ./memory_hog.py 2G
The most important fields are:
PID— Process ID%MEM— Percentage of physical memory used by the processVSZ— Virtual memory sizeRSS— Resident memory currently held in RAMSTAT— Current process stateCOMMAND— Command used to start the process
Here the PID 39937 process is clearly consuming a significant amount of memory.
Inspect the Process
Use the PID identified in the previous step:
ps -fp 39937
Output:
UID PID PPID C STIME TTY TIME CMD
root 39937 39910 0 02:56 pts/4 00:00:03 python3 ./memory_hog.py 2G
This provides additional information about the process.
The most important fields are:
PID— Process IDPPID— Parent Process IDSTIME— Process start timeTIME— Total CPU time consumedCMD— Command used to start the process
The command shows that the process is running memory_hog.py with a target allocation of 2G.
Understand the Cause
Inspect the reproduction script:
cat reproduce.sh
The shell script starts the Python memory allocation program:
python3 "$(dirname "$0")/memory_hog.py" "$MEMORY"
The Python script can then be inspected:
cat memory_hog.py
The important part is:
while allocated_bytes < amount:
remaining = amount - allocated_bytes
size = min(chunk_size, remaining)
chunk = bytearray(size)
chunk[0] = 1
allocated.append(chunk)
allocated_bytes += size
The script allocates memory in 1 MiB chunks until the requested memory amount is reached.
Each allocated chunk is stored in the allocated list:
allocated.append(chunk)
This keeps references to the allocated memory, preventing Python from releasing those objects.
The script then remains running:
while True:
time.sleep(1)
This large memory allocation is the root cause of the memory pressure in this scenario.
Fix and Verify
Once the responsible process and its cause have been identified, terminate the process using its PID:
kill 39937
Then check the memory usage again:
free -h
You can also verify the processes sorted by memory usage:
ps aux --sort=-%mem | head
The memory_hog.py process should no longer appear in the process list.
The available memory should increase, and overall memory utilization should return closer to its previous level.
File Descriptor Exhaustion
In this scenario, we intentionally create a process that continuously opens file descriptors without closing them.
The reproduction script is available in the github repository repository.
Run the reproduction script:
chmod +x ./file-descriptor/reproduce.sh
./file-descriptor/reproduce.sh
Identify File Descriptor Pressure
The first step is to check the system-wide file descriptor usage.
Use:
cat /proc/sys/fs/file-nr
Example result:
2656 0 9223372036854775807
The values represent:
allocated— Number of allocated file handlesunused— Number of unused allocated file handlesmaximum— Maximum number of file handles allowed system-wide
The allocated value represents file handles currently allocated by the kernel.
A high number of allocated file handles can indicate file descriptor pressure, especially when applications are approaching their per-process or system-wide limits.
Identify the Process
The next step is to find the process running fd_exhaustion.py.
Use:
ps aux | grep '[f]d_exhaustion'
Example result:
root 42297 0.2 0.2 18488 10504 pts/4 S+ 03:11 0:00 python3 ./fd_exhaustion.py
The most important fields are:
PID— Process ID%CPU— CPU usage%MEM— Memory usageCOMMAND— Command used to start the process
Here the python3 ./fd_exhaustion.py process is the candidate responsible for the file descriptor exhaustion.
Use the identified PID for the next step.
Inspect the Process
The next step is to determine how many file descriptors the process currently has open.
Use:
ls /proc/42297/fd | wc -l
Example result:
1024
This indicates that the process currently has 1024 file descriptors open.
You can inspect the actual descriptors with:
ls -l /proc/<PID>/fd
Example:
lr-x------ 1 root root 64 Aug 21 03:11 0 -> /dev/pts/4
lrwx------ 1 root root 64 Aug 21 03:11 1 -> /dev/pts/4
lrwx------ 1 root root 64 Aug 21 03:11 2 -> /dev/pts/4
lr-x------ 1 root root 64 Aug 21 03:11 3 -> /dev/null
lr-x------ 1 root root 64 Aug 21 03:11 4 -> /dev/null
The large number of /dev/null entries indicates that the process is continuously opening new file descriptors.
Next, check the process limits:
cat /proc/42297/limits | grep "open files"
Output:
Max open files 1024 1048576 files
The first value is the soft limit, while the second value is the hard limit.
The soft limit is the limit currently enforced for the process.
Once the process reaches this limit, attempts to open additional file descriptors will fail.
Understand the Cause
Inspect the reproduction script:
cat fd_exhaustion.py
The important part is:
while True:
try:
fd = os.open(
"/dev/null",
os.O_RDONLY
)
FILES.append(fd)
counter += 1
The script continuously opens /dev/null using os.open().
Every returned file descriptor is stored in the FILES list:
FILES.append(fd)
The descriptors are intentionally not closed while the scenario is running. As a result, the number of open file descriptors continuously increases
Eventually, os.open() fails because the process has reached its file descriptor limit.
The script then reports the error:
Failed to open new file descriptor
[Errno 24] Too many open files
This is the root cause of the file descriptor exhaustion in this scenario.
File descriptor exhaustion can have broader consequences for real applications. Processes may fail to open files, accept new connections, create sockets, or perform other operations that require file descriptors.
Fix and Verify
Once the responsible process and its cause have been identified, terminate the process using its PID:
kill 42297
Verify that the process has stopped:
ps -p 42297
If the process has stopped, the command should return no process entry.
You can also verify that the process no longer exists:
test -d /proc/<PID> && echo "Process still exists" || echo "Process stopped"
The file descriptors belonging to the process are automatically released by the kernel when the process terminates.
Therefore, /proc/<PID>/fd will no longer be available after the process has exited.
Finally, verify the system-wide file descriptor state again:
cat /proc/sys/fs/file-nr
The allocated file handle count should return closer to its previous level after the exhausted process has been terminated.
Network Connection Exhaustion
In this scenario, we intentionally create a process that continuously opens TCP connections to a local server and keeps those connections open.
The reproduction script is available in the github repository repository.
Run the reproduction script:
chmod +x ./network-connection/reproduce.sh
./network-connection/reproduce.sh
The script will display a warning before starting the scenario.
The reproduction uses 127.0.0.1:9000 as the target, so all connections remain on the local machine.
Identify Abnormal Connections
The first step is to confirm whether the system is experiencing an unusually large number of TCP connections.
Use ss:
ss -s
Example result:
Total: 1252
TCP: 2051 (estab 1023, closed 1021, orphaned 1, timewait 1020)
Transport Total IP IPv6
RAW 1 0 1
UDP 3 3 0
TCP 1030 1028 2
INET 1034 1031 3
FRAG 0 0 0
The most important fields are:
estab— Established TCP connectionsorphaned— Orphaned TCP connectionstimewait— Connections waiting to be fully closedTCP— Total TCP sockets
A significant increase in established connections can indicate abnormal connection usage.
In this scenario, the large number of estab connections is the main indicator that something is continuously creating and maintaining TCP connections.
Identify the Connection and Process
The next step is to identify which process owns the connections.
Use:
ss -tanp
Example result:
CLOSE-WAIT 1 0 127.0.0.1:46374 127.0.0.1:9000 users:(("python3",pid=46938,fd=880))
CLOSE-WAIT 1 0 127.0.0.1:39120 127.0.0.1:9000 users:(("python3",pid=46938,fd=86))
CLOSE-WAIT 1 0 127.0.0.1:44192 127.0.0.1:9000 users:(("python3",pid=46938,fd=634))
The most important information is:
State— Current TCP connection stateLocal Address:Port— Local endpointPeer Address:Port— Remote endpointusers— Process owning the socketpid— Process IDfd— File descriptor associated with the socket
Here we can identify the process responsible for the connections:
users:(("python3",pid=46938,fd=880))
The PID is:
46938
The same PID appearing across many connections is a strong indication that one process is responsible for creating the connection buildup.
Inspect the Process
Use the PID identified in the previous step:
ps -fp 46938
Output:
UID PID PPID C STIME TTY TIME CMD
root 46938 46922 0 03:42 pts/1 00:00:00 python3 ./connection_exhaustion.py 127.0.0.1 9000
This provides additional information about the process.
The most important fields are:
PID— Process IDPPID— Parent Process IDSTIME— Process start timeTIME— Total CPU time consumedCMD— Command used to start the process
The command shows that the process is running connection_exhaustion.py and connecting to 127.0.0.1:9000.
Next, check how many file descriptors the process currently has open:
ls /proc/46938/fd | wc -l
A large number of file descriptors is expected because every TCP socket consumes a file descriptor.
You can inspect the descriptors directly:
ls -l /proc/46938/fd | head
You should see entries pointing to sockets, for example:
lrwx------ 1 root root 64 Aug 21 03:42 86 -> 'socket:[123456]'
lrwx------ 1 root root 64 Aug 21 03:42 87 -> 'socket:[123457]'
lrwx------ 1 root root 64 Aug 21 03:42 88 -> 'socket:[123458]'
This confirms that the process is holding a large number of socket file descriptors.
Understand the Cause
Inspect the reproduction script:
cat connection_exhaustion.py
The important part is:
while True:
try:
sock = socket.socket(
socket.AF_INET,
socket.SOCK_STREAM
)
sock.connect((host, port))
CONNECTIONS.append(sock)
counter += 1
The script continuously creates TCP sockets and connects them to the target server.
Every successful connection is stored in the CONNECTIONS list:
CONNECTIONS.append(sock)
The sockets are intentionally not closed while the scenario is running. As a result, the number of active connections continuously increases:
The TCP server is also intentionally designed to keep accepted connections open:
connection, address = server.accept()
connections.append(connection)
This means the server does not immediately close the connections created by the client. Eventually, the client process can reach its file descriptor limit and fail to create additional sockets.
This pattern can occur in real applications when connections are not properly closed, connection pools are misconfigured, or an application continuously creates new connections instead of reusing existing ones.
Fix and Verify
Once the responsible process and its cause have been identified, terminate the process using its PID:
kill 46938
Then check the network connection summary again:
ss -s
You can also check the connections to port 9000 directly:
ss -tan | grep ':9000'
The number of established TCP connections should return to its previous level.
Conclusion
Linux performance troubleshooting is not about finding a single command that tells you why a server is slow.
It is about building a structured investigation from the symptoms you observe.
In the scenarios covered in this article, we investigated several different types of Linux performance problems:
High CPU caused by an infinite loop
High disk I/O caused by continuous writes
Memory pressure caused by excessive memory allocation
File descriptor exhaustion caused by descriptors that are never closed
Network connection exhaustion caused by continuously opened TCP connections
Although the symptoms are different, the troubleshooting approach remains consistent:
The important lesson is to avoid making assumptions based on a single metric.
High CPU does not immediately tell you which process is responsible. High memory usage does not necessarily mean the system is running out of usable memory. A large number of TCP connections does not automatically mean the network is slow.
Each symptom needs to be investigated with the right evidence.
When a production server becomes slow, the most valuable skill is not knowing how to restart it.
It is being able to answer:
What is happening, why is it happening, and how can I prove that my fix actually worked?
The scenarios in this article are intentionally simplified, but the troubleshooting methodology can be applied to much more complex production incidents.
You can reproduce all of these scenarios and investigate yourself by cloning my github repository bellow:
https://github.com/muhammadyulasfipahrizal/linux-performance-lab.git

Top comments (0)