AWS SAM + Step Functions でアクセスチェックをする。（ruby runtime）

# 概要

AWS SAM(Lambda) + Step Functions を利用してウェブサイトの外形監視を行います。

## やること

1. サイトの外形監視をする。（lambdaで実行）
2. アクセスした際にステータスコードが200 or 302の場合、メールで通知（lambda + SES）
3. アクセスアクセスした際に上記のステータスコード以外の場合は、時間をおいて再アクセスをする。  
    StepFunctionsでループの処理を実装し、最大5回実行する。
4. 正常なステータスコードが取得できなかった場合は実行を失敗として終了する。  
    失敗時の通知はCloudWatch Eventで通知予定ですが、今回は実装しません。

注意）
通常は成功時は通知をせず、失敗時に通知を行うと思いますが、今回は成功した場合に通知をする設定にしています。（学習目的のため）

# 実行環境

1. AWS CLIを設定済であること（バージョン１）
2. AWS SAMをインストール済であること
3. SESを設定済であること

## 環境設定の参考サイト

AWS CLI インストール
https://docs.aws.amazon.com/ja_jp/cli/latest/userguide/cli-chap-install.html

AWS CLI の設定
https://docs.aws.amazon.com/ja_jp/cli/latest/userguide/cli-chap-configure.html

AWS SAM CLI インストール
https://docs.aws.amazon.com/ja_jp/serverless-application-model/latest/developerguide/serverless-sam-cli-install.html

Amazon SES とは
https://docs.aws.amazon.com/ja_jp/ses/latest/DeveloperGuide/Welcome.html

Amazon SESによるメール送信環境の構築と実践
https://dev.classmethod.jp/articles/amazon-ses-build-and-practice/


# 初期設定

AWS SAM の初期化

```terminal
$ sam init --runtime ruby2.5
Which template source would you like to use?
        1 - AWS Quick Start Templates
        2 - Custom Template Location
Choice: 1

Project name [sam-app]: ruby-stepfunc

Quick start templates may have been updated. Do you want to re-download the latest [Y/n]: Y

-----------------------
Generating application:
-----------------------
Name: ruby-stepfunc
Runtime: ruby2.5
Dependency Manager: bundler
Application Template: hello-world
Output Directory: .

Next steps can be found in the README file at ./ruby-stepfunc/README.md
```

ディレクトリを移動する

```terminal
$ cd ruby-stepfunc
```

# Lambda関数作成

今回はWEBサイトのレスポンスチェックと結果をメールで通知するスクリプトを作成します。

## アクセスチェックのスクリプト

```terminal
$ mkdir -p function/check_response/
$ vi function/check_response/main.rb
```

```ruby:main.rb
require 'aws-sdk'

############################################
# アクセスのレスポンスチェック
############################################
def lambda_handler(event:, context:)
  count = event['checkresponse']['result']['count'].to_i rescue 0
  url   = event['url']
  uri   = URI.parse(url)

  http = Net::HTTP.new(uri.host, uri.port)
  http.use_ssl = uri.scheme === "https"
  headers = { "Content-Type" => "text/html" }

  begin
    response  = http.get(uri.path, headers)
    available = response.code == ('200' || '302') ? true : false
    count += 1
    result = {
      available: available,
      response_code: response.code,
      uri: uri.to_s,
      msg: response.msg,
      header: response.header,
      count: count
    }
  rescue => e
    count += 1
    result = {
      available: false,
      uri: uri.to_s,
      msg: e.message,
      count: count
    }
  end

  {
    statusCode: 200,
    body: {
      message: result[:msg],
    }.to_json,
    result:  result
  }
end
```

### メール送信機能

SESの設定は以下のサイトを参考に行ってください。
実際に外部へ送信される際は、サンドボックスの解除申請も忘れずに行ってください。

```terminal
$ mkdir -p function/send-email/
$ vi function/send-email/main.rb
```

