DEV Community

Bar Dror
Bar Dror

Posted on Originally published at codebeneath.com

Linux file Descriptor Limits and Socket Leaks: ulimit and /proc/sys/fs/file-max

Originally published on Code Beneath.

You deploy a service, it runs fine for hours, then crashes with "too many open files." The root cause is almost always a file descriptor socket leak, triggered by improper cleanup or hitting the system ulimit. Understanding the two-tier limit system (per-process and system-wide), how to detect leaks, and how to configure both layers properly is essential for any engineer running network services. This article shows you the exact mechanisms, monitoring commands, and working code to diagnose and fix descriptor exhaustion in production.

Understanding Linux File Descriptors and the ulimit System

Every open file, socket, pipe, and device in Linux is represented by a file descriptor (FD), a small integer starting at 0. The first three (0, 1, 2) are reserved for stdin, stdout, and stderr. When you open a TCP socket, bind to a port, accept a connection, or even open a regular file, the kernel assigns the next available FD number.

There are two limits that matter:

  1. Per-process soft and hard limits: controlled by ulimit -n and configured in /etc/security/limits.conf.
  2. System-wide limit: the total number of file descriptors all processes can hold, stored in /proc/sys/fs/file-max.

If your process hits its soft ulimit, open() returns an error and errno is set to EMFILE (24). If the system runs out of global descriptors, open() fails with ENFILE (23). Both are fatal to a production service. The socket leak occurs when you accumulate unclosed file descriptors until you exhaust one of these limits.

Check Your Current File Descriptor Limits

First, see what your process can do:

# Check soft limit (what the process sees)
ulimit -n

# Check hard limit (maximum you can raise to without root)
ulimit -H -n

# Check system-wide limit
cat /proc/sys/fs/file-max

# See all limits for current shell
ulimit -a
Enter fullscreen mode Exit fullscreen mode

Most distributions ship with 1024 soft limit and 65536 hard limit per process. The system-wide limit is usually 80% of your RAM in KB divided by 10. On a 16 GB machine, expect around 1.3 million descriptors.

Detect File Descriptor Socket Leaks Through Monitoring

A leak is when your process opens descriptors but never closes them. Over time, the count climbs until you hit the ulimit. The easiest way to spot this is to watch the count grow:

# For a running process, count open FDs (replace 12345 with your PID)
ls -1 /proc/12345/fd | wc -l

# Or use lsof
lsof -p 12345 | wc -l

# Watch it grow in real time
watch -n 1 'ls -1 /proc/12345/fd | wc -l'

# See which FDs are open
ls -la /proc/12345/fd/
Enter fullscreen mode Exit fullscreen mode

The /proc filesystem is the most reliable tool here. Each process gets a /proc/[PID]/fd directory with a symlink for every open descriptor. Counting the symlinks tells you exactly how many FDs the process holds right now. This works without installing lsof and is not affected by permission issues that sometimes hide lsof output.

A Runnable Socket Leak Example

Here is a Go program that leaks TCP sockets intentionally, so you can see the symptoms and how to detect them:

package main

import (
    "fmt"
    "net"
    "os"
    "time"
)

func main() {
    if len(os.Args) < 2 {
        fmt.Fprintf(os.Stderr, "usage: %s [leak|clean]\n", os.Args[0])
        os.Exit(1)
    }

    mode := os.Args[1]

    if mode == "leak" {
        // Intentional leak: connect but never close
        fmt.Println("Opening sockets without closing (leak mode)...")
        for i := 0; i < 10000; i++ {
            conn, err := net.Dial("tcp", "127.0.0.1:22")
            if err != nil {
                fmt.Printf("Dial failed at iteration %d: %v\n", i, err)
                break
            }
            // BUG: We never call conn.Close()
            if i%1000 == 0 {
                fmt.Printf("Opened %d connections (leaking)\n", i)
            }
        }
    } else if mode == "clean" {
        // Proper cleanup: close every socket
        fmt.Println("Opening and closing sockets (clean mode)...")
        for i := 0; i < 10000; i++ {
            conn, err := net.Dial("tcp", "127.0.0.1:22")
            if err != nil {
                fmt.Printf("Dial failed at iteration %d: %v\n", i, err)
                break
            }
            conn.Close()
            if i%1000 == 0 {
                fmt.Printf("Opened and closed %d connections\n", i)
            }
        }
    }

    fmt.Println("Sleeping for 60 seconds. Run: watch -n 1 'ls -1 /proc/[PID]/fd | wc -l'")
    time.Sleep(60 * time.Second)
}
Enter fullscreen mode Exit fullscreen mode

