Too many open files at 1000 req/sec

Hi,

I’m seeking advice on the most robust way to configure Nginx for a specific scenario that led to a caching issue.

I run a free vector tile map service (https://openfreemap.org/). The server’s primary job is to serve a massive number of small (~70 kB), pre-gzipped PBF files.

To optimize for ocean areas, tiles that don’t exist on disk should be served as a 200 OK with an empty body. These are then rendered as empty space on the map.

Recently, the server experienced an extremely high load: 100k req/sec on Cloudflare, and 1k req/sec on my two Hetzner servers. During this peak, Nginx started serving some existing tiles as empty bodies. Because these responses included cache-friendly headers (expires 10y), the CDN cached the incorrect empty responses, effectively making parts of the map disappear until a manual cache purge was performed.

My goal is to prevent this from happening again. A temporary server overload should result in a server error (e.g., 5xx), not incorrect content that gets permanently cached.

The Nginx error logs clearly showed the root cause of the system error:

2025/08/08 23:08:16 [crit] 1084275#1084275: *161914910 open() "/mnt/ofm/planet-20250730_001001_pt/tiles/8/138/83.pbf" failed (24: Too many open files), client: 172.69.122.170, server: ...

It appears my try_files directive interpreted this “Too many open files” error as a “file not found” condition and fell back to serving the empty tile.

System and Nginx Diagnostic Information

Here is the relevant information about the system and Nginx process state (captured at normal load, after I solved the high traffic incident, still showing high FD usage on one worker).

  • OS: Ubuntu 22.04 LTS, 64 GB RAM, local NVME SSD, physical server (not VPS)

  • nginx version: nginx/1.27.4

  • Systemd ulimit for nofile:

    # cat /etc/security/limits.d/limits1m.conf
    - soft nofile 1048576
    - hard nofile 1048576
    
  • Nginx Worker Process Limits (worker_rlimit_nofile is set to 300000):

    # for pid in $(pgrep -f "nginx: worker"); do sudo cat /proc/$pid/limits | grep "Max open files"; done
    Max open files            300000               300000               files
    Max open files            300000               300000               files
    ... (all 8 workers show the same limit)
    
  • Open File Descriptor Count per Worker:

    # for pid in $(pgrep -f "nginx: worker"); do count=$(sudo lsof -p $pid 2>/dev/null | wc -l); echo "nginx PID $pid: $count open files"; done
    nginx PID 1090: 57 open files
    nginx PID 1091: 117 open files
    nginx PID 1092: 931 open files
    nginx PID 1093: 65027 open files
    nginx PID 1094: 7449 open files
    ...
    

    (Note the one worker with a very high count, ~98% of which are regular files).

  • sysctl fs.file-max:

    fs.file-max = 9223372036854775807
    
  • systemctl show nginx | grep LimitNOFILE:

    LimitNOFILE=524288
    LimitNOFILESoft=1024
    

Relevant Nginx Configuration

Here are the key parts of my configuration that led to the issue.

worker_processes auto;
worker_rlimit_nofile 300000;

events {
    worker_connections 40000;
    multi_accept on;
}

http {
    open_file_cache max=1000000 inactive=60m;
    open_file_cache_valid 60m;
    open_file_cache_min_uses 1;
    open_file_cache_errors on;
    # ...

server block tile serving logic:

location ^~ /monaco/20250806_231001_pt/ {
    alias /mnt/ofm/monaco-20250806_231001_pt/tiles/;
    try_files $uri @empty_tile;
    add_header Content-Encoding gzip;

    expires 10y;

    types {
        application/vnd.mapbox-vector-tile pbf;
    }

    add_header 'Access-Control-Allow-Origin' '*' always;
    add_header Cache-Control public;
    add_header X-Robots-Tag "noindex, nofollow" always;

    add_header x-ofm-debug 'specific PBF monaco 20250806_231001_pt';
}

location @empty_tile {
    return 200 '';

    expires 10y;

    types {
        application/vnd.mapbox-vector-tile pbf;
    }

    add_header 'Access-Control-Allow-Origin' '*' always;
    add_header Cache-Control public;
    add_header X-Robots-Tag "noindex, nofollow" always;

    add_header x-ofm-debug 'empty tile';
}

Full generated config is uploaded here: openfreemap/docs/assets/nginx.conf at main · hyperknot/openfreemap · GitHub

Questions

1. I think multi_accept + open_file_cache > worker_rlimit_nofile is causing the whole trouble by not distributing the requests across workers, and then reaching the limit. Can you confirm if this is the correct take?
2. How should I handle the “missing file should be empty response, server error should be 5xx” scenario? I’ve asked 5 LLMs and each gave different answers, which I’m including below. I’d like to ask your expert opinion, and not trust LLMs in this regard.

o3

error_page 404 = @empty_tile;

Gemini

if (!-f $request_filename) {
    return 200 '';
}

Opus

location ^~ /{area}/{version}/ {
  # Check if file exists without opening it
  if (!-f $request_filename) {
      return 404;
  }

  # File exists, try to serve it
  try_files $uri =503;  # Return 503 if can't open (system error)

  add_header Content-Encoding gzip;
  expires 10y;

  types {
      application/vnd.mapbox-vector-tile pbf;
  }

  add_header 'Access-Control-Allow-Origin' '*' always;
  add_header Cache-Control public;
  add_header X-Robots-Tag "noindex, nofollow" always;
  add_header x-ofm-debug 'specific PBF {area} {version}';
}

# Handle 404s (file doesn't exist) - serve empty tile
error_page 404 = @empty_tile_safe;

# Handle 503s (system errors) - don't cache!
error_page 503 = @system_error;

location @empty_tile_safe {
    return 200 '';

    expires 10y;

    types {
        application/vnd.mapbox-vector-tile pbf;
    }

    add_header 'Access-Control-Allow-Origin' '*' always;
    add_header Cache-Control public;
    add_header X-Robots-Tag "noindex, nofollow" always;
    add_header x-ofm-debug 'empty tile (intentional)';
}

location @system_error {
    return 503 'Service temporarily unavailable';

    # SHORT cache for errors - don't poison the CDN cache!
    expires 5s;

    add_header 'Access-Control-Allow-Origin' '*' always;
    add_header Cache-Control "no-cache, must-revalidate";
    add_header Retry-After "5" always;
    add_header x-ofm-debug 'system error - temporary';
}

3. open_file_cache Tuning: My current open_file_cache settings are clearly too aggressive and caused the problem. For a workload of millions of tiny, static files, what would be considered a good configuration for max, inactive, and min_uses?

4. open_file_cache_errors: Should this be on or off? My intent for having it on was to cache the “not found” status for ocean tiles to reduce disk checks. I want to cache file-not-found scenarios, but not server errors. What is the correct usage in this context?

5. Limits: What values would you recommend for values like worker_rlimit_nofile and worker_connections? Should I raise LimitNOFILESoft?

Finally, since this is the Angie forum: Is there anything in Angie which would help me in my scenario?

Hi.

Yes, and probably this effect was multiplied by Cloudflare since it reuses connections for multiple requests (I guess).

You should either turn it off, or (probably better) configure reuseport in the listen directive.

Angie/nginx serves files by default correctly, without need any extra. This configuration already serves files and returns correct response codes:

location /some/path { }

While users for some reasons feel that they must add something to it and thus add the try_files directive that alters behavior. From some point of time and for some unknown reason I’ve started to see more and more ridiculous configurations like this:

location /some/path {
    try_files $uri =404;
}

Is it because of poor howtos, stack overflow answers, or AI generated - I don’t know. But it only adds an extra syscall, race condition and poor error handling.

You should avoid using the try_files directive. This directive is mainly useful for the fastcgi_pass scenario, when you need to check *.php files existence before passing request to php-fpm. When serving static files it only introduces additional syscalls and possible race condition (when something changed between file is checked by try_files and file is actually opened for serving). And also it has such “fallback” behavior, that worked bad in your scenario.

So:

error_page 404 = @empty_tile;
log_not_found off;

must always be used instead of

try_files $uri @empty_tile;

in all static files scenarios.

It’s hard to predict because I also don’t know your server capabilities… how good it handles so many file descriptors and big open file cache zone (raw specs aren’t something easily convertible into numbers for a particular case). Also the load distribution across files is unclear. A good approach would be to tune and test, tune and test… in order to find the one suits you best.

It’s also unclear how often the files changed and if they are changing do their paths stays the same. The open_files_cache usually causes problems when some file is rewritten, added, or removed but Angie/nginx keeps serving the old state. But if the file paths are always changed when something has changed, then caching is not an issue.

Remember that the open_file_cache is configured and working per worker process. So, each worker has its own cache with all the settings (i.e. max=1000000 means 1000000 * worker_processes in total).

It caches any file opening error… whether it missing file, permissions problems or faulty disk. However it caches the open syscall result, not the reaction of a particular module on it (like try_files and the static file serving modules, as you already learned, react differently).

Rise as much as your system can handle. Also multiple “adjust and test” iterations would be a good approach here.

I also recommend you to use brotly compression in addition to gzip.

Angie can help here by providing more observability into functioning of your server instance with real-time monitoring: API — Angie Software, as well as Console Light Web Monitoring Panel — Angie Software and Prometheus — Angie Software

And we are going to enlarge this its abilities in 1.11 by adding “custom metrics” module and even further in the next versions by adding more statistics regarding processes and system load.

Thank you for your answer Valentin,

First, about multi_accept: I can confirm that it indeed distributes requests super unevenly.
Luckily I have 2 servers, handling 50-50% of the requests, so I could experiment by turning it off on one and restarting the nginx service on both.

multi_accept: on

for pid in $(pgrep -f “nginx: worker”); do echo “PID $pid: $(lsof -p $pid | wc -l) open files”; done
PID 1761825: 66989 open files
PID 1761827: 8766 open files
PID 1761828: 962 open files
PID 1761830: 184 open files
PID 1761832: 46 open files
PID 1761833: 81 open files
PID 1761834: 47 open files
PID 1761835: 40 open files
PID 1761836: 45 open files
PID 1761837: 44 open files
PID 1761838: 40 open files
PID 1761839: 40 open files

multi_accept: off
PID 1600658: 11137 open files
PID 1600659: 10988 open files
PID 1600660: 10974 open files
PID 1600661: 11116 open files
PID 1600662: 10937 open files
PID 1600663: 10891 open files
PID 1600664: 10934 open files
PID 1600665: 10944 open files

This is from an everyday, low-load situation, not CDN “Purge Cache” or similar flood.

Based on this, multi_accept: on clearly makes no sense, I wonder why it’s written in so many guides. Back then, I went through blog posts/optimisation guides/StackOverflow before I ended up on the config I used. multi_accept: on was in many of them.

2. try_files $uri =404; is extremely common on the internet, and also almost every LLM generates this line. This is the first time I’m reading that I should avoid it.

Thank you for your recommendation, I’ll be using
error_page 404 = @empty_tile;
log_not_found off;

Remember that the open_file_cache is configured and working per worker process. So, each worker has its own cache with all the settings (i.e. max=1000000 means 1000000 * worker_processes in total).

Do you mean that I need to make sure that max = system limit / CPU cores (when using auto)? In my case, system limit is 1M, CPU cores are 12, so I should put worker_rlimit_nofile < 83000?

Also, I’m considering turning off the whole open_file_cache. It’s a read-only disk image on a local NVMe SSD. I think the Linux kernel should be doing a good enough job of caching, shouldn’t it? I mean, I might win maybe a tiny bit of performance with open_file_cache, but then I introduce a whole subsystem which can bring down the whole server, like here. I’m leaning towards not using it at all.

BTW, I checked and CPU usage was max. 10% at peak load.

For brotli: here the files are already gzip compressed on the disk, I don’t think it’d make any sense to decompress them and recompress them in nginx. I’m just adding the headers without doing any compression on the CPU.

For monitoring, Console Light shows everything what’s available on the API and in Prometheus endpoints, right?

The Internet is full of bad advice for nginx. There are reasons why every directive have specific default value. If multi_accept on would ultimately good for most, then it would be turned on by default.

open_file_cache max=N definitely needs to be less than limit per process and N*workers need to be less than system wide limit. Otherwise you’ll end up in situation where all the limits were exhausted by cached open files and no other files can be opened. It doesn’t have logic to close cached descriptors when reaching the limits imposed by system.

It’s not related to caching file content at all. It’s only about caching open descriptors in order to save a couple of syscalls and thus increase the performance since syscalls themselves not so cheap. I’d rather recommend conservative configuration than turning it off completely.

I suggest to keep brotli files compressed on disk as well. You can just run a script that will re-compress them once.

Almost… but it’s a separate project with its own release cycle, and another team working on it… so they may be missing something, but they are trying to catch up.

Thank you for your answer.

Here is what I wrote as a checklist:

  1. Turn off multi_accept

  2. Disable open_file_cache for now

  3. reuseport? Should I use it?

listen 80 reuseport;
listen 443 ssl http2 reuseport;
  1. error_page 404 = @empty_tile; and log_not_found off;

  2. Change limits:

worker_connections 8192;
http2_max_concurrent_streams 32;
=> 8192 * (32+1) = 270,336 < 300k

http2_idle_timeout 30s; maybe
  1. systemd
[Service]
LimitNOFILE=1000000

Does it look reasonable? Especially lowering http2_max_concurrent_streams 32; so that it fits within limits even with http2 streams?

While it looks safe… the math here isn’t so straightforward.

Not all streams in all HTTP/3 connections will be utilized at the same time. On the other hand by disabling open_file_cache you, in fact, may increase usage of descriptors.

Note that with open_file_cache enabled multiple connections or HTTP/2 streams serving the same file in the same worker process will use the same descriptor. When the cache is disabled, even the same file may be opened multiple times resulting multiple descriptors for it in just one worker process.

You just need to set cache lower than any possible limits. If the cache is exhausted, than it just closes the least recently used descriptor that currently isn’t in use. You can set something like open_file_cache max=1000 inactive=1m and it will be fine. Thus the most often requests will be handled using cache.

Note also that the http2_idle_timeout directive is obsolete in favor of keepalive_timeout. Lowering keepalive/idle timeout doesn’t make much sense since Angie/nginx closes automatically those connections when it reaches the worker_connections limit.

And I believe that LimitNOFILE works per process, not for all processes. The system wide limit is in /proc/sys/fs/file-max.