冥冥乃志

ソフトウェア開発会社でチームマネージャをしているエンジニアの雑記。アウトプットは少なめです。

follow us in feedly

Serverless Frameworkプロジェクトと開発者アセットのCDを回す

前回の続きです。

mao-instantlife.hatenablog.com

mao-instantlife.hatenablog.com

GithubにコードがプッシュされたらServerlessプロジェクトをデプロイし、デプロイ後のAPIドキュメントやクライアントライブラリの生成を継続的にする環境を作ってみましょう。

閑話:外部パッケージ管理の修正

最初からかよ、とか言わない。前回、とあるLambdaファンクションを作る際に外部ライブラリを必要としました。とりあえずは .gitignore に追加してローカルで pip install することでお茶を濁していましたが、やはりかっこ悪いですし、CD環境作るには微妙なところでもあるのでプラグインを導入してなんとかします。

github.com

やはり誰かが考えてるものですよね、遠慮なく乗っからせていただきます。ちなみに、クラメソさんの以下の記事を参考にして使い始めたけど、少しバージョンアップして使い方が変わったっぽいです。最新はドキュメントをみましょう。

dev.classmethod.jp

ローカルでは普通にグローバルに pip install してパスを通して使うという場合はプラグインを入れて requirements.txt を使う感じです。特にプロダクトコードに記載を加える必要はありません。

グローバルに pip install せずに requirements.txt の設定を元にローカルでもキックするよ、という場合は外部ライブラリをzipにまとめて使う設定にしましょう。 serverless.yml に以下の設定を追加します。

custom:
  pythonRequirements:
    zip: true

その上で、対象の外部ライブラリを使うコードの前に

import unzip_requirements

を追加します。

ダウンロードしたライブラリや自動生成されるコードのなどがあるので、以下は.gitignoreに追加しておくのが良いと思います。

  • .requirements
  • .requirements.zip
  • unzip_requirements.py

閑話休題:CD環境の整理

前回の構想、こんな感じでした。

Github(merge)-> CodeBuild -> CodeDeploy -(API Gateway)-> クライアントライブラリの生成とSwagger UIの更新

今回、CodePipelineで全体を管理できないか調べてみました。

CodePipeline is 何?

リリースのための手順、フローのモデル化、自動化のためのAWSのサービスです。で、調べ始めたんですが、割とつらみがあるっぽいです。

qiita.com

CircleCIでいいんじゃない?

ですよねー。

というわけでCircleCIをおさらいしてみました。前にちょっと使ってた時は1系だったので少し進化してましたね。

  • ビルドやテスト実行はDockerが基本になってる
  • 複数のイメージの連携もCirclCIがよしなにしてくれる
  • ジョブ毎に個別のイメージが使える
  • AWSへのデプロイ設定も楽

circleci.com

カスタムコンテナを使う

で、今回のプロジェクトはServerless Framework + Python3なわけですので、ビルド環境のイメージは専用に作ってあげる必要があります。基本的なビルド環境についてはCircleCIが使う各種ツールがインストールされた状態でのオフィシャルイメージがあるので、nodeから派生させることにしましょう。

イメージは .config/images にDockerfileをおくか、リポジトリにプッシュしておいてもいいです。構成が決まればイメージは共有できて、必要なミドルウェアは別のイメージで配備するというてもあるので、Dockerhubとかにプッシュしておくのが楽だと思います。

というわけで出来上がったのがこちらのイメージ。Dockerhubにも公開していますので、必要な方はご自由にどうぞ。

github.com

Serverless Framework + Python3で使うPythonのバージョンが3.6で、apt-getでインストールできるPythonが3.4だったし、いくつかググって出てきたバージョン3.6があると思しきリポジトリは追加しても404だったりで、結局イメージないでコンパイル・インストールしています。このあたりはもっとスマートに行きたいところだったんですが。。。

今回はデプロイまでなのでJREをインストールしていませんが、テストを流すとなるとDynamoDB Localを使うことになるのでJREのインストールが必要になります。これもこれで面倒臭いというか、LocalStackとかの利用で別のイメージ前提にした方がいいのではないかと考え中です。

github.com

qiita.com

閑話:serverless-step-functionsプラグインの制約にハマる

で、意気揚々とイメージ作ってデプロイしてみたら sls invoke stepf が動かないわけですよ。実はローカルで実行してみても同様だったと。StateMachineが見つからないとか言われます、そんなバカな。で、色々とごにょごにょしていて、StateMachineの設定からname属性を外し、命名をキャメルケースにしたらできました。実際にデプロイされた時の名前はこんな感じです。

MyAssistantGenerateClientStepFunctionsStateMachine-************

末尾はなんかハッシュっぽい、念のためマスクしています。この末尾の部分はデプロイするステージを変更したら変わりました。この辺普通に文字列でつけたりできないか確認したいところ。まあ、Step Functionsへのキックイベントも serverless.yml で書けるので実際問題はそこまできにする必要がないのかもしれませんが。

で、前回までの命名とname属性で登録された名前がこちら。

MyDashassistantDashgenerateDashclientDashdev-************

なんかname属性がある場合とか、変換とか文字数とかいくつか原因はありそうな気がします。とりあえず現状はそんなに細かい命名をしたいわけではないので、上の対応策でなんとかしました。

