WebP/AVIF Изображения в WordPress: Автоматическая конвертация через Nginx

Изображения составляют 50-80% веса типичной WordPress-страницы. Даже при включённом lazy loading и правильных размерах, JPEG и PNG остаются неоправданно тяжёлыми. Современные форматы WebP и AVIF обеспечивают то же визуальное качество при размере на 30-70% меньше, что напрямую влияет на метрики Core Web Vitals (LCP, CLS).

Популярные плагины вроде ShortPixel или Imagify конвертируют изображения при загрузке в WordPress, что создаёт три проблемы: нагрузка на PHP при каждой загрузке медиафайла, зависимость от сторонних API (часто платных), и невозможность конвертировать уже загруженные тысячи изображений без долгих процессов. В этом руководстве настраивается автоматическая конвертация на уровне Nginx — изображения конвертируются один раз при первом запросе и кэшируются, а WordPress вообще не знает об этом процессе.

Сравнение форматов изображений

Прежде чем настраивать конвертацию, стоит понять разницу между форматами и их поддержку браузерами.

Формат Сжатие vs JPEG Поддержка браузеров Особенности
JPEG Базовый 100% Устаревший, но универсальный
WebP На 25-35% меньше 97% (все современные) Поддержка прозрачности, анимации
AVIF На 50-70% меньше 92% (Chrome, Firefox, Safari 16+) Лучшее качество, медленнее конвертация

Оптимальная стратегия: отдавать AVIF поддерживающим браузерам, WebP — остальным современным, JPEG — совсем старым (IE11, старые Android). Nginx умеет это делать автоматически через проверку заголовка Accept.

Шаг 1: Проверка поддержки модулей в Nginx

Для автоматической конвертации нужны модули ngx_http_image_filter_module (встроен в большинство сборок) и внешние инструменты конвертации. Проверяем наличие модулей:

nginx -V 2>&1 | grep -o with-http_image_filter_module

Если модуль отсутствует, его нужно установить. Для Ubuntu/Debian:

sudo apt install nginx-extras

Пакет nginx-extras включает все дополнительные модули, включая image_filter.

Шаг 2: Установка инструментов конвертации

Nginx сам не конвертирует изображения — он вызывает внешние утилиты. Устанавливаем cwebp (для WebP) и avifenc (для AVIF):

# Установка WebP tools
sudo apt install webp

# Установка AVIF tools (libavif)
sudo apt install libavif-bin

# Проверка установки
cwebp -version
avifenc --version

Если libavif-bin недоступен в репозиториях (для старых Ubuntu), устанавливаем через snap или компилируем из исходников. Альтернатива — использовать ImageMagick, который поддерживает оба формата:

sudo apt install imagemagick
convert -version | grep -i "avif\|webp"

Шаг 3: Настройка Nginx для автоматической конвертации

Создаём отдельный location-блок для обработки изображений. Открываем конфиг сайта /etc/nginx/sites-available/wordpress и добавляем перед основным location /:

# Кэш для конвертированных изображений
proxy_cache_path /var/cache/nginx/images levels=1:2 keys_zone=images_cache:10m max_size=10g inactive=60m;

# Обработка изображений с автоматической конвертацией
location ~* \.(jpe?g|png)$ {
    # Проверяем поддержку AVIF браузером
    set $avif_support 0;
    if ($http_accept ~* "avif") {
        set $avif_support 1;
    }
    
    # Проверяем поддержку WebP
    set $webp_support 0;
    if ($http_accept ~* "webp") {
        set $webp_support 1;
    }
    
    # Приоритет: AVIF > WebP > оригинал
    set $image_format "original";
    if ($webp_support = 1) {
        set $image_format "webp";
    }
    if ($avif_support = 1) {
        set $image_format "avif";
    }
    
    # Пробуем отдать конвертированную версию из кэша
    try_files /wp-content/uploads-cache/$image_format$uri.$image_format @convert_$image_format;
    
    # Заголовки для кэширования
    expires 30d;
    add_header Cache-Control "public, immutable";
    add_header Vary "Accept";
}

