Loading...

Tips サーバーレス構成でExcelをPDFに変換する

Excelをサーバーレス構成でPDFに変換するTipsです。
ExcelファイルをPDFに変換する方法として弊社では今までデスクトップアプリであればExcelを使用して、WebアプリではVBExcelやOpenOffice/LibreOfficeを使用してきました。

デスクトップアプリではお客様がライセンスを保持しているのを前提でExcelをActiveX経由で自由に操作していましたが、Webアプリではライセンスの関係でExcelを使わず別の代替手段を使用しておりました。

VBExcelであればかなりの再現性があり非常に有用ですが費用がかかるのが難点です。また、OpenOfficeやLibreOfficeはオープンソースなので費用は係らず気軽にインストールが出来ますが、環境を維持するのが大変でした。

そこでLibreOfficeをAWSのLambda環境で動かせないかと言う課題に真剣に取り組んでみました。

AWS 環境を整える

MBCではシステムを構築する際、開発・テスト・本番環境の3環境を作成します。それぞれの環境毎に設定が必要となります。

使用するAWSサービス
  • AWS System Manager Parameter Store: 環境設定を保存する
  • Cognito: API の認証に使用する
  • Route53: APIのドメイン名を設定する
  • ACM: SSL証明書を設定する


弊社で使用する場合API経由ではなくLambda Invokeで使用する為Cognito等は不要ですが、セットアップはしておきます。

SSM パラメータ
  • /${ServiceName}/${Env}/cognito-arn: CognitoのARNを指定する
  • /${ServiceName}/${Env}/excel-to-pdf-domain-name: Excel to PDFのドメイン名を指定する
  • /${ServiceName}/certificate-arn: ACMで作成した証明書のARNを指定する
  • /${ServiceName}/hosted-zone-id: Excel to PDFで使用するドメイン名を管理しているRoute53のHost Zone IDを指定する

Dockerファイル

UbuntuをベースにLibreOfficeをインストールし、日本語フォント、Lambda実行ファイルをロードさせます。

FROM ubuntu:22.04

ENV DEBIAN_FRONTEND=noninteractive

# OS関連セットアップ
RUN echo "Asia/Tokyo" > /etc/timezone
RUN apt-get -y update

# 日本語環境のセットアップ
RUN apt-get install language-pack-ja-base language-pack-ja locales

RUN locale-gen ja_JP.UTF-8
ENV LANG ja_JP.UTF-8
ENV LANGUAGE jp_JP:ja
ENV LC_ALL ja_JP.UTF-8

# 必要なモジュールのインストール
RUN apt-get install -y vim

# ubuntuデスクトップインストール
RUN apt-get install -y --no-install-recommends ubuntu-desktop

# Python 関連インストール
RUN apt-get install -y libpq-dev build-essential libxml2-dev wget git cmake curl unzip sudo libglib2.0-0 libsm6 libxrender1 libfontconfig1 vim gcc
RUN apt install -y software-properties-common
RUN add-apt-repository ppa:deadsnakes/ppa
RUN apt install -y python3.11 python3.11-venv

## LibreOffice のセットアップ
#RUN apt-get install -y language-pack-ja-base language-pack-ja ibus-kkc libreoffice libreoffice-l10n-ja libreoffice-help-ja
RUN apt-get -y install software-properties-common
RUN add-apt-repository -y ppa:libreoffice/ppa
RUN apt-get -y update
RUN apt-get install -y language-pack-ja-base language-pack-ja ibus-kkc libreoffice libreoffice-l10n-ja libreoffice-help-ja

# フォントのインストール
RUN apt-get install -y fonts-noto-cjk fonts-ipafont fonts-ipaexfont

# venv セットアップ
RUN python3.11 -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
RUN . activate \
RUN pip install -U pip

# アプリケーションのセットアップ
ENV APP_PATH='/var/task'
ENV PYTHONPATH='/var/task'
ENV PATH="$APP_PATH:$PATH"
WORKDIR $APP_PATH/

COPY ./ $APP_PATH/

# アプリケーションのセットアップ 2
RUN pip install -r requirements.txt

