DEV Community

Cover image for Everything That Broke When I Put Chromium and LibreOffice Inside One Lambda Function
Yuuki Yamashita
Yuuki Yamashita

Posted on

Everything That Broke When I Put Chromium and LibreOffice Inside One Lambda Function

Lambda has supported container images up to 10 GB for years now, which means you can, in theory, ship almost anything as a function: a headless browser, an office suite, a pile of CJK fonts. I recently had a reason to ship all three in the same image, and I want to walk through what actually happened, because the distance between "the container runs on my laptop" and "the container runs on Lambda" turned out to be five distinct failures, none of which appeared in any error message I would call helpful.

The reason first, briefly. I built a small app that turns natural-language chat into Marp slide decks. The browser preview is easy, but users want files: PDF and, more importantly, editable PPTX, the kind where you can click a text box in PowerPoint afterward and fix a typo. Marp renders PDF through Chromium, and its editable PPTX path additionally shells out to LibreOffice Impress. So the export worker needs a browser and an office suite in the same place, invoked a few times an hour at best. That traffic shape is the whole argument for Lambda: a Fargate service would idle around the clock for a function that works maybe twenty minutes a day. One container Lambda, both binaries, scale to zero. That was the plan.

The plan survived contact with Docker just fine. It was Lambda that had opinions.

Failure one: marp-cli hangs forever, silently

The very first invocation didn't error. It just sat there until the function timed out, with nothing in the logs beyond my own "starting conversion" line.

The cause is almost embarrassing. When you spawn a child process inside Lambda's Node.js runtime, the child's stdin is an open pipe by default. marp-cli sees an open stdin and politely waits for Markdown to arrive on it, forever, no matter that you passed an input file as an argument. Nothing about "waiting for stdin" gets printed. The fix is one flag:

const args = ["--no-stdin", "--allow-local-files"];
Enter fullscreen mode Exit fullscreen mode

I have since noticed the same failure mode in other CLIs run inside Lambda. If a child process hangs with no output, suspect stdin before anything exotic.

Failure two: Chromium dies with "Connection closed"

With the hang fixed, Chromium started crashing instead: Error: Connection closed, from somewhere deep inside Marp's puppeteer layer.

This one is structural. Chromium's normal process model assumes things Lambda simply does not provide. It wants to fork a zygote process and sandbox its renderers; Lambda forbids the sandbox. It wants shared memory in /dev/shm; Lambda gives you a tiny one. It wants a writable filesystem in places that are read-only. Any one of these kills the browser during startup, and the error you see is just the connection to a process that already died.

The flags that make Chromium survive this environment are well documented individually:

--no-sandbox --disable-dev-shm-usage --disable-gpu
--disable-software-rasterizer --single-process --no-zygote
Enter fullscreen mode Exit fullscreen mode

The Lambda-specific twist is that marp-cli doesn't expose low-level browser arguments, so there is nowhere to put these flags. The workaround I landed on is a wrapper script baked into the image, registered as the "browser" itself:

RUN printf '#!/bin/sh\nexec /usr/bin/chromium --no-sandbox --disable-dev-shm-usage --disable-gpu --disable-software-rasterizer --single-process --no-zygote "$@"\n' \
      > /usr/local/bin/chromium-marp \
    && chmod +x /usr/local/bin/chromium-marp

ENV CHROME_PATH=/usr/local/bin/chromium-marp
Enter fullscreen mode Exit fullscreen mode

Marp happily accepts the wrapper via --browser-path, executes it as if it were Chromium, and the flags ride along on every launch. This trick generalizes to any tool that lets you point at a browser binary but not at browser flags.

Failure three: LibreOffice wants a home

PDF export now worked. The editable PPTX path still failed, because LibreOffice on first launch tries to create a user profile under $HOME, and in a Lambda container $HOME points somewhere read-only.

The fix has two layers. In the image, set a safe default:

ENV HOME=/tmp
Enter fullscreen mode Exit fullscreen mode

And in the handler, I go one step further and point HOME at the per-invocation work directory I create under /tmp anyway:

await execFileAsync(MARP_BIN, args, {
  cwd: work,
  env: { ...process.env, HOME: work },
  timeout: 115000,
});
Enter fullscreen mode Exit fullscreen mode

That second layer matters more than it looks. Lambda reuses execution environments between invocations, and a stale LibreOffice profile from a previous run can leave lock files behind. Giving every invocation a fresh HOME inside its own mkdtemp directory, deleted in a finally block, means no state survives to sabotage the next request.

Failure four: the font that answers to a different name

My decks are mostly Japanese, so the image bundles fonts-noto-cjk plus seven Japanese font families fetched from the google/fonts repository at build time. Six of them worked immediately. The seventh, M PLUS Rounded 1c, silently fell back to a default font in the rendered PDF while looking perfect in the browser preview.

