Создаем сервер для конвертации docx в pdf
Недавно возникла у нас необходимость конвертировать 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-ки.