閑話休題:継続的デプロイの設定

というわけで今回の内容を反映した .circleci/config.yml がこちらです。走り過ぎても面倒臭いので現状はmasterへのプッシュ、マージの時のみ発動、CodeGeneratorもまだ常時キックする感じでもないためStepFunctionsの設定をパスするように変更しています。

version: 2
jobs:
  build:
    docker:
      - image: shinsukeabe/circleci-serverless-python3:latest
    steps:
      - checkout
      - run:
          name: install project dependencies plugins
          command: sudo npm install serverless-dynamodb-local
      - run:
          name: deploy serverless functions
          command: sls deploy -v
      - run:
          name: distribute developers assets
          command: sls invoke stepf --name myAssistantGenerateClient
general:
  branches:
    only:
      - master

github.com

まとめ

というわけで、CircleCIで自動デプロイまで仕掛けました。今回テストは仕掛けてませんが、ここまでできて入れば後は細かいつらみとの戦いになります。ここからしばらくはServerlessとLambdaの使い方を深めていく方向に試していこうかと思います。KMSとか使ってないですし、Lambdaに合ったデータの永続化のあり方とか。キリが良くなったら続けます。

Serverless Frameworkでデプロイしたサービスからswagger.jsonを作りSDKやドキュメントを生成する

先日の続きです。

前回はServerless Frameworkを使ってAPI Gatewayでサービスを公開してみる、というところまでやりました。

個人や自分のチームだけが使ってメンテナンスするシングルサービスで構成されるアプリケーションならこれでガンガン開発すれば良いと思います。ただ、ものによってはチームで複数のサービスから構成される複雑なシステムを作る、といったケースや、外部に公開するAPIをもつサービスの一部としてこれらのサービスを開発する、というケースも想定されます。このような場合に必要なのが、開発者向けのドキュメントやクライアントライブラリを提供することです。

今回はそのあたりの整備をやっていきたいと思います。例によってリポジトリはここです。

github.com

github.com

一つ増えていますが、これはクライアントライブラリなどの開発者アセットの生成と配備を行うLambdaファンクション群をまとめたプロジェクトです。複数のプロジェクトから使い回せるように別プロジェクトに分けてあります。

最終的には、Github(merge)-> CodeBuild -> CodeDeploy -(API Gateway)-> クライアントライブラリの生成とSwagger UIの更新、みたいな流れををCodePipelineで作りたいわけですが、長くなったので最後の「クライアントライブラリの生成とSwagger UIの更新」の部分にフォーカスしています。

とりあえず、何をするにしてもswaggerファイルがあれば勝てると思うのでそこを目指すことにしましょう。

API GatewayからSwaggerファイルをエクスポートする

API GatewayAPIにはSwaggerファイルの形式でエクスポートするための get-export があるのでそれを使います。REST APIもありますが、Lambdaで叩こうと思うのでboto3を使っています。

import boto3
import os
import logging

apigateway = boto3.client('apigateway')
s3 = boto3.resource('s3')


def generate_swagger(event, context):
    try:
        staged_api = [api for api in apigateway.get_rest_apis()['items']
                      if api['name'] == os.environ['REST_API_NAME']].pop()

        exported_api = apigateway.get_export(
            restApiId=staged_api['id'],
            stageName=os.environ['REST_API_STAGE_NAME'],
            exportType="swagger"
        )

        swagger_bucket = s3.Bucket(os.environ['SWAGGER_FILE_BUCKET'])

        swagger_file = swagger_bucket.Object('swagger.json')
        swagger_file.put(
            Body=exported_api['body'].read().decode('utf-8'),
            ContentEncoding='utf-8',
            ContentType='text/plane',
            ACL='public-read'
        )
        return "/".join(["https://s3-ap-northeast-1.amazonaws.com",
                         os.environ['SWAGGER_FILE_BUCKET'],
                         "swagger.json"])
    except Exception as e:
        logging.error("Generate Swagger-File Failed. Detail:{0}".format(e))
        raise Exception("Coudn't create swagger file. Detail:{0}".format(e))

apigateway.get_export をキックする前に apigateway.get_rest_apis を叩いているのは、 get_export でのAPI指定にIDが必要で、 serverless.yml に書かれている設定だけではまかなえなかったからです。これについてはデプロイする環境名など、アプリケーションの情報を使うのでアプリケーション内に含まれるLambdaファンクションとしています。

ちなみに、これは後々ElasticBeanstalkで立てるSwagger GeneratorがURLでアクセスする必要があるのでアクセス権を public-read にしています。

(当たり前だけど)ローカル実行時のユーザとデプロイ時のユーザが違うので注意

Serverless Frameworkにはローカル実行オプションがあって、リソースへのアクセスさえできればデプロイせずにも実行できるのですが、実行ユーザに関する注意点があることに今更気づきました。ローカル実行時のユーザが割と強い権限なので、 serverless.yml に権限つけ忘れても動いてしまうんですよね。で、デプロイして実行たら権限が足りなくてこける、という(デプロイ自体は成功する)。

今回は、API Gateway関連の権限をつけ忘れていたので、以下を参考にしました。

docs.aws.amazon.com

APIドキュメントを充実させる

