Ansibleでいい感じのサーバレスなマイクロサービスが構築できたのでまとめてみたよ(API Gateway + AWS Lambda + Swagger UI + S3 website hosting)

サーバレスなマイクロサービスが世に浸透してからずいぶんたちました。
ただ、APIの定義やらデプロイフローで少し悩む事が多く
どうするといい感じになるかなーと考えていました。
 
そんな中、Ansible2.4でAPI Gatewayのモジュールが追加されました。
https://docs.ansible.com/ansible/2.4/aws_api_gateway_module.html

きっちり使えたら中々いいんじゃないかな?と思ったので少しまとめてみました。

元々やりたかった目的

  • APIの仕様書をできるだけ楽にドキュメント化
  • 極力Githubで管理できること
  • 運用コストが極力かからないこと
  • FaaSの中で自由にできること
  • できるだけAnsibleにとじこもること

なんで?

マイクロサービスは性質上
数多く作られてその後しっかりと運用されない物もあります。
ドキュメントを作りこんでもメンテされないと
結局ソースを全部読んで仕様を確認する事になったり。

それならドキュメントを自動生成する所まで
デプロイフローに含めたかったのです。
Ansibleで全部やろうと思ったのは学習コストの兼ね合いですが
マイクロサービスを作るドキュメントとしての意味合いもありました。

冪等性いいよね…。

使用するサービス

サービス 使用用途 補足
API Gateway APIの定義 定義はSwagger Specファイルで行う
AWS Lambda APIから呼ばれる処理の実装 今回は余り取り扱いません
Swagger UI APIの実行テスト+ドキュメント 動的に生成します
S3 website hosting Swagger UIの設置場所 一番取り扱いが楽なサーバレスな静的ファイルホスト先として使用します

これらを基本的にAnsibleで実装・管理していきます。

取り扱わないこと

Lambdaの管理、デプロイについては長くなりすぎるので対象外とします。

色々素晴らしい記事があるのでそちらをご覧ください。
https://qiita.com/marcy-terui/items/db8dae512af3c553fe72
https://qiita.com/kikusumk3/items/119bfb2da854c2b83791

まぁLambdaに関しては実装の意味合いが強いので
最初はAnsibleじゃなくていいなーと思ってました。
が、ほかが案外すんなりAnsibleに落としこんでくれたので
ちょっとLambdaのデプロイだけ浮いちゃってるかもしれないですね。

各サービスでの設定

API Gateway

API定義を考える

どのようなAPIにするか考えておきます。
ここでは説明の為に
仮決めとして以下のようなAPIを作ると定義します。

  • /squirrel
    • GET - リスのデータを参照
    • PUT - 新規リスのデータを作成
    • POST - リスのデータを更新
    • DELETE - リスのデータを削除

内容はなんでも良かったので適当にリスのデータを操作するAPIを例にします。
リスな理由は…まぁかわいいですよね。

参考画像として、出来たものは
このような画面から叩くこととなります。

test_swagger.png

API定義ファイルをつくる

https://editor.swagger.io/
なれるまではswagger editorを使う方がいいでしょう。
文法チェックなどをしてくれるので
エディタではなく確認用途としてだけでも便利です。

API定義ファイルは長くなるので先に注意箇所だけ説明します。
全体は最後に記載しておくので、先に知りたい方は一番最後をご覧ください。

lambda proxy integrationsを使う

API Gatewayからlambdaに接続するときには
lambda proxy integrations(Lambdaプロキシ統合)と
呼ばれるタイプを使うといいでしょう。

https://docs.aws.amazon.com/ja_jp/apigateway/latest/developerguide/set-up-lambda-proxy-integrations.html

感覚的に少しわかりづらいのですが
単純にlambda関数を叩くか
API Gatewayからproxyとしてパラメーターごと
lambda関数が呼ばれるかの違いとなります。

lambda proxy integrationsを使う際は
レスポンスなども決まった方法にする必要がある点には注意しましょう。

