Перейти к содержимому
Поиск
Разработка

Sharp: обработка изображений на Node.js — мастерская и 8 сценариев

Готовим картинки для каталога, аватаров и обложек. Сравниваем 72 результата в мастерской, запускаем обработчик Sharp и размещаем его на Linux VPS.

Александр Некрасов
Sharp — обработка изображений в Node.js, форматы и размеры

Пользователь загрузил фотографию на несколько мегабайт, а в каталоге она занимает карточку шириной 320 пикселей. Уменьшить её через CSS недостаточно: браузер всё равно скачает большой файл. Для быстрого сайта нужны подходящие размеры, формат, качество и понятный процесс подготовки изображений. Эту серверную работу удобно выполнять через Sharp — библиотеку обработки изображений для Node.js.

В статье разберём восемь прикладных сценариев, сравним реальные файлы в мини-мастерской и запустим собственный обработчик. В исходниках есть интерфейс, Node.js API, пакетная команда, четыре учебные иллюстрации, 72 подготовленных результата, тесты и размещение на Linux VPS. Все примеры используют Sharp 0.35.5. Исходные изображения созданы кодом: их можно свободно использовать для обучения и проверки.

Что такое Sharp и какой результат он даёт

Sharp принимает изображение, выполняет цепочку преобразований и возвращает новый файл или буфер. Он работает на сервере Node.js и использует libvips. В типичном приложении это этап между загрузкой оригинала и выдачей изображения посетителю: проверить вход, исправить ориентацию, подготовить варианты, сохранить результат и записать его параметры в базу.

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

Sharp не является визуальным редактором и не строит интерфейс загрузки. Поля, предпросмотр, очереди, права пользователя и хранение оригиналов остаются частью приложения. Для простого внутреннего инструмента достаточно формы и небольшого API. Для общего медиасервиса потребуется отдельный процесс обработки, хранилище и контроль нагрузки. Эта разница важна уже при выборе места, где будет выполняться преобразование.

Документация: Sharp: назначение и возможности.

Где применять: от каталога до документооборота

В интернет-магазине один оригинал превращается в миниатюру списка, изображение карточки и большую версию галереи. В CRM сотрудник прикладывает фотографию к заявке, а в списке нужна только небольшая превью. В личном кабинете сервис формирует аватар с ограниченным размером. В базе знаний обложки приводятся к единому соотношению сторон, чтобы лента не прыгала при загрузке.

Другой сценарий — подготовка уже накопленной медиатеки. Вы запускаете пакетную команду, создаёте новые варианты рядом с оригиналами, проверяете результат и только затем меняете ссылки в приложении. Такой процесс позволяет внедрить WebP или уменьшенные копии постепенно, не перезаписывая все исходники одной необратимой операцией.

Для документов, схем и скриншотов важнее читаемость тонких линий, чем минимальный вес. Для фотографии важна сохранность деталей и оттенков. Поэтому универсальный рецепт «всё в качество 40» обычно плох. В мастерской можно увидеть различия, но окончательный набор настроек выбирают на реальных изображениях своего продукта и на том масштабе, в котором их увидит пользователь.

Мини-мастерская: восемь задач и 72 результата

Сначала выберите карточку задачи. У каждого сценария свой исходник, размер и режим размещения. Затем переключите WebP, AVIF или JPEG и передвиньте качество между 40, 70 и 90. Справа изменится настоящий файл, рассчитанный Sharp, а ниже — его размер, геометрия и разница относительно оригинала. Полученный вариант можно скачать вместе с JavaScript-рецептом.

На странице статьи нет серверной загрузки пользовательских файлов. Мастерская переключает 72 заранее рассчитанных результата, а не изображает работу Sharp средствами Canvas. Это позволяет безопасно изучать эффекты без отправки фотографий на сайт. В скачиваемом проекте та же страница подключается к Node.js API и показывает дополнительную форму: там собственный файл действительно обрабатывается сервером.

Сравнение веса относится только к нашим четырём учебным иллюстрациям. Процент не означает обещанную экономию на любой фотографии. Мы также не показываем придуманное время обработки: скорость зависит от процессора, входного файла, формата и параметров. На телефоне изображения располагаются друг под другом, а код прокручивается внутри своего блока.