ちなみに、普通にServerless Framework使っているだけの状態でSwaggerファイルを生成しても、連携するもしくはユーザになる開発者に有用なアセットを作ることができません。エンドポイントの説明とかモデルの定義とかが空の状態のままなんですね。というわけで、APIドキュメントを充実させるために以下のプラグインを導入します。

www.npmjs.com

基本的な使い方はドキュメントに書いてある通りなんですが、statusCodeはstringを要求されるみたいなので気をつけましょう。

Swaggerの定義では、リクエスト/レスポンスのモデルをJsonSchemaで定義します。このプラグインでは、それらの定義を別ファイルに切り出しておくことができます。それが以下の部分です。

    models:
      - name: "CreateLifeLogRequest"
        description: "ライフログを作成する時のリクエスト"
        contentType: "application/json"
        schema: "${file(models/create_life_log_request.json)}"
      - name: "LifeLog"
        description: "ライフログのモデル"
        contentType: "application/json"
        schema: "${file(models/lie_log.json)}"

実際のJsonSchemaの定義は以下。

{
  "type": "object",
  "properties": {
    "event": {
      "description": "ライフイベントの内容を記載してください。",
      "type": "string"
    },
    "insight": {
      "description": "ライフイベントが起こった時の感情を記載してください。(任意)",
      "type": "string"
    }
  },
  "required": ["event"]
}

で、モデルをJsonSchemaで表せるということは、Lambdaファンクション内でスキーマをベースにしたチェックができるということなんですね。

import json
import logging
import os
import time
import uuid
import jsonschema

from commons import aws_resources


def create_life_log(event, context):
    try:
        dynamodb = aws_resources.get_dynamodb(event)
        timestamp = int(time.time() * 1000)

        data = json.loads(event['body'])
        request_schema = json.load(open(os.environ['REQUEST_MODEL_SCHEMA']))
        jsonschema.validate(data, request_schema)
        table = dynamodb.Table(os.environ['LIFE_EVENT_TABLENAME'])

        item = {
            'id': str(uuid.uuid1()),
            'event': data['event'],
            'createdAt': timestamp,
            'updatedAt': timestamp
        }

        if 'insight' in data:
            item['insight'] = data['insight']

        table.put_item(Item=item)

        response = {
            "statusCode": 200,
            "body": json.dumps(item)
        }

        return response
    except KeyError as e:
        logging.error("Validation Failed")
        raise Exception("Coudn't create life log.Detail:{0}".format(e))
        return

jsonschema.validate(data, request_schema) の部分がそれです。ちなみに、Lambdaの実行環境には jsonschema は含まれていないので、プロジェクトにインストールしてデプロイ時のzipファイルに含まれるようにしてあげる必要があります。どうしようか迷ったんですが、とりあえずインストールした外部パッケージ自体はgitignoreには追加してます。

と、ここまで書きながら調べたら、 lambda-uploderというプラグインがあるんですね、今度使ってみたいと思います。

dev.classmethod.jp

SwaggerファイルからSDKを作る

さて、ここからは生成したSwaggerファイルを活用していきます。Swaggerファイルから各種コード生成を行うためのSwagger Codegeneratorというツールがあるので、活用します。

https://swagger.io/swagger-codegen/swagger.io

これは、Dockerhubにある公式イメージで、ジェネレータをWebサービスとして起動可能なので、Elastic Beanstalkを使ってサービスを立てます。その時Dockerrun.aws.jsonは以下のような感じ。

{
  "AWSEBDockerrunVersion": "1",
  "Image": {
    "Name": "swaggerapi/swagger-generator",
    "Update": true
  },
  "Ports": [
    {
      "ContainerPort": "8080"
    }
  ]
}

例えばJavaのクライアントライブラリを生成したい場合は以下のようにリクエストしてください。

curl -X POST --header 'Content-type: application/json' --header 'Accept: application/json' -d '{"swaggerUrl": "swagger.jsonのURL"}' EBのURL/api/gen/clients/java

このAPIが請け負うのはライブラリの生成要求のみで、ダウンロードは別のAPIが用意されています。先のAPIの結果から code を取得して以下のように投げます。

curl -X GET "EBのURL/api/gen/download/取得したcode" -H "accept: application/octet-stream" > library.zip

なお、これで生成されるライブラリのダウンロードはワンタイムな模様で、ダウンロードに失敗したら生成からやり直しが必要です。

これをLambdaファンクションとして書いたものが以下です。これは割と使い回せるので、別に配布用のサービスを作っています。

import json
import os
import boto3
import urllib.request
s3 = boto3.resource('s3')


def generate_client_api(swagger_url, language):
    api_base = {
        "url": "/".join([os.environ['SWAGGER_API_URL'],
                        os.environ['GENERATE_CLIENT_URL'],
                        language]),
        "method": "POST",
        "headers": {"Content-Type": "application/json"}
    }
    obj = {"swaggerUrl": swagger_url}
    json_data = json.dumps(obj).encode("utf-8")

    return urllib.request.Request(api_base['url'],
                                  data=json_data,
                                  method=api_base['method'],
                                  headers=api_base['headers'])


def download_client_api(code):
    return "/".join([os.environ['SWAGGER_API_URL'],
                     os.environ['DOWNLOAD_CLIENT_URL'],
                     code])