# Конвертация в AVIF
location @convert_avif {
    alias /var/www/wordpress;
    
    # Вызываем скрипт конвертации
    content_by_lua_block {
        local uri = ngx.var.uri
        local source = "/var/www/wordpress" .. uri
        local cache_dir = "/var/www/wordpress/wp-content/uploads-cache/avif"
        local dest_dir = cache_dir .. string.match(uri, "(.*/)")
        local dest = cache_dir .. uri .. ".avif"
        
        -- Создаём директорию, если не существует
        os.execute("mkdir -p " .. dest_dir)
        
        -- Конвертируем через avifenc (качество 65, скорость 6)
        local cmd = "avifenc --min 20 --max 30 --speed 6 " .. source .. " " .. dest .. " 2>/dev/null"
        os.execute(cmd)
        
        -- Если конвертация успешна, отдаём файл
        if os.execute("test -f " .. dest) then
            ngx.header["Content-Type"] = "image/avif"
            ngx.say(ngx.location.capture("/internal_avif" .. uri .. ".avif").body)
        else
            -- Fallback на оригинал
            ngx.exec("@serve_original")
        end
    }
}

# Конвертация в WebP
location @convert_webp {
    alias /var/www/wordpress;
    
    content_by_lua_block {
        local uri = ngx.var.uri
        local source = "/var/www/wordpress" .. uri
        local cache_dir = "/var/www/wordpress/wp-content/uploads-cache/webp"
        local dest_dir = cache_dir .. string.match(uri, "(.*/)")
        local dest = cache_dir .. uri .. ".webp"
        
        os.execute("mkdir -p " .. dest_dir)
        
        -- Конвертируем через cwebp (качество 80)
        local cmd = "cwebp -q 80 " .. source .. " -o " .. dest .. " 2>/dev/null"
        os.execute(cmd)
        
        if os.execute("test -f " .. dest) then
            ngx.header["Content-Type"] = "image/webp"
            ngx.say(ngx.location.capture("/internal_webp" .. uri .. ".webp").body)
        else
            ngx.exec("@serve_original")
        end
    }
}

# Отдача оригинала (fallback)
location @serve_original {
    alias /var/www/wordpress;
    expires 30d;
    add_header Cache-Control "public, immutable";
}

# Внутренние location для отдачи конвертированных файлов
location ~* ^/internal_(avif|webp)(.*\.(avif|webp))$ {
    internal;
    alias /var/www/wordpress/wp-content/uploads-cache/$1$2;
}

Конфиг использует Lua-модуль (lua-nginx-module), который встроен в nginx-extras. Если Lua недоступен, можно использовать альтернативный подход через image_filter или внешние скрипты.

Шаг 4: Альтернативный метод без Lua (через image_filter)

Если Lua-модуль не установлен или не работает, используется встроенный image_filter для конвертации. Этот метод проще, но менее гибкий:

# Простая конвертация через image_filter (только WebP)
location ~* \.(jpe?g|png)$ {
    set $webp "";
    
    # Проверяем поддержку WebP
    if ($http_accept ~* "webp") {
        set $webp "1";
    }
    
    # Если WebP поддерживается и файл существует в кэше
    if (-f $request_filename.webp) {
        set $webp "${webp}2";
    }
    
    # Отдаём WebP версию
    if ($webp = "12") {
        rewrite ^(.*)$ $1.webp break;
    }
    
    # Иначе конвертируем на лету
    image_filter convert webp;
    image_filter_jpeg_quality 80;
    image_filter_buffer 10M;
    
    # Кэшируем результат
    image_filter_store /var/www/wordpress/wp-content/uploads-cache$uri.webp;
    
    expires 30d;
    add_header Cache-Control "public, immutable";
    add_header Vary "Accept";
}

Этот метод проще, но не поддерживает AVIF и менее эффективен для больших изображений.

Шаг 5: Предварительная конвертация существующих изображений

Конвертация на лету при первом запросе создаёт задержку для пользователя. Чтобы этого избежать, конвертируем все существующие изображения заранее через bash-скрипт:

#!/bin/bash
# /usr/local/bin/convert-images.sh

