Rails on Lambdaの環境構築

# はじめに
Rails+Lambdaの記事が少なかったので投稿します。
この記事はRails+Lambda(+APIGateway)で `GET /me` が実行できるところを目指します。
Railsはある程度分かるけど、Docker使ったことない、サーバレスやりたい人向けです。
今回SAMは使わずある泥臭いやり方で行います。
mysqlは今回コード上では使いませんが、デプロイの関係で構築は行っています。もしRDSやDyanamo使いたい場合は適時調べて行ってください。

# 環境
Docker for Windows
Ruby 2.7
Rails 6
MySQL 5.x

# ディレクトリ構成
```

rails_api
├── Dockerfile
├── docker-compose.yml
├── Gemfile
├── Gemfile.lock
```

# 各種ファイル
## Dockerfile
native extension を含む gem を lambci/lambdaの docker イメージでビルドしなおしをするため、イメージはLambdaのものを使用します。

```dockerfile

FROM lambci/lambda:build-ruby2.7

# install required libraries
RUN yum -y install mysql-devel

# install bundler
RUN gem install bundler

WORKDIR /tmp
ADD Gemfile Gemfile
ADD Gemfile.lock Gemfile.lock
RUN bundle install

WORKDIR /app
COPY . /app
```

## docker-compose.yml
```yml
version: '3'
services:
  mysql:
    image: mysql:5.7
    command: mysqld --character-set-server=utf8 --collation-server=utf8_general_ci
    ports:
      - "3306:3306"
    volumes:
      - mysql-data:/var/lib/mysql
    environment:
      MYSQL_DATABASE: app_development
      MYSQL_USER: root
      MYSQL_PASSWORD: password
      MYSQL_ROOT_PASSWORD: password
  app:
    tty: true
    stdin_open: true
    build: .
    command: bash -c "rm -f tmp/pids/server.pid && bundle exec rails s -p 3000 -b '0.0.0.0'"
    volumes:
      - .:/app
    ports:
      - "3000:3000"
    depends_on:
      - mysql
volumes:
  mysql-data:
    driver: local
```

## Gemfile
railsは最新版をとりあえず。  
すでにローカル環境でRailsを動かせている人は不要です。

```ruby
source 'https://rubygems.org'
gem 'rails', '~> 6.0.2', '>= 6.0.2.1'
```

## Gemfile.lock
空ファイルで作成
すでにローカル環境でRailsを動かせている人は不要です。

# Rails作成（Rails new + Rails Server）
ネット環境の良い場所で行いましょう。そこそこ時間がかかります。  
すでにローカル環境でRailsを動かせている人は不要です。

```shell

$ docker-compose run app bundle exec rails new . --force --database=mysql
Starting rails_api_mysql_1 ... done               exist
       force  README.md
   identical  Rakefile
      create  .ruby-version
      create  config.ru
      create  .gitignore
       force  Gemfile
以下省略
```

ここでGemfileが新しく生成されるので不要なgemを削除します。（必要な場合は入れてください。）

```ruby
## 以下削除
gem 'sass-rails', '>= 6'
# Transpile app-like JavaScript. Read more: https://github.com/rails/webpacker
gem 'webpacker', '~> 4.0'
# Turbolinks makes navigating your web application faster. Read more: https://github.com/turbolinks/turbolinks
gem 'turbolinks', '~> 5'
# Build JSON APIs with ease. Read more: https://github.com/rails/jbuilder
gem 'jbuilder', '~> 2.7'
```

## docker build ＆ railsサーバ起動
### Railsサーバ起動
```shell-session
$ docker-compose build
$ docker-compose up
※問題があればここでエラーします。
app_1    | => Booting Puma
app_1    | => Rails 6.0.2.1 application starting in development
app_1    | => Run `rails server --help` for more startup options
app_1    | /var/runtime/gems/actionpack-6.0.2.1/lib/action_dispatch/middleware/stack.rb:37: warning: Using the last argument as keyword parameters is deprecated; maybe ** should be added to the call
app_1    | /var/runtime/gems/actionpack-6.0.2.1/lib/action_dispatch/middleware/static.rb:110: warning: The called method `initialize' is defined here
app_1    | Puma starting in single mode...
app_1    | * Version 4.3.3 (ruby 2.7.0-p0), codename: Mysterious Traveller
app_1    | * Min threads: 5, max threads: 5
app_1    | * Environment: development
app_1    | * Listening on tcp://0.0.0.0:3000
app_1    | Use Ctrl-C to stop
```

とりあえず、[localhost:3000](http://localhost:3000) で確認

Mysql2::Error::ConnectionErrorが発生しているので、すこし修正します。

### database.ymlの修正
将来のことも考え、productionも記載します。
productionには環境変数で設定しているため、Lambdaの環境変数を使ってユーザ名、パスワードなどを設定します。

```yml
default: &default
  adapter: mysql2
  encoding: utf8
  pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
  timeout: 5000
  username: root
  password: password
  host: mysql

development:
  <<: *default
  database: app_development

# Warning: The database defined as "test" will be erased and
# re-generated from your development database when you run "rake".
# Do not set this db to the same as development or production.
test:
  <<: *default
  database: app_test

production:
  <<: *default
  username: <%= ENV['MYSQL_USER'] %>
  password: <%= ENV['MYSQL_ROOT_PASSWORD'] %>
  database: <%= ENV['MYSQL_DATABASE'] %>
  host: <%= ENV['MYSQL_HOST'] %>
  socket: <%= ENV['MYSQL_SOCKET'] %>
