Understanding and Solving the “No File Descriptors Available” Error in Docker Builds in Python
When you’re building Python applications in Docker containers using modern package managers like UV, you might encounter a frustrating error that stops your build dead in its tracks: No file descriptors available (os error 24). This error can be particularly puzzling because it often appears during what should be a routine package installation step.
To truly understand this error, we need to start with how Python actually runs your code. When you write Python code in a .py file, what you’re creating is human-readable source code. However, the Python interpreter doesn’t directly execute this source code. Instead, it first transforms it into something called bytecode, which is a lower-level representation that’s much faster for the interpreter to process.
Imagine you’re giving directions to someone in a foreign language. You could either translate each sentence as you go, which would be slow and repetitive, or you could translate everything once into their language and then just read from your translation. Python takes the second approach. When you run a Python file for the first time, the interpreter compiles it into bytecode and typically saves this compiled version as a .pyc file in a pycache directory. The next time you run the same code, Python can skip the compilation step and use the pre-compiled bytecode directly, which significantly speeds up program startup.
Diagnosis
This compilation process isn’t just a nice-to-have optimization. For large Python applications with hundreds or thousands of modules, the difference between running pre-compiled bytecode versus compiling everything at runtime can be measured in seconds or even minutes of startup time. This is especially critical in production environments where you might be spinning up new container instances frequently, such as in serverless architectures or during auto-scaling events.
UV is a next-generation Python package manager designed to be extremely fast and efficient. One of its powerful features is the ability to pre-compile all Python bytecode during the Docker build process. This is controlled by an environment variable called UV_COMPILE_BYTECODE
. When you set this variable to 1, UV doesn’t just install your Python packages; it also proactively compiles every single .py file in those packages into bytecode.
The compilation process is quite sophisticated. UV needs to scan through all installed packages, identify every Python source file, spawn subprocesses to handle the compilation in parallel, and manage all the file reading and writing operations. For a typical web application with popular frameworks and libraries, this might mean processing thousands of Python files. Each file needs to be opened, read, compiled, and then the bytecode needs to be written to a new .pyc file.
This is where things get interesting from a systems perspective. Every time UV opens a file, creates a subprocess, or establishes a pipe for inter-process communication, it uses something called a file descriptor. File descriptors are essentially handles that the operating system uses to track open resources. The operating system limits how many file descriptors each process can have open at once, and in Docker containers, this limit is often set to a conservative default of 1024.
The “No file descriptors available” error occurs when UV’s bytecode compilation process tries to use more file descriptors than the system allows. Let me paint you a picture of what’s happening under the hood. When UV starts compiling bytecode for a large package like flash-attn or any PyTorch-related library, it might spawn dozens of Python subprocesses to parallelize the work. Each subprocess needs at least three file descriptors for standard input, output, and error streams. Then, each subprocess opens multiple Python source files to read and compile them.
If UV spawns 50 subprocesses and each one opens 20 files, you’re looking at over 1000 file descriptors being used simultaneously. Add in the file descriptors that UV itself needs, plus any that the Docker build process is using, and you quickly exceed the 1024 limit. The result is that the operating system refuses to open any more files or create any more processes, and UV crashes with the dreaded “os error 24”.
This problem is particularly acute with scientific computing packages like flash-attn, transformers, or torch because these packages contain massive numbers of Python files. The flash attention package, for instance, includes numerous optimization modules, CUDA bindings, and utility functions, each in its own Python file that needs compilation.
Solutions
The simplest solution to this problem is to tell UV not to compile bytecode during the build process at all. You can do this by setting UV_COMPILE_BYTECODE=0 in your Dockerfile. When you disable bytecode compilation, UV changes its behavior completely. Instead of trying to compile everything during the build, it simply installs the Python packages and copies all the .py files to their appropriate locations. No compilation happens, no subprocesses are spawned for compilation, and therefore no file descriptor exhaustion occurs.
Here’s what this looks like in practice. In your Dockerfile, you would modify your environment variables like this: instead of having UV_COMPILE_BYTECODE=1
, you set it to 0. The build process becomes much simpler and faster. UV downloads your packages, extracts them, places the files where they need to go, and moves on. No compilation means no file descriptor crisis.
However, this simplicity comes with a cost that you need to carefully consider. When your container starts up in production and Python begins importing modules, it will need to compile each module’s bytecode on the fly. This happens the first time each module is imported. For a large application, this can add significant latency to your application’s startup time. Even worse, if you’re running multiple worker processes, each worker might end up compiling the same modules independently, wasting CPU cycles and memory.
The impact of runtime compilation extends beyond just startup time. In serverless environments or container orchestration systems that frequently start new instances, this compilation overhead happens again and again. Each cold start pays the full compilation penalty. Additionally, any compilation errors that might exist in your dependencies won’t be discovered until runtime, potentially causing production failures that could have been caught during the build process.
The second solution is more sophisticated but generally better for production environments. Instead of disabling bytecode compilation, you temporarily increase the file descriptor limit just for the problematic build step. This allows UV to complete its compilation process successfully while maintaining all the benefits of pre-compiled bytecode.
The implementation involves using the shell’s ulimit command to increase the file descriptor limit before running UV. In your Dockerfile, you wrap the UV sync command with a shell invocation that first raises the limit. For example, you might use sh -c “ulimit -n 4096 && uv sync — no-build-isolation-package flash-attn”
. This command first increases the file descriptor limit to 4096, then runs UV with that increased limit.
What makes this solution elegant is that the increased limit only applies to that specific build step. The final runtime container isn’t affected, and you don’t need to modify system-wide settings. The build process can use as many file descriptors as it needs for compilation, and once that’s done, your runtime container operates with normal limits.
This approach preserves all the benefits of bytecode compilation. Your production containers start up quickly because all the Python code is already compiled. There’s no runtime CPU overhead for compilation. All compilation errors are caught during the build process, not in production. The bytecode is optimized and ready to run, giving you the best possible performance.
The main drawback is that builds take longer because compilation is CPU-intensive. You also need to ensure your build environment allows ulimit modifications, which might not be the case in some restricted CI/CD environments. Additionally, the compiled bytecode files make your Docker images slightly larger, though this is usually a worthwhile tradeoff for the performance benefits.
Your Judgement
The decision between these two solutions depends heavily on your specific use case and requirements. For development environments where you’re frequently rebuilding containers and iterating on code, disabling bytecode compilation often makes sense. Build speed is usually more important than runtime performance during development, and you can tolerate slower application startups.
For production environments, especially those with strict performance requirements or frequent container startups, keeping bytecode compilation enabled is almost always the better choice. The one-time cost of longer builds is vastly outweighed by the ongoing benefits of faster startup times and lower runtime CPU usage. This is particularly true for applications running in serverless environments, where cold starts directly impact user experience.
Consider also the debugging implications of each approach. With bytecode compilation enabled, any issues with Python code compilation will be discovered during the build process. This gives you a chance to fix problems before they reach production. With compilation disabled, these issues only surface at runtime, potentially causing production outages that could have been prevented.
In practice, many teams use different configurations for different environments. You might disable bytecode compilation for local development builds to speed up iteration, while enabling it for staging and production builds. This can be implemented using Docker build arguments, allowing you to use the same Dockerfile with different configurations.
For production builds, I recommend not just increasing the file descriptor limit, but setting it generously high. A limit of 4096 or even 8192 provides plenty of headroom for even the largest Python applications. The temporary increase during build time has no negative effects, and it ensures your builds won’t fail even as your application grows and adds more dependencies.
It’s also worth considering the broader context of your build pipeline. If you’re using multi-stage Docker builds, the bytecode compilation happens in the builder stage, and only the compiled results are copied to the final runtime image. This keeps your runtime images lean while still benefiting from pre-compilation. You can also leverage Docker’s build cache to avoid recompiling unchanged dependencies, significantly speeding up subsequent builds.