laravel接入supervisord使用Imagick,ImageMagick ,gotenberg,mercure

Laravel 接入 ImageMagick(Supervisord容器) + Gotenberg + Mercure 开发文档

适配你当前的 docker-compose 环境:

  • 原有架构:Pimcore 一套栈,supervisord 容器内置 ImageMagick7/Imagick PHP扩展,专门跑耗时任务
  • Gotenberg:gotenberg:3000 HTML/Office → PDF 转换
  • Mercure:mercure:80 SSE 实时消息推送
  • 目标:Laravel 复用现有Pimcore基础设施,不重复部署ImageMagick、Gotenberg、Mercure;图片处理任务交给 supervisord 异步消费,PDF转换直接HTTP调用Gotenberg,消息推送调用Mercure
  • 重要前提:Laravel 容器加入 pimcore-studio bridge 网络,保证服务名DNS互通

目录

  1. 整体架构说明
  2. Docker 网络与连通性配置(必做)
  3. 一、Mercure 实时推送模块(Laravel Publisher + 前端订阅)
  4. 二、Gotenberg PDF/文档转换模块(HTTP表单API)
  5. 三、ImageMagick/Imagick 图片处理模块(RPC/队列投递到 supervisord,重点)
  6. Laravel 服务封装、异常、日志、配置
  7. 测试用例 & 调试命令
  8. 安全规范、踩坑清单、性能调优
  9. 附录:参考资源

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:

  1. HTML内图片、css资源,必须公网可访问;或者attach资源一起提交
  2. 大文档、复杂表格,timeout拉长
  3. 接口是 multipart/form-data,不能传JSON

5. 模块三:ImageMagick / Imagick 图片处理(核心:Laravel投递任务 → supervisord消费)

重点:Laravel不执行Imagick代码,代码跑在 supervisord 容器内部
任务流转:

  1. Laravel组装任务JSON,投递到rabbitmq队列 laravel-image-process
  2. supervisord 新增一个php consumer进程,常驻监听队列
  3. consumer拿到任务,使用本机 Imagick 扩展处理图片:resize、crop、水印、psd导出、格式转换
  4. 处理完成:写入共享存储(你宿主机 /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. 安全、坑点、性能调优清单

安全

  1. Mercure JWT密钥不要硬编码在代码,统一.env管理;生产更换密钥
  2. Gotenberg不要暴露公网,仅内网服务调用
  3. ImageMagick policy.xml 严格限制:禁止EPS/PS高危delegate,限制内存、像素上限
  4. RabbitMQ guest账号生产必须修改密码,不要对外暴露15672管理端口
  5. 所有传入图片路径做白名单校验,防止路径穿越(../)

坑清单

  1. ❌ 路径不一致:supervisord 和 Laravel 必须共享同一个挂载目录,否则找不到原图、无法写入输出
  2. ❌ Imagick代码绝对不能写在Laravel容器里执行,Laravel容器没有这个扩展
  3. ❌ Gotenberg HTML内相对路径图片无法加载,必须绝对公网url
  4. ❌ Mercure跨域:前端域名和mercure不同域时,CORS需要配置,或者nginx反向代理mercure到主域名 /hub
  5. ❌ supervisord consumer进程崩溃自动重启,注意无限重试坏文件导致死循环,建议增加最大重试次数

性能调优

  1. ImageMagick内存:policy.xml设置 memory="512MiB" map="1GiB",超大图使用磁盘缓存
  2. consumer进程数不要盲目开大,Imagick单任务内存开销大,4核机器一般2~4个进程足够
  3. Gotenberg渲染PDF是CPU密集,大导出任务单独限流、排队
  4. Mercure适合实时通知,不要传超大payload

9. 附录

  1. Gotenberg官方文档:https://gotenberg.dev/docs/routes
  2. Symfony Mercure PHP SDK:https://symfony.com/doc/current/mercure.html
  3. ImageMagick policy安全模板:你现有的 imagemagick-policy.xml
  4. RabbitMQ php-amqplib:https://github.com/php-amqplib/php-amqplib

如果你需要,我可以继续输出:

  • 配套的 Laravel 控制器示例(图片上传+投递任务、PDF导出接口)
  • 或者 给你写nginx反向代理mercure到主域名,解决前端跨域的nginx配置片段

posted on 2026-10-29 18:19  zh7314  阅读(4)  评论(0)    收藏  举报