Встроенная мастерская имеет собственную прокрутку. На небольшом экране удобнее открыть её отдельно.

Первый рецепт: вход, преобразование и выход

Цепочка Sharp описывает обработку, но полезный результат появляется при вызове метода вывода, например toFile или toBuffer. Для фонового задания удобно записать файл, для HTTP-ответа — получить Buffer. В обоих случаях обработка асинхронная: await нужен, чтобы узнать об успехе или поймать ошибку, а не продолжать выполнение с ещё не готовым результатом.

Ниже минимальный пример для доверенного локального файла. Путь здесь задаёт разработчик. В пользовательский HTTP API такой параметр переносить нельзя: посетитель не должен выбирать файл на сервере. В рабочем примере из архива сервер получает ограниченное двоичное тело, проверяет его и выбирает только один из разрешённых рецептов.

Сохраните пример как resize.mjs рядом с изображением, установите пакет и запустите Node.js. Для проекта из архива используется npm ci, потому что package-lock.json уже фиксирует зависимости. Команда npm install ниже нужна только для нового чистого эксперимента.

npm install sharp@0.35.5
node resize.mjs
import sharp from 'sharp';

await sharp('input.jpg', { limitInputPixels: 20_000_000 })
  .autoOrient()
  .resize({ width: 960, withoutEnlargement: true })
  .webp({ quality: 70 })
  .toFile('output.webp');

Документация: вывод изображения в файл или буфер.

Карточка товара: заполнить рамку через cover

В сетке каталога обычно нужны одинаковые прямоугольники. Если исходники имеют разные пропорции, часть изображения приходится обрезать. Режим cover сохраняет пропорции, увеличивает или уменьшает изображение до заполнения рамки и убирает лишние края. Он подходит для фотографии окружения, обложки или товара с достаточным запасом фона.

В первом сценарии используется размер 480 × 360. Попробуйте переключиться на мобильный вариант 320 × 240: композиция похожа, но физический размер файла меняется. Это разные изображения, а не два CSS-масштаба одной картинки. На странице каталога выбор варианта затем можно поручить srcset.

Обрезка по центру не всегда сохраняет главный объект. Для портретов часто полезна верхняя часть кадра, для товарной съёмки — заранее заданная точка фокуса. Автоматическую стратегию стоит оценивать на своём наборе материалов. Если недопустимо потерять даже небольшой край предмета, используйте следующий сценарий с contain.

await sharp('input.jpg').autoOrient()
  .resize({ width: 480, height: 360, fit: 'cover', position: 'centre', withoutEnlargement: true })
  .webp({ quality: 70 }).toFile('card.webp');

Товар целиком: contain и управляемые поля

Режим contain помещает весь предмет внутрь заданной рамки. Пропорции сохраняются, а свободное пространство заполняется выбранным фоном. Это удобно для бутылок, упаковки, оборудования и других объектов, у которых нельзя обрезать верх или нижнюю часть. Одинаковый цвет полей объединяет каталог даже при очень разных исходниках.

Шахматная подложка мастерской принадлежит интерфейсу, а не картинке. Она помогает различать прозрачные области. Во втором сценарии поля непрозрачные, а в сценарии прозрачного фона альфа-канал сохраняется для WebP и AVIF. Сравните их визуально, затем скачайте файлы: так легче понять, где заканчивается изображение и начинается оформление страницы.

Параметр withoutEnlargement запрещает увеличение маленького источника. Это полезное ограничение для интерфейса, где не хочется получать размытую картинку из иконки. При таком запрете итоговая геометрия может зависеть от размера входа; после обработки используйте реальные width и height из результата, а не обещайте клиенту размер только по параметрам запроса.

await sharp('product.png').autoOrient()
  .resize({ width: 480, height: 360, fit: 'contain',
    background: '#f4f1e7', withoutEnlargement: true })
  .webp({ quality: 70 }).toFile('product-card.webp');

Документация: resize, fit и ограничения увеличения.

Аватар: положение кадра важнее формы рамки

