cloud-computing-1484538_800

こんにちは、kyoです。
今回はAWSのサービスを利用して、手軽なモックサーバを作る話をします。

アプリ開発でテストする時、APIのレスポンスを返すモックなどテスト用のサーバが必要になる場面が多々あります。
一般的には、簡単なサーバサイドアプリを立ち上げて、リクエストに対して固定なレスポンスを返す方法が使われていると思います。
しかし、レスポンスを変えたい・対象APIを追加したい場合、その度サーバ側の修正が発生してしまいメンテナンス作業が煩雑になってしまいがちです。

そこで、AWSのサービスを利用して、殆どノンコストでメンテナンス不要なモックサーバを作りました。

要件/機能

今回モックサーバは下記の要件で作ってみました:

  • レスポンスは設定可能(UI上でできる)
  • APIの新規追加・削除が可能
  • レスポンスデータのバージョン管理ができる(誤って変更しても簡単に復旧できる)
  • 各種メソッドをサポート(GET/POST/PUT/DELETE)
  • レスポンスはJSON/XML両方設定可能

アーキテクチャ

今回はAWSのいくつのサービスを利用し、サーバレスの構成になります。

構成図:
pic1_800px

構成の流れ

  1. S3にホスティングした管理サイトから、必要なAPIと対応のレスポンスデータを追加
  2. 追加したレスポンスデータはS3に保存(管理サイトはCognitoを利用して、一時的に指定S3へのアクセス権を取得)
  3. S3のPutイベントが発火し、API ManagerLambdaをキック
  4. API Managerはファイル名から対応のAPIGatewayResourceを作成し、Resourceに対するMethodを作成(GET/POST/PUT/DELETE/OPTIONS)、バックエンドはすべてAPI ResponseLambdaに設定
  5. 対象APIをリクエストする場合、APIGateway経由でAPI ResponseLambdaをキックし、LambdaS3からリクエストのMethodとAccept Type(json/xml)に対応のレスポンスファイルを検索して、最新バージョンの中身をレスポンスとして返す
  6. レスポンスデータを更新する場合、同じ名前のファイルをS3に上げることで、バージョンニングが更新され、APIのレスポンスも更新される。バージョンを戻す場合は管理サイトから最新のバージョンを削除するだけ
  7. S3のDeleteイベントもAPI Managerをキックし、特定のAPIのレスポンスが一つもない(Method✕DataType)場合、APIGatewayから対象Resourceをまるごと削除

そんなに複雑な構成ではないですが、AWSの機能がうまく連携できて、要件を満たすことができました。
特に

  • モックサーバのAPI自体も、Lambdaで動的に管理する(APIGatewayのAPIを利用して)
  • レスポンスデータはS3に格納したファイルを利用、バケットのバージョンニング機能を有効にして、レスポンスのバージョンを管理

という点はクラウドならではと思います。

構成詳細

サーバ側(AWS)

S3

レスポンスデータを格納するS3を用意する。
設定項目は

  • バケットのバージョンニング機能を有効
  • PutとDeleteのイベントを有効、対象はAPI ManagerのLambda

バージョンニング有効な場合、ファイルを削除する時DeleteではなくDeleteMarketCreatedのイベントが発火するが、今回ファイル削除の動作はバージョン指定したAPI経由の削除のため、普通にDeleteイベントが発火する

レスポンスデータのファイルは、[API名].[Method].[Type]の名前で格納される。
例えばgetABCというAPIのGetリクエストに対してのJSONレスポンスは、getABC.GET.jsonという感じで格納する

API Manager

API ManagerのLambdaはキックされた時、イベントを判別してそれぞれの処理を走る。(2つのLambdaに分けることもできるが、割りと被る内容が多いのを一つにまとめた)

Putイベントなら、対応のResourceを追加する。
既にResourceを作成された場合(他種類のMethodレスポンスが追加されたなど)は何もせずに終了。Deleteなら逆の処理を行う。
レスポンスデータが一個も存在しない場合、Resourceを削除する。

apigateway.getResources({restApiId: bucketConfig.apiId}).promise()
.then((data) => {
  let foundResource = false;

  for (let i = 0; i  { return createApi(resourceId, "GET") })
    .then((resourceId) => { return createApi(resourceId, "POST") })
    .then((resourceId) => { return createApi(resourceId, "PUT") })
    .then((resourceId) => { return createApi(resourceId, "DELETE") })
    .then((resourceId) => { return createApi(resourceId, "OPTIONS") })
    .then(deployApi)
  } else { //found resource , delete resource
    return s3.listObjects({Bucket: bucket, Prefix: path}).promise()
    .then((data) => {
      if (data.Contents.length === 0) return deleteApi(rid);
      else return "nothing to do";
    })
  }
})

