S3に置いたPDFをテキストに変換するLambda関数+LambdaレイヤーをCFnで構築する

はじめに

故あって S3 バケットに PDF を置いたらその内容のテキストを処理して結果をまた S3 に置く という機能を実装する機会がありました。勉強もかねて調べながらやっていたところで、ある程度かたちになってきたので、コアとなるところをブログにしておきたいと思います。

Goal

下記のような状態をゴールとします。

  • 特定の S3 バケットに PDF を置いたら、テキストデータだけ抽出して別の S3 バケットに保存される環境を作る
  • PDF -> テキストの変換は AWS Lambda を使う(言語は Python 3.6 )
  • AWS Lambda Layer を使う
    • マネジメントコンソールのインラインエディタでコードを確認したいから
  • CloudFormation (CFn) を使う
  • Python の native module は Docker を使ってなんとかする
    • 手元の環境に左右されないようにするため
  • Serverless Framework は 使わない

正直 Serverless Framework を使った方がいろいろと便利だし使わない理由もないと思うんですが、個人的な勉強も兼ねていたので抽象度の低いところから始めました。そのほうが個々の動作を把握しやすいという老害的な理由です。

謝辞

下記を実装するに当たって、多方面のサイトを参考にさせてもらいました。この場を借りて感謝致します。

特にこちらの記事は、Python でなく Node.js を使う場合には是非ご参照下さい。

S3にcreateObjectをトリガーにLambdaを起動するCloudformationテンプレート

また、もちろん AWS 公式ドキュメントもあちこち参考にしています。コード内の細かいところはこちらも参照してください。

動作

簡単に図解しました。

構築時(薄黄)

  • 作業者の手元で ZIPファイル(Lambda 関数、Lambda レイヤー)を作成
    • Lambda レイヤー用の ZIP ファイルには Python の native module が含まれるため、Docker を使用して作成する
    • Docker イメージは Amazon Linux (AL1) を使用(AWS Lambda 環境準拠)
  • 準備用の S3 バケットに ZIP ファイルを置く
    • 準備用の S3 バケットは別途用意してください
  • CFn がそれらを読み込みつつ必要な環境を構築する
    • Lambda 関数, Lambda Layer, IAMロール/ポリシー
    • S3 バケット x2

動作時(薄赤 -> 薄青、薄緑)

  • トリガー用の S3 バケットに PDF ファイルを置く(薄赤)
  • それを検知して Lambda が動作し、良い具合に処理してテキストデータを生成
    • 必要な Python モジュールは全て Layer にまとめる
    • 動作時のログは CloudWatch Logs へ(薄緑)
  • できあがったテキストデータを出力用の S3 バケットに置かれる(薄青)

CFn テンプレートや Lambda コードの類いは後ろにまとめて掲載してます。上述した参考リンク先と合わせてご確認ください。

構築方法

注釈を交えながら解説します。

下記手順を実行すると AWS に対する課金が発生します、ご注意下さい!

0. 前提

以下、下記の環境が整っている前提でご説明します。

  • AWS CLI が使える
  • Docker が使える

下記説明で使用するファイル・コードは記事末尾にまとめて掲載していますので、もし試される婆は、そちらをお手元に用意しつつご覧ください。

1. ZIP ファイルの作成

Docker コンテナ内でシェルスクリプト( create_lambda_zip.sh )を動作させます。Docker が動作する環境1で実行してください。

これにより、必要な Python パッケージを python/ 以下にまとめて layer.zip を作成し、同時に Lambda 関数用の Python スクリプト( lambda_function.py )を圧縮して lambda.zip とします。

手元の環境( MacBook Pro 2017 )だと実行には 1分半 〜 2分程度かかりましたが、このあたりは回線の太さや、パッケージリポジトリの負荷・引きにも依ると思われます。

docker run -t --rm -v `pwd`:/home amazonlinux:1 \
    /bin/bash -ue /home/create_lambda_zip.sh

ここで多くの方が 「Dockerfile 書けよ」 って突っ込むと思うのですが、なんとなく「コンテナ使い捨てとはこういうことだ!」というイメージがあったのでこうしました。時間がもったいなくない人だけマネしてください。