# AWS Lambdaセットアップ
RUN pip install awslambdaric

RUN chmod +x /var/task/entry.sh
ENTRYPOINT [ "/var/task/entry.sh" ]
CMD [ "app.lambda_handler" ]

# for debug
#CMD tail -f /dev/null

LambdaはPython3.11です。フォントはnoto-cjk, ipafontをインストールさせています。

Excel to PDF プロジェクトの作成

Python を実行するために必要なモジュールを requirements.txt に記載します。

aws-lambda-powertools
python-dotenv
aws_xray_sdk
sentry-sdk
boto3

app.py には Lambda ハンドラーを記載します。

import getpass
import json
import os
import subprocess
import sys
from os.path import expanduser

import sentry_sdk
from aws_lambda_powertools import Tracer, Metrics, Logger
from aws_lambda_powertools.event_handler import ApiGatewayResolver
from aws_lambda_powertools.event_handler.api_gateway import CORSConfig
from aws_lambda_powertools.logging import correlation_paths
from aws_lambda_powertools.utilities import parameters
from aws_lambda_powertools.utilities.typing import LambdaContext
from sentry_sdk.integrations.aws_lambda import AwsLambdaIntegration

SYSTEM_ID = os.getenv('SYSTEM_ID', 'mbc')
SYSTEM_TENANT_ID = os.getenv('SYSTEM_TENANT_ID', 'common')
SERVICE_NAME = os.getenv('SERVICE_NAME', 'mbc-excel-to-pdf')
ENVIRONMENT = os.getenv('ENVIRONMENT', 'dev')

logger = Logger(service=SERVICE_NAME, log_uncaught_exceptions=True)

tracer = Tracer()
metrics = Metrics(service='excel-to-pdf', namespace='{}_{}'.format(ENVIRONMENT, SERVICE_NAME))

CORS_ORIGINS = os.getenv('CORS_ORIGINS', '*')
cors_config = CORSConfig(allow_origin=CORS_ORIGINS, max_age=300)
api_app = ApiGatewayResolver(cors=cors_config)

CURRENT_DIRECTORY = os.path.dirname(os.path.abspath(__file__))
sys.path.append(os.path.abspath(CURRENT_DIRECTORY + '/'))

import excel_to_pdf

api_app.include_router(excel_to_pdf.router, prefix='/')


@metrics.log_metrics
@logger.inject_lambda_context(correlation_id_path=correlation_paths.API_GATEWAY_REST, log_event=True)
@tracer.capture_lambda_handler(capture_response=False)
def lambda_handler(event, context: LambdaContext):
    return api_app.resolve(event, context)

excel_to_pdf.py を作成し以下のように記載します。

import base64
import getpass
import json
import os
import subprocess
import tempfile
from http import HTTPStatus
from os.path import expanduser

from aws_lambda_powertools import Tracer, Metrics, Logger
from aws_lambda_powertools.event_handler.api_gateway import Response, Router
from aws_lambda_powertools.event_handler.exceptions import ServiceError, InternalServerError

CURRENT_DIRECTORY = os.path.dirname(os.path.abspath(__file__))

tracer = Tracer()
metrics = Metrics()
router = Router()

logger = Logger(log_uncaught_exceptions=True)