lambdaをコールすればいいだけであれば
lambda proxy integrationsにする必要はないです。
ただ、APIのパラメーターを取得する場合は
Body Mapping Templatesというマッピング設定をする必要が出てきます。
私の場合はlambda proxy integrationsを使う方が結果的に楽でした。

swagger_spec.yml
      x-amazon-apigateway-integration:
        uri:
          "arn:aws:apigateway:ap-northeast-1:lambda:path/2015-03-31/functions/{{ your_lambda_arn }}/invocations"
        passthroughBehavior: when_no_match
        httpMethod: POST
        type: aws_proxy

この記載をAPIメソッドごとに記載する必要があります。
(定義を別に書き、呼び出すごとで記載は簡略化できます)
この時、httpMethodの値を常にPOSTにしておく必要があるので注意しましょう。
https://docs.aws.amazon.com/ja_jp/apigateway/latest/developerguide/api-gateway-swagger-extensions-integration.html
API GatewayへはGETメソッドでアクセスする場合でも
API Gatewayからlambda関数へはPOSTでアクセスする事となるようです。

色々設定できる項目は多いのですが
最低限このぐらいの設定があれば良いでしょう。

CORS対応

ブラウザから叩かれないAPIであればこの設定は不要です。
今回はSwagger UIから実行テストできる状態にしたいので
この設定が必要となります。

Swagger UIから送るリクエストではpreflightリクエストが送られます。
CORSに詳しくない人は良質なドキュメントが揃っていますのでそちらを読みましょう。
https://dev.classmethod.jp/etc/about-cors/
(私はブラウザクライアントについては素人以下なので大変参考になりました)

preflightリクエストをSwaggerで定義すると以下のようになりました。

swagger_spec.yml
    options:
      consumes:
        - application/json
      produces:
        - application/json
      tags:
        - CORS
      x-amazon-apigateway-integration:
        type: mock
        responses:
          "default":
            statusCode: "200"
            responseParameters:
              method.response.header.Access-Control-Allow-Headers : "'Content-Type,X-Amz-Date,Authorization,X-Api-Key,api-key'"
              method.response.header.Access-Control-Allow-Methods : "'*'"
              method.response.header.Access-Control-Allow-Origin : "'https://s3-ap-northeast-1.amazonaws.com'"
            responseTemplates:
              application/json: "{}"
      responses:
        200:
          description: 200 response
          headers:
            Access-Control-Allow-Headers:
              type: "string"
            Access-Control-Allow-Methods:
              type: "string"
            Access-Control-Allow-Origin:
              type: "string"

S3 website hosting上のSwaggerからのリクエストは
headerにパラメーターがつくので
optionsリクエストでallow headerを返す事が重要となります。
originはTokyoリージョンでS3のドメインをそのまま使うなら上記設定で。
独自ドメインで運用する想定ならそのURLに変更してください。

API Gatewayをデプロイする

Ansibleから作った定義ファイルをデプロイします。
https://docs.ansible.com/ansible/2.4/aws_api_gateway_module.html

roles/deploy-api-gateway/tasks/main.yml
- set_fact:
    api_gateway_id:
      xxxxxxx
    stage_name:
      test

- name: deploy API gateway
  aws_api_gateway:
    api_id: "{{ api_gateway_id }}"
    state: present
    stage: "{{ stage_name }}"
    swagger_text: "{{ lookup('file','files/swagger_spec.yml') }}"
    deploy_desc: API Deployment description.

swaggerファイルの指定方法はなぜか3つもオプションがあるのですが
一番使い慣れてるlookupでの文字列指定にしてます。
api_idは最初指定なしで、
2回目からは作成されたAPI GatewayのIDを参照してください。
この指定をせずに叩き続けると大量にAPI Gatewayが作成されることに…。
しかも1分間に2個までしか削除リクエストが通らないので
少し面倒な事になります。
(なりました)

このあたりaws_api_gateway_factモジュールみたいな物ができれば
綺麗なAnsibleになるんですが今後に期待ですね。

