DEV Community

Akash Pal
Akash Pal

Posted on

Taming Zombie Chromium in Docker: Why We Added Tini as PID 1

Html2Pdf is officially live in production:

If you have ever run headless Chromium, Puppeteer, or Playwright inside a Docker container in production, you might have run into a quiet, insidious issue: zombie processes piling up in your process table, container shutdowns that hang for 10 seconds before being brutally killed by SIGKILL, or random container crashes under high traffic.

In our project—html2pdf, a high-performance HTML-to-PDF microservice built with Fastify, Puppeteer, and Node 22—we recently ran into this exact challenge.

Here is the story of how zombie processes were sneaking into our containers, the hidden quirks of Linux's PID 1, and how adding tini in a two-line Dockerfile change fixed the problem for good.


1. The Context: Headless Chromium in Production

Our backend service (api/) accepts HTML payloads, renders pixel-perfect documents using headless Chromium, uploads the resulting PDFs to cloud storage, and returns presigned URLs.

Under the hood, every PDF generation delegates to Puppeteer:

// api/helpers/pdfHelper.ts
export async function saveHtmlToPdf(htmlString: string, jobId: string, options: PdfOptions = {}) {
    const browser = await puppeteer.launch({
        headless: true,
        args: [
            '--no-sandbox',
            '--disable-setuid-sandbox',
            // ...
        ],
    });
    const page = await browser.newPage();
    await page.setContent(htmlString, { waitUntil: 'load' });
    const pdfBuffer = await page.pdf({ /* format, margins, headers */ });
    await browser.close();
    // ... upload and return
}
Enter fullscreen mode Exit fullscreen mode

This works smoothly on local machines. But inside a Linux container, headless Chromium is not just a single process. It is a multi-process architecture consisting of:

  • The Browser parent process
  • The Zygote process (which forks new renderer processes)
  • Renderer processes (one per tab or isolated context)
  • The GPU process
  • Utility / Network / Crash-reporter subprocesses

When you run hundreds or thousands of PDF conversions per hour, dozens of these child processes are spawned, executed, and closed continuously.

And that is where container physics starts to work against you.


2. The Problem: The Zombie Process Apocalypse

When a Linux process finishes executing, it doesn't immediately vanish from the system. It enters a state called TASK_DEAD or ZOMBIE ([defunct] in ps output). The operating system keeps an entry in the process table holding its PID, exit code, and resource statistics so that its parent can read it using the wait() or waitpid() system calls.

Once the parent reads the exit status, the zombie is cleared ("reaped").

[Parent Process]
     │
     ├─ forks ──► [Child Subprocess]
     │                    │
     │                 executes
     │                    │
     │                 terminates
     │                    ▼
     │            [ZOMBIE / DEFUNCT]
     │                    │
     ├─ waitpid() ────────┘ (Reaped & Removed)
     ▼
Enter fullscreen mode Exit fullscreen mode

What happens when the parent dies or crashes?

If a parent process terminates before its children, those children become orphans. In a standard Linux operating system, orphaned processes are immediately reparented to the ancestor process: PID 1 (traditionally init or systemd).

PID 1 has a fundamental operating system contract:

  1. Adopt all orphaned processes.
  2. Constantly call waitpid(-1, ...) upon receiving SIGCHLD to reap zombies.

The Docker Catch: Node.js as PID 1

In a typical Docker container:

CMD ["node", "index.js"]
Enter fullscreen mode Exit fullscreen mode

Because node is the first process launched in the container, Node.js runs as PID 1.

Container PID Namespace
└─ PID 1: node index.js
    └─ PID 14: chromium (browser)
        └─ PID 18: chromium (renderer)
        └─ PID 22: chromium (crash-reporter)
Enter fullscreen mode Exit fullscreen mode

Here lies the architectural friction:

  • Node.js is an application runtime, not an OS init system. Node does not listen for SIGCHLD signals from arbitrary orphaned processes, nor does it run a reaping loop.
  • When Chromium renderers crash, timeout, or get detached, they are reparented to PID 1 (Node).
  • Node ignores them. The defunct process table entries stay in memory forever.

Over time, running ps aux inside the container reveals an ever-growing graveyard:

appuser   14  0.0  0.0      0     0 ?   Z    14:20   0:00 [chromium] <defunct>
appuser   22  0.0  0.0      0     0 ?   Z    14:21   0:00 [chromium] <defunct>
appuser   47  0.0  0.0      0     0 ?   Z    14:23   0:00 [chromium] <defunct>
Enter fullscreen mode Exit fullscreen mode

Eventually, the container exhausts the kernel's process ID limit (kernel.pid_max or container cgroup pids.max), throwing:

Error: spawn ENOMEM / EAGAIN: Resource temporarily unavailable
Enter fullscreen mode Exit fullscreen mode