Compile and run:

go build -o fd_leak fd_leak.go

# In terminal 1, run leak mode
./fd_leak leak > /tmp/leak.log &
PID=$!

# In terminal 2, watch the FD count climb
watch -n 1 "echo 'FD count:'; ls -1 /proc/$PID/fd 2>/dev/null | wc -l; echo 'Last 5 FDs:'; ls -1 /proc/$PID/fd 2>/dev/null | tail -5"

# Compare with clean mode
./fd_leak clean > /tmp/clean.log &
Enter fullscreen mode Exit fullscreen mode

In leak mode, the FD count will steadily climb to 1024 (the soft limit), then the program will fail with "too many open files." In clean mode, the count stays constant because every socket is closed immediately after use. This is the most obvious diagnostic: a monotonically increasing FD count is a leak.

Proper Resource Cleanup in Production Code

The fundamental rule: always pair open with close, and do it in a way that survives panics or early returns. Here is a robust pattern in Go, Java, and Python:

package main

import (
    "bufio"
    "fmt"
    "io"
    "net"
)

func handleConnection(conn net.Conn) error {
    // Defer ensures Close runs even if we return early or panic
    defer conn.Close()

    reader := bufio.NewReader(conn)
    for {
        line, err := reader.ReadString('\n')
        if err != nil {
            if err == io.EOF {
                return nil // Clean close
            }
            return err
        }
        fmt.Printf("Received: %s", line)
    }
}

func main() {
    listener, err := net.Listen("tcp", ":9999")
    if err != nil {
        panic(err)
    }
    defer listener.Close()

    for {
        conn, err := listener.Accept()
        if err != nil {
            panic(err)
        }
        // Spawn goroutine; handleConnection will close the connection
        go handleConnection(conn)
    }
}
Enter fullscreen mode Exit fullscreen mode

The defer statement is your safety net. Even if handleConnection panics, the defer runs and closes the socket. In Java and Python, use try-finally or context managers:

public void handleConnection(Socket socket) throws IOException {
    try {
        InputStream input = socket.getInputStream();
        BufferedReader reader = new BufferedReader(new InputStreamReader(input));
        String line;
        while ((line = reader.readLine()) != null) {
            System.out.println("Received: " + line);
        }
    } finally {
        socket.close();
    }
}
Enter fullscreen mode Exit fullscreen mode
import socket

def handle_connection(sock):
    try:
        while True:
            data = sock.recv(1024)
            if not data:
                break
            print(f"Received: {data.decode()}")
    finally:
        sock.close()

listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
listener.bind(("127.0.0.1", 9999))
listener.listen(5)

while True:
    conn, addr = listener.accept()
    # Spawn thread; handle_connection will close the socket
    import threading
    t = threading.Thread(target=handle_connection, args=(conn,))
    t.daemon = True
    t.start()
Enter fullscreen mode Exit fullscreen mode

Configuring ulimit Permanently

Raising the soft limit to the hard limit for your application is essential. There are three ways to configure ulimit settings:

Method 1: /etc/security/limits.conf

# Add this line to /etc/security/limits.conf
myuser soft nofile 65536
myuser hard nofile 65536

# Or for all users
* soft nofile 65536
* hard nofile 65536

# Restart your shell or log in again for changes to take effect
Enter fullscreen mode Exit fullscreen mode

Method 2: Systemd service file

# In your service file
[Service]
LimitNOFILE=65536
LimitNOFILEHard=65536
Enter fullscreen mode Exit fullscreen mode

Method 3: Raise at startup in your application