stageには設定するAPI Gatewayの環境状態を指定すればいいのかな。
記載された文字列でstageが作成されるようです。

Lambdaに権限を付与する

Lambdaにポリシーを加え
API Gatewayからの実行を許可します。
ここもAnsibleのモジュールで提供されているので使用します。

roles/config-lambda-policy/tasks/main.yml
- set_fact:
    api_gateway_id:
      xxxxxxx
    aws_account_id:
      0123456789
    function_name:
      your_lambda_name

- name: Lambda function permit
  lambda_policy:
    state: present
    function_name: "{{ function_name }}"
    statement_id: "policy_{{ item | hash('md5') }}"
    action: lambda:InvokeFunction
    principal: apigateway.amazonaws.com
    source_arn: "{{ item }}"
  with_items:
    - arn:aws:execute-api:ap-northeast-1:{{ aws_account_id }}:{{ api_gateway_id }}/*/GET/squirrel
    - arn:aws:execute-api:ap-northeast-1:{{ aws_account_id }}:{{ api_gateway_id }}/*/PUT/squirrel
    - arn:aws:execute-api:ap-northeast-1:{{ aws_account_id }}:{{ api_gateway_id }}/*/POST/squirrel
    - arn:aws:execute-api:ap-northeast-1:{{ aws_account_id }}:{{ api_gateway_id }}/*/DELETE/squirrel

apigateway.amazonaws.comのprincipalに対して
lambda:InvokeFunctionのactionを許可します。
source_arnもAPI Gatewayに記載されているので参照してください。
メソッド・URLごとに許可が必要です。

また、statement_idはアカウントごとにユニークな文字列が必要です。
同じstatement_idでポリシーを設定すると上書きされてしまいます。
arnをそのまま記載したかったのですが、許可されていない記号があったため
仕方なくmd5でハッシュ化して逃げています。
なおoptionsメソッドはlambdaに流さずAPI Gatewayで返してしまうので
権限設定は不要です。

この権限設定が終われば、API Gatewayのテスト実行ができるようになります。
今度はLambdaでAPI Gatewayが解釈できる形でレスポンスを整えてあげます。

lambda proxy integrations設定になっていない場合は不要です。
lambda関数の戻り値がそのままレスポンスボディになります。

AWS Lambda

基本的に普通のLambda関数で良いのですが
レスポンスだけフォーマットに沿う必要があります。
https://docs.aws.amazon.com/ja_jp/apigateway/latest/developerguide/api-gateway-create-api-as-simple-proxy-for-lambda.html

Node.jsやJavaでは例がありますが、Pythonの場合は以下のようになります。

test_function.py
import json

def lambda_handler(event, context):
    body = {"test": "test request"}
    return {
        "statusCode": 200,
        "headers": {
            "Access-Control-Allow-Origin": "https://s3-ap-northeast-1.amazonaws.com"
        },
        "body": json.dumps(body)
    }

戻り値の辞書型の変数にstatusCode、headers、bodyを入れます。
ここで注意したいのが、headersにAccess-Control-Allow-Originを
入れて返却する必要があること。
かつ、S3 website hostingの場合サブドメインになるので
ワイルドカードの指定は無効になるということ。

リクエストの受け付け用CORSはAPI Gatewayで行えばいいのですが
そのリクエストをブラウザで参照するために
lambda proxy側でも指定が必要なのでした。
(複数箇所でCPRS対応しないといけないのがツボでした)

JavaScript側でCORSの読み取りエラーが出た場合は
ブラウザなどからコンソールを見て切り分けをすると良いでしょう。
optionsリクエストが500エラー出ている場合はAPI Gatewayで受け口のエラー。
status 200が帰ってきてなおかつ読み取れない場合は
レスポンスヘッダーを疑ってもみると良いでしょう。

Swagger UI

このあたりはAnsibleが追いついていないので
shellモジュールからaws client利用します。