Для профиля нужен квадрат, но это не означает, что лицо расположено точно в центре исходной фотографии. В сценарии аватара задано position: north. Такой выбор сохраняет верхнюю часть кадра, где в нашем учебном портрете находится голова. Круглая форма отображения при этом может задаваться CSS: хранить квадратную растровую картинку обычно достаточно.

В настоящем сервисе лучше дать человеку выбрать область обрезки или хотя бы показать предпросмотр до сохранения. Сервер должен повторить принятый выбор и проверить координаты. Нельзя доверять произвольным значениям ширины, высоты и позиции только потому, что их создал ваш собственный клиентский интерфейс.

Аватар отличается от большой фотографии профиля назначением. Маленькая версия нужна в списках и сообщениях, большая — на отдельной странице. Если хранить только один сильно уменьшенный файл, позже уже нельзя получить качественный вариант для другого места. Оригинал сохраняют отдельно по своей политике доступа, а производные версии можно пересоздать.

await sharp('portrait.jpg').autoOrient()
  .resize({ width: 240, height: 240, fit: 'cover', position: 'north', withoutEnlargement: true })
  .webp({ quality: 70 }).toFile('avatar.webp');

Обложка и мобильные варианты: одна фотография, разные задачи

Широкий баннер и вертикальная карточка требуют разных решений по композиции. Простое уменьшение ширины не поможет, если главный объект исчезает при обрезке. Поэтому рецепт обложки хранится отдельно от рецепта каталога. В архиве это именованные пресеты с понятными размерами; интерфейс передаёт их идентификаторы вместо свободной цепочки команд.

Для адаптивной страницы создают несколько размеров и указывают их в srcset. Атрибут sizes описывает предполагаемую ширину картинки в макете. Браузер учитывает доступный размер и плотность пикселей. Если написать sizes неправильно, можно снова заставить телефон скачивать слишком большую версию, хотя генератор изображений работает без ошибок.

Ниже идея разметки для двух подготовленных файлов. В реальном шаблоне ширину и высоту берут из метаданных результата. Это позволяет заранее зарезервировать место и уменьшить скачки страницы. Lazy loading подходит для изображений ниже первого экрана; ключевую обложку страницы не следует автоматически делать отложенной.

<img src="card-480.webp"
     srcset="card-320.webp 320w, card-480.webp 480w"
     sizes="(max-width: 600px) 100vw, 480px"
     width="480" height="360"
     alt="Предмет на светлом фоне" loading="lazy">

WebP, AVIF и JPEG: сравниваем конкретный результат

Одинаковое число quality не означает одинаковую визуальную потерю в разных кодеках. В мастерской качество 70 используется как удобная точка сравнения, а не универсальный эквивалент. Переключите формат и посмотрите на границы фигур, тонкие линии, плавный фон и итоговый размер. Для фотографии полезно дополнительно проверить кожу, волосы, мелкую фактуру и небо.

AVIF может дать небольшой файл, но цена кодирования зависит от параметров и вычислительных ресурсов. WebP часто удобен как основной формат веб-каталога. JPEG остаётся полезным для совместимости с внешними системами и привычными рабочими процессами. Выбор формата должен учитывать не только браузер, но и то, куда пользователь потом скачает или передаст файл.

В API разрешены три конкретных формата, три уровня качества и небольшие параметры effort. Это предсказуемее, чем открывать посетителю все настройки кодека. Для публичного массового сервиса тяжёлую обработку лучше отправлять в очередь, возвращая идентификатор задачи. Рядом с этой темой полезна статья о BullMQ и прогрессе фоновых задач.

const webp = await sharp(input).resize(960).webp({ quality: 70, effort: 4 }).toBuffer();
const avif = await sharp(input).resize(960).avif({ quality: 70, effort: 3 }).toBuffer();
const jpeg = await sharp(input).resize(960).jpeg({ quality: 70, mozjpeg: true }).toBuffer();

Прозрачность: почему JPEG требует выбранного фона

У прозрачного PNG есть цвет и альфа-канал. Для предмета без фона это полезно: одна картинка может использоваться на разных поверхностях интерфейса. При переводе в JPEG альфа-канал сохранить нельзя, поэтому приложение должно явно выбрать цвет подложки. Иначе результат может оказаться неожиданным для пользователя.