createAPIではAPIGatewayの一連リソースを作成

function createApi(resourceId, method) {
  const params = {
    authorizationType: 'NONE',
    httpMethod: method, 
    resourceId: resourceId,
    restApiId: bucketConfig.apiId,
    apiKeyRequired: false
  };
  return apigateway.putMethod(params).promise()   //APIGatewayのMethodを作成
  .then((data) => {
    const params = {
      httpMethod: method, 
      resourceId: resourceId,
      restApiId: bucketConfig.apiId,
      type: method === 'OPTIONS' ? 'MOCK' : 'AWS',  //OPTIONSの場合はLambdaではなくMOCKを使う
      integrationHttpMethod: 'POST',
      passthroughBehavior: "WHEN_NO_TEMPLATES",
      requestTemplates: requestTemplate,  //中身は後述
      uri: "arn:aws:apigateway:ap-northeast-1:lambda:path/2015-03-31/functions/arn:aws:lambda:REGION:ACCOUNTID:function:LAMBDA名" + "/invocations"        
    };
    return apigateway.putIntegration(params).promise();  //MethodのIntegrationを作成
  })
  .then((data) => {
    const params = {
      httpMethod: method, 
      resourceId: resourceId,
      restApiId: bucketConfig.apiId,
      statusCode: '200',
      responseModels: {
        "application/json": "Empty",
        "application/xml": "Empty"
      },
      responseParameters: method === 'OPTIONS' ? {
        "method.response.header.Access-Control-Allow-Origin": false,
        "method.response.header.Access-Control-Allow-Methods": false,
        "method.response.header.Access-Control-Allow-Headers": false
      } : {
        "method.response.header.Access-Control-Allow-Origin": false
      }
    };
    return apigateway.putMethodResponse(params).promise();  //MethodのResponseを作成、CORSの対処が必要
  })
  .then((data) => {
    const params = {
      httpMethod: method,
      resourceId: resourceId,
      restApiId: bucketConfig.apiId,
      statusCode: '200',
      responseParameters: method === 'OPTIONS' ? {
        "method.response.header.Access-Control-Allow-Origin": "'*'",
        "method.response.header.Access-Control-Allow-Methods": "'POST,GET,PUT,DELETE,OPTIONS'",
        "method.response.header.Access-Control-Allow-Headers": "'Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token'"
      }:
      {
        "method.response.header.Access-Control-Allow-Origin": "'*'"
      },
      responseTemplates:  method === 'OPTIONS' ? {} : {
        "application/json": "$input.json('$')",
        "application/xml": "$input.path('$').xml"
      }
    };
    return apigateway.putIntegrationResponse(params).promise(); //MethodのIntegrationResponseを作成、OPTIONSの場合はCORSの内容が必要
  })
  .then((data) => {
    return resourceId;
  });
}

APIGatewayResourceを作成できたら、apigateway.createDeployment()でStageにデプロイが必要

Resource削除の場合はもっと簡単:

function deleteApi(resourceId) {
  let params = {
    resourceId: resourceId,
    restApiId: bucketConfig.apiId
  };
  return apigateway.deleteResource(params).promise();
}

ResourceのIDとAPIIDを指定してdeleteResourceすれば全部消える。

API Response

API ResponseLambdaは、リクエストしたAPI名、Method(GET/POST/PUT/DELETE)、Accept(必要なデータのタイプ)に応じて、対応のレスポンスを返す。
ただし、まずはそれらの情報をLambdaに渡さなければいけない。
そこは先ほどAPIGatewayIntegrationを作成する時に使ったrequestTemplateを利用する。
設定した内容はAPIGatewayのコンソールからも確認できる:

pic2_800px

requestTemplate変数の中身:

  "application/json": "{\n\
    #foreach($queryParam in $input.params().querystring.keySet())\n\
      \"$queryParam\": \"$util.escapeJavaScript($input.params().querystring.get($queryParam))\",\n\
    #end\n\
    \"accept\": \"$input.params('Accept')\",\n\
    \"api\": \"$context.resourcePath\",\n\
    \"method\": \"$context.httpMethod\"\n}",

  "application/x-www-form-urlencoded": "#set($httpPost = $input.path('$').split(\"&\"))\n{\n\
    #foreach( $keyValue in $httpPost )\n\
      #set($data = $keyValue.split(\"=\"))\n\
      \"$data[0]\" : #if($data.size() > 1)\"$data[1]\"#else\"\"#end,\n\
    #end\n\
    \"accept\": \"$input.params('Accept')\",\n\
    \"api\": \"$context.resourcePath\",\n\
    \"method\": \"$context.httpMethod\"\n}"