Go example using syscall to configure ulimit programmatically:

package main

import (
    "fmt"
    "syscall"
)

func main() {
    // Raise soft limit to hard limit
    var rlim syscall.Rlimit
    err := syscall.Getrlimit(syscall.RLIMIT_NOFILE, &rlim)
    if err != nil {
        panic(err)
    }

    fmt.Printf("Current: soft=%d, hard=%d\n", rlim.Cur, rlim.Max)

    // Set soft to hard
    rlim.Cur = rlim.Max
    err = syscall.Setrlimit(syscall.RLIMIT_NOFILE, &rlim)
    if err != nil {
        fmt.Printf("Setrlimit failed: %v (might need CAP_SYS_RESOURCE)\n", err)
    } else {
        fmt.Printf("Raised soft limit to %d\n", rlim.Max)
    }
}
Enter fullscreen mode Exit fullscreen mode

Monitoring System-Wide File Descriptor Limits

Even if a single process can open 65536 descriptors, the system might allow only 2 million total. When you hit this, all processes fail to open files. Monitor both:

# Current system-wide limit
cat /proc/sys/fs/file-max

# Currently allocated descriptors (sum across all processes)
cat /proc/sys/fs/file-nr

# Output format: allocated, unused, max
# Example: 2048    128    262144
Enter fullscreen mode Exit fullscreen mode

If allocated is close to max, you have too many processes holding descriptors. Raise file-max:

# Temporary (lost on reboot)
echo 2097152 | sudo tee /proc/sys/fs/file-max

# Permanent: add to /etc/sysctl.conf
echo "fs.file-max = 2097152" | sudo tee -a /etc/sysctl.conf
sudo sysctl -p
Enter fullscreen mode Exit fullscreen mode

End-to-End Monitoring Script

Here is a bash script that monitors both per-process and system-wide limits to catch socket leaks early:

#!/bin/bash
# Monitor file descriptor usage

set -e