UPLOADS_DIR="/var/www/wordpress/wp-content/uploads"
CACHE_DIR="/var/www/wordpress/wp-content/uploads-cache"

# Создаём структуру кэша
mkdir -p "$CACHE_DIR/webp"
mkdir -p "$CACHE_DIR/avif"

# Находим все JPEG и PNG файлы
find "$UPLOADS_DIR" -type f \( -iname "*.jpg" -o -iname "*.jpeg" -o -iname "*.png" \) | while read file; do
    # Вычисляем относительный путь
    rel_path="${file#$UPLOADS_DIR}"
    
    # Конвертируем в WebP
    webp_dest="$CACHE_DIR/webp$rel_path.webp"
    mkdir -p "$(dirname "$webp_dest")"
    if [ ! -f "$webp_dest" ]; then
        cwebp -q 80 "$file" -o "$webp_dest"
        echo "WebP: $rel_path"
    fi
    
    # Конвертируем в AVIF
    avif_dest="$CACHE_DIR/avif$rel_path.avif"
    mkdir -p "$(dirname "$avif_dest")"
    if [ ! -f "$avif_dest" ]; then
        avifenc --min 20 --max 30 --speed 6 "$file" "$avif_dest" 2>/dev/null
        echo "AVIF: $rel_path"
    fi
done

echo "Конвертация завершена"

Делаем скрипт исполняемым и запускаем:

sudo chmod +x /usr/local/bin/convert-images.sh
sudo /usr/local/bin/convert-images.sh

Для сайтов с тысячами изображений процесс может занять несколько часов. Запускаем в screen или tmux, чтобы скрипт не прервался при закрытии SSH-сессии.

Шаг 6: Автоматическая конвертация новых загрузок

Чтобы новые изображения, загружаемые через WordPress, автоматически конвертировались, настраиваем inotify для отслеживания изменений в папке uploads:

# Установка inotify-tools
sudo apt install inotify-tools

Создаём systemd-сервис для постоянного мониторинга:

# /etc/systemd/system/image-watcher.service
[Unit]
Description=Watch WordPress uploads for new images
After=network.target

[Service]
Type=simple
User=www-data
ExecStart=/usr/local/bin/watch-uploads.sh
Restart=always

[Install]
WantedBy=multi-user.target

Создаём скрипт мониторинга:

#!/bin/bash
# /usr/local/bin/watch-uploads.sh

UPLOADS_DIR="/var/www/wordpress/wp-content/uploads"
CACHE_DIR="/var/www/wordpress/wp-content/uploads-cache"

inotifywait -m -r -e create -e moved_to --format '%w%f' "$UPLOADS_DIR" | while read file; do
    # Проверяем, что это изображение
    if [[ "$file" =~ \.(jpe?g|png)$ ]]; then
        rel_path="${file#$UPLOADS_DIR}"
        
        # Конвертируем в WebP
        webp_dest="$CACHE_DIR/webp$rel_path.webp"
        mkdir -p "$(dirname "$webp_dest")"
        cwebp -q 80 "$file" -o "$webp_dest" 2>/dev/null
        
        # Конвертируем в AVIF
        avif_dest="$CACHE_DIR/avif$rel_path.avif"
        mkdir -p "$(dirname "$avif_dest")"
        avifenc --min 20 --max 30 --speed 6 "$file" "$avif_dest" 2>/dev/null
        
        echo "Converted: $rel_path"
    fi
done

Включаем и запускаем сервис:

sudo chmod +x /usr/local/bin/watch-uploads.sh
sudo systemctl daemon-reload
sudo systemctl enable image-watcher
sudo systemctl start image-watcher

Теперь при каждой загрузке изображения через WordPress оно автоматически конвертируется в оба формата в течение нескольких секунд.

Шаг 7: Отключение плагинов конвертации в WordPress

Если раньше использовались плагины вроде ShortPixel, Imagify или EWWW Image Optimizer, их нужно деактивировать, чтобы избежать двойной конвертации и конфликтов:

# Через WP-CLI
wp plugin deactivate shortpixel-image-optimiser
wp plugin deactivate imagify
wp plugin deactivate ewww-image-optimizer