def distribute_client(event, context):
    request = generate_client_api(event['swaggerUrl'], event['language'])

    with urllib.request.urlopen(request) as res:
        code = json.loads(res.read().decode('utf-8'))['code']
        with urllib.request.urlopen(download_client_api(code)) as client_res:
            client_bucket = s3.Bucket(os.environ['CLIENT_SDK_BUCKET'])
            client_file = client_bucket.Object(
                event['serviceName'] +
                '/{0}/{0}-generated-client.zip'.format(event['language']))
            client_file.put(Body=client_res.read())

    response = {
        "statusCode": 200
    }

    return response

Swaggerファイルの生成とクライアントの生成をStepFunctionsで繋ぐ

Swaggerファイルとクライアントの生成を個別にLambdaファンクションで行うことには成功しました。どうせならこの辺繋げたいですよね?

というわけでStep Functionsを使って繋げましょう。StepFunctionsとは、視覚的なワークフローを使って、コンポーネントやLambdaファンクションを繋ぐ分散アプリケーションを構築するためのサービスです。

aws.amazon.com

前職時代にSOAPでこういうソリューションを使った記憶があって、大規模SIerが好きそうな感じがしすね。時代は巡る糸車。

ステートマシンという実行ワークフローを構築し、その中のタスクでLambdaの実行などをします。ステートマシンはjsonベースのAmazon State Languageで書かれるようです。

docs.aws.amazon.com

ドキュメントに日本語はありませんが、東京リージョンにもちゃんときているので、安心して使いましょう。なお、当然といえば当然ですが、さすがに同じアカウント内のLambdaファンクションじゃないとワークフローに使えません。

というわけで、こんな感じのStep Functionsを作りました。

f:id:mao_instantlife:20170721212911p:plain

{
  "Comment": "Swaggerファイルの生成に成功したら、各言語向けにライブラリを生成する。",
  "StartAt": "SetServiceName",
  "States": {
    "SetServiceName": {
      "Comment": "サービス名の設定",
      "Type": "Pass",
      "Result": "my-assistant",
      "ResultPath": "$.serviceName",
      "Next": "GenerateSwaggerFile"
    },

    "GenerateSwaggerFile": {
      "Type": "Task",
      "Comment": "Swaggerファイルの生成",
      "Resource": "Swaggerファイル生成のLambdaファンクションARN",
      "Catch": [{
        "ErrorEquals": ["UnhandledError"],
        "Next": "GenerateErrorFallback"
      }],
      "ResultPath": "$.swaggerUrl",
      "Next": "GenerateClientParallel"
    },

    "GenerateErrorFallback": {
         "Type": "Pass",
         "Result": "This is a fallback from a generate swagger file Lambda function exception",
         "End": true
    },

    "GenerateClientParallel": {
      "Type": "Parallel",
      "End": true,
      "Branches": [
        {
          "StartAt": "SetLanguageJava",
          "States": {
            "SetLanguageJava": {
              "Comment": "言語設定にJavaを追加する",
              "Type": "Pass",
              "Result": "java",
              "ResultPath": "$.language",
              "Next": "GenerateClientJava"
            },

            "GenerateClientJava": {
              "Type": "Task",
              "Resource": "クライアント生成のLambdaファンクションARN",
              "InputPath": "$",
              "OutputPath": "$",
              "End": true
            }
          }
        },
        {
          "StartAt": "SetLanguageRuby",
          "States": {
            "SetLanguageRuby": {
              "Comment": "言語設定にRubyを追加する",
              "Type": "Pass",
              "Result": "ruby",
              "ResultPath": "$.language",
              "Next": "GenerateClientRuby"
            },

            "GenerateClientRuby": {
              "Type": "Task",
              "Resource": "クライアント生成のLambdaファンクションARN",
              "InputPath": "$",
              "OutputPath": "$",
              "End": true
            }
          }
        },
        {
          "StartAt": "SetLanguageJavascript",
          "States": {
            "SetLanguageJavascript": {
              "Comment": "言語設定にJavaScriptを追加する",
              "Type": "Pass",
              "Result": "javascript",
              "ResultPath": "$.language",
              "Next": "GenerateClientJavaScript"
            },

            "GenerateClientJavaScript": {
              "Type": "Task",
              "Resource": "クライアント生成のLambdaファンクションARN",
              "InputPath": "$",
              "OutputPath": "$",
              "End": true
            }
          }
        },
        {
          "StartAt": "SetLanguageScala",
          "States": {
            "SetLanguageScala": {
              "Comment": "言語設定にScalaを追加する",
              "Type": "Pass",
              "Result": "scala",
              "ResultPath": "$.language",
              "Next": "GenerateClientScala"
            },

            "GenerateClientScala": {
              "Type": "Task",
              "Resource": "クライアント生成のLambdaファンクションARN",
              "InputPath": "$",
              "OutputPath": "$",
              "End": true
            }
          }
        }
      ]
    }
  }
}

Swaggerファイルの生成に成功したら、JavaRubyJavaScriptScalaのクライアント生成をパラレルで実行するようにしています。Swaggerファイルの生成に失敗した場合はLambdaファンクションから例外を発生させて、それをキャッチしてStateMachinを止めるように作ってあります。カスタムエラーもドキュメントにはあるのですが、うまく動きませんでした。

