AWS LambdaでサーバレスSinatraカウントアプリ

こんにちは、Life is Tech!でWebサービスプログラミングコースのメンターをしております「がはく」です。

昨日のAWS re:Inventのkeynoteで発表されリリースされたAWS LambdaのRubyランタイム対応、早速試してみました。

# 今回作るもの
<img width="428" alt="スクリーンショット 2018-12-01 8.18.16.png" src="https://qiita-image-store.s3.amazonaws.com/0/145867/2863d53f-2f6e-3aa8-3036-5d5b376e4e5c.png">
初学者のメンバーには最初に作っていただく、カウントアプリを今回作っていきたいと思います。
プラスボタンが押されたらDB内の指定のレコードの値が１足されるというような実装を行っております。
AWSよりSinatraをLambdaで動かすサンプルがアップされていたので参考にしました。
こちら→[serverless-sinatra-sample
](https://github.com/aws-samples/serverless-sinatra-sample)

# 構成図
![Untitled Diagram.png](https://qiita-image-store.s3.amazonaws.com/0/145867/d5c3ae72-63d6-3fae-1343-e1b7c4143c86.png)

node.jsでWeb作るときの構成とほぼ同じです。API GatewayよりLambda関数を呼び出し、Lambdaと相性のいいNoSQLのDynamoDBにカウントした数字を入れます。

# 作業環境
・git
・Ruby(2.5.0)
・AWS CLI
等は必要になるので適宜インストールしてください。

# 使うgem
今回は以下のgemを使用します。

```:Gemfile
gem 'sinatra'
gem 'rack-contrib'
gem 'aws-record'
```
dynamoDBを使用するためこのような構成になっています。終わったら、bundle installをかけるのをお忘れなく。

# #1 コントローラの作成

基本的には/がリクエストされたらdynamoDB内の指定のレコードの数字を返し、/minusや/plusがリクエストされたらdynamoDB内の指定のレコードの数字を１ずつ増減させ、/にリダイレクトさせるような実装を行っております。

```ruby:app.rb
require 'bundler/setup'
Bundler.require
require 'aws-record'

class CountTable
  include Aws::Record
  string_attr :id, hash_key: true
  integer_attr :number
  epoch_time_attr :ts
end


before do
  if CountTable.scan().empty?
    table = CountTable.new(id: SecureRandom.uuid,number: 0,ts: Time.now)
    table.save!
  end
end

get '/' do
  table = CountTable.scan()
  @number = table.first.number
  erb :index
end

get '/plus' do
  table = CountTable.scan()
  count = table.first
  number = count.number + 1
  record = CountTable.update(id: count.id,number: number,ts: Time.now)
  redirect "/"
end

get '/minus' do
  table = CountTable.scan()
  count = table.first
  number = count.number - 1
  record = CountTable.update(id: count.id,number: number,ts: Time.now)
  redirect "/"
end

```
# #2 viewの実装
viewはよしなに作っていきます。

```erb:index.erb
<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <title>Serverless-Count</title>
</head>

<body>
<h1><%= @number %></h1>
<form action="/plus" method="get">
  <input type="submit" value="+">
</form>

<form action="/minus" method="get">
  <input type="submit" value="-">
</form>
</body>
</html>
```

# #3 lambdaの動きの実装
この辺はAWSが提供しているサンプルをそのまま使わせていただきました。(特に弄る必要はないと思ったので)
ただ、結構queryの返し方に癖があったり、rackの動きに気持ち悪いところがあるので、時間あるときに直していきたいですね。

```lambda.rb
require 'json'
require 'rack'

# Global object that responds to the call method. Stay outside of the handler
# to take advantage of container reuse
$app ||= Rack::Builder.parse_file("#{File.dirname(__FILE__)}/app/config.ru").first

def handler(event:, context:)
  # Environment required by Rack (http://www.rubydoc.info/github/rack/rack/file/SPEC)
  env = {
    "REQUEST_METHOD" => event['httpMethod'],
    "SCRIPT_NAME" => "",
    "PATH_INFO" => event['path'] || "",
    "QUERY_STRING" => event['queryStringParameters'] || "",
    "SERVER_NAME" => "localhost",
    "SERVER_PORT" => 443,

    "rack.version" => Rack::VERSION,
    "rack.url_scheme" => "https",
    "rack.input" => StringIO.new(event['body'] || ""),
    "rack.errors" => $stderr,
  }
  # Pass request headers to Rack if they are available
  unless event['header'].nil?
    event['headers'].each{ |key, value| env[key] = "HTTP_#{value}" }
  end

  begin
    # Response from Rack must have status, headers and body
    status, headers, body = $app.call(env)

    # body is an array. We simply combine all the items to a single string
    body_content = ""
    body.each do |item|
      body_content += item.to_s
    end

    # We return the structure required by AWS API Gateway since we integrate with it
    # https://docs.aws.amazon.com/apigateway/latest/developerguide/set-up-lambda-proxy-integrations.html
    response = {
      "statusCode" => status,
      "headers" => headers,
      "body" => body_content
    }
  rescue Exception => msg
    # If there is any exception, we return a 500 error with an error message
    response = {
      "statusCode" => 500,
      "body" => msg
    }
  end
  # By default, the response serializer will call #to_json for us
  response
end
```

```ruby:config.ru
require 'rack'
require 'rack/contrib'
require_relative './app'

set :root, File.dirname(__FILE__)

run Sinatra::Application
```


# #4 CloudFormationのyaml作成
こちらも先程のAWSのサンプルを参考にし、改変しました。
変えた点がテーブル名だったり、Lambda関数の名前だったりします。ここでガガッとAWS周りの設定をしていきます。

```template.yaml
AWSTemplateFormatVersion: '2010-09-09'
Transform: 'AWS::Serverless-2016-10-31'

Resources:
  SinatraFunction:
    Type: 'AWS::Serverless::Function'
    Properties:
      FunctionName: CountApp
      Handler: lambda.handler
      Runtime: ruby2.5
      Policies:
        - DynamoDBCrudPolicy:
            TableName: !Ref CountTable
      CodeUri: "./"
      MemorySize: 512
      Timeout: 30
      Events:
        SinatraApi:
            Type: Api
            Properties:
                Path: /
                Method: ANY
                RestApiId: !Ref SinatraAPI
  SinatraAPI:
    Type: AWS::Serverless::Api
    Properties:
      Name: SinatraAPI
      StageName: Prod
      DefinitionBody:
        swagger: '2.0'
        basePath: '/Prod'
        info:
          title: !Ref AWS::StackName
        paths:
          /{proxy+}:
            x-amazon-apigateway-any-method:
              responses: {}
              x-amazon-apigateway-integration:
                uri:
                  !Sub 'arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${SinatraFunction.Arn}/invocations'
                passthroughBehavior: "when_no_match"
                httpMethod: POST
                type: "aws_proxy"
          /:
            get:
              responses: {}
              x-amazon-apigateway-integration:
                uri:
                  !Sub 'arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${SinatraFunction.Arn}/invocations'
                passthroughBehavior: "when_no_match"
                httpMethod: POST
                type: "aws_proxy"
  ConfigLambdaPermission:
    Type: "AWS::Lambda::Permission"
    DependsOn:
    - SinatraFunction
    Properties:
      Action: lambda:InvokeFunction
      FunctionName: !Ref SinatraFunction
      Principal: apigateway.amazonaws.com
  CountTable:
    Type: AWS::Serverless::SimpleTable
    Properties:
      TableName: CountTable
      PrimaryKey:
        Name: id
        Type: String
      ProvisionedThroughput:
        ReadCapacityUnits: 5
        WriteCapacityUnits: 5
Outputs:
  SinatraAppUrl:
    Description: App endpoint URL
    Value: !Sub "https://${SinatraAPI}.execute-api.${AWS::Region}.amazonaws.com/Prod/"
```

# #5 デプロイ
ここまで来たらあとはデプロイするだけです。まずはgemを `vendor/bundle` に入れます。(gemごとS3にアップし、lambdaにインポートしないといけないため)

```
bundle install --deployment
```

続いて、今までのファイルをパッケージし、S3にアップロードします。(S3バゲットは事前に作っておいてください)

```
$ aws cloudformation package \
     --template-file template.yaml \
     --output-template-file serverless-output.yaml \
     --s3-bucket <S3 budget>
```


最後にこちらをCloudFormationを用いてデプロイします。

```
$ aws cloudformation deploy --template-file serverless-output.yaml \
     --stack-name serverless-sinatra \
     --capabilities CAPABILITY_IAM
```

これで完成です。
`https://<hogehoge>.execute-api.ap-northeast-1.amazonaws.com/Prod`　でアクセスできるはずです。
カスタムドメインにしたければARMを用意して、API GatewayとRoute53でよしなにやるとできます。

# まとめ
いよいよRubyにもサーバレスの時代がやってきました。
乗るしかねえこのビッグウェーブにと触ってみましたが、IoTを用いたサービス等、ミニマムなサービスをデプロイするのにすごく便利だなと思いました。これからしっかり活用して行きたいですね。