@tracer.capture_method
@router.post('/')
def excel_to_pdf():
    logger.info('PDF出力処理を開始します。')

    app = router.api_resolver
    if not app.current_event.get('body'):
        raise ServiceError(HTTPStatus.BAD_REQUEST, 'Validation Error Must Be Not Null {}.'.format('body'))

    result = {}

    # 入力パラメータの取得
    request_data = app.current_event.json_body

    if 'file_data' in request_data:
        file_data = request_data['file_data']

        with tempfile.TemporaryDirectory() as dir_name:
            logger.info('PDF出力処理を開始します。 {}'.format(dir_name))

            excel_file_name = 'excel_file.xlsx'
            pdf_file_name = 'excel_file.pdf'
            excel_file_path = os.path.join(dir_name, excel_file_name)
            pdf_file_path = os.path.join(dir_name, pdf_file_name)

            # Excelファイルの書き出し
            with open(excel_file_path, 'wb') as f:
                f.write(base64.b64decode(file_data))

            # PDFを生成する
            env = os.environ.copy()
            env['HOME'] = '/tmp'
            console_command = 'soffice --headless --norestore --invisible --nodefault --nofirststartwizard --nolockcheck --nologo --convert-to pdf:calc_pdf_Export --outdir {} {}'.format(
                dir_name, excel_file_path)
            logger.info({'message': 'LibreOffice 実行 {}'.format(console_command), 'env': env})
            proc = subprocess.run(console_command, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
                                  env=env)
            logger.info('STDOUT: {}'.format(proc.stdout.decode()))
            if proc.stderr:
                logger.error('STDERR: {}'.format(proc.stderr))
                raise InternalServerError('PDF変換中にエラーが発生しました。 エラー: {}'.format(proc.stderr))

            if os.path.exists(pdf_file_path):
                with open(pdf_file_path, 'rb') as f:
                    file_data = f.read()
                    result = {'file_data': base64.b64encode(file_data).decode(), }
                    logger.info('PDF変換処理は正常に終了しました。'.format(pdf_file_path))
            else:
                raise InternalServerError(
                    'PDFへ変換したファイルが見つかりません。 pdf_file_path: {} excel_file_path {}'.format(pdf_file_path,
                                                                                                         excel_file_path))
            try:
                if os.path.exists(excel_file_path):
                    os.remove(excel_file_path)
                if os.path.exists(pdf_file_path):
                    os.remove(pdf_file_path)
                subprocess.call('rm -rf /tmp/*', shell=True)
            except Exception as e:
                pass
    return Response(status_code=HTTPStatus.OK, content_type='application/json', body=json.dumps(result), )

CloudFormation にてデプロイをする

AWS環境の用意が完了したら template.yml を作成しCloudFormation を使用してAWS環境へデプロイします。

AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: >


Globals:
  Function:
    Timeout: 30
    Tracing: Active
  Api:
    TracingEnabled: True

Parameters:
  ServiceName:
    Default: mbc-excel-to-pdf
    Type: String
  Env:
    AllowedValues:
      - prod
      - stg
      - dev
    Default: dev
    Type: String

