2025, Oct 20 16:16
Почему Wand/ImageMagick в Django падает под gunicorn и как это исправить
После апгрейда до Ubuntu 24 LTS превью PDF в Django через Wand/ImageMagick падает под gunicorn: не находится Ghostscript. Покажем причину (PATH) и два решения.
После миграции сервера с Ubuntu 22 LTS на 24 LTS давняя функция в Django, отвечающая за рендер PNG‑превью первой страницы PDF, стала падать в продакшене. Та же конверсия работала из командной строки и даже в Django shell в том же виртуальном окружении, но ломалась при запуске через административный интерфейс за gunicorn и nginx. Ошибка выполнения от Wand и ImageMagick — классический сбой делегата.
Проблема в контексте
Приложение читает PDF, растрирует только первую страницу и возвращает PNG‑blob. Основная логика предельно проста и годами была стабильной. Сейчас окружение: Ubuntu 24 LTS, Python 3.12.3, Django 5.2.4, Wand 0.6.13, nginx 1.24.0 и gunicorn 23.0.0; Docker не используется. При загрузке процесс падает с ошибкой чтения ImageMagick о недостающем делегате.
MagickReadImage возвращает false, но не выбрасывает исключение ImageMagick. Это может происходить, когда отсутствует делегат или он возвращает EXIT_SUCCESS, не создав растр.
Минимальный код, воспроизводящий сбой
Сердце генерации превью — Wand и ImageMagick: берём первую страницу PDF и получаем PNG‑blob. Логика проста и не менялась; здесь различаются лишь локальные имена:
from wand.image import Image, Color
def pdf_preview_bytes(src_path):
    with Image(filename=src_path) as doc_img:
        with Image(doc_img.sequence[0]) as page_zero:
            with page_zero.convert('png') as png_page:
                png_page.background_color = Color('white')
                png_page.alpha_channel = 'remove'
                return png_page.make_blob()
Что на самом деле не так
Wand — это привязка к ImageMagick. А ImageMagick, в свою очередь, делегирует растрирование PDF Ghostscript. На обновлённой системе прямые конверсии работают: Ghostscript успешно превращает PDF в PNG, команда convert у ImageMagick тоже проходит. Тот же фрагмент Wand успешно выполняется в Django shell внутри venv проекта. Однако код, запускаемый из админки за gunicorn, падает с ошибкой делегата. Ключевая подсказка: в том рантайме Ghostscript не фигурирует в выводе конфигурации делегатов ImageMagick, и сообщение об ошибке явно говорит о недостающем делегате. Корневая причина в том, что gunicorn был запущен с урезанным PATH: в нём была только директория bin виртуального окружения, без /usr/bin, где лежит gs. В итоге ImageMagick не находил Ghostscript во время выполнения, когда его вызывал Wand в веб‑воркер‑процессе.
Политика, связанная с PDF, уже была достаточно разрешительной, а прямые CLI‑конверсии подтверждали, что весь инструментарий установлен и работает. Несоответствие оказалось чисто средовым — между интерактивными шеллами и сервисным юнитом.
Исправление 1: сделайте Ghostscript доступным в сервисе gunicorn
Решение — обеспечить, чтобы процесс gunicorn наследовал PATH с директорией, где находится системный бинарник Ghostscript. Если вы управляете gunicorn через systemd, добавьте /usr/bin в PATH в конфигурации сервиса (оставив bin виртуального окружения первым), затем перезапустите сервис. После этого существующий код с Wand работает как прежде.
[Service]
Environment="PATH=/path/to/venv/bin:/usr/bin"
Когда gunicorn стартует с таким окружением, ImageMagick находит Ghostscript, цепочка делегатов восстанавливается, и приведённый выше код Wand возвращает ожидаемые PNG‑байты без каких‑либо правок.
Исправление 2: обойти делегаты и вызывать Ghostscript напрямую
В качестве альтернативы, чтобы исключить любые проблемы с обнаружением делегатов, можно вызывать Ghostscript напрямую и возвращать PNG‑байты. Это повторяет исходное поведение: получается PNG первой страницы с белым фоном и без альфа‑канала, и такой результат можно подставить туда, где раньше возвращался blob.
import os
import subprocess
import tempfile
# Опции Ghostscript повторяют исходное поведение
_GS_FLAGS = "-dSAFER -sDEVICE=png16m -r120 -dFirstPage=1 -dLastPage=1 -dGraphicsAlphaBits=4 -dTextAlphaBits=4 -o"
def pdf_cover_blob(pdf_path, gs_exec="gs"):
    with tempfile.TemporaryDirectory() as tmp_dir:
        out_png = os.path.join(tmp_dir, "page.png")
        cmd = [gs_exec] + _GS_FLAGS.split() + [out_png, pdf_path]
        subprocess.run(cmd, check=True)
        with open(out_png, "rb") as fh:
            return fh.read()
Тип возвращаемого значения совпадает с png_page.make_blob(), так что это можно использовать как запасной вариант, если вы хотите полностью убрать зависимость от конфигурации делегатов ImageMagick во время выполнения.
Почему это важно
Сервисы, которые запускает менеджер процессов вроде systemd, часто получают более жёсткое или просто иное окружение, чем ваш шелл. Эту разницу легко упустить при отладке, особенно после обновления ОС, когда меняются пути пакетов или делегаты. Рендеринг PDF в Wand зависит не только от самого Python‑пакета, но и от способности ImageMagick найти Ghostscript. Если у воркер‑процесса усечённый PATH, вы увидите расплывчатые ошибки вроде «delegate missing», даже когда все компоненты установлены и безупречно работают из командной строки.
Практические выводы
Если связка Wand + ImageMagick падает при конвертации PDF только в продакшене за gunicorn и nginx, но работает в обычном шелле и в Django shell, сперва проверьте окружение рантайма. Убедитесь, что PATH сервиса gunicorn включает /usr/bin, чтобы Ghostscript можно было найти. Если хотите полностью отвязаться от конфигурации делегатов, надёжной альтернативой будет прямой вызов Ghostscript через subprocess, который возвращает PNG‑байты. Оба подхода возвращают исходное поведение без изменений логики или результата вашего приложения.