The TTF's internal family name is not M PLUS Rounded 1c. It is Rounded Mplus 1c. The web preview never noticed because it loads the font from Google Fonts under the public name; fontconfig inside the container only knows the internal one. The CSS fix is to list both:

font-family: 'M PLUS Rounded 1c', 'Rounded Mplus 1c', sans-serif;
Enter fullscreen mode Exit fullscreen mode

If a bundled font mysteriously doesn't apply, fc-list | grep -i <something> inside the container tells you what name fontconfig actually indexed. I now do this check for every font I bundle, because trusting the marketing name cost me an evening.

Failure five: the image is 3.18 GB

Everything worked. And the image was 3.18 GB, for a function whose job takes a few seconds.

To be clear about what the 10 GB limit does and doesn't buy you: Lambda accepts an image this size without complaint, and after the first pull the layers are cached close to the execution environment, so steady-state latency is fine. Where size hurts is the first cold start after every deploy, ECR storage and transfer, and, less tangibly, your own willingness to iterate. Pushing three gigabytes to try a one-line Dockerfile change gets old fast.

So where did the weight come from? Running docker history pointed at something I had stopped seeing: the build toolchain. The Lambda Runtime Interface Client (aws-lambda-ric) compiles native code during npm install, which drags in g++, cmake, automake, python3, and libcurl4-openssl-dev. All of it was still sitting in the final image, doing nothing at runtime. The font-download step likewise left curl and its certificate chain behind.

The fix is the standard one, multi-stage builds, but the way the stages split is worth showing because it maps cleanly onto why each dependency exists:

# Stage 1: fonts — the only stage that needs network tooling
FROM debian:bookworm-slim AS fonts
RUN apt-get update && apt-get install -y --no-install-recommends \
      curl ca-certificates
COPY install-fonts.sh /tmp/
RUN sh /tmp/install-fonts.sh

# Stage 2: builder — the only stage that needs a compiler
FROM node:22-bookworm-slim AS builder
RUN apt-get update && apt-get install -y --no-install-recommends \
      g++ make cmake autoconf automake libtool pkg-config python3 \
      libcurl4-openssl-dev ca-certificates
WORKDIR /build
COPY package.json ./
RUN npm install --omit=dev && npm cache clean --force

# Stage 3: runtime — only what the function touches while running
FROM node:22-bookworm-slim AS runtime
RUN apt-get update && apt-get install -y --no-install-recommends \
      chromium libreoffice-impress fonts-noto-cjk \
      fonts-noto-color-emoji fontconfig \
    && rm -rf /var/lib/apt/lists/*
COPY --from=fonts /usr/share/fonts/truetype/marpfonts /usr/share/fonts/truetype/marpfonts
RUN fc-cache -f /usr/share/fonts/truetype/marpfonts
COPY --from=builder /build/node_modules ./node_modules
COPY package.json index.mjs ./
Enter fullscreen mode Exit fullscreen mode

Result: 3.18 GB down to 2.39 GB, a 790 MB cut, about 25%. Not a heroic number, and I want to be honest about why it stops there: what remains is the product. Chromium, LibreOffice Impress, and CJK fonts are simply large, and no build trick shrinks a font file. The 25% that left was pure waste, compilers and download tools that had no business being in a runtime image, and the discipline of asking "which stage actually needs this?" is what found it.

The boring settings that made it stable

For completeness, the configuration this runs on: arm64, 3,008 MB of memory, a 180-second function timeout with a 115-second timeout on the conversion process itself, and /tmp raised to 2,048 MB because a work directory holding Markdown, theme CSS, and a generated PPTX adds up. The function writes results to S3 and returns a presigned URL rather than the file itself, since a finished deck can exceed Lambda's response size limit, and a one-day lifecycle rule cleans the bucket.

Would I do it again?

Yes, for this traffic shape, without much hesitation. The alternatives all keep something warm: an ECS service, a Fargate task, an EC2 box. For a personal app that converts a handful of decks a day, a function that costs literally nothing between invocations wins, even carrying a 2.39 GB image.

But the general lesson I took is that the 10 GB container limit changes what Lambda accepts, not what Lambda is. The execution environment is still a locked-down, read-only, sandbox-hostile place with a small /dev/shm and a recycled lifecycle, and every large desktop-grade binary you bring carries assumptions that environment will violate. The five failures above are really one failure repeated: software assuming it runs on a normal computer. Lambda is not a normal computer. That is both the constraint and, at this price, the appeal.

The app itself is live at marp-ai-app.vercel.app and the full Dockerfile is in the repo.

Top comments (0)