if [ $# -eq 0 ]; then
    echo "Usage: $0 [PID]"
    exit 1
fi

PID=$1
INTERVAL=2

echo "Monitoring FD usage for PID $PID (interval ${INTERVAL}s)"
echo "Press Ctrl+C to stop"
echo ""

while true; do
    clear
    echo "=== System-wide ==="
    FILE_NR=$(cat /proc/sys/fs/file-nr)
    FILE_MAX=$(cat /proc/sys/fs/file-max)
    ALLOCATED=$(echo $FILE_NR | awk '{print $1}')
    MAX=$FILE_MAX
    PCT=$((ALLOCATED * 100 / MAX))
    echo "Allocated: $ALLOCATED / $MAX ($PCT%)"
    echo ""

    echo "=== Process $PID ==="
    if [ ! -d "/proc/$PID" ]; then
        echo "Process not found"
        exit 1
    fi

    FD_COUNT=$(ls -1 /proc/$PID/fd 2>/dev/null | wc -l)
    SOFT_LIMIT=$(cat /proc/$PID/limits 2>/dev/null | grep "open files" | awk '{print $4}')
    HARD_LIMIT=$(cat /proc/$PID/limits 2>/dev/null | grep "open files" | awk '{print $5}')

    echo "Open FDs: $FD_COUNT"
    echo "Soft limit: $SOFT_LIMIT"
    echo "Hard limit: $HARD_LIMIT"
    echo ""

    if [ $FD_COUNT -gt 0 ]; then
        echo "Top 10 open FD types:"
        ls -1 /proc/$PID/fd | xargs -I {} sh -c "readlink /proc/$PID/fd/{} 2>/dev/null || echo '(unknown)'" | sort | uniq -c | sort -rn | head -10
    fi

    sleep $INTERVAL
done
Enter fullscreen mode Exit fullscreen mode

Common Mistakes

Mistake Symptom Fix
Forgetting to close sockets in a loop FD count climbs steadily; crashes at ulimit Use defer, finally, or context managers. Always pair open with close.
Closing in the wrong goroutine/thread Leaks or double-close panics Close in the same function that opened. Use defer to guarantee it runs.
Raising only soft limit, not hard limit Process still crashes after a while Set both soft and hard, or use Setrlimit at startup.
Not monitoring system-wide limit All processes fail suddenly even though individual processes look fine Watch /proc/sys/fs/file-nr and file-max. Raise file-max before you need it.
Checking only lsof, trusting it is complete Real leak missed because lsof lost access or was slow Use ls /proc/[PID]/fd as the source of truth. It is instant and never lies.

Integration with Security Best Practices

For production deployment, file descriptor limits are part of a broader hardening strategy. Use ulimit to constrain processes, isolate services in containers with their own limits, and log any failures. A network service should treat EMFILE (too many open files) as a fatal error and alert on-call immediately, because it means either the application has a leak or the infrastructure is under attack (connection flood).

Testing Under Load

Before production, stress-test your service to find leaks early. Use tools like Apache Bench, wrk, or custom load generators to hammer your service for 10 to 60 minutes while watching FD count. If it climbs, you have a leak. If it stays constant, you are ready.

# Example: benchmark while monitoring
PID=$(pgrep -f "your_app")
(watch -n 1 "ls -1 /proc/$PID/fd | wc -l") &
WATCH_PID=$!

# Run load test for 5 minutes
ab -n 100000 -c 100 http://127.0.0.1:8080/

# Stop monitor
kill $WATCH_PID
Enter fullscreen mode Exit fullscreen mode

FAQ

What is the difference between soft and hard limit?

The soft limit is what the process actually enforces. The hard limit is the maximum you can raise the soft limit to without root privilege. A non-root process can lower a hard limit but cannot raise it. Root can raise both arbitrarily.

Can I set per-process limits without modifying limits.conf?

Yes, from inside your application using syscall.Setrlimit (Go), Resource.setRlimit (Java), or resource.setrlimit (Python). This runs at program startup and takes effect immediately. It requires the capability CAP_SYS_RESOURCE (usually available to root or in privileged containers).

Why does my Go server still show EMFILE even after raising ulimit?

Check two things: First, verify the process actually has the raised limit using cat /proc/[PID]/limits. Second, check if you are also hitting the system-wide limit by reading /proc/sys/fs/file-nr. If the allocated count is near file-max, raise file-max.

Is it safe to set file-max very high?

Yes. File descriptors are allocated on demand, so a high file-max does not use memory. On a 64-bit system, setting it to 2 to 4 million is common and safe. Avoid extreme values (10+ million) unless you know the workload.

What if /proc/sys/fs/file-nr shows allocated near max but I do not see high process FD counts?

Some of those descriptors might be held by the kernel itself (inode caches, dentry caches, etc.). Use lsof -p to check individual processes. If totals do not add up, the kernel is likely holding them. This is normal and usually harmless, but if you are running out of descriptors, containers or process isolation helps.

How do I track which FDs are sockets vs files?

Run readlink /proc/[PID]/fd/[FD_NUM]. Socket FDs show as socket:[inode_number]. Regular files show as /path/to/file. Pipes show as pipe:[inode_number]. This helps identify leaks: if you see thousands of socket:12345 entries, you have leaked sockets.

Can I recover from EMFILE without restarting the process?

You can attempt to close old sockets or files, but by the time you hit EMFILE, your process is crippled. The safer approach is to fail fast, alert, and restart. Some frameworks offer circuit breaker patterns that reject new connections before hitting the limit, giving you time to gracefully drain and restart.

Should I use a connection pool to avoid opening too many sockets?

Yes. A connection pool reuses sockets, so you open, say, 50 to 200 persistent connections and keep them alive. This is much more efficient than opening a new socket for every request. See database driver documentation (pgx, mysql, redis) for pool tuning. A well-tuned pool with proper timeouts and health checks will almost never leak.

How does Docker or Kubernetes affect file descriptor limits?

Containers inherit the host kernel's file-max but can have their own per-container limits set via --ulimit (Docker) or resources.limits (Kubernetes). Set both the host-level file-max and the container-level ulimit to ensure consistent behavior. Check limit inside the container with ulimit -n.

Top comments (0)