В мастерской JPEG всегда получает белый фон. Для прозрачного сценария белый цвет задан и для полей resize, и для flatten. Это важная практическая деталь: фон, добавленный при размещении в рамке, тоже должен соответствовать формату результата. Нельзя рассчитывать, что один поздний вызов исправит любое сочетание операций без проверки реальных пикселей.

В тестах проверяется не только отсутствие альфа-канала, но и светлый цвет углового пикселя. Такая проверка полезнее утверждения «метод flatten вызван». Пользователь видит итоговую картинку, поэтому именно её свойства должны подтверждать, что рецепт решает задачу.

await sharp('product.png')
  .resize({ width: 480, height: 480, fit: 'contain', background: '#ffffff' })
  .flatten({ background: '#ffffff' })
  .jpeg({ quality: 70 }).toFile('product-white.jpg');

EXIF: пиксели лежат боком, браузер показывает правильно

Некоторые камеры сохраняют пиксели в одной ориентации, а положение фотографии записывают в EXIF. Поэтому metadata может сообщить 800 × 600, хотя браузер показывает портрет. Если просто убрать метаданные, не повернув сами пиксели, итоговая картинка окажется боком. Для этого в начале рецепта вызывается autoOrient.

В нашем портрете специально записана Orientation=6. Браузер учитывает её уже при показе исходника, поэтому слева фотография тоже выглядит вертикально. Это не значит, что пример ничего не делает. Серверный тест проверяет реальную геометрию исходника, полученную геометрию результата и отсутствие старой Orientation после преобразования.

Для продукта такая нормализация должна выполняться до дальнейших расчётов обрезки и компоновки. Иначе координаты, выбранные пользователем на правильно отображённом портрете, могут не совпасть с координатами сырого растрового массива. Удобнее один раз привести ориентацию к понятной системе, а затем строить остальные операции.

const before = await sharp('portrait.jpg').metadata();
console.log(before.width, before.height, before.orientation);

const { data, info } = await sharp('portrait.jpg')
  .autoOrient().resize({ width: 360, height: 480, fit: 'inside' })
  .webp({ quality: 70 }).toBuffer({ resolveWithObject: true });
console.log(info.width, info.height);

Документация: autoOrient и операции над изображением.

Водяной знак: накладываем доверенный локальный материал

Наложение логотипа или служебной метки полезно для превью, учебных материалов и внутреннего согласования. В сценарии водяного знака используется собственная векторная маркировка. В ней нет внешних шрифтов, удалённых ресурсов или пользовательской разметки, поэтому результат воспроизводится и на Windows, и в минимальном Linux-контейнере.

Оверлей не должен превышать размер базового изображения. В рабочей функции сначала рассчитывается уменьшенная основа, затем определяется её реальная геометрия и подгоняется метка. Это учитывает крошечный загруженный файл и withoutEnlargement. Без такой проверки нормальный рецепт для больших фотографий может неожиданно падать на маленькой иконке.

Сам водяной знак не является системой защиты авторских прав: его можно обрезать или закрыть. В приложении он скорее сообщает назначение версии. Оригинал и маркированное превью должны храниться раздельно, чтобы сотрудник не потерял чистый исходник после изменения правил оформления.

const base = await sharp(input).autoOrient().resize(720).png().toBuffer();
const metadata = await sharp(base).metadata();
const mark = await sharp('trusted-logo.png')
  .resize({ width: Math.min(150, metadata.width), height: Math.min(44, metadata.height), fit: 'inside' })
  .png().toBuffer();
await sharp(base).composite([{ input: mark, gravity: 'southeast' }])
  .webp({ quality: 70 }).toFile('marked.webp');

Документация: наложение изображений.

Метаданные, цвет и исходный файл

Метаданные помогают узнать формат, размеры, ориентацию и наличие альфа-канала до основного преобразования. При этом чтение metadata не заменяет полное декодирование: повреждённый файл может пройти раннюю стадию и упасть позже. Поэтому обработка результата всё равно находится внутри try/catch и возвращает пользователю понятную ошибку.

