Profiling a Dockerized Moodle with Xdebug and QCachegrind on macOS

If you run Moodle under moodle-docker on a Mac and a page feels slow, eyeballing the code only gets you so far. The honest answer is a profiler. 🐳 Here’s how I wired up Xdebug for both step-debugging and on-demand profiling, dumped the results to my host, and opened them in QCachegrind — plus two gotchas that ate an hour before I spotted them.

Why bake Xdebug into the image

The official moodlehq/moodle-php-apache image ships without Xdebug on purpose, and the documented fix is a runtime pecl install. That works until your next down/up, when the container is recreated from the bare base image and your extension vanishes. So instead I extended the base image with a tiny Dockerfile and wired it into local.yml, which moodle-docker auto-includes. Now Xdebug survives rebuilds.

1
2
3
4
5
ARG MOODLE_DOCKER_PHP_VERSION=8.3
FROM moodlehq/moodle-php-apache:${MOODLE_DOCKER_PHP_VERSION}
# Idempotent: skip the install if a prior build already baked Xdebug in.
RUN php -m | grep -qi '^xdebug$' || (pecl install xdebug && docker-php-ext-enable xdebug)
COPY xdebug.ini /usr/local/etc/php/conf.d/zz-xdebug.ini

The config runs Xdebug in both debug and profile mode, but only when triggered — so normal browsing pays zero overhead. The profiler writes to a directory that I bind-mount to my Mac, so the output files are sitting right there in the host filesystem.

1
2
3
4
5
6
xdebug.mode=debug,profile
xdebug.start_with_request=trigger          ; only fires on ?XDEBUG_TRIGGER=1
xdebug.client_host=host.docker.internal    ; works on Docker Desktop for Mac
xdebug.client_port=9003
xdebug.output_dir=/var/www/xdebug          ; bind-mounted to the host
xdebug.use_compression=0

With that in place, profiling a page is just a query parameter: visit http://localhost:8000/?XDEBUG_TRIGGER=1 and a cachegrind.out.* file lands in the mounted directory. On macOS the tool you want is QCachegrind (the Qt port of KDE’s KCachegrind — KCachegrind itself is Linux/KDE). Install it with brew install qcachegrind graphviz, then open the file:

1
qcachegrind ./xdebug/out/cachegrind.out.*

Gotcha #1: BuildKit’s separate image store

My first build tagged the custom image with the same name as the base image. Two problems compounded. First, that overwrote the base tag in my local store, so the next build’s FROM pulled an already-Xdebugged image and pecl install failed with “already installed”. Second — the sneaky one — BuildKit on Docker Desktop writes to a containerd snapshotter store that the classic runtime doesn’t read from. So I’d rebuild, see “Successfully built”, recreate the container… and it was running stale bytes from a different store. The build and the runtime were looking at two different image stores. 😤

The fix was to give the custom image a distinct tag and build it with the classic builder so it lands where the runtime actually reads:

1
2
3
4
DOCKER_BUILDKIT=0 docker build \
  --build-arg MOODLE_DOCKER_PHP_VERSION=8.3 \
  -t moodle-webserver-xdebug:8.3 \
  ./xdebug

Gotcha #2: Xdebug 3.5 gzips the output by default

This one is quieter. Xdebug 3.5 sets xdebug.use_compression=on by default, so your profiler files come out as cachegrind.out.<host>.<time>.gz. QCachegrind can’t read a gzipped cachegrind file — it just won’t open it, with no especially helpful error. The one-line fix is in the config above: xdebug.use_compression=0. After that the files are plain ASCII text, and QCachegrind opens them happily. 💡

The payoff

Once it’s set up, the loop is tight: hit a slow page with the trigger parameter, alt-tab to QCachegrind, and the call map shows you exactly which functions are eating the wall-clock and how many times they’re called. For a sprawling codebase like Moodle — where one page render can fan out into hundreds of database calls and plugin hooks — that call graph is worth far more than another round of guessing. 🎉

This entry was posted in Moodle and tagged , , , , . Bookmark the permalink.

Comments are closed.