これはAPIGatewayが貰ったリクエストの情報を、Lambdaに渡すためのマッピングのテンプレート(jsonとform両方サポート)。

今回は$contextからresourcePath(API名)とhttpMethod、さらにリクエストヘッダのAcceptを取り出してパラメータとして渡す。
他のリクエストパラメータもJSONに変換して一緒に渡しているが、今回のモックサーバはパラメータ対応してないので実際は見ていない。
今後のエンハンスでパラメータに応じて対応なレスポンスを返すことも可能です。

パラメータが貰ったら、後はS3に検索して、対応のレスポンスを返すだけ。レスポンスがない場合はデフォルトのエラーメッセージなどを返すことも可能。

jsonタイプの処理:

if (contentType === "json") {
  s3.getObject({
    Bucket: bucket,
    Key: [event.api.slice(1),event.method,"json"].join('.')
  }).promise()
  .then((data) => {
    console.log(data);
    try {
      result = JSON.parse(data.Body);
    } catch (e) {
      result = {error: "not valid json data"};
    }
    callback(null, result);
  })
  .catch((err) => {
    console.log(err);
    callback(null, { warn: event.method + " response not configured" });
  });
}

管理画面

UIはこういう感じ(CSSは一切掛けてない、極めてシンプルなもの):

pic3

  • 管理画面のサイトはS3のweb hosting機能を利用し、静的リソースのみで構成(実際はhtmlとjsそれぞれ一ファイルだけ)。
  • 画面を表示した時、CognitoIdentityPoolにアクセスし、一時的な権限を取得
function initCredentials() {
  AWS.config.region = region;
  AWS.config.credentials = new AWS.CognitoIdentityCredentials({
    IdentityPoolId: PoolID,
  });
  return new Promise((resolve, reject) => {
    AWS.config.credentials.get((err) => {
      if(err) reject();
      else {
        s3 = new AWS.S3({params: {Bucket: bucket}});
        resolve();
      }
    });
  });
}
  • CognitoではUnauthenticated identitiesを有効し、ログイン不要で指定S3バケットのPut/Delete権限を取得できるようにする(セキュリティー面では、S3のバケットポリシーでIP制限を掛けている、社内ネットワーク以外では権限を取られても使えない)
  • レスポンスデータのバージョンを追加する時、設定したデータをテキストファイルとしてレスポンスデータを格納用のS3にPutします。ファイル名は[API名].[Method].[Type]で構成します。
  • レスポンスデータのバージョンを削除する場合、バージョン付きのキーに対してs3.deleteObjectを発行(この場合、対象バージョンが完全削除されるため普通にDeleteイベントが発火)。

アプリ側(モック API)

リクエストする時、Acceptヘッダでレスポンスタイプを指定します。Acceptapplication/xmlと完全一致の場合のみXMLを返し、ほかはすべてJSONで返します。

これはAPIGateway側の仕様(?)かもしれない、`application/xml`が指定されても、他に`text/html`などが入っていたらすべてJSONで返された

使用例:

#JSONデータの場合
$ curl -XPOST 'https://ResouceID.execute-api.Region.amazonaws.com/Stage/API' -H 'Accept: application/json'
{"warn":"POST response not configured"}%

#XMLデータの場合
$ curl -XPOST 'https://ResouceID.execute-api.Region.amazonaws.com/Stage/API' -H 'Accept: application/xml'
POST response not configured%

コスト

今回はサーバレス構成なのでコストはかなり控えめです。
仮に月10万回のGET/POSTリクエストが発行して、コストを試算すると…:

  • Lambda: 月100万回まで無料なので基本無料になる。(他のLambdaも走っていれば話は別だが)
  • APIGateway: 100万回の呼び出しで$4.25、データ転送$0.14/GB。10万回で呼び出し料金$0.425、転送は仮に一回10KB、10KB * 100000 = 10GB、料金は$1.4
  • Cognito: 無料
  • S3: ストレージは$0.0330/GB、リクエストはGETの場合$0.0037/10,000リクエスト、GET以外$0.0047/1,000リクエスト。仮に月10万回のGET/PUTでも0.037+0.47=$0.507

この構成でかかる費用は、合計約月2ドルになります!

モックサーバは月10万回呼び出されることはあまりないので、実際ほぼノンコストと言えるでしょう。