オンプレの遅ぇ処理をAWS Lambdaで改善できるか検証した話

# はじめに

**これは [Media Do Advent Calendar 2019](https://qiita.com/advent-calendar/2019/mediado) の17日目の記事です。**

こんにちは、株式会社メディアドゥに入社して4ヶ月ほど経ちました、[ogady](https://twitter.com/gadyma)です。

突然ですが、電子書籍はEPUBというファイル形式であることが一般的です。
（EPUBって何？という方は、[弊社のエンジニアブログ](https://techdo.mediado.jp/entry/2019/04/01/112244)で触れていますのでご参照ください。）

現行のシステムでは、EPUBファイルを登録するときにちゃんとした形式になっているかをチェックする処理があります。オンプレミス環境で動いているのですが、この処理が100ファイルで大体15分ほどかかります。
チェック処理をシリアルに実行しているためファイル数が増えれば増えるほど時間がかかってしまっているっぽい。

今回はEPUBチェック機能だけを切り出してLambdaで処理させたら、**どれくらいパフォーマンス改善できるか**、試しにやってみました。

# 技術スタック

- インフラ：AWS (Lambda、Lambda Layer、SQS、DynamoDB、S3、CDK)
- 開発言語：Go 1.13
- EPUBチェックツール：[w3c/epubcheck](https://github.com/w3c/epubcheck)

## 1. システム構成

- 納品されたEPUBに対してEPUBチェックをパラレルで実行するために、S3のアップロードイベントで Lambdaを発火し、同時実行させる。
- Lambdaの同時実行回数はデフォルト1,000なので、同時に1,000以上のEPUBファイルがS3にアップロードされた時のために、SQSを間にかます。
- 結果はDynamoDBに格納し、チェックが正常に完了したEPUBファイルを`EPUBチェック済みバケット`に移動させる。

 ![epubcheckOnAWS.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/331794/ababa0cb-03cd-b069-f2e2-16b041bc973d.png)


## 2. Lambda関数の実装

Usecaseの処理自体はこんな感じ
S3や、DynamoDB、SQS関連の処理は定番のものなので省略します。

```go
package usecase

import (
	"fmt"
	"log"
	"net/url"
	"os"
	"os/exec"
	"strings"

	"github.com/ogady/epubcheckerOnCloud/lambdaSrc/domain/model"
	dynamoDBRepo "github.com/ogady/epubcheckerOnCloud/lambdaSrc/domain/repository/dynamodb"
	s3Repo "github.com/ogady/epubcheckerOnCloud/lambdaSrc/domain/repository/s3"
)

type EpubCheckUsecase interface {
	EpubCheck(string) error
}
type EpubCheckImpl struct {
	s3Repo       s3Repo.Repository
	dynamoDBRepo dynamoDBRepo.Repository
}

func NewEpubCheckImpl(s3Repo s3Repo.Repository, dynamoDBRepo dynamoDBRepo.Repository) EpubCheckUsecase {

	epubCheckImpl := &EpubCheckImpl{
		s3Repo:       s3Repo,
		dynamoDBRepo: dynamoDBRepo,
	}
	return epubCheckImpl
}

func (u *EpubCheckImpl) EpubCheck(key string) error {

	file, err := u.s3Repo.DownloadFile(key)

	if err != nil {
		err = fmt.Errorf("***QueryEscape エラー *** %w", err)
		return err
	}

	res, err := execEpubCheck(file.Name())
	if err != nil {
		err = fmt.Errorf("***epubcheck実行時エラー*** %w", err)
		return err
	}

	log.Printf("***EPUBチェック成功*** %#v\n", res)

	splitRes := strings.Split(res, "\n")

	var epubCheckInfo model.EpubCheckInfo
	var epubcheckResult model.EpubcheckResult
	for _, resRow := range splitRes {

		switch {
		case strings.Contains(resRow, "Validating"):
			epubcheckResult.DefaultMessage = resRow
		case strings.Contains(resRow, "No errors or warnings detected."):
			epubcheckResult.CheckCompleteMesssage = resRow
		case strings.Contains(resRow, "Check finished with errors"):
			epubcheckResult.CheckCompleteMesssage = resRow
		case strings.Contains(resRow, "SUPPRESSED"):
			epubcheckResult.SuprressedMessage = resRow
		case strings.Contains(resRow, "USAGE"):
			epubcheckResult.UsageMessage = resRow
		case strings.Contains(resRow, "INFO"):
			epubcheckResult.InfoMessage = resRow
		case strings.Contains(resRow, "WARNING"):
			epubcheckResult.WarningMessage = resRow
		case strings.Contains(resRow, "ERROR"):
			epubcheckResult.ErrorMessage = resRow
		case strings.Contains(resRow, "FATAL"):
			epubcheckResult.FatalMessage = resRow
		case strings.Contains(resRow, "Messages"):
			epubcheckResult.CheckStatus = resRow
		default:

		}

	}

	/*
	 チェック結果をセーブする。
	*/
	buffKeys := strings.Split(key, "/")
	fileName, err := url.QueryUnescape(buffKeys[len(buffKeys)-1])
	if err != nil {
		err = fmt.Errorf("***QueryEscape エラー *** %w", err)
		return err
	}

	epubCheckInfo.UserID = "testUser"
	epubCheckInfo.FileName = fileName
	epubCheckInfo.Result = epubcheckResult

	err = u.dynamoDBRepo.Save(epubCheckInfo)

	if err != nil {
		err = fmt.Errorf("***EPUBチェック結果保存失敗*** %w", err)
		return err
	}

	/*
	 チェック完了したファイルをULし、チェック未済のバケットから削除する。
	*/
	log.Printf("***EPUBアップロード開始***\n")

	err = u.s3Repo.UploadFile(key, file)
	if err != nil {
		err = fmt.Errorf("***EPUBアップロードエラー*** %w", err)
		return err
	}

	err = u.s3Repo.DeleteFile(key)
	if err != nil {
		err = fmt.Errorf("***EPUBファイル削除エラー*** %w", err)
		return err
	}

	log.Printf("***EPUBファイル削除完了*** %#v\n", err)

	os.Remove(file.Name())
	return nil
}

func execEpubCheck(filePath string) (string, error) {
	encfilePath, err := url.QueryUnescape(filePath)
	res, err := exec.Command("java", "-jar", "/opt/java/lib/epubcheck.jar", encfilePath).CombinedOutput()
	log.Printf("Stdout of epubcheck -> %s", res)
	if err != nil {
		return string(res), err
	}
	return string(res), nil
}
```



### 標準のEPUBチェッカーがJava製なんだけど → Lambda Layer使ってみる

EPUBチェックには「W3C」が無償で提供している、[w3c/epubcheck](https://github.com/w3c/epubcheck)を使用します。
今回のLamda関数は社内推奨言語であるGoで作るので、java製のepubcheckツールをLambda Layerに切り出します。そしてLambda関数で、`os/exec` パッケージを使用して、外部コマンド発効することでEPUBチェックを実行します。

Lambda Layerにソースをアップロードする際は、Javaの場合以下のようにzipファイルにします。

```
hoge.zip
└ java/lib/hoge.jar
```

Lambda Layerは、` /opt` 配下に展開されるため、Goで`exec.Command()` する際には`/opt`以下のjarファイルを指定して実行できます。

``` go
res, err := exec.Command("java", "-jar", "/opt/java/lib/hoge.jar", encfilePath).CombinedOutput()
```

## 3. AWS CDKでIaC

インフラ構成は、AWS CDKで構成します。

``` js
import cdk = require('@aws-cdk/core');
import iam = require('@aws-cdk/aws-iam');
import lambda = require('@aws-cdk/aws-lambda');
import dynamodb = require('@aws-cdk/aws-dynamodb');
import sqs = require('@aws-cdk/aws-sqs');
import s3n = require('@aws-cdk/aws-s3-notifications');
import s3 = require('@aws-cdk/aws-s3');
import { SqsEventSource } from '@aws-cdk/aws-lambda-event-sources';
import { RemovalPolicy } from '@aws-cdk/core';

export class EpubcheckerOnCloudStack extends cdk.Stack {
  constructor(scope: cdk.Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    // DynamoDB
    const epubCheckResult = new dynamodb.Table(this, 'epubCheckResult', {
      partitionKey: { name: 'file_name', type: dynamodb.AttributeType.STRING },
      billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
      removalPolicy: RemovalPolicy.DESTROY,
    });

    // Lambda Layer
    const epubckeckerLayer = new lambda.LayerVersion(this, 'epubckeckerLayer', {
      code: lambda.Code.asset('./lambdaLayerSrc'),
      compatibleRuntimes: [lambda.Runtime.JAVA_8, lambda.Runtime.GO_1_X],
      description: 'A layer to check EPUB',
    });

    // LambdaFunction
    const lambdaFunction = new lambda.Function(this,
      "epubcheckfunc", {
      functionName: "epubcheckfunc",
      runtime: lambda.Runtime.GO_1_X,
      code: lambda.Code.asset("./lambdaSrc"),
      handler: "handler",
      memorySize: 1280,
      timeout: cdk.Duration.minutes(15),
      environment: {
        "CHECKED_EPUB_BUCKET_NAME": "epub-check-completed",
        "UNCHECKED_EPUB_BUCKET_NAME": "epub-check-uncompleted",
        "REGION": "ap-northeast-1",
        "DYNAMODB_NAME": epubCheckResult.tableName,
      },
      layers: [epubckeckerLayer],
    });

    // lambda permission for dynamoDB
    epubCheckResult.grantReadWriteData(lambdaFunction)

    // S3 bucket epubCheckCompleted
    const epubCheckCompletedBucket = new s3.Bucket(this, "epub-check-completed", {
      bucketName: "epub-check-completed",
      blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
      removalPolicy: RemovalPolicy.DESTROY,
    });

    // S3 bucket epubCheckUncompleted
    const epubCheckUncompletedBucket = new s3.Bucket(this, "epub-check-uncompleted", {
      bucketName: "epub-check-uncompleted",
      blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
      removalPolicy: RemovalPolicy.DESTROY,
    });

    // S3 , Granted for lambda permission
    epubCheckCompletedBucket.grantReadWrite(lambdaFunction);
    epubCheckUncompletedBucket.grantDelete(lambdaFunction);
    epubCheckUncompletedBucket.grantReadWrite(lambdaFunction);

    // SQS
    const queue = new sqs.Queue(this, "epubCheckQueue", {
      queueName: "epubCheckQueue",
      visibilityTimeout: cdk.Duration.minutes(16),
    });

    // SQS, Granted for lambda permission
    queue.grantConsumeMessages(lambdaFunction);

    // S3, Add EventNotification
    epubCheckUncompletedBucket.addEventNotification(s3.EventType.OBJECT_CREATED, new s3n.SqsDestination(queue), { suffix: '.epub' });

    // lambda, Add EventSource
    lambdaFunction.addEventSource(new SqsEventSource(queue, {
      batchSize: 1,
    }));

  }
}
```



## 4. 使ってみよう

試しに100ファイルS3にアップロードしてみます。
CloudWatchのログはこんな感じ

<img width="1189" alt="ラムダスタート.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/331794/afeed430-dac1-a977-20ab-09e8df4f2b9a.png">
<img width="1193" alt="Epubチェック完了.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/331794/c51e7d27-abb6-0c7c-afc7-48547989954c.png">
<img width="1162" alt="ラムダエンド.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/331794/c4b6e08a-c9b1-0567-6a72-b1c2777fb436.png">

 × 100ファイル分

しっかり同時実行されて15秒ほどで100件のEPUBチェックが完了しました。

最初の15分と比べると圧倒的早さになりました！

## 後書き

今回は検証の立ち位置で実装しましたが、これを実際に現行システムから切り出せれば、大量の電子書籍が納品された時、これまでより素早く捌いていけるかなーと思います。


## 参考にした記事など

- [AWS公式ドキュメント](https://docs.aws.amazon.com/ja_jp/lambda/latest/dg/configuration-layers.html)