ドキュメントを読む限りは、他には分岐やリトライなどもあり、基本的なワークフローは問題なく実装できそうです。

ただし、StateMachineのinputとoutputは特殊で慣れが必要だな、と感じました。StateMachine自体のInputとOutputを全てのタスクでコンテキストとして共有して、やりとりしている感じです。全てのタスクにはStateMachineのInputが渡されます。そのため、あるタスクの結果を後続タスクに曳き回したいときは、Inputの構成にそれを追加してあげる必要が出てきます。 GenerateSwaggerFile タスクの "ResultPath": "$.swaggerUrl", となってる箇所がその箇所です。このタスクの処理結果をInputの swaggerUrl という属性に放り込んでいます。

ちなみに、Parallelで並列実行する場合は各ブランチごとにInput領域がコピーされるため、ブランチ内では独立してInput領域を扱うことができ、名前が被っても問題ありません。実行時に実際に渡されたInputを見てみましょう。Java生成ブランチでのものとRuby生成ブランチでのものです。

f:id:mao_instantlife:20170721212930p:plain

f:id:mao_instantlife:20170721212943p:plain

上記のように、同じInputの属性でも別々の値がセットされています。

StepFunctionsをServerlessフレームワークの設定として書く

ここまでくると、アプリケーションのサービスをデプロイしたらStep FunctionsのStateMachineも作って欲しいですよね?というわけで、こんなプラグインを導入しました。

github.com

このプラグインを導入すれば、 serverless.yml にStep Functionsの設定をかけるようになります。基本的にはjson形式がyaml形式になるだけです。

github.com

ただ、StateMachineの登録される名前がちょっとあれでですね、今回の設定でデプロイしたらこんな感じになりました。

MyDashassistantDashgenerateDashclientDashdev-HJGGH29DKCX7

ちょっと待って、 -Dash に変換されるとか望んでない。。。

SwaggerファイルからSwagger UIを作る

で、最後にSwagger UIなわけですが、これもswagger-uiもdockerイメージとして起動可能なのがわかったので、今回はちょっと省略させてもらいます。

https://hub.docker.com/r/swaggerapi/swagger-ui/hub.docker.com

基本的なEBの構成はイメージの変更だけでSwagger Codegenと同じでいいはず。環境変数でSwaggerファイルを参照するので、更新されたら念のためアプリケーションサーバの再起動をすれば問題なさそうです。

まとめ

長くなりましたが、これで開発者向けの各種アセットをワンアクションで生成、配備することができるようになりました。次は、コードの更新に合わせてそれを実行する、というプロセスができれば、Serverless Frameworkでマイクロサービスを作る上での基盤というかリファレンスアーキテクチャ的なものができるかな、と。次はその辺りに取り組んでみたいと思います。

Serverless Frameworkを使ってみる

先日、社内システムの一部を再検討するときに、API Gateway + LambdaでServerlessマイクロサービスを選択肢に入れておこうという話をしました。で、言い出しっぺでほっとくのもあれなので、ちょっと自前で検証しておこうというのがこのエントリです。Lambda触ってみたかったですし。

本当だったら開発フローや近隣プロジェクトの開発者へのドキュメントやライブラリ公開まで含めた継続的デプロイまで整備してからリファレンスアーキテクチャ的な形のものを発信したかったんですけど、思ったより検証範囲が大きくなりすぎるので、Serverless frameworkを使ってデプロイしてみたところでエントリ的には一旦区切ります。もう何番煎じかわからないので、細かい設定などは書かずに、やりたいことと参考のリファレンスを整理していこうと思います。

その後の経過については別エントリであげる予定。

先に現状のリポジトリをご査収ください。

github.com

Serverless framework is 何?

serverless.com

Serverlessアーキテクチャを作るためのフレームワークとツール類をまとめたものです。AWSだけを対象にしたものではなく、Azureなどの各種クラウド環境に対応しています。LambdaのキックイベントはAPI Gatewayに限らず、S3の更新やCloudWatchあたりを設定することができます。今回私はPythonを使ってプロジェクトを作成しました。AWS関連のエントリではいつも大変お世話になってるクラメソさんのブログを読めば一通り使うことができます。

dev.classmethod.jp

デプロイ時の定義は serverless.yml という設定ファイルに記述され、記述に基づいたアプリケーションの構成をCloudFormationで作って後はよしなにしてくれます。Elastic Beanstalkと同じく、後からCloudFormationとかの構成の設定をみて勉強するという使い方もありかもしれません。

ちなみに、デフォルトだとイベント設定されていないので、そのままデプロイしてもAPI GatewayAPIは作成されません。また、デフォルトのリージョンはus-east-1です。東京リージョンにデプロイするときは serverless.yml にリージョンをちゃんと指定しておきましょう。

ちなみに、細かい話ですがデプロイしたLambdaファンクションの反映にはコンソールで10〜20秒くらいラグがありました。そんなもんなんですかね?テンプレートで出力されたプロジェクトをテスト的にデプロイしてキックするだけならLambdaの無料枠を超えることもないでしょうから、CloudFormation設定を確保するS3の利用料くらいですみます。

ドキュメント

充実しているのでここから探しましょう。

serverless.com