1度API Gatewayで定義ファイルを読み込んだあと
Swagger用ドキュメントをAPI Gatewayから発行する必要があります。
API Gatewayで定義したAPI定義ファイルに
エンドポイントなど実際にAPIを叩くために必要な実体を付与する必要がある為ですね。

Swagger UI ドキュメントの発行

aws cliからドキュメントの生成を行います。
もしcreate-documentation-versionのサブコマンドが無いと言われた場合
aws cliのバージョンを確認しましょう。

このようなPlaybookになりました。

roles/config-swagger-docuent/tasks/main.yml
- set_fact:
    api_gateway_id:
      xxxxxxx
    stage_name:
      test

- name: create swagger document
  shell: >
    d=`date +"%Y%m%d%I%M%S"`;
    aws apigateway \
    create-documentation-version \
    --rest-api-id {{ api_gateway_id }} \
    --documentation-version ${d} \
    --stage-name {{ stage_name }};

documentation-versionはひとつのAPI Gatewayでユニークな番号の必要があるようです。
このPlaybookを実行するたびに新しいバージョンが発行されます。
現状では問題ないですが
古いドキュメントの消し込みが必要になる可能性もありますね。

Swagger UIドキュメントの取得

発行したAPI Gatewayの定義ファイルをエクスポートします。
ここでは/tmpディレクトリに出力することとします。

roles/config-swagger-docuent/tasks/main.yml
- set_fact:
    api_gateway_id:
      xxxxxxx
    stage_name:
      test

- name: export swagger document
  shell: >
    aws apigateway \
    get-export \
    --parameters extensions='integrations' \
    --rest-api-id {{ api_gateway_id }} \
    --stage-name {{ stage_name }} \
    --accepts application/yaml \
    --export-type swagger \
    /tmp/swagger-ui-generated.yaml

このコマンドでは、yaml形式を明示的に指定しています。
未指定ならjsonファイルで出力されます。

また、extensions='integrations'の指定で
直接SwaggerからAPI Gatewayを叩ける形式にしてくれます。

この節の操作だけ、Webコンソールで行うのも良いでしょう。
API Gatewayを初めて触る場合はどのように発行されるか
見ておくと理解しやすくなります。

Website Hosting S3(Swagger UIの置き場所)

S3のバケットを作成し、website化しましょう。
そこにGithubから落としてきたSwagger UIの実体と
生成したSwagger UIの定義ファイルをデプロイしましょう。

ここではさらっとサンプルを書いて流すこととします。

Bucket作成

roles/create-s3-bucket/tasks/main.yml
- set_fact:
    s3_bucket_name:
      your_bucket_name

- name: create s3 bucket.
  s3_bucket:
    state: present
    name: "{{ s3_bucket_name }}"
    policy: "{{ lookup('file', 'policy-file.json') }}"

- name: setting s3 website hosting.
  s3_website:
    state: present
    name: "{{ s3_bucket_name }}"

SwaggerのアクセスにIP制限をつける場合
ポリシーファイルは以下のようになります。

policy-file.json
{
    "Version": "2008-10-17",
    "Statement": [
        {
            "Sid": "AllowPublicRead",
            "Effect": "Allow",
            "Principal": {
                "AWS": "*"
            },
            "Action": "s3:GetObject",
            "Resource": "arn:aws:s3:::{{ bucket_name }}/*",
            "Condition": {
                "IpAddress": {
                    "aws:SourceIp": "{{ your_ip_address }}/32"
                }
            }
        }
    ]
}

Swagger UIのデプロイ

GithubからSwagger UIをダウンロードしてデプロイします。
リポジトリにはWebpack化される前の一式が入っているのですが
distディレクトリに出来た後の物があるので
そちらをそのまま使ってしまいしょう。
 
と、ここで気付いたのですがnpmでdist後のパッケージが提供されてましたね。
まぁパッケージに依存しないでデプロイできるということで…。
index.htmlの中に参照する定義ファイルのURLが指定されているので
lineinfileモジュールで書き換えておきます。