Resources:
  ApiGateway:
    Type: AWS::Serverless::Api
    Properties:
      Name: !Sub '${Env}-${ServiceName}-excel-to-pdf-services'
      StageName: !Ref Env
      OpenApiVersion: 3.0.2
      Auth:
        Authorizers:
          CognitoAuthorizer:
            UserPoolArn: !Sub '{{resolve:ssm:/${ServiceName}/${Env}/cognito-arn}}'
      Cors:
        AllowOrigin: "'*'"
        AllowHeaders: "'Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token'"
        AllowMethods: "'GET, OPTIONS, POST, PUT, DELETE, PATCH'"
        # AllowCredentials: true
      Domain:
        DomainName: !Sub '{{resolve:ssm:/${ServiceName}/${Env}/excel-to-pdf-domain-name}}'
        CertificateArn: !Sub '{{resolve:ssm:/${ServiceName}/certificate-arn}}'
        EndpointConfiguration: REGIONAL
        Route53:
          HostedZoneId: !Sub '{{resolve:ssm:/${ServiceName}/hosted-zone-id}}'
      MethodSettings:
        - DataTraceEnabled: true
          LoggingLevel: 'INFO'
          ResourcePath: '/*'
          HttpMethod: '*'
          MetricsEnabled: true
      AccessLogSetting:
        DestinationArn: !Sub 'arn:aws:logs:${AWS::Region}:${AWS::AccountId}:log-group:/api-gateway/rest/${Env}-${ServiceName}-excel-to-pdf-services'
        Format: '{ "requestId":"$context.requestId", "ip": "$context.identity.sourceIp", "caller":"$context.identity.caller", "user":"$context.identity.user","requestTime":"$context.requestTime", "httpMethod":"$context.httpMethod","resourcePath":"$context.resourcePath", "path": "$context.path", "status":"$context.status","protocol":"$context.protocol", "responseLength":"$context.responseLength", "xrayTraceId": "$context.xrayTraceId" }'

  ApiGatewayRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: !Sub '${Env}-${ServiceName}-excel-to-pdf-api-gateway-role'
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: "Allow"
            Action: "sts:AssumeRole"
            Principal:
              Service: [ apigateway.amazonaws.com ]
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/service-role/AmazonAPIGatewayPushToCloudWatchLogs

  ApiGatewayAccount:
    Type: AWS::ApiGateway::Account
    Properties:
      CloudWatchRoleArn: !GetAtt ApiGatewayRole.Arn

  FunctionRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: !Sub '${Env}-${ServiceName}-excel-to-pdf-role'
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: "Allow"
            Action: "sts:AssumeRole"
            Principal:
              Service: [ lambda.amazonaws.com, sns.amazonaws.com, sqs.amazonaws.com ]
      Policies:
        - PolicyName: !Sub '${Env}-${ServiceName}-excel-to-pdf-policy'
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: "Allow"
                Action:
                  - "logs:CreateLogGroup"
                  - "logs:CreateLogStream"
                  - "logs:PutLogEvents"
                  - "logs:PutLogEvents"
                  - "xray:Put*"
                Resource: "*"
              - Effect: "Allow"
                Action:
                  - "ssm:GetParameter"
                Resource: "*"
              - Effect: "Allow"
                Action:
                  - "appconfig:GetConfiguration"
                  - "cloudwatch:DescribeAlarms"
                Resource: "*"
              - Effect: "Allow"
                Action:
                  - "sns:Publish"
                  - "sns:ListTopics"
                Resource: "*"
              - Effect: "Allow"
                Action:
                  - "sqs:ReceiveMessage"
                  - "sqs:DeleteMessage"
                  - "sqs:GetQueueAttributes"
                Resource: "*"
              - Effect: "Allow"
                Action:
                  - "ec2:CreateNetworkInterface"
                  - "ec2:DescribeNetworkInterfaces"
                  - "ec2:DeleteNetworkInterface"
                  - "ec2:AssignPrivateIpAddresses"
                  - "ec2:UnassignPrivateIpAddresses"
                Resource: "*"

  Function:
    Type: AWS::Serverless::Function # More info about Function Resource: https://github.com/awslabs/serverless-application-model/blob/master/versions/2016-10-31.md#awsserverlessfunction
    Properties:
      PackageType: Image
      FunctionName: !Sub '${Env}-${ServiceName}-excel-to-pdf-function'
      Role: !GetAtt FunctionRole.Arn
      MemorySize: 2048
      Environment:
        Variables:
          Home: /tmp
          ENVIRONMENT: !Ref Env
          LANG: ja_JP.UTF-8
          LANGUAGE: jp_JP:ja
          LC_ALL: ja_JP.UTF-8
      Events:
        FunctionApi:
          Type: Api
          Properties:
            Auth:
              Authorizer: CognitoAuthorizer
            Path: /{proxy+}
            Method: ANY
            RestApiId: !Ref ApiGateway
      Timeout: 900
    Metadata:
      Dockerfile: Dockerfile
      DockerContext: ./
      DockerTag: !Sub '${Env}-${ServiceName}-excel-to-pdf-function'

  FunctionAlarm:
    Type: AWS::CloudWatch::Alarm
    Properties:
      AlarmName: !Sub "${Env}-${ServiceName}-excel-to-pdf-alarm"
      AlarmDescription: "Alarm if error occur"
      Namespace: "AWS/Lambda"
      MetricName: "Errors"
      Dimensions:
        - Name: "FunctionName"
          Value: !Sub '${Env}-${ServiceName}-excel-to-pdf-function'
      Statistic: "Sum"
      Period: "60"
      EvaluationPeriods: "1"
      Threshold: "1"
      ComparisonOperator: "GreaterThanOrEqualToThreshold"
      AlarmActions:
        - Fn::Join:
            - ":"
            - - !Sub 'arn:aws:sns:${AWS::Region}'
              - Ref: 'AWS::AccountId'
              - !Sub '${Env}-${ServiceName}-alarm'
      TreatMissingData: notBreaching

Top