强曰为道
与天地相似,故不违。知周乎万物,而道济天下,故不过。旁行而不流,乐天知命,故不忧.
文档目录

GraphicsMagick 图像处理完整教程 / 第11章 Docker 与服务化

第11章 Docker 与服务化

11.1 Docker 基础镜像

11.1.1 使用官方镜像

# 拉取官方镜像
docker pull graphicsmagick/graphicsmagick

# 基本使用
docker run --rm -v $(pwd):/work graphicsmagick/graphicsmagick \
  gm convert /work/input.jpg -resize 800x600 /work/output.jpg

# 查看版本
docker run --rm graphicsmagick/graphicsmagick gm version

# 查看支持格式
docker run --rm graphicsmagick/graphicsmagick gm convert -list format

11.1.2 自定义 Dockerfile

# Dockerfile — 生产环境 GraphicsMagick 镜像
FROM debian:bookworm-slim

LABEL maintainer="[email protected]"
LABEL description="GraphicsMagick 生产环境镜像"

# 安装依赖
RUN apt-get update && apt-get install -y --no-install-recommends \
    graphicsmagick \
    ghostscript \
    librsvg2-common \
    libjpeg62-turbo \
    libpng16-16 \
    libtiff6 \
    libwebp7 \
    libfreetype6 \
    liblcms2-2 \
    fonts-noto-cjk \
    && rm -rf /var/lib/apt/lists/*

# 创建工作目录
WORKDIR /work

# 设置环境变量
ENV MAGICK_TMPDIR=/tmp \
    MAGICK_MEMORY_LIMIT=2GiB \
    MAGICK_MAP_LIMIT=4GiB \
    MAGICK_DISK_LIMIT=8GiB \
    OMP_NUM_THREADS=4

# 验证安装
RUN gm version && gm convert -list format | wc -l

ENTRYPOINT ["gm"]
CMD ["version"]

构建:

docker build -t gm-prod .
docker run --rm gm-prod convert -list format

11.1.3 带编程语言的镜像

# Dockerfile.python — Python + GraphicsMagick
FROM python:3.11-slim

# 安装 GraphicsMagick
RUN apt-get update && apt-get install -y --no-install-recommends \
    graphicsmagick \
    libgraphicsmagick++1-dev \
    && rm -rf /var/lib/apt/lists/*

# 安装 Python 绑定
RUN pip install --no-cache-dir Wand

WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY . .

CMD ["python", "app.py"]

11.2 批量处理容器

11.2.1 一次性批量处理

# 批量缩放目录中的图片
docker run --rm \
  -v $(pwd)/input:/input \
  -v $(pwd)/output:/output \
  graphicsmagick/graphicsmagick \
  sh -c 'for f in /input/*.jpg; do
    gm convert "$f" -resize "800x600>" -quality 85 \
      "/output/$(basename $f)"
  done'

11.2.2 使用 Docker Compose

# docker-compose.yml
version: '3.8'

services:
  image-processor:
    build: .
    volumes:
      - ./input:/input:ro
      - ./output:/output
    environment:
      - OMP_NUM_THREADS=4
      - MAGICK_MEMORY_LIMIT=2GiB
    command: >
      sh -c '
        for f in /input/*.jpg; do
          gm convert "$$f" \
            -auto-orient \
            -resize "1200x1200>" \
            -quality 85 \
            -strip \
            "/output/$$(basename $$f)"
        done
        echo "处理完成: $$(ls /output/*.jpg | wc -l) 张图片"
      '

  # 定时任务
  scheduler:
    build: .
    volumes:
      - ./input:/input:ro
      - ./output:/output
    entrypoint: /bin/sh
    command: -c 'echo "0 */6 * * * /app/process.sh" | crontab - && crond -f'

11.2.3 并行批量处理

# Dockerfile.parallel
FROM debian:bookworm-slim

