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

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章 最佳实践