なお create_lambda_zip.sh は必要最低限の機能に限定してありますが、実際に使うときには、標準出力を捨てたり逆に環境変数や経過を示すメッセージを出力させたりと、何かと工夫した方がいいと思っています。

2. ZIP ファイルと CFn テンプレートのアップロード

ZIP ファイルができたらそれを S3 にアップロードします。CloudFormation から参照できればいいので、保存する S3 バケットはパブリックである必要はありません。

aws s3 cp lambda.zip s3://<準備用S3バケット名>/
aws s3 cp layer.zip s3://<準備用S3バケット名>/

ちなみにですが、準備用 S3 バケットがまだなければさくっと作ってしまうのも手です。

aws s3 md s3://<準備用S3バケット名>

3. スタック作成(AWS CLI)

それでは CFn のスタックを作成します。問題なければこちらも数分で各種リソースが作成されるでしょう。

STACK_NAME=s3-pdf2txt-poc
BUCKET_NAME=<準備用S3バケット名>
HASH=<何か適当な文字列>

aws cloudformation create-stack \
    --stack-name $STACK_NAME \
    --parameters \
        ParameterKey=S3BucketBuild,ParameterValue=$BUCKET_NAME \
        ParameterKey=S3BucketTrigger,ParameterValue=s3-pdf2txt-poc-trigger-$HASH \
        ParameterKey=S3BucketOutput,ParameterValue=s3-pdf2txt-poc-output-$HASH \
    --template-body file://s3-pdf2txt-poc_cfn.yaml \
    --capabilities CAPABILITY_NAMED_IAM

8行目で、CFn テンプレート内のパラメータ S3BucketBuild を置き換えてます。上で ZIP ファイルをおいた S3 バケット名をここで指定して下さい。

また、作成する S3 バケットは全世界でユニークな名前である必要があるので、ここではぶつからないように適当な文字列(例えば AWS アカウント ID とか、ランダムなハッシュ値とか)をくっつけます。 3 行目の変数にいれておいてください。

S3 バケット名の命名規則についてお詳しくない方は、この機会に下記ドキュメントにも目を通しておきましょう:

もちろんマネジメントコンソールから「スタックの作成」をしても良いのですが、その場合は上記パラメータの修正を Web UI 上で行って下さい。

数分待って、ステータスが CREATE_COMPLETE になれば完了です。

STACK_NAME=s3-pdf2txt-poc
aws cloudformation describe-stacks \
    --stack-name $STACK_NAME \
    --query 'Stacks[].StackStatus'
[
    "CREATE_COMPLETE"
]

使い方

現状、CFn によって S3 バケットが 2つ作成された状態と思います。

STACK_NAME=s3-pdf2txt-poc
aws s3 ls | grep $STACK_NAME
2019-02-22 14:08:21 s3-pdf2txt-poc-output-XXXXXXXX
2019-02-22 14:09:15 s3-pdf2txt-poc-trigger-XXXXXXXX

このうちトリガー側( s3-pdf2txt-poc-trigger-XXXXXXXX )に何か PDF ファイル(拡張子 .pdf )を置いてみて下さい(あまり大きくなく、かつ文字の多いものがお勧めです)。

aws s3 cp sample_pdf_file.pdf s3://s3-pdf2txt-poc-trigger-XXXXXXXX/

置いた PDF ファイル名 + .txt (上の例なら sample_pdf_file.pdf.txt )が出力用 S3 バケットに出来ていたら完成です!

aws s3 ls s3://s3-pdf2txt-poc-output-XXXXXXXX/

ダウンロードして適当なテキストエディタで開いてみて下さい。正直 PDF には様々な仕様や方言があって、今回使用した pdfminer.six モジュールで対応しきれないものもあるかもしれません。その場合は各自頑張ってコード書いてみてください!

なお置いた PDF の大きさにもよりますが、変換には時間がかかる場合があります。その場合は CloudWatch Logs をみてみて、変換中なのか、それとも何かしらの理由で異常終了しているのか、確認してみて下さい。

変換にかかる時間について

例えばですが、AWS の ホワイトペーパー にある下記 PDF (15ページ)を変換したところ 30 秒程度かかりました。

CloudWatch Logs は下記の通り:

REPORT ... Duration: 29806.45 ms Billed Duration: 29900 ms Memory Size: 128 MB Max Memory Used: 90 MB

