pid of None and a 0-byte log file. That is a genuinely confusing place to start debugging.
The reason is a good one. And the fix is 30 lines of C, followed by a decision that’s more interesting than the C.
Your own process disappears
Upsun mounts/proc inside every container with hidepid. That option changes who’s allowed to see a process. Instead of every process on the box being world-readable through /proc/<pid>, a process is visible only to callers that would be allowed to ptrace it. This is part of how Upsun hardens its containers, and most of the time you never notice it.
The kernel decides “allowed to ptrace” with a check called ptrace_may_access. One of its conditions is that the target process has to be dumpable. A dumpable process is one the kernel will write a core dump for, and by extension one that same-user tooling is allowed to inspect. Normal processes are dumpable by default.
Here’s where Airflow and the platform disagree. Airflow’s Task SDK runs each task under a small supervisor process, and that supervisor calls prctl(PR_SET_DUMPABLE, 0) on the task. In plain terms, the task tells the kernel “nobody sharing my user id gets to read my memory”. Airflow does this on purpose, to stop one task from reading another task’s memory, environment, and secrets through ptrace or process_vm_readv.
On a machine with an ordinary /proc, that’s a reasonable hardening step and nothing breaks. On a hidepid /proc, it backfires. The moment the task marks itself non-dumpable, it fails the ptrace_may_access check, so it vanishes from /proc, including from the supervisor that spawned it. The supervisor watches its task with psutil, psutil goes looking for /proc/<pid>, finds nothing, and raises NoSuchProcess. The task is killed off before it does any real work.
So you get two hardening mechanisms that are each correct on their own, and together they make Airflow unable to see its own children.
Thirty lines of C
Airflow reachesprctl the way a lot of Python does: through ctypes.CDLL(None).prctl(...), which resolves the libc symbol at runtime with dlsym. Anything resolved that way can be intercepted with LD_PRELOAD. That’s the whole opening.
The interposer swallows exactly one call, the “make me non-dumpable” one, and passes every other prctl straight through to the real implementation:
LD_PRELOAD on the process that runs tasks:
PR_SET_DUMPABLE with a non-zero value, or any other prctl option, goes through untouched.
Now your tasks run.
The variables you set regardless
Whichever isolation level you land on, Airflow needs a handful of values set as environment variables on the environment before the first deploy. The sensitive ones should be set as sensitive variables so they stay out of logs and the config file.
The rest, the database connection, the executor choice, the DAGs folder, and the log folder, live in the config. Airflow reads any
AIRFLOW__SECTION__KEY environment variable as a config override, which is what makes it comfortable to run this way. The database connection comes from the Upsun relationship, decoded into a SQLAlchemy URL at startup and exported as AIRFLOW__DATABASE__SQL_ALCHEMY_CONN.
The interesting part: how much isolation do you want back?
Airflow turned that protection on for a reason. By neutralizing it, you’ve reopened the door it was closing: two tasks that share a container can read each other’s memory, environment variables, and secrets. Whether that matters depends entirely on who writes your DAGs. If every DAG in your deployment comes from the same team, targets the same data, and holds the same secrets, then a task reading another task’s environment is not a threat. You’ve decided the blast radius is one team’s own code. That’s a legitimate choice, and it buys you the simplest setup. If your DAGs come from different teams, or handle secrets that shouldn’t mix, that shared container is a problem you’ll want to fix. Upsun gives you a few ways up that ladder, and they cost progressively more to run.You don’t need the isolation
The plainest deployment runs the whole of Airflow in one container: the API server, the scheduler, the triggerer, the DAG processor, and the workers, all supervised together. Tasks run as subprocesses next to everything else. One thing worth flagging from experience: give that container real headroom. Airflow’s scheduler and workers are memory-hungry, and forking task subprocesses out of a cramped container is where you meet flaky, hard-to-reproduce failures. A generous container profile with guaranteed resources pays for itself in debugging time you don’t spend. The next step out is a dedicated worker. The web container keeps the scheduler and the UI, and the task execution moves to its own container that you can size and scale on its own:localhost, the worker reaches Airflow’s execution API over the environment’s public route instead. This still runs multiple tasks side by side in the worker container, so the isolation trade-off is unchanged. What you gain is the ability to scale scheduling and execution independently, which is often the real reason to split them.
You want the isolation, cheaply
The cheapest way to get isolation back is to stop running tasks side by side. Set your executor parallelism to 1, and a task never shares its container with another task, because there is no other task. Nothing to peek at. This is a real option for low-volume pipelines, and it costs nothing extra. It also serializes everything, so it falls apart the moment you need two things to run at once. For a nightly batch that walks a handful of steps, that can be perfectly fine. For anything with fan-out, it isn’t.You want the isolation, properly
The version I’m happiest with uses task containers. A task container is an on-demand, run-to-completion container: something triggers it through the API, it runs a single command with full access to your environment’s services, and it’s destroyed when the command exits. Run each Airflow task in its own task container and the isolation question disappears. Nothing else shares that container, so the non-dumpable protection you removed was protecting against a neighbor that no longer exists. TheLD_PRELOAD shim is still needed for psutil to see the process, but its only downside is gone.
Airflow doesn’t ship with an executor that knows how to do this, so you write one. Airflow 3’s executor interface is small: the scheduler hands you ExecuteTask workloads, each carrying the task’s identity and a short-lived API token, and you decide where they run. Instead of enqueueing to Celery, the executor triggers a task container and passes the serialized workload in as an environment variable:
Why go to the trouble
At the end of the ladder you have Airflow running the way it’s meant to, and you have it inside an Upsun environment. That second part is what makes the effort worth it. Branch an environment and Upsun clones its data, so a staging copy of your Airflow comes up with a real Postgres database instead of an empty one. Add object storage and the buckets clone alongside it, so your DAGs run against realistic data in staging without touching production. You get scoped secrets, per-environment variables, and the same container hardening this whole article started with, applied to a data platform you can branch, test, and throw away. The 30 lines of C get you in the door. The isolation model is the choice worth making deliberately, and it’s nice that the platform lets you make it at all.Task containers are in prerelease. Read the task containers documentation to see how they work and request access on your project.