```

再度Railsサーバ起動＋[localhost:3000](http://localhost:3000) 確認

```
$ docker-compose up
```

ここでエラーがでなければ、一応初期セットアップ完了、APIサーバとして動かすため、事前に/meを作成しておきます。

## APIの作成
コントローラーとRouteのみ記載しlocalhostで確認します。

* app/controller/me_controller.rb

```rb
class MeController < ApplicationController
  def index
    render json: { 'me' => 'me' }
  end
end
```

* config/routes.rb

```ruby
Rails.application.routes.draw do
  # For details on the DSL available within this file, see https://guides.rubyonrails.org/routing.html
  resources :me, only: [:index]
end
```

``GET /me`` の確認
念のためdockerコンテナを再起動して、APIの確認を行います。


```
$ docker-compose up
$ curl localhost:3000/me
{"me":"me"}
```

# Lambda（+ API Gateway）化
## Lambda 用の Rack ブリッジサンプルをダウンロード
下記のサンプルソースからLambda用のブリッジサンプルを入手し、
https://github.com/aws-samples/serverless-sinatra-sample/blob/master/lambda.rb  
中身のパスを１箇所だけ修正します。
 
```diff
- $app ||= Rack::Builder.parse_file("#{__dir__}/app/config.ru").first
+ $app ||= Rack::Builder.parse_file("#{__dir__}/config.ru").first
```
## Native Extensions のローカルへのリビルド
dockerの中に入って作業します。この作業を行わないとNative Extensions系のライブラリが動作しないです。Postgresqlを使用する場合は別途コピー元のディレクトリを変更します。

dockerの中に入るコマンド

```
$ docker-compose ps
※ここでdockerの名前を確認
$ docker exec -it docker名 bash
bash-4.2# 
```

リビルド

```
bash-4.2# bundle install --path /bundle
bash-4.2# mv /bundle /app/vendor/
bash-4.2# sed -e 's|/bundle|vendor/bundle|' -i .bundle/config
bash-4.2# cat .bundle/config
---
BUNDLE_PATH: "vendor/bundle"

# 下記作業はmysqlを使う場合
bash-4.2# /sbin/ldconfig -p | grep mysql | cut -d\> -f2
bash-4.2# cp /usr/lib64/mysql/libmysqlclient.so.18 lib/
```

動くか確認します。

```
$ docker-compose stop
$ docker-compose up
$ curl http://localhost:3000/me
```

ファイルをzipで固めてlambdaへデプロイします。今回はdockerの中でやっています。

```
$ zip -r lambda_function.zip .bundle app config Gemfile* config.ru lambda_function.rb vendor lib public
```

## Lambdaの作成
※細かい部分は省略。  
デプロイするときzip容量が大きいのでS3からアップロード。
Lambda作成時の環境変数は以下のとおり。

|Name|Value|
|---|---|
|BOOTSNAP_CACHE_DIR|/tmp/cache|
|BUNDLE_APP_CONFIG|/var/task/.bundle|
|PASSWORD|password|
|RAILS_ENV|production|
|RAILS_LOG_TO_STDOUT|true|
|RAILS_SERVE_STATIC_FILES|true|
|USER|root|

ポイントは``bootsnap_cache_dir``で、bootsnap は ${WORKING_DIR}/tmp/cache にキャッシュを生成することになっており、このままLambdaを使うと、`/var/task/tmp` 自体は読み込み専用ディレクトリなっているためエラーが発生します。

### テストイベントの作成
新しいテストイベントの作成 > イベントテンプレート > Amazon API Gateway AWS Proxy を選択
`path` `httpMethod` `pathParameters` を下記のように`/me`用に変更する

```json
"path": "/me"
"httpMethod": "GET"
"pathParameters": {
    "proxy": "/me"
  }
```
テストを実行して下記レスポンスが返ってくるか確認する

```json
{
  "statusCode": 200,
  "headers": {
    "X-Frame-Options": "SAMEORIGIN",
    "X-XSS-Protection": "1; mode=block",
    "X-Content-Type-Options": "nosniff",
    "X-Download-Options": "noopen",
    "X-Permitted-Cross-Domain-Policies": "none",
    "Referrer-Policy": "strict-origin-when-cross-origin",
    "Content-Type": "application/json; charset=utf-8",
    "ETag": "W/\"hogehoge\"",
    "Cache-Control": "max-age=0, private, must-revalidate",
    "X-Request-Id": "hoge-hoge-hoge-hoge-hoge",
    "X-Runtime": "0.004623"
  },
  "body": "{\"me\":\"me\"}"
}
```

## API Gatewayの作成＋Lambda連携
※細かい部分は省略。
proxyとして動作。
<img width="859" alt="apigateway.PNG" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/601431/47f55a33-987c-e749-aa3d-b9f610ba6604.png">

ステージからAPIGatewayのデプロイを行い、APIの実行を行う

```
$ curl https://hogehoge.execute-api.ap-northeast-1.amazonaws.com/hoge/me
{"me":"me"}
```

# おわり
あとはRoute53やAPI Gatewayのカスタムドメインの設定を行えば本番運用可能です。またAPIGatewayの代わりにALBを使用することも可能です。その場合Lambdaのコードは修正不要です。