ServerlessでさくっとWebサイトを構築する

社内ユースのちょっとしたツールや検証用に画面があると便利だなということで、ServerlessでWebサイトを作ってみました。

今回はR&Dの成果物について、エンジニア以外のメンバーからフィードバックしてもらう目的でWebサイトを作成します。プロダクション環境に導入する際は、アーキテクチャ面でいろいろ考慮する必要がありそうですが、とにかく動けばいいといったものであれば本当にさくっと作れました。

# 1.構成
![composition.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/551923/60e15ae2-eed9-2165-f940-dc6a30ed173e.png)

- S3
- API Gateway
- Lambda
- DynamoDB

全てAWSのサービスを利用します。社内ユースということで、S3、API GatewayへのアクセスはIPアドレスで制限します。


# 2.Lambda
Pythonで書こうと思ったのですが、外部ライブラリをLambdaで利用するにはローカルでPIPして落としてきたものをアップロードしなくてはなりません。例えばnumpyなど、Cを利用しているライブラリだとWindows環境でPIPしたものをLambdaにアップロードしても動きません。

Linux動作環境を作るのも面倒だったので、今回はJavaで書きます。

## 2.1.ビルドシステムの設定

gradleの設定

```groovy:build.gradle
plugins {
    id 'java'
}

group 'hoge'
version '1.0-SNAPSHOT'

sourceCompatibility = 11
compileJava.options.encoding = 'UTF-8'
compileTestJava.options.encoding = 'UTF-8'

repositories {
    mavenCentral()
}

dependencies {
    implementation 'com.amazonaws:aws-java-sdk-bom:1.11.228'
    implementation 'com.amazonaws:aws-java-sdk-dynamodb'
    implementation 'com.amazonaws:aws-java-sdk-s3'
    implementation 'com.amazonaws:aws-lambda-java-core:1.2.0'
    compileOnly 'org.projectlombok:lombok:1.18.10'
    annotationProcessor 'org.projectlombok:lombok:1.18.10'
    testCompile group: 'junit', name: 'junit', version: '4.12'
}

task buildZip(type: Zip) {
    from compileJava
    from processResources
    into('lib') {
        from configurations.runtimeClasspath
    }
}

build.dependsOn buildZip
```

<br>

ローカルで記述したプログラムをlambdaで動くようにするのに必要なライブラリは1つです。

- aws-lambda-java-core

buildタスクを実行するとbuild\distributions配下にzipファイルができるので、それをLambdaにアップロードすることで外部ライブラリに依存したプログラムを動かすことができます。

詳細ならびにMavenを利用したbuildについては、下記サイトが参考になります。
https://docs.aws.amazon.com/ja_jp/lambda/latest/dg/lambda-java-how-to-create-deployment-package.html


## 2.2.プログラム

API Gatewayのproxy統合を利用する前提でプログラムを書きます。詳細は後述しますが、proxy統合を利用することでリクエストとレスポンスをいい感じに処理してくれるので、POJOでリクエストを受け取ってPOJOでレスポンスができます。

```java:HelloAPI.java
import com.amazonaws.services.lambda.runtime.Context;
import com.amazonaws.services.lambda.runtime.RequestHandler;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.val;


public class HelloAPI implements RequestHandler<RequestDto, ResponseDto> {
  private static final ObjectMapper mapper = new ObjectMapper();

  @Override
  public ResponseDto handleRequest(RequestDto request, Context context) {
    val response = new ResponseDto();
    val headers = new HashMap<String, String>();
    // CORS用の設定
    headers.put("Access-Control-Allow-Origin", "*");
    headers.put("Access-Control-Allow-Headers", "Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token");
    headers.put("Access-Control-Allow-Methods", "POST");
    response.setHeaders(headers);
    response.setStatusCode(200);
    response.setBase64Encoded(false);

    // リクエストのbodyはAPI GatewayからString型で送られてくるのでJson文字列をデシリアイズ
    RequestBodyDto reqBody = mapper.readValue(request.getBody(), RequestBodyDto.class);

    val resBody = new ResponseBodyDto();
    // *** 中略、適宜処理を記述する ***

    try {
      // レスポンスのbodyもAPI GatewayにString型で送る必要があるのでJson文字列にシリアライズ
      response.setBody(mapper.writeValueAsString(resBody));
    } catch (JsonProcessingException e) {
      response.setStatusCode(500);
      e.printStackTrace();
    }

    return response;
  }
}
```

<br>

リクエスト、レスポンス、コンテキストのデータ型については、下記サイトを参考にしました。
https://docs.aws.amazon.com/ja_jp/lambda/latest/dg/java-programming-model-handler-types.html
https://willhamill.com/2016/12/12/aws-api-gateway-lambda-proxy-request-and-response-objects


**【注意事項】**

- リクエストが29秒でタイムアウトする
- （当然ですが）サーバローカルのファイルは永続化できない、/tmp/には読み書きできる

API Gatewayはリクエストを受けてから29秒でタイムアウトする仕様です。タイムアウト時間を短くすることは可能ですが、これ以上長くすることはできません。

今回は1リクエストあたり100万件以上のデータを処理しなければならず、DynamoDBのQueryだとパーティションキーを指定しても29秒以内にデータ取得が終わりません。代替手段としてS3上にシリアライズしたオブジェクトを保管しておきロードすることにしました。