一方で、下記の PDF は 2分弱かかりました。

REPORT ... Duration: 116729.61 ms Billed Duration: 116800 ms Memory Size: 128 MB Max Memory Used: 100 MB

現状は Lambda の実行時間を 5 分( 300 秒)に制限していますので、大きな PDF だと変換しきれないこともあるかもしれません。その場合は CFn テンプレートの 98行目 Timeout を伸ばしてみて下さい。2

後片付け

CFn で作ってますので、作られたスタックを削除すればひととおりきれいになります。

ただし、作成した S3 バケットにデータ(オブジェクト)が残っているとそこで削除失敗してしまうので、バケットの中を(あるいはバケットごと)削除した後でスタック削除してください。

まとめ

Serverless Framework を使わずに、S3 トリガーの Lambda 環境を作ってみました。動作を知るには抽象度の低いところで手を動かしてみるのが最高ですね!

何かの参考になれば幸いです。

コード

今回使用したコード類を公開します。なるべく動作上必要最低限になるように書いてあるので、エラー処理や例外処理がまったく考えられていません。ご了承下さい。

.
├── lambda_function.py
├── requirements.txt
├── create_lambda_zip.sh
└── s3-pdf2txt-poc_cfn.yaml
  • lambda_function.py
    • Lambda 関数のコードそのもの
  • requirements.txt
    • Lambda 関数で使用する Python module のリスト。下のシェルスクリプトが参照する
  • create_lambda_zip.sh
    • Lambda 関数ならびに Lambda Layer 構築で使用する ZIP ファイルを作成するスクリプト。Docker 内で実行される
  • s3-pdf2txt-poc_cfn.yaml
    • CFn テンプレート。S3 バケット x2 と Lambda/Lambda Layer +それに付随する諸々を作成する

lambda_function.py

#!/usr/bin/env python

from pdfminer.pdfinterp import PDFResourceManager, PDFPageInterpreter
from pdfminer.converter import TextConverter
from pdfminer.layout import LAParams
from pdfminer.pdfpage import PDFPage
from io import StringIO, BytesIO
import boto3
import json
import urllib
from os import environ

s3 = boto3.resource('s3')

OUTPUT_BUCKET = environ['OUTPUT_BUCKET']

def pdfload(pdf_obj):
    rsrcmgr = PDFResourceManager()
    codec = "utf-8"
    text = ""

    # PDFから文字を抽出し連結する
    with StringIO() as output:
        device = TextConverter(
            rsrcmgr, output, codec=codec, laparams=LAParams())
        # PDFデータをfile object的に扱う
        with BytesIO(pdf_obj.read()) as input:
            interpreter = PDFPageInterpreter(rsrcmgr, device)
            for page in PDFPage.get_pages(input):
                interpreter.process_page(page)
                text += output.getvalue()
        device.close()
    return (text)

# Main
def lambda_handler(event, context):

    # トリガーになったS3オブジェクトの情報を取得
    s3_bucket_name = event['Records'][0]['s3']['bucket']['name']
    s3_object_name = urllib.parse.unquote_plus(
        event['Records'][0]['s3']['object']['key'],
        encoding='utf-8'
    )

    # 出力するCSV名を組み立てる
    output_csv_filename = f'{s3_object_name}.txt'

    # PDFデータを読み込み
    pdf_obj = s3.Object(s3_bucket_name, s3_object_name).get()['Body']
    output_text = pdfload(pdf_obj)

    # TXT出力
    s3.Object(OUTPUT_BUCKET, output_csv_filename).put(
        Body = output_text
    )

    # 戻り
    return ({"output": f'{output_csv_filename}'})

requirements.txt

pdfminer.six
chardet

create_lambda_zip.sh

#!/bin/bash -ue

mkdir /home/python
cd $_

# パッケージインストール
yum -y install git python36 python36-pip zip
python3 -m pip install pip --upgrade

# Pythonモジュールのダウンロード
python3 -m pip install -r /home/requirements.txt -t .
rm -rf bin *.dist-info *.egg-info

# パーミッション
find . -type f -exec chmod 644 {} \;
find . -type d -exec chmod 755 {} \;

# Zip作成
cd ..
zip -r9 - python > layer.zip
zip - lambda_function.py > lambda.zip