Также отключаем встроенную в WordPress 5.8+ автоматическую конвертацию в WebP (если она включена), чтобы не создавать дубликаты:

// В functions.php темы
add_filter( 'image_editor_output_format', function( $formats ) {
    // Отключаем автоматическую конвертацию WordPress
    $formats['image/jpeg'] = 'image/jpeg';
    $formats['image/png'] = 'image/png';
    return $formats;
} );

Шаг 8: Настройка кэширования и очистки

Конвертированные изображения занимают место на диске. Настраиваем автоматическую очистку старых файлов и удаление кэша при удалении оригиналов.

Очистка старых файлов кэша:

Добавляем в cron задачу для удаления конвертированных версий удалённых оригиналов:

#!/bin/bash
# /usr/local/bin/clean-image-cache.sh

UPLOADS_DIR="/var/www/wordpress/wp-content/uploads"
CACHE_DIR="/var/www/wordpress/wp-content/uploads-cache"

# Находим все файлы в кэше
find "$CACHE_DIR" -type f \( -name "*.webp" -o -name "*.avif" \) | while read cached_file; do
    # Вычисляем путь к оригиналу
    rel_path="${cached_file#$CACHE_DIR/webp}"
    rel_path="${rel_path%.webp}"
    rel_path="${rel_path%.avif}"
    original="$UPLOADS_DIR$rel_path"
    
    # Если оригинал не существует, удаляем кэш
    if [ ! -f "$original" ]; then
        rm -f "$cached_file"
        echo "Removed: $cached_file"
    fi
done

Добавляем в cron для еженедельного запуска:

0 3 * * 0 /usr/local/bin/clean-image-cache.sh

Очистка при удалении медиафайла через WordPress:

Добавляем хук в functions.php для удаления кэша при удалении изображения из медиатеки:

add_action( 'delete_attachment', function( $post_id ) {
    $file = get_attached_file( $post_id );
    $upload_dir = wp_upload_dir();
    $rel_path = str_replace( $upload_dir['basedir'], '', $file );
    
    $cache_dir = $upload_dir['basedir'] . '/uploads-cache';
    
    // Удаляем WebP версию
    $webp_file = $cache_dir . '/webp' . $rel_path . '.webp';
    if ( file_exists( $webp_file ) ) {
        unlink( $webp_file );
    }
    
    // Удаляем AVIF версию
    $avif_file = $cache_dir . '/avif' . $rel_path . '.avif';
    if ( file_exists( $avif_file ) ) {
        unlink( $avif_file );
    }
} );

Шаг 9: Мониторинг экономии трафика

Чтобы оценить эффективность конвертации, анализируем размеры файлов и трафик:

# Сравнение размеров оригиналов и конвертированных версий
echo "=== Статистика конвертации ==="

original_size=$(du -sh /var/www/wordpress/wp-content/uploads | awk '{print $1}')
webp_size=$(du -sh /var/www/wordpress/wp-content/uploads-cache/webp | awk '{print $1}')
avif_size=$(du -sh /var/www/wordpress/wp-content/uploads-cache/avif | awk '{print $1}')

echo "Оригиналы: $original_size"
echo "WebP: $webp_size"
echo "AVIF: $avif_size"

# Подсчёт средней экономии
find /var/www/wordpress/wp-content/uploads -type f \( -name "*.jpg" -o -name "*.jpeg" \) | head -100 | while read file; do
    rel_path="${file#/var/www/wordpress/wp-content/uploads}"
    webp_file="/var/www/wordpress/wp-content/uploads-cache/webp$rel_path.webp"
    avif_file="/var/www/wordpress/wp-content/uploads-cache/avif$rel_path.avif"
    
    if [ -f "$webp_file" ] && [ -f "$avif_file" ]; then
        orig_size=$(stat -c%s "$file")
        webp_size=$(stat -c%s "$webp_file")
        avif_size=$(stat -c%s "$avif_file")
        
        webp_save=$((100 - (webp_size * 100 / orig_size)))
        avif_save=$((100 - (avif_size * 100 / orig_size)))
        
        echo "$rel_path: WebP -${webp_save}%, AVIF -${avif_save}%"
    fi