Sharp по умолчанию не переносит все исходные метаданные в выходной файл. Для веб-превью это часто удобно, в том числе из-за EXIF с местом съёмки. Но производная копия не заменяет архив оригиналов. Если приложение предназначено для профессиональной фотографии, требования к ICC, EXIF и авторским полям нужно определить отдельно и проверить на реальном рабочем процессе.

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

const metadata = await sharp(input, { limitInputPixels: 20_000_000 }).metadata();
const useful = {
  format: metadata.format, width: metadata.width, height: metadata.height,
  orientation: metadata.orientation ?? 1, alpha: metadata.hasAlpha,
};

Документация: чтение metadata.

Настоящий HTTP API: файл входит, изображение выходит

В скачиваемом проекте POST /api/transform получает двоичное тело изображения. Выбранные preset, format и quality передаются в query. Это простой контракт без multipart: в одном запросе находится ровно один файл, а имя исходника серверу не нужно. Ответ содержит изображение и заголовки с фактическими размерами.

Форма мастерской обращается только к тому серверу, с которого открыта. Пока идёт обработка, настройки блокируются: результат не может внезапно появиться под уже другим выбранным рецептом. После завершения файл показывается рядом с исходником и доступен для скачивания. Если переключить сценарий, интерфейс вернётся к учебному набору и объяснит, что собственную картинку нужно обработать заново.

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

const result = await fetch('/api/transform?preset=card&format=webp&quality=70', {
  method: 'POST', headers: { 'Content-Type': file.type }, body: file,
  signal: AbortSignal.timeout(12000),
});
if (!result.ok) throw new Error((await result.json()).error);
const blob = await result.blob();

Ограничения входа и понятные ошибки

Размер сжатого файла и число пикселей — разные ограничения. Небольшой PNG может описывать очень большое одноцветное полотно, которое потребует заметно больше памяти после декодирования. Поэтому в примере установлены оба предела: 5 МиБ входных байтов и 20 миллионов пикселей. Дополнительно разрешены только статические JPEG, PNG и WebP.

Расширение имени и Content-Type не считаются доказательством формата. Сначала проверяется сигнатура, затем metadata декодера; SVG и анимация отвергаются. Параметры обработки выбираются из белого списка. API не загружает удалённые адреса, не читает указанный посетителем путь и не выполняет произвольную команду.

Ошибки различаются по причине: 400 для параметров, 413 для размера, 415 для неподдерживаемого типа, 422 для повреждения или невозможности обработки, 429 для занятости. Пользователю показывается исправимое действие, а внутренние сообщения декодера не пересылаются в интерфейс. Внешняя авторизация и права сотрудников добавлены на уровне reverse proxy в инструкции VPS.

try {
  const result = await transform(input, options);
  response.end(result.data);
} catch (error) {
  response.writeHead(error.status ?? 500, { 'Content-Type': 'application/json' });
  response.end(JSON.stringify({ error: error.status ? error.message : 'Ошибка обработчика.' }));
}

Документация: параметры входа и limitInputPixels.

Параллельность, память и очередь

Асинхронный JavaScript не делает обработку бесплатной. Несколько больших декодирований одновременно могут занять память и CPU, даже если цикл событий не заблокирован. В стенде одновременно допускаются две обработки, а за минуту — двадцать принятых попыток на весь процесс. Такой общий предел хорошо подходит для небольшого закрытого демонстрационного инструмента.

Sharp concurrency и число одновременно обслуживаемых запросов регулируют разные вещи. В примере concurrency равен одному, а кеш libvips ограничен. Это не рекомендация копировать числа на любой сервер. Их нужно выбирать после измерений на ожидаемых файлах и с учётом памяти, доступной контейнеру. В Compose предусмотрен дополнительный предел 512 МБ.

Если требуется надёжная массовая обработка, запрос загрузки лучше быстро завершить после сохранения задания. Работник очереди формирует варианты, записывает результат и обновляет статус. Пользователь видит прогресс и может вернуться позже. Для такого процесса нужны повторяемость операций, понятные имена производных файлов и обработка частичного успеха.

sharp.cache({ memory: 32, files: 0, items: 32 });
sharp.concurrency(1);
// Это параметры учебного процесса. Предел HTTP-параллельности задаётся отдельно.