# 後片付け
rm -rf /home/python

s3-pdf2txt-poc_cfn.yaml

---
AWSTemplateFormatVersion: "2010-09-09"
Description: "s3-pdf2txt-poc"
Parameters:
  LambdaFunctionName:
    Type: String
    Default: s3-pdf2txt-poc-lambda
  LambdaLayerName:
    Type: String
    Default: s3-pdf2txt-poc-lambda-layer
  LambdaRoleName:
    Type: String
    Default: s3-pdf2txt-poc-role
  LambdaPolicyName:
    Type: String
    Default: s3-pdf2txt-poc-policy
  S3BucketBuild:
    Type: String
    Default: <S3-BUCKET-NAME>
  S3ObjectZipLambda:
    Type: String
    Default: lambda.zip
  S3ObjectZipLayer:
    Type: String
    Default: layer.zip
  S3BucketTrigger:
    Type: String
    Default: s3-pdf2txt-poc-trigger-XXXXXXXX
  S3BucketOutput:
    Type: String
    Default: s3-pdf2txt-poc-output-XXXXXXXX
Resources:
  ConvLambdaRole:
    Type: "AWS::IAM::Role"
    Properties:
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Principal:
              Service:
                - lambda.amazonaws.com
            Action:
              - "sts:AssumeRole"
      ManagedPolicyArns:
        - "arn:aws:iam::aws:policy/CloudWatchLogsFullAccess"
      RoleName: !Ref "LambdaRoleName"
  ConvLambdaRoleInlinePolicy:
    Type: "AWS::IAM::Policy"
    Properties:
      PolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: "Allow"
            Action: "s3:Get*"
            Resource:
              - !Join
                - ""
                - - "arn:aws:s3:::"
                  - !Ref "S3BucketTrigger"
                  - "/*"
          - Effect: "Allow"
            Action: "s3:PutObject"
            Resource:
              !Join
                - ""
                - - "arn:aws:s3:::"
                  - !Ref "S3BucketOutput"
                  - "/*"
      PolicyName: !Ref "LambdaPolicyName"
      Roles:
        - Ref: "ConvLambdaRole"
  ConvLambdaLayer:
    Type: "AWS::Lambda::LayerVersion"
    Properties:
      CompatibleRuntimes:
        - python3.6
      Content:
        S3Bucket: !Ref "S3BucketBuild"
        S3Key: !Ref "S3ObjectZipLayer"
      LayerName: !Ref "LambdaLayerName"
  ConvLambda:
    Type: "AWS::Lambda::Function"
    Properties:
      Code:
        S3Bucket: !Ref "S3BucketBuild"
        S3Key: !Ref "S3ObjectZipLambda"
      FunctionName: !Ref "LambdaFunctionName"
      Environment:
        Variables:
          OUTPUT_BUCKET: !Ref "S3BucketOutput"
      Handler: "lambda_function.lambda_handler"
      Layers:
        - !Ref ConvLambdaLayer
      MemorySize: 128
      Role: !GetAtt "ConvLambdaRole.Arn"
      Runtime: "python3.6"
      Timeout: 300
  ConvLambdaPermission:
    Type: "AWS::Lambda::Permission"
    Properties:
      Action: "lambda:InvokeFunction"
      FunctionName: !GetAtt
        - ConvLambda
        - Arn
      Principal: "s3.amazonaws.com"
      SourceArn:
        !Join
          - ""
          - - "arn:aws:s3:::"
            - !Ref "S3BucketTrigger"
  SrcS3Bucket:
    Type: "AWS::S3::Bucket"
    DependsOn: "ConvLambdaPermission"
    Properties:
      BucketName: !Ref 'S3BucketTrigger'
      NotificationConfiguration:
        LambdaConfigurations:
          - Event: "s3:ObjectCreated:*"
            Filter:
              S3Key:
                Rules:
                  - Name: suffix
                    Value: pdf
            Function: !GetAtt
              - ConvLambda
              - Arn
  DstS3Bucket:
    Type: "AWS::S3::Bucket"
    Properties:
      BucketName: !Ref 'S3BucketOutput'

注釈


  1. かつ外部サイトからのダウンロードが可能なところ。本記事は Docker for Mac で確認しました。 
  2. ただし当然、その分コストがかかります。お気をつけください。