The Second Catch: Broken Signal Handling (SIGTERM)

Linux gives PID 1 special status regarding signals: the kernel does not register default signal handlers for PID 1.

If PID 1 has not explicitly registered a handler for SIGTERM or SIGINT, the kernel will silently discard the signal.

When deploying a new version to Render, Kubernetes, or Docker:

  1. Docker sends SIGTERM to PID 1.
  2. If Node or the shell doesn't cleanly handle and propagate it to all children, the container ignores it.
  3. Docker waits for a graceful stop timeout (typically 10 seconds).
  4. Docker gives up and sends SIGKILL (exit 137).
  5. In-flight PDF renders are violently severed mid-write, resulting in corrupted files and interrupted HTTP requests.

3. The Solution: Enter tini

To solve both zombie process accumulation and signal propagation, you need an init process acting as PID 1.

While full init systems like systemd or upstart are bloated and inappropriate for minimal Docker images, tini (krallin/tini) was designed specifically for containers:

  • Tiny binary footprint: ~20 KB.
  • Single purpose: Registers as PID 1, spawns your target command as its child, forwards all incoming signals (SIGTERM, SIGINT, etc.) to the child process and process group, and reliably reaps any adopted zombie children.
Container PID Namespace with tini
└─ PID 1: tini --
    └─ PID 7: node index.js
        └─ PID 15: chromium
Enter fullscreen mode Exit fullscreen mode

Why not just use docker run --init?

Docker supports an --init flag which injects a built-in tini binary into the container. However:

  • Managed PaaS environments (like Render, Google Cloud Run, AWS ECS Fargate, or custom Kubernetes clusters) do not always allow you to pass arbitrary flags like --init to the Docker engine.
  • Packaging tini directly inside your Dockerfile guarantees that the container is self-contained and behaves consistently everywhere—on a developer's laptop, in CI pipelines, and across any production host.

4. How We Implemented Tini in html2pdf

Implementing tini in our production api/Dockerfile took just a few modifications.

Step 1: Install tini in the Runtime Stage

Our production image uses a multi-stage build: node:22-alpine for compiling TypeScript, and node:22-slim (Debian) for runtime dependencies (Chromium, C++ graphics libraries, fontconfig).

We added tini to the apt-get installation list:

# api/Dockerfile (Runtime Stage)
FROM node:22-slim

RUN apt-get update && apt-get install -y --no-install-recommends \
      tini \
      chromium \
      chromium-sandbox \
      fonts-liberation \
      # ... other shared libraries
    && rm -rf /var/lib/apt/lists/*
Enter fullscreen mode Exit fullscreen mode

Step 2: Configure ENTRYPOINT and CMD

Next, we configured tini as the container ENTRYPOINT, passing -- to denote the end of tini options, followed by the normal CMD:

# tini acts as PID 1 to reap zombie Chromium sub-processes and forward signals cleanly.
ENTRYPOINT ["/usr/bin/tini", "--"]

CMD ["node", "index.js"]
Enter fullscreen mode Exit fullscreen mode

Because ENTRYPOINT and CMD are combined in Docker's exec format, the container executes:

/usr/bin/tini -- node index.js
Enter fullscreen mode Exit fullscreen mode

tini launches as PID 1, spawns node index.js as its immediate child, and sits quietly in the background.


5. What We Are Optimizing: The Impact

Problem Area Before tini (Node as PID 1) After tini (tini as PID 1)
Zombie Sub-processes Crashed/exited Chromium helpers persisted as <defunct> entries. Instantly reaped on SIGCHLD. Zero zombie buildup.
PID Resource Leaks Long-running containers under high load risked kernel PID exhaustion (EAGAIN). Clean process table even after 100,000+ conversions.
Container Shutdowns Ignored SIGTERM, hanging until Docker forced SIGKILL after 10s. SIGTERM forwarded instantly to Fastify and Chromium. Clean, graceful exit in <1 second.
Deployment Reliability Active conversions risk corruption on sudden SIGKILL during redeploy. Inflight jobs get time to finish or gracefully abort.
Image & CPU Overhead N/A +23 KB to the image size; 0.0% CPU overhead.

6. Takeaways for Containerized Node & Browser Workloads

If your container does any of the following:

  1. Spawns child processes (child_process.fork, child_process.spawn, execFile).
  2. Runs headless browsers (Puppeteer, Playwright, Selenium).
  3. Executes CLI tools dynamically (ffmpeg, ImageMagick, pandoc).

Do not let Node.js or Bash be your PID 1 without an init helper.

Using tini is a minimal, low-risk, high-return infrastructure upgrade:

  • Two lines in your Dockerfile.
  • Bulletproof process lifecycle.
  • Zero surprises in production.

Top comments (0)