Недавно возникла у нас необходимость конвертировать docx в pdf. Задачка элеменатарно решается руками через GUI таких популярных приложений как Microsoft Office и Libreoffice. Открыл файл нажал "Сохранить Как..." и будет тебе счастье.

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

Так как разработка велась под linux, идею использования MS Office мы сразу отбросили и начали искать другие способы грамотной реализации поставленной задачи. К счастью, в libreoffice обнаружился headless режим, который запускал офис без интерфейса и позволял конвертировать один документ в другой.

Так было решено написать простую утилиту для нашего веб-приложения, которой только и нужно будет, что файлы подбрасывать. Получилось вот что (писали на asyncio):

async def docx2pdf_handle(request):
    # создаем временный файлик
    with tempfile.NamedTemporaryFile() as output:

        # записываем присланные данные во временный файл
        reader = await request.multipart()
        docx = await reader.next()

        while True:
            chunk = await docx.read_chunk()
            if not chunk:
                break
            output.write(chunk)

        # отдаем конвертеру
        p = await asyncio.create_subprocess_shell(" ".join([
            '/usr/bin/soffice',
            '--headless',
            '--convert-to',
            'pdf',
            '--outdir',
            os.path.abspath(os.path.dirname(output.name)),
            os.path.abspath(output.name)
        ]))
        await p.wait()

        # стримим результат конвертации обратно клиенту
        path = "{}.pdf".format(output.name)
        response = web.StreamResponse(
            status=200,
            reason="OK",
        )
        response.content_type = 'application/pdf'

        await response.prepare(request)

        with open(path, 'rb') as f:
            while True:
                chunk = f.read(8192)
                if not chunk:
                    break
                response.write(chunk)
                await response.drain()

        os.remove(path)

        return response

Но тут нас ждало неприятное открытие - libreoffice, по умолчанию, не дает запускать больше одной копии приложения, даже в headless режиме. Из-за этого все наши идеи с асинхронным выполнением оказались бесполезны. Как только количество одновременных запросов превышало единицу, ковертация не происходила и сервер выдавал ошибку.

Пришлось процедуру конвертации переписать через вызов обычного subprocess, чтобы она происходила синхронно:

p = subprocess.Popen([
    '/usr/bin/soffice',
    '--headless',
    '--convert-to',
    'pdf',
    '--outdir',
    os.path.abspath(os.path.dirname(output.name)),
    os.path.abspath(output.name)
])
p.wait()

Мы стали искать решение, чтобы разрешить libreoffice запускать несколько копий одновременно. Были предложения передавать в качестве параметра -env:SingleAppInstance=false или разворачивать несколько копий soffice systemd сервисов. В общем, решения либо не работали, либо казались недостаточно гибкими. В итоге, мы стали смотреть в сторону докера.

Также, в пользу докера был тот факт, что наш лид изначально не хотел устанавливать libreoffice на продакшен сервер.

Создаем контейнер под libreoffice с микросервисом

Первым делом, мы упаковали наш конвертер-микросервис в отдельный файлик main.py:

import tempfile

from aiohttp import web
import os
import subprocess


async def docx2pdf_handle(request):
    with tempfile.NamedTemporaryFile() as output:
        reader = await request.multipart()
        docx = await reader.next()

        while True:
            chunk = await docx.read_chunk()
            if not chunk:
                break
            output.write(chunk)

        p = subprocess.Popen([
            '/usr/bin/soffice',
            '--headless',
            '--convert-to',
            '--convert-to',
            'pdf',
            '--outdir',
            os.path.abspath(os.path.dirname(output.name)),
            os.path.abspath(output.name)
        ])
        p.wait()

        path = "{}.pdf".format(output.name)
        response = web.StreamResponse(
            status=200,
            reason="OK",
        )
        response.content_type = 'application/pdf'

        await response.prepare(request)

        try:
            with open(path, 'rb') as f:
                while True:
                    chunk = f.read(8192)
                    if not chunk:
                        break
                    response.write(chunk)
                    await response.drain()
        except Exception as ex:
            print(ex)
            response = web.Response(status=400)

        os.remove(path)

        return response

if __name__ == '__main__':
    app = web.Application()
    app.router.add_post('/docx2pdf', docx2pdf_handle)

    web.run_app(app, port=int(os.getenv('PORT', "6000")))

После, мы создали Dockerfile, который позволял бы нам запускать libreoffice изолированно и добавили в него запуск нашего микросервиса. Вот такой файл получился:

FROM python:3.6.2-jessie

RUN echo deb http://ftp.ru.debian.org/debian/ jessie main non-free contrib >> /etc/apt/sources.list \
    && apt-get update \
    && apt-get install -y \
    libreoffice-writer \
    openjdk-7-jre-headless \
    ttf-mscorefonts-installer \
    && pip install aiohttp \
    && rm -rf /var/lib/apt/lists/*

ADD main.py /proxy/main.py

ENV PORT 6000

CMD ["python", "/proxy/main.py"]

В таком варианте мы уже могли собрать образ, запустить его и потестить. Но он все еще не позволял нам обрабатывать более одного запроса за раз.

Так мы решили, что будем использовать nginx в качестве балансировщика, а для того, чтобы обеспечить возможность параллелить обработку документов, развернем несколько конейнеров образа. Для решения такой задачи идеально подошел docker-compose.

Мы создали файл docker-compose.yml со следующим содержимым:

version: "3"
services:
  libre1:
    build:
      context: .
  libre2:
    build:
      context: .
  libre3:
    build:
      context: .
  balancer:
    image: "nginx:1.13.5-alpine"
    ports:
      - "6000:80"
    volumes:
      - ./nginx.conf:/etc/nginx/conf.d/default.conf

И добавили файл nginx.conf:

upstream libre_balancer {
    server libre1:6000;
    server libre2:6000;
    server libre3:6000;
}

server {
    listen 80;

    location / {
        proxy_pass http://libre_balancer;
    }

}

Запустили нашу конфигурацию:

docker-compose up

И попробовали прогнать тест:

import shutil
from multiprocessing import Pool
import requests

def action(i):
    with open("template.docx", 'rb') as f:
        r = requests.post("http://0.0.0.0:6000/docx2pdf", files={
            'upload_file': f
        }, stream=True)

        if r.status_code == 200:
            with open('out/out{}.pdf'.format(i + 1), 'wb') as f:
                r.raw.decode_content = True
                shutil.copyfileobj(r.raw, f)
        else:
            print(r.status_code, r.content)


s = requests.Session()

if __name__ == '__main__':
    pool = Pool(6)
    pool.map(action, range(100))

Все отлично сработало. Никаких падений не наблюдалось. Nginx исправно распределял нагрузку, а микросервисы выдавали свежесозданные pdf-ки.