まずはGuideから読んでいけばいいと思います。最初に動かすまでは、Resource,Variables,Packaging,IAMあたりは必要に応じて読むくらいの心持ちで、大丈夫かと。

ただし、Workflowは読んで置くべきです。

serverless.com

開発時の変更ケースに沿ったデプロイの流れやstageやサービスレベルでのアカウント管理についての注意事項、Serverless Architectureの設計上の勘所あたりが書いてあってとても有用です。

Pythonに関する自分用リンク

まだなんとなくな理解の部分

  • stageって何?
  • CloudFormationちゃんと使ったことない
  • version のピン打ちができるけど、CIとかかけるんだったらやっといた方がいいかも
  • Lambdaに置けるエラー処理は結局どうやるのが良さげなのか 関数エラー (Python) - AWS Lambda
  • Lambdaファンクションの粒度 Step Functions使ったService層的なものの導入は必要になりそう

シンプルなレスポンスを返すマイクロサービスを作る

で、ここからが本題。実際に開発の場に持ち込むためには以下が足りないんですよ。

  • ローカル実行時のDynamoDBの取り扱い
  • テスト

どちらもない状態でチーム開発するのはしんどいですよね。というわけでこの辺りを整備してみます。

ローカル実行用にDynamoDBを使いたい

AWS上に作られているDynamoDBのテーブルにアクセスするのは簡単です。公式にもサンプルがあって、その通りに書けばとりあえずはデータのCRUDは試すことができます。

github.com

スキーマレスだからserverless.ymlにIDしか定義してなくてデータ突っ込んでるの強引だなあ、とか諸々思うところはあるのですが、それは今議論するところではないのでそっとしておきます。

で、CloudFormationがよしなにテーブル作ってくれたりするのはいいのですが、そのままでは一度でもデプロイしてしまうとローカル実行でもこのDynamoDBテーブルを使うようです。チームで使ったりする場合はちょっと不便。開発者の個別の実行は、AWSが用意しているDynamoDB ローカルを使ってあげるようにしたいですね。

docs.aws.amazon.com

で、見つけたのがこのプラグインです。

www.npmjs.com

で、私のソースは以下のQiita記事を参考に書いているので、 event[isOffline] を参照してデータソースを切り分けているのですが、

qiita.com

これは別のserverless-offline使う前提の名残です。なぜか私の環境ではpython3.6がunsupportedと出てるのでちょっと保留中です。ぶっちゃけserverless-offline自体はいらないんじゃないか、とも考え中。

Lambdaファンクションにテストを書きたい

初めはdoctestを使って書こうかと思ってたんですが、Lambdaファンクションのハンドラーになる関数だと、DynamoDBとかリソース使い出すとserverless.ymlのコンテキスト渡しづらくなってしんどいんですね。ファンクションの中で適切に分割されたpythonの関数であれば、doctestで十分にかけると思いますが。

というわけで、ハンドラーレベルのテストはServerless Frameworkのローカル実行機能を前提にbats使ってみることにしました。E2Eテストとユニットテストの2段階というイメージです。

qiita.com

リポジトリにもbotsを使ったテストを書いてます。が、一応、テストが書けたけど、シェルスクリプト力の不足でアサーションが非常に弱い状態。誰かまさかり投げて。。。

一旦のまとめ

というわけで、Serverless Frameworkを使ってチームでマイクロサービスを開発する上での方向性とか基盤の整理みたいなものの試行錯誤をしてみました。その一応の成果が先のリポジトリであります。いくつかいけてないところはありますが、そこはこれから洗練させる余地はあると思いますので、まずはこの辺で開発者視点は区切っておこうというのが今回のエントリの主旨です。

で、次はサービスのマネージャ視点になります。サービスは作って終わりではなく、他のサービスやアプリケーションの開発者に提供しなければならないリソースを整備することが必要になりますね、APIドキュメントとかライブラリとか。これらをデプロイに応じて自動で整備をする、というところをやっていきたいと思います。

Swaggerのファイル仕様が不満だったのでRAMLを触って比較してみる

前回、こういうの書きました。

mao-instantlife.hatenablog.com

で、最後に触れたSwaggerの定義ファイルへの不満点。これに悶々としていたところ、隣の席の人にRAMLというのを教えてもらったので比較がてら使ってみました。

RAML is 何?

Welcome | RAML

系統としてはSwaggerと同じく、yaml形式の仕様ファイルから公開向けドキュメントやライブラリ、サーバスタブなどを作るための定義仕様です。仕様のみの提供で、開発環境やコード生成ツールなどはサードパーティやコミュニティで開発されています。

例えばライブラリ生成なんかはこれ。

scraml.io

API Gatewayにデプロイしたいなーというときは、AWSが公式に出しているAPIGateway Importerを使いましょう。

github.com

開発環境

デファクトになりそうなのが、API WorkbenchとAPI Designerでした。以下、両者の概要を載せておきます。私はAPI Workbenchを使っています。

API Workbench

Atomプラグインとして提供されていますが、β版です。

API Workbench

一応0.8にも1.0にも対応を謳っていて、Swaggerの公式エディタやAPI Designerと違ってローカルのgitとも連携しやすいのが楽です。

Getting Started

