Tips サーバーレス(Lambda)構成で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
呼出方法
file_data
パラメータにBase64エンコードしたExcelファイルを埋め込み Lambda InvokeやAPIから呼び出します。