オブジェクトのシリアライズ、デシリアライズには[kryo](https://github.com/EsotericSoftware/kryo)を使うのが便利です。


## 2.3.Lambdaの設定

![lambda_settings.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/551923/0307b602-d7c3-4d4a-7567-d727f16f5b82.png)

Lambda関数作成後、

- コードエントリタイプ
「.zipまたはjarファイルを...」か「Amazon S3からの...」のどちらかを選択し、buildしてできたファイルをアップロードします。

- ハンドラ
「フルパッケージ名.クラス名::実行メソッド名」を記載します。

以上で、Lambda関数は作成完了です。



# 3.API Gateway

## 3.1. IPアドレスによるアクセス制限

APIを作成したら、さっそく接続元のIPアドレスを制限してしまいましょう。
サイドバーの「リソースポリシー」から設定します。

```json
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Principal": "*",
            "Action": "execute-api:Invoke",
            "Resource": "arn:aws:execute-api:region:account-id:api-id/*/*/*"
        },
        {
            "Effect": "Deny",
            "Principal": "*",
            "Action": "execute-api:Invoke",
            "Resource": "arn:aws:execute-api:region:account-id:api-id/*/*/*",
            "Condition": {
                "NotIpAddress": {
                    "aws:SourceIp": [
                        "0.0.0.0/32",
                        "0.0.0.0/32"
                    ]
                }
            }
        }
    ]
}
```

値はサンプルですのでResourceには作成したAPIのarnを、SourceIpにはアクセスを許可したいIPアドレスを設定します。
設定については、下記サイトを参考にしました。
https://aws.amazon.com/jp/premiumsupport/knowledge-center/api-gateway-resource-policy-whitelist/

APIキーを利用したアクセス制限も可能なようですが、今回は試しませんでした。


## 3.2.リソースとメソッドの作成

「アクション」ボタンからリソースとメソッドの作成を行います。
<br>

- リソースの作成

![apigateway_resource.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/551923/669c3fef-f345-4865-2450-f3a48421acb9.png)

リソースを作成することでAPIのパスやパスパラメータを指定することが可能です。パスパラメータを指定する場合は、/hoge/{fuge}と{}でくくります。
受け取ったパスパラメータは、`request.getPathParameters().get("fuge")`とすることで利用できます。
「API Gateway CORSを有効にする」には、チェックを入れます。

CORSを有効にすると、プリフライト用のOPTIONSメソッドを自動的に生成してくれます。
CORSについて、この記事では詳しく触れませんので下記サイトを参考にしてください。
https://developer.mozilla.org/ja/docs/Web/HTTP/CORS
<br>

- メソッドの作成

![apigateway_method.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/551923/4dae6b32-ea80-5c39-3170-b52f0b07713c.png)

「Lambdaプロキシ統合の使用」にはチェックを入れます。
プロキシ統合を使用することで、POST時のリクエスト本文やパスパラメータをLambdaで扱いやすいように処理してくれます。

## 3.3.テスト

![apigateway_method_test.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/551923/664df46d-be3c-8eff-6eaf-d2be0eb8cd20.png)

パスパラメータやリクエスト本文を指定してLambdaを実行することができるので、テストして動作が問題ないか確認します。


## 3.4.APIのデプロイ

リソースやメソッドが準備できたらデプロイしましょう。デプロイすることで初めて外部からAPIにアクセスすることができます。
デプロイ時には、既存のバージョンを更新するか新しいバージョンを作成するか選択できるので、APIの互換性を考慮したバージョン管理も可能です。

キャッシュの有効化などいろんな設定ができるので、詳細については下記サイトが参考になります。
https://docs.aws.amazon.com/ja_jp/apigateway/latest/developerguide/set-up-stages.html


# 4.S3でホストする

**【注意事項】**
S3でWebサイトをホスティングすることが可能ですが、エンドポイントがSSL通信に対応しておりません。
IDやパスワード、個人情報といったデータを扱う場合は、本記事を参考にしないでください。
下記サイトに、CloudFrontを利用したHTTPS化の情報を記載されています。
https://aws.amazon.com/jp/premiumsupport/knowledge-center/cloudfront-https-requests-s3/

## 4.1.ホスティングの設定

S3バケットのプロパティから「Static website hosting」を有効にします。


## 4.2.IPアドレスによるアクセス制限

アクセス権限のバケットポリシーを設定して、特定のIPアドレスのみからアクセスできるようにします。

```json
{
    "Version": "2012-10-17",
    "Id": "VPCe and SourceIP",
    "Statement": [
        {
            "Sid": "VPCe and SourceIP",
            "Effect": "Deny",
            "Principal": "*",
            "Action": "s3:*",
            "Resource": [
                "arn:aws:s3:::bucket-name",
                "arn:aws:s3:::bucket-name/*"
            ],
            "Condition": {
                "NotIpAddress": {
                    "aws:SourceIp": [
                        "0.0.0.0/32",
                        "0.0.0.0/32"
                    ]
                }
            }
        }
    ]
}
```

値はサンプルですのでResourceには作成したAPIのarnを、SourceIpにはアクセスを許可したいIPアドレスを設定します。
設定については、下記サイトを参考にしました。
https://aws.amazon.com/jp/premiumsupport/knowledge-center/block-s3-traffic-vpc-ip/


## 4.3.資材のアップロード

HTML/CSS/JSなど、資材一式をアップロードすれば完了です。

APIを実行する場合はfetch APIのmodeに```cors```を設定する必要があります。
こんな感じでしょうか。

```javascript
fetch(reqURI, {
  method: "POST",
  mode: "cors",
  headers: {"Content-Type":"plain/text"},
  body: JSON.stringify({
    hoge: "fuge"
  })
})
```