Документация: кеш и параллельность Sharp.

Пакетная команда: подготовить каталог без перезаписи

Команда из архива проходит по доверенному локальному каталогу и создаёт для каждого поддерживаемого файла две WebP-версии: обычную карточку и мобильную. Обработка последовательная, поэтому небольшой сервер не получает сотни заданий одновременно. SVG-исходники генератора пропускаются; используются подготовленные растровые файлы.

Результаты записываются с флагом wx: существующий файл не перезаписывается. Это позволяет безопасно заметить повторный запуск или конфликт имён. Если одна операция завершилась ошибкой, команда сообщает её и продолжает со следующим исходником, а итоговый код завершения становится ненулевым. Уже созданные файлы сохраняются.

Такой пакет не является одной транзакцией. Если нужен принцип «всё или ничего», создавайте отдельную папку релиза, проверяйте полный набор и переключайте приложение на неё после успеха. Для повседневной подготовки каталога важнее заранее договориться об именах, хранении оригиналов и порядке публикации новых ссылок.

npm run batch -- fixtures generated-first
# Повторный запуск: выберите новый каталог, старые файлы не перезаписываются.
npm run batch -- fixtures generated-second

Windows: от распаковки до своей фотографии

Для нативного запуска нужен Node.js 24 LTS. Распакуйте проект в отдельную папку, откройте PowerShell и выполните команды ниже. npm ci установит зависимости из lockfile, включая нативный пакет Sharp для Windows. Сборка создаст учебные изображения и локальную копию Bootstrap, после чего тесты проверят API и результаты обработки.

Открывайте именно 127.0.0.1, как указано в инструкции: приложение проверяет Host. На странице появится форма собственного файла. Выберите небольшой PNG или JPEG, поменяйте формат, нажмите обработку и скачайте результат. Остановка сервера выполняется через Ctrl+C в том окне терминала, где он запущен.

Если npm сообщает о несовместимой платформе, проверьте версию Node и заново установите зависимости на этой машине. Копировать node_modules с Linux или из другого контейнера не нужно. В исходном архиве сохраняются package.json и lockfile, а бинарные зависимости устанавливаются под конкретную среду.

cd C:\Projects\sharp-project
node --version
npm ci --ignore-scripts --no-audit --no-fund
npm run build
npm test
npm start

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

Linux и Docker Compose: повторяемый запуск

На Linux с установленным Node.js 24 команды npm остаются теми же. Для воспроизводимой среды в проекте есть Dockerfile на Node 24.22.2 Bookworm slim. Сборка устанавливает зависимости, генерирует варианты и запускает тесты. Ошибка теста останавливает создание рабочего образа, поэтому неисправный рецепт не проходит этот этап незаметно.

Compose публикует приложение только на loopback-порту 8113. Внешний посетитель не получает прямой доступ к API. Контейнер работает от непривилегированного пользователя с read-only файловой системой, а временный каталог ограничен. Это полезная основа для закрытого инструмента; общедоступный сервис дополнительно требует полноценного управления пользователями и нагрузкой.

Если Docker сообщает об исчерпании пулов сетей, это проблема конфигурации конкретного хоста, а не Sharp. Не удаляйте чужие сети командой глобальной очистки. Для этого одиночного сервиса можно создать локальный override с network_mode: bridge либо согласовать отдельную подсеть. В основной архив такой обходной путь не навязывается.

docker compose up -d --build
docker compose ps
docker compose logs --tail=100 app
docker compose exec app node scripts/health.mjs
# Остановить и снова запустить только этот проект:
docker compose stop
docker compose start

Linux VPS: домен, HTTPS и закрытый доступ

Практический путь на сервере описан в README: отдельный домен, Docker с портом на 127.0.0.1, Caddy перед приложением и basic_auth для сотрудника. DNS должен указывать на сервер, а порты 80 и 443 должны быть доступны для выпуска сертификата и посещения сайта. Если эти порты уже обслуживает Nginx, настройте существующий сервер вместо запуска второго слушателя.