roles/deploy-swagger-ui/tasks/main.yml
- set_fact:
    swagger_version:
      3.9.3
    deploy_swagger_key_prefix:
      test
    s3_bucket_name:
      your_bucket_name

- name: download Swagger UI For Github
  unarchive:
    src: "https://github.com/swagger-api/swagger-ui/archive/v{{ swagger_version }}.tar.gz"
    dest: /tmp/
    remote_src: yes
    creates: "/tmp/swagger-ui-{{ swagger_version }}/"

- name: rewrite index.html file
  lineinfile:
    path: "/tmp/swagger-ui-{{ swagger_version }}/dist/index.html"
    regexp: "^    url: .*"
    line: "    url: './swagger-ui-generated.yaml',"

- name: deploy swagger ui
  s3_sync:
    bucket: "{{ s3_bucket_name }}"
    file_root: /tmp/swagger-ui-{{ swagger_version }}/dist/
    key_prefix: "{{ deploy_swagger_key_prefix }}/"

- name: deploy generated swagger spec file
  aws_s3:
    mode: put
    bucket: "{{ s3_bucket_name }}"
    src: /tmp/swagger-ui-generated.yaml
    object: "/{{ deploy_swagger_key_prefix }}/swagger-ui-generated.yaml"

まとめ

最初に作るときはちょっと頑張る必要がありますが
一度作っちゃえば流用が効くし
いい感じに使えるんじゃないかなーと。

AWS SAMやchaliceなんかも良さそうなのですが
できるだけスリムな構成にしたい場合や
Ansibleで各サービスをコントロールしつつ
マイクロサービスにできるのはいいんじゃないかなーと思います。
 

Swagger Specファイル例

Swagger Specの例を載せておきます。
長いですが説明のために冗長な書き方してる所が多いので
ちゃんと書いたら半分以下にはなるかな?

swagger-spec.yml
---
swagger: "2.0"
info:
  version: "0.01"
  title: test_api
basePath: /
schemes:
  - https