Getting Startedで一通りRAMLの仕様を通り抜けられるところは好感高いです。ただし、まだβ版なので結構バギー。Getting Startedの通りにやろうとするとエラーになる(主にツールの操作で、ramlファイルの編集をすればいいので回避策はある)レベルの完成度。不安定ではありますが、提供されている機能的にも充実しているので使いながら待っていても十分なレベル。

API Designer

Swaggerのエディタと同じくWebベースのデザイナ、エディタです。保存はブラウザのLocal Databaseになります。RAMLはファイルの分割、インクルードができるので作ったらzipでダウンロードしてソース管理に、というワークフローになってちょっと辛いです。また、公式のdockerイメージもないので、その辺は改善して欲しいところ。

github.com

Swaggerと比較して嬉しいところ

で、数時間ほどAPI WorkbenchのGetting Startedを通しでやってみながら確認していたわけですが、Swaggerよりも嬉しいファイル仕様がいくつかありました。個人的にはこれがあるだけですごく嬉しい。どれも定義ファイルの記述を短く、シンプルにしてくれるものだと感じました。

リソースタイプ

リソース(URL)ごとにメソッドのパターンをまとめられる仕様です。inputとoutputは型パラメータのように抽象化して指定することが可能なので、再利用できます。だいたいAPIの構成ってリソースの種類ごとに似通う(in/outの型以外)よね、という発想はちゃんと仕様にしておいてくれて嬉しい限り。

resourceTypes:
  Collection:
    get:
      reponses:
        200:
          body:
            applicaton/json:
              type: <<item>>[]
    post:
      body:
        application/json:
          type: <<item>>

上記は GET でリソースのリストを返し、POST で新規作成する、 item を型パラメータとして持つリソースのパターンを示しています。これをあるリソースに適用するときはこんな感じです。

/newResource # これがURLになる箇所
  type: { Collection: {item: newResourceType} } # itemに実際の型定義を指定

トレイト

トレイトはリソース単位ではなくHTTPメソッドの振る舞い(パラメータのとり方とレスポンスの返し方、型)を抽象化することができる機能です。例えば、特定の項目に対するフィルタリングの振る舞いを複数のリソースの同じメソッドで実行する場合、以下のようなトレイトを用意します。

traits:
  FilterableByColumnKeyword:
    queryParameter:
      include:
        type: string
      exclude:
        type: string

これで、特定のキーワードが含まれる場合と含まれない場合のメソッドの振る舞いを定義しました。キーワードはクエリパラメータで指定する形式です。これをあるリソースのGETメソッドに定義する場合は以下です。

/newResource:
  get:
    is: [FilterableByColumnKeyword]

サブタイプ

型定義をシンプルにするためにサブタイプの定義仕様もあります。

例えば、Supertypeという基本的な型があって、その派生型で一部属性を共有するSubtypeがあるというケース。

type:
  Supertype:
    properties:
      id: string
      name: string
  Subtype:
    type: Supertype
    properties:
      company: string

のような定義で、Supertypeの定義に追加するような派生型が作れます。型のテンプレート的な用途で使うよりは、ちゃんとモデリングして派生の関係を作ってから定義した方がいいと思います。

uses定義

in/outの型やリソースタイプ、トレイトなどを別ファイルに切り出して定義して、それを読み込む機能です。Swaggerの言語仕様にこれが見つからなくてファイルがだだ長くなって「え〜」てな感じだったので、私にとってはこれだけでもRAML圧勝です。

uses:
  namespace: other_library.raml

のような形式で記述します。

結局やりたいことって

これから先、デプロイをサービス単位にAPI Gateway + LambdaみたいなServerlessにしていくのはデファクトになっていくのかなあ、と思っているのですが、開発者で共有するドキュメントは欲しいです。それに、サービスだけではなく、アプリケーションとして取りまとめるためにはライブラリも必要です。そのために境界はRAMLやSwaggerのようにそれらの生成を前提とした仕様定義を行って、サービスの実装はそれを元にServerless frameworkで行う、みたいな役割分担になってくるのかなあ、と思います。Serverless SPAみたいな構成だとそもそもServerless frameworkだけでいいじゃん、的な結論になると思いますが。

次は、Serverless frameworkを少し触ってからRAMLと繋いでみようかと。

ところで、サービスごとに分割して複数サービスを束ねてアプリケーション作るようなアーキテクチャにすると、トランザクションとか大変そうなんですが、みなさんどうしてるんでしょうね。後、DDDでいうサービス層の一部がフロントエンドでの実装に変わってしまうのではないかとも思ってて、この辺もみなさんどうしてるのか気になりますね。

Swaggerを使ってライブラリ非公開のAPIにライブラリを作って見た

仕事で少し検証をしてみたくなったAPIサービスが見つかりました。

codenberg.io

オンデマンドに印刷・発送をするためのAPIサービスなんですが、対応予定言語にJavaがない!REST APIだからどうとでもなるとは言え、フレームワーク的な部分のコードをゴリゴリと書きたくないですよね。本末転倒な楽しさに目覚めてしまう可能性もありますし(そっちかよ)。

というわけで、API仕様も公開されていますし、Swaggerの練習がてら自力でライブラリ生成をして使ってみました。

Swagger is 何?

swagger.io

Open API Specificationの開発者ツールフレームワークで、APIのデザイン、ドキュメンテーション、開発を支援する仕様とツール群を提供しています。Open API SpecificationとはアプリケーションにRESTful APIを作るための書式です。