```ruby:main.rb
require 'aws-sdk'
require "net/http"

############################################
# メール送信
############################################
def lambda_handler(event:, context:)
  sender     = ENV['SENDER']
  ses_region = ENV['SES_REAGION'] || "us-west-1"
  encoding   = ENV['ENCODING']    || "UTF-8" 
  recipient  = event['email']

  subject  = "アクセス監視の通知"
  textbody = event['checkresponse']['result']['msg'] rescue "no text"
  ses = Aws::SES::Client.new(region: ses_region)
  begin
    # Provide the contents of the email.
    resp = ses.send_email({
      destination: {
        to_addresses:  [ recipient ],
      },
      message: {
        body:    { text: { charset: encoding, data: textbody } },
        subject: { charset: encoding, data: subject }
      },
      source: sender,
    })
    result = {
      msg: "Email sent!"
    }

  # If something goes wrong, display an error message.
  rescue Aws::SES::Errors::ServiceError => error
    result = {
      msg: "Email not sent. Error message: #{error}"
    }
  end

  {
    statusCode: 200,
    body: {
      message: result[:msg],
    }.to_json,
    result:  result
  }
end
```

# Step Functionの設定

以下、AWS SAMのyamlになります。（StepFunctionsの設定を含む）

```terminal
$ vi template.yaml
```

`your-email`, `your-ses-region` にはそれぞれSESで設定した適した値を入れてください。
（sesでdomain認証をした場合は@前の文字列は任意に決定できます。）

```yaml:template.yaml
AWSTemplateFormatVersion: '2010-09-09'
Transform: 'AWS::Serverless-2016-10-31'
Description: |
  ruby-stepfunc
  SAM Template for ruby-stepfunc

Mappings:
  prd:
    Ses:
      sender: [your-email]
      region: [your-ses-region]
  stg:
    Ses:
      sender: [your-email]
      region: [your-ses-region]

Parameters:
  Environment:
    Description: Type of this environment.
    Type: String
    Default: prd
    AllowedValues:
      - prd
      - stg

Globals:
  Function:
    Timeout: 120
    Handler: main.lambda_handler
    Runtime: ruby2.5
    MemorySize: 128

Resources:

  LambdaRole:
      Type: 'AWS::IAM::Role'
      Properties:
        RoleName: !Sub '${AWS::StackName}-check-response-role'
        Path: /
        AssumeRolePolicyDocument:
          Statement:
            - Effect: "Allow"
              Principal:
                Service:
                  - "lambda.amazonaws.com"
              Action:
                - "sts:AssumeRole"
        ManagedPolicyArns:
          - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
          - arn:aws:iam::aws:policy/service-role/AWSLambdaVPCAccessExecutionRole
          - arn:aws:iam::aws:policy/AmazonEC2ContainerServiceFullAccess
          - arn:aws:iam::aws:policy/AmazonSESFullAccess

  StatesExecutionRole:
    Type: "AWS::IAM::Role"
    Properties:
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: "Allow"
            Principal:
              Service:
                - !Sub states.${AWS::Region}.amazonaws.com
            Action: "sts:AssumeRole"
      Path: "/"
      Policies:
        - PolicyName: StatesExecutionPolicy
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: Allow
                Action:
                  - "lambda:InvokeFunction"
                Resource: "*"

  CheckResponse:
    Type: AWS::Serverless::Function
    Properties:
      FunctionName: !Sub 'CheckResponse-${AWS::StackName}'
      CodeUri: function/check-response/
      Role: !GetAtt LambdaRole.Arn

  SendEmail:
    Type: AWS::Serverless::Function
    Properties:
      FunctionName: !Sub 'SendEmail-${AWS::StackName}'
      CodeUri: function/send-email/
      Role: !GetAtt LambdaRole.Arn
      Environment:
        Variables:
          SENDER: !FindInMap [ !Ref Environment, Ses, sender ]
          SES_REAGION: !FindInMap [ !Ref Environment, Ses, region ]
          ENCODING: UTF-8

  ServiceCheck:
    Type: "AWS::StepFunctions::StateMachine"
    Properties:
      DefinitionString: !Sub |-
        {
          "Comment": "Check WEBSITE resoponse",
          "StartAt": "CheckResponse",
          "States": {
            "CheckResponse": {
              "Type": "Task",
              "Resource": "${CheckResponse.Arn}",
              "ResultPath": "$.checkresponse",
              "Next": "ChoiceState"
            },
            "ChoiceState": {
              "Type": "Choice",
              "Choices": [
                {
                  "Variable": "$.checkresponse.result.available",
                  "BooleanEquals": true,
                  "Next": "SendEmail"
                },
                {
                  "And": [
                    {
                      "Variable": "$.checkresponse.result.available",
                      "BooleanEquals": false
                    },
                    {
                      "Variable": "$.checkresponse.result.count",
                      "NumericGreaterThanEquals": 5
                    }
                  ],
                  "Next": "FunctionEnd"
                }
              ],
              "Default": "wait_seconds1"
            },
            "wait_seconds1": {
              "Type": "Wait",
              "Seconds": 10,
              "Next": "CheckResponse"
            },
            "SendEmail": {
              "Type": "Task",
              "Resource": "${SendEmail.Arn}",
              "End": true
            },
            "FunctionEnd": {
              "Type": "Fail",
              "Cause": "Email not sent.",
              "Error": "Error"
            }
          }
        }
      RoleArn: !GetAtt [ StatesExecutionRole, Arn ]

Outputs:
  ServiceCheck:
    Description: Check Function ARN
    Value: !Ref: ServiceCheck
```

