laravel接入supervisord使用Imagick,ImageMagick ,gotenberg,mercure
Laravel 接入 ImageMagick(Supervisord容器) + Gotenberg + Mercure 开发文档
适配你当前的
docker-compose环境:
- 原有架构:Pimcore 一套栈,
supervisord容器内置 ImageMagick7/Imagick PHP扩展,专门跑耗时任务- Gotenberg:
gotenberg:3000HTML/Office → PDF 转换- Mercure:
mercure:80SSE 实时消息推送- 目标:Laravel 复用现有Pimcore基础设施,不重复部署ImageMagick、Gotenberg、Mercure;图片处理任务交给 supervisord 异步消费,PDF转换直接HTTP调用Gotenberg,消息推送调用Mercure
- 重要前提:Laravel 容器加入
pimcore-studiobridge 网络,保证服务名DNS互通
目录
- 整体架构说明
- Docker 网络与连通性配置(必做)
- 一、Mercure 实时推送模块(Laravel Publisher + 前端订阅)
- 二、Gotenberg PDF/文档转换模块(HTTP表单API)
- 三、ImageMagick/Imagick 图片处理模块(RPC/队列投递到 supervisord,重点)
- Laravel 服务封装、异常、日志、配置
- 测试用例 & 调试命令
- 安全规范、踩坑清单、性能调优
- 附录:参考资源
1. 整体架构说明
❗ 关键设计决策:
Laravel 本身不安装 ImageMagick / Imagick 扩展!
图片裁切、加水印、CMYK处理、PSD/TIFF缩略这类重负载任务,通过 RabbitMQ 投递任务到 Pimcore 的 supervisord 容器消费,由 supervisord 内的 Imagick 执行。
好处:
- 不污染Laravel容器,PHP扩展隔离
- 图片CPU/内存消耗全部隔离在supervisord,不会打满Laravel-FPM
- 复用你已经配置好的
imagemagick-policy.xml安全策略- 共用同一套RabbitMQ(你compose里自带rabbitmq)
| 能力 | 承载服务 | 调用方式 |
|---|---|---|
| 图片处理(resize/crop/watermark/psd转图) | supervisord(内置Imagick+ImageMagick7) | Laravel → RabbitMQ投递任务 → supervisord消费执行,结果回写存储 |
| HTML/URL/Office转PDF、PDF合并 | gotenberg | Laravel Guzzle HTTP multipart/form-data调用 |
| 实时消息推送(订单、进度、后台通知) | mercure | Laravel 直接POST JWT鉴权Hub接口,前端EventSource订阅 |
数据流简图:
Laravel
├─> Mercure HTTP API 【同步推送消息】
├─> Gotenberg HTTP API 【同步生成PDF】
└─> RabbitMQ → supervisord(Imagick) 【异步图片 heavy 处理】
区分:
- 轻量简单图片:Laravel可直接用GD;复杂/大图/PSD/TIFF/CMYK 全部丢给 supervisord
- 为什么不直接HTTP调用supervisord做图片?PHP Imagick不是http服务,没有原生接口;所以走RabbitMQ任务队列是Pimcore生态标准方案
2. Docker 网络与连通性配置(前置)
2.1 Laravel Compose 网络配置
你的Pimcore栈网络名:pimcore-studio
Laravel的docker-compose.yml中,php/laravel服务加入这个外部网络:
services:
laravel:
networks:
- pimcore-studio
- default
networks:
pimcore-studio:
external: true
name: pimcore-studio
2.2 连通性校验(Laravel容器内执行)
# Gotenberg
curl http://gotenberg:3000/health
# Mercure Hub
curl http://mercure/.well-known/mercure
# RabbitMQ(投递图片任务用)
curl http://rabbitmq:15672/api/overview
# supervisord 不需要curl直接访问,是消费方,只通过rabbitmq通信
2.3 .env 基础配置(Laravel)
# -------- Mercure --------
MERCURE_HUB=http://mercure/.well-known/mercure
MERCURE_PUBLISHER_JWT=yOuq2R5mkX3AloQ0cvX2K4wqBslG7PeWMmSIwXs9PdvGxhJ038QLE7KDDWqiC0UqkxxRKOjhEu7t8cJKnG44NjQnZzk0DyFKjQ7nsKoZ2kF5ZNJRhpB4fpMdfA5izreANoVzV76R1QFLAWMQQ4hL0LkhWvw7NnAUM9tASNMlyr70tL4W5ws2uF8UHLp3klm5u8k1ubdSYrcxFS93yNtb3badp7IavtA5dn88qSUNhmkRBi6o6I2mgZvUFSL8azo6
MERCURE_SUBSCRIBER_JWT=yOuq2R5mkX3AloQ0cvX2K4wqBslG7PeWMmSIwXs9PdvGxhJ038QLE7KDDWqiC0UqkxxRKOjhEu7t8cJKnG44NjQnZzk0DyFKjQ7nsKoZ2kF5ZNJRhpB4fpMdfA5izreANoVzV76R1QFLAWMQQ4hL0LkhWvw7NnAUM9tASNMlyr70tL4W5ws2uF8UHLp3klm5u8k1ubdSYrcxFS93yNtb3badp7IavtA5dn88qSUNhmkRBi6o6I2mgZvUFSL8azo6
# -------- Gotenberg --------
GOTENBERG_URL=http://gotenberg:3000
GOTENBERG_TIMEOUT=120
# -------- RabbitMQ(图片任务队列,对接supervisord) --------
RABBITMQ_HOST=rabbitmq
RABBITMQ_PORT=5672
RABBITMQ_USER=guest
RABBITMQ_PASSWORD=guest
RABBITMQ_QUEUE=laravel-image-process
2.4 config/services.php 追加
'gotenberg' => [
'url' => env('GOTENBERG_URL'),
'timeout' => env('GOTENBERG_TIMEOUT', 120),
],
'mercure' => [
'hub' => env('MERCURE_HUB'),
'publisher_jwt' => env('MERCURE_PUBLISHER_JWT'),
'subscriber_jwt' => env('MERCURE_SUBSCRIBER_JWT'),
],
'rabbitmq_image' => [
'host' => env('RABBITMQ_HOST'),
'port' => env('RABBITMQ_PORT'),
'user' => env('RABBITMQ_USER'),
'password' => env('RABBITMQ_PASSWORD'),
'queue' => env('RABBITMQ_QUEUE'),
],
2.5 Laravel RabbitMQ 依赖安装
用官方主流 php-amqplib/php-amqplib,不要用老旧包
composer require php-amqplib/php-amqplib
说明:supervisord 这边,我们新增一个 consumer 进程监听
laravel-image-process队列,消费图片任务,下面第三部分会写 supervisord 配置。
3. 模块一:Mercure 实时推送
适用场景:PDF生成进度、图片处理进度、后台通知、订单状态变更
模式:Laravel作为Publisher发布消息;前端JS EventSource订阅Topic
当前Mercure配置:MERCURE_EXTRA_DIRECTIVES: anonymous,公开topic不需要订阅JWT
3.1 封装服务类 app/Services/MercureService.php
<?php
namespace App\Services;
use Symfony\Component\Mercure\Jwt\StaticTokenProvider;
use Symfony\Component\Mercure\Publisher;
use Symfony\Component\Mercure\Update;
use Illuminate\Support\Facades\Log;
class MercureService
{
protected Publisher $publisher;
public function __construct()
{
$hub = config('services.mercure.hub');
$key = config('services.mercure.publisher_jwt');
$tokenProvider = new StaticTokenProvider($key, [
'mercure' => [
'publish' => ['*']
]
]);
$this->publisher = new Publisher($hub, $tokenProvider);
}
/**
* @param string|array $topics
* @param array $payload
* @param bool $isPrivate 是否私有消息(私有订阅方需要subscriber jwt)
* @return void
*/
public function publish(string|array $topics, array $payload, bool $isPrivate = false): void
{
try {
$update = new Update(
topics: $topics,
data: json_encode($payload, JSON_UNESCAPED_UNICODE),
private: $isPrivate
);
($this->publisher)($update);
} catch (\Exception $e) {
Log::error('Mercure publish failed', [
'msg' => $e->getMessage(),
'topics' => $topics,
'payload' => $payload
]);
}
}
}
3.2 使用示例(Controller / Job 内)
use App\Services\MercureService;
$mercure = app(MercureService::class);
$mercure->publish('task/image/1001', [
'task_id' => 1001,
'status' => 'processing',
'progress' => 30,
'msg' => '图片正在处理'
]);
3.3 前端订阅JS示例
const topic = 'task/image/1001';
const es = new EventSource(`http://mercure/.well-known/mercure?topic=${topic}`);
es.onmessage = (ev) => {
const data = JSON.parse(ev.data);
console.log('收到推送', data);
};
es.onerror = () => {
console.error('SSE连接断开');
};
3.4 私有消息说明
如果 $isPrivate=true,前端必须在url带上 ?authorization=Bearer {subscriberJwt},否则收不到。
4. 模块二:Gotenberg PDF & Office转换服务
支持:HTML视图转PDF、URL转PDF、docx/xlsx转PDF、PDF合并
4.1 封装服务 app/Services/GotenbergService.php
<?php
namespace App\Services;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class GotenbergService
{
protected string $baseUrl;
protected int $timeout;
public function __construct()
{
$this->baseUrl = rtrim(config('services.gotenberg.url'), '/');
$this->timeout = config('services.gotenberg.timeout');
}
/**
* blade视图渲染转PDF
*/
public function htmlToPdf(string $viewName, array $viewData, array $options = []): string
{
$html = view($viewName, $viewData)->render();
$form = [
'paperSize' => $options['paperSize'] ?? 'A4',
'landscape' => $options['landscape'] ?? false,
'printBackground' => $options['printBackground'] ?? true,
'marginTop' => $options['marginTop'] ?? '0.4',
'marginBottom' => $options['marginBottom'] ?? '0.4',
];
try {
$resp = Http::timeout($this->timeout)
->attach('index.html', $html, 'index.html')
->post("{$this->baseUrl}/forms/chromium/convert/html", $form);
if ($resp->successful()) {
return $resp->body();
}
throw new \RuntimeException("Gotenberg error: " . $resp->status() . ' ' . $resp->body());
} catch (\Exception $e) {
Log::error('Gotenberg html2pdf fail', ['err' => $e->getMessage()]);
throw $e;
}
}
/**
* Office文档(docx/xlsx)转PDF
* @param string $filePath 本地文件绝对路径
*/
public function officeToPdf(string $filePath): string
{
if (!file_exists($filePath)) {
throw new \RuntimeException("文件不存在:{$filePath}");
}
$content = file_get_contents($filePath);
try {
$resp = Http::timeout($this->timeout)
->attach('file', $content, basename($filePath))
->post("{$this->baseUrl}/forms/libreoffice/convert");
if ($resp->successful()) {
return $resp->body();
}
throw new \RuntimeException("Office convert failed " . $resp->body());
} catch (\Exception $e) {
Log::error('Gotenberg office2pdf fail', ['err' => $e->getMessage()]);
throw $e;
}
}
}
4.2 调用示例
$gt = app(GotenbergService::class);
// Blade视图导出PDF
$pdfBin = $gt->htmlToPdf('pdf.order', ['order' => $order], [
'paperSize' => 'A4',
'landscape' => false
]);
// 保存文件
file_put_contents(storage_path('app/order-123.pdf'), $pdfBin);
// 直接返回下载
// return response()->streamDownload(fn() => print $pdfBin, 'order.pdf');
重要注意事项 Gotenberg:
- HTML内图片、css资源,必须公网可访问;或者attach资源一起提交
- 大文档、复杂表格,timeout拉长
- 接口是
multipart/form-data,不能传JSON
5. 模块三:ImageMagick / Imagick 图片处理(核心:Laravel投递任务 → supervisord消费)
重点:Laravel不执行Imagick代码,代码跑在 supervisord 容器内部
任务流转:
- Laravel组装任务JSON,投递到rabbitmq队列
laravel-image-process- supervisord 新增一个php consumer进程,常驻监听队列
- consumer拿到任务,使用本机 Imagick 扩展处理图片:resize、crop、水印、psd导出、格式转换
- 处理完成:写入共享存储(你宿主机
/mnt/pimcore_studio),可选推送Mercure通知前端
5.1 共享存储约定(必须)
你现有挂载:/mnt/pimcore_studio 同时挂载给 php、supervisord、nginx
- 原图路径、输出缩略图路径统一放在这个共享目录,两边容器都可读可写
禁止:只存在Laravel容器本地,supervisord读不到
5.2 Laravel 生产者:投递任务
新建 app/Services/ImageQueueProducer.php
<?php
namespace App\Services;
use PhpAmqpLib\Connection\AMQPStreamConnection;
use PhpAmqpLib\Message\AMQPMessage;
use Illuminate\Support\Facades\Log;
class ImageQueueProducer
{
protected AMQPStreamConnection $connection;
protected $channel;
protected string $queue;
public function __construct()
{
$cfg = config('services.rabbitmq_image');
$this->queue = $cfg['queue'];
$this->connection = new AMQPStreamConnection(
$cfg['host'],
$cfg['port'],
$cfg['user'],
$cfg['password']
);
$this->channel = $this->connection->channel();
$this->channel->queue_declare($this->queue, false, true, false, false);
}
/**
* 提交图片处理任务
* @param array $task
* [
* 'task_id' => int,
* 'src_path' => '/mnt/pimcore_studio/original/xxx.psd',
* 'dst_path' => '/mnt/pimcore_studio/thumb/xxx.jpg',
* 'action' => 'resize', // resize / crop / watermark / psd2jpg
* 'params' => [ 'w'=>800, 'h'=>600, 'quality'=>85 ]
* ]
*/
public function push(array $task): void
{
$msg = new AMQPMessage(
json_encode($task, JSON_UNESCAPED_UNICODE),
['delivery_mode' => AMQPMessage::DELIVERY_MODE_PERSISTENT]
);
$this->channel->basic_publish($msg, '', $this->queue);
Log::info('Image task pushed', $task);
}
public function close()
{
$this->channel->close();
$this->connection->close();
}
}
Laravel投递调用示例
$producer = app(ImageQueueProducer::class);
$producer->push([
'task_id' => 10001,
'src_path' => '/mnt/pimcore_studio/original/demo.psd',
'dst_path' => '/mnt/pimcore_studio/thumb/demo_800.jpg',
'action' => 'resize',
'params' => [
'w' => 800,
'h' => 800,
'quality' => 85
]
]);
5.3 Supervisord 消费端(在 supervisord 容器内运行)
这个PHP脚本放在Pimcore项目目录,supervisord容器可以直接读取
文件路径:./.docker/consumers/image-consumer.php
<?php
// image-consumer.php 【跑在 supervisord 容器!自带 Imagick】
require __DIR__ . '/../../vendor/autoload.php';
use PhpAmqpLib\Connection\AMQPStreamConnection;
use PhpAmqpLib\Message\AMQPMessage;
$host = getenv('RABBITMQ_HOST') ?: 'rabbitmq';
$port = 5672;
$user = 'guest';
$pass = 'guest';
$queue = 'laravel-image-process';
$connection = new AMQPStreamConnection($host, $port, $user, $pass);
$channel = $connection->channel();
$channel->queue_declare($queue, false, true, false, false);
$callback = function (AMQPMessage $msg) {
$task = json_decode($msg->getBody(), true);
$taskId = $task['task_id'];
try {
$src = $task['src_path'];
$dst = $task['dst_path'];
$action = $task['action'];
$params = $task['params'];
if (!file_exists($src)) {
throw new Exception("源文件不存在 {$src}");
}
$img = new Imagick($src);
switch ($action) {
case 'resize':
$img->resizeImage($params['w'], $params['h'], Imagick::FILTER_LANCZOS, 1, true);
$img->setImageCompressionQuality($params['quality']);
break;
case 'crop':
$img->cropImage($params['w'], $params['h'], $params['x'], $params['y']);
break;
case 'psd2jpg':
$img->setIteratorIndex(0); // PSD取第一层
$flatten = $img->flattenImages();
$flatten->setImageFormat('jpg');
$flatten->setImageCompressionQuality($params['quality'] ?? 85);
$flatten->writeImage($dst);
$flatten->destroy();
$img->destroy();
$msg->ack();
return;
}
$img->writeImage($dst);
$img->destroy();
// 可选:处理成功推送Mercure通知前端
$hub = 'http://mercure/.well-known/mercure';
$jwt = 'yOuq2R5mkX3AloQ0cvX2K4wqBslG7PeWMmSIwXs9PdvGxhJ038QLE7KDDWqiC0UqkxxRKOjhEu7t8cJKnG44NjQnZzk0DyFKjQ7nsKoZ2kF5ZNJRhpB4fpMdfA5izreANoVzV76R1QFLAWMQQ4hL0LkhWvw7NnAUM9tASNMlyr70tL4W5ws2uF8UHLp3klm5u8k1ubdSYrcxFS93yNtb3badp7IavtA5dn88qSUNhmkRBi6o6I2mgZvUFSL8azo6';
$tokenProvider = new Symfony\Component\Mercure\Jwt\StaticTokenProvider($jwt,['mercure'=>['publish'=>['*']]]);
$publisher = new Symfony\Component\Mercure\Publisher($hub, $tokenProvider);
$update = new Symfony\Component\Mercure\Update("task/image/{$taskId}", json_encode([
'task_id' => $taskId,
'status' => 'success',
'dst' => $dst
]));
$publisher($update);
$msg->ack();
} catch (Throwable $e) {
error_log("Image consumer error task {$taskId}: " . $e->getMessage());
// nack 重回队列,可配置重试次数
$msg->nack(requeue: true);
}
};
$channel->basic_consume($queue, '', false, false, false, false, $callback);
while ($channel->is_consuming()) {
$channel->wait();
}
$channel->close();
$connection->close();
5.4 把consumer加入 supervisord 配置 ./.docker/pimcore.conf
追加一段:
[program:laravel-image-consumer]
command=php /var/www/html/.docker/consumers/image-consumer.php
directory=/var/www/html
autostart=true
autorestart=true
numprocs=2
startretries=10
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
environment=RABBITMQ_HOST="rabbitmq"
numprocs=2代表同时起2个Imagick消费进程,根据服务器内存调整;ImageMagick非常吃内存,不要开太多
重启supervisord容器生效:
docker compose restart supervisord
查看进程状态:docker compose exec supervisord supervisorctl status
5.5 安全限制
复用你已经挂载的 imagemagick-policy.xml,限制允许读取的格式、内存、磁盘,防止恶意文件RCE。
6. 统一异常、日志、任务状态表(可选推荐)
建议Laravel建一张 image_tasks 表持久化任务状态:task_id、src、dst、status[pending/processing/success/failed]、error_msg、created_at、finished_at。
- 投递时写入pending
- consumer处理成功/失败,可HTTP回调Laravel更新状态(或者只靠Mercure推送前端)
迁移示例:
$table->id('task_id');
$table->string('src_path');
$table->string('dst_path');
$table->string('action');
$table->json('params');
$table->enum('status',['pending','processing','success','failed'])->default('pending');
$table->text('error')->nullable();
$table->timestamps();
7. 测试 & 调试命令
# 查看rabbitmq队列消息
docker compose exec rabbitmq rabbitmqctl list_queues
# 查看supervisord消费进程
docker compose exec supervisord supervisorctl status
# 单独跑consumer调试
docker compose exec supervisord php /var/www/html/.docker/consumers/image-consumer.php
# 测试Gotenberg健康
docker compose exec laravel curl http://gotenberg:3000/health
# 测试Mercure hub
docker compose exec laravel curl http://mercure/.well-known/mercure
8. 安全、坑点、性能调优清单
安全
- Mercure JWT密钥不要硬编码在代码,统一.env管理;生产更换密钥
- Gotenberg不要暴露公网,仅内网服务调用
- ImageMagick policy.xml 严格限制:禁止EPS/PS高危delegate,限制内存、像素上限
- RabbitMQ guest账号生产必须修改密码,不要对外暴露15672管理端口
- 所有传入图片路径做白名单校验,防止路径穿越(
../)
坑清单
- ❌ 路径不一致:supervisord 和 Laravel 必须共享同一个挂载目录,否则找不到原图、无法写入输出
- ❌ Imagick代码绝对不能写在Laravel容器里执行,Laravel容器没有这个扩展
- ❌ Gotenberg HTML内相对路径图片无法加载,必须绝对公网url
- ❌ Mercure跨域:前端域名和mercure不同域时,CORS需要配置,或者nginx反向代理mercure到主域名
/hub - ❌ supervisord consumer进程崩溃自动重启,注意无限重试坏文件导致死循环,建议增加最大重试次数
性能调优
- ImageMagick内存:policy.xml设置
memory="512MiB" map="1GiB",超大图使用磁盘缓存 - consumer进程数不要盲目开大,Imagick单任务内存开销大,4核机器一般2~4个进程足够
- Gotenberg渲染PDF是CPU密集,大导出任务单独限流、排队
- Mercure适合实时通知,不要传超大payload
9. 附录
- Gotenberg官方文档:https://gotenberg.dev/docs/routes
- Symfony Mercure PHP SDK:https://symfony.com/doc/current/mercure.html
- ImageMagick policy安全模板:你现有的
imagemagick-policy.xml - RabbitMQ php-amqplib:https://github.com/php-amqplib/php-amqplib
如果你需要,我可以继续输出:
- 配套的 Laravel 控制器示例(图片上传+投递任务、PDF导出接口)
- 或者 给你写nginx反向代理mercure到主域名,解决前端跨域的nginx配置片段
QQ二群 166427999
如果项目有技术瓶颈问题,请联系↓↓
QQ: 903464207
微信: zx903464207
浙公网安备 33010602011771号