done

Типичная экономия: WebP — 25-40%, AVIF — 50-70% от размера оригинального JPEG.

Шаг 10: Интеграция с CDN (Cloudflare, BunnyCDN)

Если используется CDN, нужно убедиться, что он корректно кэширует разные форматы изображений для разных браузеров. Критично правильный заголовок Vary: Accept, который уже добавлен в конфиг Nginx.

Настройка Cloudflare:

В Cloudflare включаем Polish (автоматическая конвертация в WebP) только если не используется собственная конвертация через Nginx, чтобы избежать двойной обработки. Если Nginx уже конвертирует изображения, Polish нужно отключить.

Настройка Page Rules:

Для корректного кэширования разных форматов создаём Page Rule:

# URL: custom-code.ru/wp-content/uploads/*
# Settings:
# - Cache Level: Cache Everything
# - Edge Cache TTL: 1 month
# - Browser Cache TTL: 1 year

Чек-лист настройки конвертации изображений

  • ✅ Установлены инструменты cwebp и avifenc
  • ✅ Nginx настроен для автоматической конвертации с fallback
  • ✅ Существующие изображения конвертированы через скрипт
  • ✅ Настроен inotify для автоматической конвертации новых загрузок
  • ✅ Плагины конвертации в WordPress деактивированы
  • ✅ Встроенная конвертация WordPress отключена через фильтр
  • ✅ Настроена автоматическая очистка кэша
  • ✅ CDN настроен для корректного кэширования с Vary: Accept
  • ✅ Экономия трафика измерена и задокументирована

Решение частых проблем

Изображения не конвертируются (отдаётся оригинал)

Проверяем логи Nginx: sudo tail -f /var/log/nginx/error.log. Частые причины:

  • Недостаточно прав у Nginx на запись в uploads-cache — выполняем sudo chown -R www-data:www-data /var/www/wordpress/wp-content/uploads-cache
  • Не установлены cwebp или avifenc — проверяем через which cwebp
  • Lua-модуль не загружен — проверяем через nginx -V | grep lua

Конвертация слишком медленная

AVIF конвертируется в 10-50 раз медленнее WebP. Для больших изображений (больше 2 МБ) процесс может занимать 10-30 секунд. Решения:

  • Увеличиваем параметр --speed в avifenc (от 0 до 10, где 10 — быстрее, но хуже качество)
  • Конвертируем только изображения меньше определённого размера
  • Используем предварительную конвертацию через скрипт вместо on-the-fly

Браузер не получает WebP/AVIF

Проверяем заголовки ответа через DevTools (Network → изображение → Response Headers). Должны быть:

  • Content-Type: image/webp или image/avif
  • Vary: Accept

Если отдаётся image/jpeg, значит Nginx не распознал поддержку формата. Проверяем, что браузер отправляет заголовок Accept: image/avif,image/webp,image/apng,image/*,*/*;q=0.8.

CDN кэширует только JPEG для всех браузеров

Проблема в отсутствии заголовка Vary: Accept. CDN должен кэшировать разные версии для разных значений Accept. Убеждаемся, что заголовок добавлен в конфиг Nginx и не удаляется CDN. В Cloudflare проверяем настройки Caching → Configuration → Browser Cache TTL.

После конвертации изображения выглядят хуже

Увеличиваем качество конвертации. Для WebP меняем -q 80 на -q 90. Для AVIF меняем --min 20 --max 30 на --min 30 --max 40. Это увеличит размер файлов, но улучшит качество.

Итог

Автоматическая конвертация изображений через Nginx — наиболее эффективный способ оптимизации медиафайлов в WordPress. Конвертация происходит один раз, результаты кэшируются, а WordPress продолжает работать как обычно, не зная о процессе. Комбинация WebP и AVIF с правильным fallback даёт экономию трафика до 70%, что напрямую улучшает метрики Core Web Vitals и скорость загрузки сайта. В сочетании с FastCGI Cache и Redis Object Cache это создаёт полноценный стек оптимизации для высоконагруженных WordPress-проектов.