**注意**
ロールの権限はモリモリになっています。
本番運用の際にはしっかり権限を絞ってください。


## StepFunctionsの補足説明

### 構成図

![構成図.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/29570/b124e947-9c4d-6d14-eeab-3bb1f10e0c35.png)

| Step | 説明 |
|----|----|
| CheckResponse | 指定したURLのレスポンスを確認します。 |
| ChoiseState | CheckResponseのレスポンスによって、SendEmail,wait_second1に分岐します。 |
| SendEmail | 指定したURLへアクセス結果を送信します。 |
| wait_seconds1 | 10秒時間をあけてからCheckResponseへ際アクセスします。 |
| FunctionEnd | 指定したループの上限を超えた場合、処理は失敗として終了します。 |
| End | 終了 |

### フローサンプル１（成功）

![構成図success.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/29570/95a2ff72-51e9-95bb-9946-c1b35413cff3.png)

1. CheckResponseで指定したURLのレスポンスを確認します。
2. ChoiseStateでCheckResponseのレスポンスが200 or 302 であることを確認できましたのでSendEmailに分岐します。
3. SendEmailで指定したURLへアクセス結果を送信します。


### フローサンプル2（失敗）

![failure.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/29570/c547f1d7-ffcf-83ae-1719-8ddda42f64e2.png)

1. CheckResponseで指定したURLのレスポンスを確認します。
2. ChoiseStateでCheckResponseのレスポンスが200 or 302 以外であることが確認できましたのでwait_seconds1に分岐します。
3. wait_seconds1で10秒時間をあけてからCheckResponseへ際アクセスします。
4. 2,3の処理を繰り返して、200 or 302のステータスコードを取得できなかったので、FunctionEndへ分岐します。
5. FunctionEndで意図的に処理を失敗にして終了します。


## 条件分岐・ループ処理処理


# デプロイ

cloudformationで使用する、template.yamlの生成とlambdaのファイルをS3へ固めてアップロードする。

`bucket_name` は事前に準備をしたバケット名を指定してください。

```terminal
$ sam package --template-file template.yaml \
    --output-template-file packaged.yaml \
    --s3-bucket [bucket_name]
```

template.ymlを実行する（CloudFormation）

`stack_name` はCloudFormationのスタック名になります。任意の名前を決めて実行してください。

```terminal
$ sam deploy --template-file packaged.yaml \
    --parameter-overrides Environment=stg \
    --stack-name [stack_name] \
    --capabilities CAPABILITY_IAM CAPABILITY_NAMED_IAM 
```


{
  "email": "yoshioka@farend.jp",
  "url": "https://mica1111.g2staging.farend.ne.jp/"
}

## 実行

### コマンドラインから実行する

ステートマシンの実行時に入力する値のjsonファイルを作成します。

```terminal
$ vi input_value.json
```

```json:input_value.json
{
  "email": "[your_notification_emal]",
  "url": "https://[check_url]/"
}
```

[statemachine_arn] は先ほど作成した、Step FunctionsのステートマシンのARNを入力します。
ARNはCloudFormationの出力から確認もできます。

```terminal
$ aws stepfunctions start-execution --state-machine-arn [statemachine_arn]  --name `uuid` --input file://input_value.json
```

### AWSコンソールから実行する

先ほど作成したStep Functionsのステートマシンを選択し、「実行の開始」をクリックし、以下の値を入力して「実行の開始」をしてください。

```json
{
  "email": "[your_notification_emal]",
  "url": "https://[check_url]/"
}
```

# まとめ（所感）

今回実行した処理程度でしたらLambda一つで実行しても良いかと思います。
ただ、Lambdaは実行時間で課金されるので、Sleepを使用する際にはStepFunctionsでループして間にwaitを挟んで実行した方が良さそうです。または今回は試していませんが、並列実行が必要な処理の場合などに威力を発揮しそうです。