ツール類は以下の3つで構成されています。

  • Swagger Editor -> まあその名の通り、オンラインエディタ(Local Datastoreに保存)かnodeかdockerを選べます
  • Swagger Codegen -> Open API Specificationから各種環境用のサーバスタブやライブラリを生成するためのツール
  • Swagger UI -> Open API Specificationからインタラクティブな機能を持った開発者向けドキュメントを生成するツールとその開発ドキュメントそのもの

今回サーバスタブは作っていません。

Open API Specification

細かい仕様はここを読みましょう。

Swagger Specification

定義はymlでもjsonでも書けます。エンドポイントや認証フロー、APIのIn/Outの各種データモデルを定義することができます。

Swaggerに感じる可能性

個人的には、このAPI SpecificationをAPI Gatewayに流してエンドポイントはそっちで管理、実装レイヤはAWS Lambdaという構成にできる方がいいのではないかと思っています。フロントエンドとサービスを切り離して、コンテキストでわけられた各種サービスのエンドポイントをつなぎ合わせて構成に、というとなんかMicroserviceっぽいですね。この構成にする上で、開発者に公開するドキュメントやライブラリは必須なわけですが、設計レベルからのアプローチで設計ドキュメントと実装の一致を測りやすくなっているのは良い感触です。

コンテキストの境界づけをAPIを提供する単位で行うことができ、別のコンテキストの裏口的なアクセスをなるべくブロックすることができます。そういう意味でDDDの各種実装パターンにおける実際のアーキテクチャのリファレンスが少し変わるのではないかと(パターン自体はあまり変わらないのではないかと思っている)。

というわけで使ってみた

swagger.yaml自体は仕様に基づいて書いただけなので、リポジトリをご査収ください。

github.com

で、検証している時にいくつか課題があったのでその内容をメモっておきます。

Swagger UIからAPIにアクセスできない

実際に仕様を書いてSwagger UIからアクセスしてみようとすると、こんなエラーが出ました。

Fetch API cannot load https://api.codenberg.io/v1/auth/token. Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource. Origin 'http://editor.swagger.io' is therefore not allowed access. The response had HTTP status code 404. If an opaque response serves your needs, set the request's mode to 'no-cors' to fetch the resource with CORS disabled.

Swagger UIからのアクセスは当然Originではないので、許可されていませんよ、ということ。オンラインエディタで最初に確認しましたが、dockerのコンテナ立ててlocalhostでエディタ立ててもVS Codeの拡張機能使っても同様でした。コンテナはまあ想像していましたが、VS Codeの拡張機能もnode使っているんでしょうね。ちなみに、認証に限らずAPI全般で同じ状態でした。

今回のケースはサーバサイドの問題です。どこまで許可するか、とかセキュリティの問題はありそうですが、少なくとも開発、テスト時に必要なレベルは許可されていないとツール類からアクセスできないのは非常に不便ですね。プルリクエスト投げた時にコメントで追加しています。

生成されるライブラリの認証フロー実装

Javaのライブラリを出力して、検証してみました。で、サンプルとして一緒に出力されるコードが以下のような感じなんですよね。

package sample;


import io.swagger.client.*;
import io.swagger.client.auth.*;
import io.swagger.client.model.*;
import io.swagger.client.api.FormatsApi;


/**
 * Created by mao on 2017/06/26.
 */
public class Application {


    public static void main(String[] args) {
        ApiClient defaultClient = Configuration.getDefaultApiClient();


        // Configure OAuth2 access token for authorization: codenberg_auth
        OAuth codenberg_auth = (OAuth) defaultClient.getAuthentication("codenberg_auth");
        codenberg_auth.setAccessToken("Access Token");


        FormatsApi apiInstance = new FormatsApi();
        try {
            InlineResponse2001 result = apiInstance.getFormats(new Body3());
            System.out.println(result);
        } catch (ApiException e) {
            System.err.println("Exception when calling FormatsApi#getFormatById");
            e.printStackTrace();
        }
    }
}

出力されるライブラリはOAuthの認証フローは実装されず、AccessTokenをセットする前提になっている。認証フローは別のライブラリなどで考慮する出力仕様のようです。他の言語のライブラリ出力ではどうなるかまでは検証していませんが、まあこの辺は知っていればいいだけの話かと。

あと、若干クラス名のつけ方が自動生成っぽいというかアレですね。この辺はもしかしたら定義の仕方次第で回避方法はあるのかも。

ちなみに、ライブラリからのアクセスはうまくいきました。いちいち出力して検証するわけには行かないので、クロスドメインの問題自体はサーバサイドで対応した方がいいですが、回避方法というか検証方法自体は残されている、ということで。

まとめ(というかお願いプリーズ)

自力でライブラリを生成して試せる、というのは非常にありがたいですね。API開発者は各言語用のライブラリを提供する必要はないので、swagger.yamlだけでも公開しておいてくれると非常に助かります。はいはいAPIね、Javaがねーじゃんそっ閉じってことをしなくて済むようになるので。

ちなみに私はSwaggerファイルの仕様というか、長くなりがちなファイル構成に不満がありまくりんぐでしたので、RAMLに乗り換えてみようかと思っております。