paths:
  /squirrel:
    get:
      summary: |
        リスのデータを参照
      tags:
        - Example API
      produces:
      - application/json
      parameters:
        - in: query
          name: id
          schema:
            type: integer
          description: 取得するリスIDです
      responses:
        200:
          description: 正当にリクエストは処理されました。
          headers:
            Access-Control-Allow-Origin:
              type: "string"
        503:
          description: |
            サーバ内部エラーです。
            任意の回数リトライしても解決されない場合、お問い合わせ下さい。
          headers:
            Access-Control-Allow-Origin:
              type: "string"
      x-amazon-apigateway-integration:
        uri:
          "arn:aws:apigateway:ap-northeast-1:lambda:path/2015-03-31/functions/{{your_lambda_arn}}/invocations"
        httpMethod: POST
        type: aws_proxy
    put:
      produces:
      - application/json
      tags:
        - Example API
      summary: |
        新規リスのデータを作成
      parameters:
        - in: body
          name: body
          required: true
          schema:
            type: object
          description: |
            投稿するリスのデータ。

            request example)
            > {"body": {"type": 0, "name":"example name", "description": "description example."}}
      responses:
        200:
          description: 正当にリクエストは処理されました。
          schema:
            title: success request response
            type: object
            properties:
              created_id:
                type: integer
            headers:
              Access-Control-Allow-Origin:
                type: "string"
        403:
          description: |
            APIリクエストが許可されていません。

            レスポンス例
            > {"message": "Failed IP authentication of 198.51.100.0"}
          schema:
            title: error auth condition response
            type: object
            description: 認証に失敗しました
            properties:
              message:
                type: string
              value:
                type: string
          headers:
            Access-Control-Allow-Origin:
              type: "string"
        503:
          description: |
            サーバ内部エラーです。
          headers:
            Access-Control-Allow-Origin:
              type: "string"
      x-amazon-apigateway-integration:
        uri:
          "arn:aws:apigateway:ap-northeast-1:lambda:path/2015-03-31/functions/{{your_lambda_arn}}/invocations"
        passthroughBehavior: when_no_match
        httpMethod: POST
        type: aws_proxy
    post:
      produces:
      - application/json
      tags:
        - Example API
      summary: |
        リスのデータを更新
      parameters:
        - in: query
          name: id
          schema:
            type: integer
          description: 更新するリスIDです
        - in: body
          name: body
          required: true
          schema:
            type: object
          description: |
            更新するリスのデータ。

            request example)
            > {"body": {"type": 0, "name":"example name", "description": "description example."}}
      responses:
        200:
          description: 正当にリクエストは処理されました。
          schema:
            title: success request response
            type: object
            properties:
              created_id:
                type: integer
            headers:
              Access-Control-Allow-Origin:
                type: "string"
        403:
          description: |
            APIリクエストが許可されていません。

            レスポンス例
            > {"message": "Failed IP authentication of 198.51.100.0"}
          schema:
            title: error auth condition response
            type: object
            description: 認証に失敗しました
            properties:
              message:
                type: string
              value:
                type: string
          headers:
            Access-Control-Allow-Origin:
              type: "string"
        503:
          description: |
            サーバ内部エラーです。
          headers:
            Access-Control-Allow-Origin:
              type: "string"
      x-amazon-apigateway-integration:
        uri:
          "arn:aws:apigateway:ap-northeast-1:lambda:path/2015-03-31/functions/{{your_lambda_arn}}/invocations"
        passthroughBehavior: when_no_match
        httpMethod: POST
        type: aws_proxy
    delete:
      produces:
      - application/json
      summary: |
        削除用APIです。
      tags:
        - Example API
      parameters:
        - in: query
          name: id
          schema:
            type: integer
          description: 削除するリスIDです
      responses:
        200:
          description: 正当にリクエストは処理されました。
          headers:
            Access-Control-Allow-Origin:
              type: "string"
        403:
          description: |
            APIリクエストが許可されていません。

            レスポンス例
            > {"message": "Failed IP authentication of 198.51.100.0"}
          headers:
            Access-Control-Allow-Origin:
              type: "string"
        503:
          description: |
            サーバ内部エラーです。
            任意の回数リトライしても解決されない場合、お問い合わせ下さい。
          headers:
            Access-Control-Allow-Origin:
              type: "string"
      x-amazon-apigateway-integration:
        responses:
          default:
            statusCode: "200"
        uri:
          "arn:aws:apigateway:ap-northeast-1:lambda:path/2015-03-31/functions/{{your_lambda_arn}}/invocations"
        passthroughBehavior: when_no_match
        httpMethod: POST
        type: aws_proxy
    options:
      parameters:
        - name: target_api_domain
          in: path
          type: string
          required: true
          description: |
            メンテナンス対象のドメインをパスで指定します。
            OPTIONSはCORSの為に用意されたメソッドなので
            直接コールされる事は想定されていません。
      consumes:
        - application/json
      produces:
        - application/json
      tags:
        - CORS
      x-amazon-apigateway-integration:
        type: mock
        requestTemplates:
          application/json: "{\"statusCode\": 200}"
        responses:
          "default":
            statusCode: "200"
            responseParameters:
              method.response.header.Access-Control-Allow-Headers : "'Content-Type,X-Amz-Date,Authorization,X-Api-Key,api-key'"
              method.response.header.Access-Control-Allow-Methods : "'*'"
              method.response.header.Access-Control-Allow-Origin : "'https://s3-ap-northeast-1.amazonaws.com'"
            responseTemplates:
              application/json: "{}"
      responses:
        200:
          description: 200 response
          headers:
            Access-Control-Allow-Headers:
              type: "string"
            Access-Control-Allow-Methods:
              type: "string"
            Access-Control-Allow-Origin:
              type: "string"
Sign up for free and join this conversation.
Sign Up
If you already have a Qiita account log in.