PUBLIC_ORIGIN задаёт ожидаемый внешний адрес. Он нужен для проверки Host и Origin, но сам не создаёт HTTPS. Файл с этим параметром хранится отдельно от папки релиза. Пароль доступа вводится в caddy hash-password интерактивно; полученный хеш сохраняется в закрытом импортируемом файле. Реальные значения не входят в публичный архив.

Сначала проверьте конфигурацию Caddy, затем перезагрузите её и выполните два запроса: без пароля должен быть 401, с паролем health должен вернуть ok. После этого загрузите учебное изображение через браузер. Один зелёный health проверяет доступность процесса, но ещё не доказывает работоспособность декодера и отдачи результата.

PUBLIC_ORIGIN=https://images.example.com
images.example.com {
    import /etc/caddy/sharp-auth.caddy
    request_body {
        max_size 6MB
    }
    reverse_proxy 127.0.0.1:8113
}
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
curl -I https://images.example.com/
curl -u editor https://images.example.com/health

Документация: защита доступа в Caddy.

Обновление, журналы, резервирование и откат

Учебный API не сохраняет пользовательские картинки: вход и выход живут в памяти запроса. Поэтому здесь нет папки загрузок, которую нужно регулярно очищать, и базы, требующей резервирования. Если вы добавите хранение оригиналов, оно станет отдельной частью системы со своими правами, сроками хранения и резервными копиями.

Журналы ограничены по размеру. Приложение не печатает тело изображения и имя пользовательского файла. Access log reverse proxy всё равно содержит маршрут и параметры, поэтому персональную информацию не следует помещать в query. При расследовании ошибки сначала сопоставьте HTTP-статус, время и нагрузку, затем воспроизведите проблему на разрешённом учебном файле.

Обновление выполняется в новой папке релиза. Соберите образ, пройдите встроенные тесты, запустите контейнер, проверьте health и реальную обработку. Старую папку оставьте до завершения проверки. Простой откат из README пересобирает предыдущую версию и запускает её с прежней конфигурацией; это понятный учебный процесс, но не обещание обновления без простоя.

cd /srv/sharp/current
sudo docker compose --env-file /srv/sharp/runtime.env -p sharp-prod ps
sudo docker compose --env-file /srv/sharp/runtime.env -p sharp-prod logs --tail=100 app
sudo journalctl -u caddy -n 100 --no-pager

Что проверено и как перенести решение в свой сервис

В проекте проверяются настоящие изображения и HTTP-ответы. Тесты читают все 72 подготовленных файла, сверяют их размеры, формат, длину и отсутствие старой EXIF Orientation. Отдельно проверяются прозрачность WebP, белый фон JPEG, поворот портрета, маленький вход с водяным знаком, повреждённый файл и превышение лимита пикселей.

HTTP-проверки покрывают успешную обработку, неподдерживаемый тип, лишние параметры, чужой Host и Origin, запрет выдачи серверных исходников и ограничение частоты. Набор выполняется на Windows и в Linux-контейнере. Эти проверки не означают, что сервис уже выдержит произвольную публичную нагрузку: для неё нужны отдельные измерения и архитектура очередей.

В собственный проект удобно перенести именованные пресеты, нормализацию ориентации, проверки входа и правило хранения оригинала отдельно от производных версий. Интерфейс должен объяснять человеку, что изменится: обрезка, фон, размер или формат. Чем понятнее это решение до отправки файла, тем меньше неожиданностей после сохранения.

Исходники и документация

Архив содержит полный код мастерской, генератор учебных данных, Node.js API, CLI, тесты, контейнер и подробный README. Для запуска не нужен ключ внешнего API. Собственный код и иллюстрации доступны под MIT; у Sharp, libvips и других зависимостей свои лицензии, перечисленные в THIRD-PARTY-LICENSES.txt. При распространении собранного приложения сохраняйте уведомления установленных пакетов.

Для развития проекта полезно связать этот материал с загрузкой файлов и хранением производных версий. На сайте уже есть материал о FilePond; точный сценарий интеграции выбирайте под свой backend. Очередь обработки, файловое хранилище и выдача изображений могут быть разными компонентами одного продукта, а Sharp отвечает за конкретное преобразование пикселей.