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

**Html2Pdf** is officially live in production:

- **Live Web App & Interactive Demo**: [html2pdf.akcraft.app](https://html2pdf.akcraft.app/)
- **Official TypeScript / ESM SDK**: [@akcraft/html2pdf-sdk](https://www.npmjs.com/package/@akcraft/html2pdf-sdk)
- **Live Web API**: [api.html2pdf.akcraft.app](https://api.html2pdf.akcraft.app/)

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**](https://html2pdf.akcraft.app/), 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:

```typescript
// 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
}
```

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"**).

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

### 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:

```dockerfile
CMD ["node", "index.js"]
```

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

```plaintext
Container PID Namespace
└─ PID 1: node index.js
    └─ PID 14: chromium (browser)
        └─ PID 18: chromium (renderer)
        └─ PID 22: chromium (crash-reporter)
```

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:

```bash
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>
```

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

```plaintext
Error: spawn ENOMEM / EAGAIN: Resource temporarily unavailable
```

### 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](https://github.com/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.

```plaintext
Container PID Namespace with tini
└─ PID 1: tini --
    └─ PID 7: node index.js
        └─ PID 15: chromium
```

### 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:

```dockerfile
# 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/*
```

### 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`:

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

CMD ["node", "index.js"]
```

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

```bash
/usr/bin/tini -- node index.js
```

`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 &lt;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.