RUN apt-get update && apt-get install -y --no-install-recommends \
    graphicsmagick \
    parallel \
    && rm -rf /var/lib/apt/lists/*

COPY process.sh /app/process.sh
RUN chmod +x /app/process.sh

ENTRYPOINT ["/app/process.sh"]
#!/bin/bash
# process.sh
INPUT_DIR="${1:-/input}"
OUTPUT_DIR="${2:-/output}"
JOBS="${3:-$(nproc)}"

mkdir -p "$OUTPUT_DIR"

export -f process_one
process_one() {
  local f="$1"
  local out="$OUTPUT_DIR/$(basename "$f")"
  gm convert "$f" -resize "1200x1200>" -quality 85 -strip "$out" && \
    echo "✅ $(basename "$f")" || echo "❌ $(basename "$f")"
}

find "$INPUT_DIR" -name "*.jpg" | parallel -j "$JOBS" process_one {}
echo "处理完成"

11.3 REST API 服务

11.3.1 Flask API 服务

# Dockerfile.api
FROM python:3.11-slim

RUN apt-get update && apt-get install -y --no-install-recommends \
    graphicsmagick \
    && rm -rf /var/lib/apt/lists/*

RUN pip install --no-cache-dir flask wand gunicorn

WORKDIR /app
COPY api.py .

EXPOSE 5000
CMD ["gunicorn", "-w", "4", "-b", "0.0.0.0:5000", "api:app"]
# api.py
import os
import uuid
import tempfile
from flask import Flask, request, jsonify, send_file
from wand.image import Image

app = Flask(__name__)
UPLOAD_DIR = tempfile.mkdtemp()

@app.route('/resize', methods=['POST'])
def resize():
    """缩放图像
    参数:
      - file: 图像文件
      - width: 目标宽度
      - height: 目标高度
      - quality: JPEG 质量 (默认 85)
    """
    if 'file' not in request.files:
        return jsonify({'error': 'No file uploaded'}), 400

    file = request.files['file']
    width = request.form.get('width', 800, type=int)
    height = request.form.get('height', 600, type=int)
    quality = request.form.get('quality', 85, type=int)

    # 保存上传文件
    input_path = os.path.join(UPLOAD_DIR, f"{uuid.uuid4()}.jpg")
    output_path = os.path.join(UPLOAD_DIR, f"{uuid.uuid4()}.jpg")
    file.save(input_path)

    try:
        with Image(filename=input_path) as img:
            img.auto_orient()
            img.resize(width, height)
            img.compression_quality = quality
            img.strip()
            img.save(filename=output_path)

        return send_file(output_path, mimetype='image/jpeg')
    except Exception as e:
        return jsonify({'error': str(e)}), 500
    finally:
        os.unlink(input_path)

@app.route('/thumbnail', methods=['POST'])
def thumbnail():
    """生成缩略图(正方形裁剪)"""
    if 'file' not in request.files:
        return jsonify({'error': 'No file uploaded'}), 400

    file = request.files['file']
    size = request.form.get('size', 200, type=int)
    quality = request.form.get('quality', 85, type=int)

    input_path = os.path.join(UPLOAD_DIR, f"{uuid.uuid4()}.jpg")
    output_path = os.path.join(UPLOAD_DIR, f"{uuid.uuid4()}.jpg")
    file.save(input_path)

    try:
        with Image(filename=input_path) as img:
            img.auto_orient()
            # 按短边缩放
            w, h = img.width, img.height
            if w > h:
                img.resize(size, int(h * size / w))
            else:
                img.resize(int(w * size / h), size)
            # 居中裁剪
            img.crop(width=size, height=size, gravity='center')
            img.compression_quality = quality
            img.strip()
            img.save(filename=output_path)

        return send_file(output_path, mimetype='image/jpeg')
    except Exception as e:
        return jsonify({'error': str(e)}), 500
    finally:
        os.unlink(input_path)

@app.route('/info', methods=['POST'])
def info():
    """获取图像信息"""
    if 'file' not in request.files:
        return jsonify({'error': 'No file uploaded'}), 400

    file = request.files['file']
    input_path = os.path.join(UPLOAD_DIR, f"{uuid.uuid4()}.jpg")
    file.save(input_path)

    try:
        with Image(filename=input_path) as img:
            return jsonify({
                'width': img.width,
                'height': img.height,
                'format': img.format,
                'depth': img.depth,
                'colorspace': str(img.colorspace),
                'size_bytes': os.path.getsize(input_path)
            })
    except Exception as e:
        return jsonify({'error': str(e)}), 500
    finally:
        os.unlink(input_path)

@app.route('/health')
def health():
    return jsonify({'status': 'ok'})

if __name__ == '__main__':
    app.run(host='0.0.0.0', port=5000, debug=False)

11.3.2 使用示例

# 构建并运行
docker build -t gm-api -f Dockerfile.api .
docker run -d -p 5000:5000 --name gm-api gm-api

# 缩放图像
curl -X POST http://localhost:5000/resize \
  -F "[email protected]" \
  -F "width=800" \
  -F "height=600" \
  -o resized.jpg

# 生成缩略图
curl -X POST http://localhost:5000/thumbnail \
  -F "[email protected]" \
  -F "size=200" \
  -o thumb.jpg

# 获取图像信息
curl -X POST http://localhost:5000/info \
  -F "[email protected]"

11.3.3 Node.js Express API

// server.js
const express = require('express');
const multer = require('multer');
const gm = require('gm').subClass({ imageMagick: false });
const path = require('path');
const fs = require('fs');

const app = express();
const upload = multer({ dest: '/tmp/uploads/' });

app.post('/resize', upload.single('file'), (req, res) => {
  const { width = 800, height = 600, quality = 85 } = req.body;

  gm(req.file.path)
    .autoOrient()
    .resize(parseInt(width), parseInt(height))
    .quality(parseInt(quality))
    .strip()
    .stream('jpeg')
    .pipe(res);

  // 清理上传文件
  res.on('finish', () => fs.unlinkSync(req.file.path));
});

app.post('/info', upload.single('file'), (req, res) => {
  gm(req.file.path).identify((err, info) => {
    fs.unlinkSync(req.file.path);
    if (err) return res.status(500).json({ error: err.message });
    res.json({
      width: info.size.width,
      height: info.size.height,
      format: info.format,
      filesize: info.Filesize
    });
  });
});

app.listen(3000, () => console.log('Image API running on :3000'));

11.4 微服务架构

11.4.1 完整 Docker Compose 微服务

# docker-compose.microservices.yml
version: '3.8'

services:
  # 图像处理 API
  api:
    build:
      context: .
      dockerfile: Dockerfile.api
    ports:
      - "5000:5000"
    environment:
      - OMP_NUM_THREADS=2
      - MAGICK_MEMORY_LIMIT=1GiB
      - REDIS_URL=redis://redis:6379
    depends_on:
      - redis
    deploy:
      replicas: 3
      resources:
        limits:
          memory: 2G
          cpus: '2'

  # 后台批量处理 worker
  worker:
    build:
      context: .
      dockerfile: Dockerfile.worker
    environment:
      - OMP_NUM_THREADS=4
      - MAGICK_MEMORY_LIMIT=4GiB
      - REDIS_URL=redis://redis:6379
    depends_on:
      - redis
    deploy:
      replicas: 2

  # Redis 队列
  redis:
    image: redis:7-alpine
    volumes:
      - redis_data:/data

  # 监控面板
  dashboard:
    image: adminer
    ports:
      - "8080:8080"

volumes:
  redis_data:

11.4.2 Worker 进程

# worker.py
import redis
import json
import os
from wand.image import Image

r = redis.from_url(os.environ.get('REDIS_URL', 'redis://localhost:6379'))

def process_task(task):
    task_type = task.get('type')
    input_path = task.get('input')
    output_path = task.get('output')

    if task_type == 'resize':
        with Image(filename=input_path) as img:
            img.resize(task['width'], task['height'])
            img.compression_quality = task.get('quality', 85)
            img.strip()
            img.save(filename=output_path)

    elif task_type == 'thumbnail':
        with Image(filename=input_path) as img:
            size = task.get('size', 200)
            w, h = img.width, img.height
            if w > h:
                img.resize(size, int(h * size / w))
            else:
                img.resize(int(w * size / h), size)
            img.crop(width=size, height=size, gravity='center')
            img.compression_quality = task.get('quality', 85)
            img.strip()
            img.save(filename=output_path)

    elif task_type == 'watermark':
        with Image(filename=input_path) as img:
            with Image(filename=task['watermark']) as wm:
                img.watermark(wm, transparency=0.5, left=20, top=20)
            img.save(filename=output_path)

def main():
    print("Worker 启动,等待任务...")
    while True:
        _, task_json = r.brpop('image_tasks')
        task = json.loads(task_json)
        try:
            process_task(task)
            r.lpush('completed_tasks', json.dumps({'status': 'ok', **task}))
            print(f"✅ 完成: {task['type']} - {task['input']}")
        except Exception as e:
            r.lpush('completed_tasks', json.dumps({'status': 'error', 'error': str(e), **task}))
            print(f"❌ 失败: {task['type']} - {e}")

if __name__ == '__main__':
    main()

11.5 CI/CD 集成

11.5.1 GitHub Actions

# .github/workflows/optimize-images.yml
name: Optimize Images

on:
  push:
    paths:
      - 'static/image/**'

jobs:
  optimize:
    runs-on: ubuntu-latest
    container: graphicsmagick/graphicsmagick

    steps:
      - uses: actions/checkout@v4

      - name: Optimize images
        run: |
          for img in static/image/*.{jpg,jpeg,png}; do
            [ -f "$img" ] || continue
            echo "优化: $img"
            gm mogrify \
              -strip \
              -quality 85 \
              -resize "2000x2000>" \
              "$img"
          done

      - name: Commit optimized images
        run: |
          git config user.name "Image Optimizer"
          git config user.email "[email protected]"
          git add -A
          git diff --cached --quiet || \
            git commit -m "chore: optimize images [skip ci]" && git push

11.5.2 GitLab CI

# .gitlab-ci.yml
image: graphicsmagick/graphicsmagick

optimize-images:
  stage: build
  script:
    - |
      for img in public/image/*.{jpg,png}; do
        [ -f "$img" ] || continue
        gm mogrify -strip -quality 85 -resize "1200x1200>" "$img"
      done
  only:
    changes:
      - "public/image/**"

11.6 Kubernetes 部署

11.6.1 部署配置

# k8s-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: gm-api
spec:
  replicas: 3
  selector:
    matchLabels:
      app: gm-api
  template:
    metadata:
      labels:
        app: gm-api
    spec:
      containers:
      - name: gm-api
        image: gm-api:latest
        ports:
        - containerPort: 5000
        env:
        - name: OMP_NUM_THREADS
          value: "2"
        - name: MAGICK_MEMORY_LIMIT
          value: "1GiB"
        resources:
          requests:
            memory: "512Mi"
            cpu: "500m"
          limits:
            memory: "2Gi"
            cpu: "2"
        livenessProbe:
          httpGet:
            path: /health
            port: 5000
          initialDelaySeconds: 5
          periodSeconds: 10
        readinessProbe:
          httpGet:
            path: /health
            port: 5000
          initialDelaySeconds: 5
          periodSeconds: 5
---
apiVersion: v1
kind: Service
metadata:
  name: gm-api-service
spec:
  selector:
    app: gm-api
  ports:
  - port: 80
    targetPort: 5000
  type: ClusterIP
---
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: gm-api-hpa
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: gm-api
  minReplicas: 2
  maxReplicas: 10
  metrics:
  - type: Resource
    resource:
      name: cpu
      target:
        type: Utilization
        averageUtilization: 70

11.7 无服务器 (Serverless)

11.7.1 AWS Lambda

# lambda_function.py
import json
import base64
import tempfile
from wand.image import Image

def lambda_handler(event, context):
    # 解码 base64 图像
    image_data = base64.b64decode(event['body'])
    width = event.get('queryStringParameters', {}).get('width', 800)
    height = event.get('queryStringParameters', {}).get('height', 600)

    with tempfile.NamedTemporaryFile(suffix='.jpg') as tmp_in, \
         tempfile.NamedTemporaryFile(suffix='.jpg') as tmp_out:

        tmp_in.write(image_data)
        tmp_in.flush()

        with Image(filename=tmp_in.name) as img:
            img.resize(int(width), int(height))
            img.compression_quality = 85
            img.strip()
            img.save(filename=tmp_out.name)

        with open(tmp_out.name, 'rb') as f:
            result = f.read()

    return {
        'statusCode': 200,
        'headers': {'Content-Type': 'image/jpeg'},
        'body': base64.b64encode(result).decode('utf-8'),
        'isBase64Encoded': True
    }

11.7.2 Cloudflare Workers(通过容器)

// 使用 container-based workers
export default {
  async fetch(request) {
    const formData = await request.formData();
    const file = formData.get('file');
    const width = formData.get('width') || 800;

    // 调用容器化 API
    const response = await fetch('https://gm-api.internal/resize', {
      method: 'POST',
      body: formData
    });

    return new Response(response.body, {
      headers: { 'Content-Type': 'image/jpeg' }
    });
  }
};

11.8 生产环境最佳实践

11.8.1 安全建议

要点 推荐措施
文件大小限制 最大 10MB-50MB
尺寸限制 最大 8000x8000
格式白名单 仅允许 jpg, png, webp, gif
文件名清理 重命名为 UUID
临时文件清理 处理完立即删除
超时设置 30 秒-60 秒
内存限制 每实例 1-2GB
并发限制 根据内存计算

11.8.2 监控指标

关键指标:
- 请求处理时间 (P50, P95, P99)
- 内存使用量
- CPU 使用率
- 错误率
- 队列深度 (worker 模式)
- 临时文件空间

11.9 本章小结

要点 说明
官方 Docker 镜像可用 graphicsmagick/graphicsmagick
自定义镜像更灵活 选择需要的格式库
REST API 封装是主流 Flask/Express + Wand/gm
微服务架构适合大规模 API + Worker + Redis 队列
K8s HPA 自动扩缩 基于 CPU 利用率
安全限制必不可少 文件大小、格式、超时

扩展阅读

  1. GraphicsMagick Docker Hub
  2. Docker 最佳实践
  3. Kubernetes 部署指南
  4. Gunicorn 配置指南
  5. Redis 队列模式

上一章第10章 编程接口 (API) 下一章第12章 最佳实践