KAKEHASHI Tech Blog

カケハシのEngineer Teamによるブログです。

まったく新しい開発体験をもたらすServerless Stackとは何か

はじめに

こんにちは、LINE上で動くおくすり連絡帳 Pocket Musubiというサービスを開発している種岡です。
この記事では、Serverless Stackapollo-server-lambdaを使って、AWS Lambda上でApollo Serverを動かしてみたのでご紹介します。

対象読者

以下に興味がある方は読んで頂ければ幸いです。

  • CDKのラッパーであるServerless Stackについて
  • AWS Lambda上でApollo GraphQLサーバーの起動について

とくに、Serveless StackのLive Lambda Development機能がオススメです。

このように Hello, World! を出力するLambdaに紐づくエンドポイントがあったとして

hello-world.png (162.2 kB)

Local環境にあるLambdaコードを修正し、AWS側のエンドポイント経由で再度リクエスト投げると即座に修正が反映されたことを確認できます。

hello-world-2.png (167.8 kB)

それではシステム構成を含めて詳細を説明します。

システム構成

今回構築したシステムのハイレベルアーキテクチャはこちらになります。

serverless-stack-apollo-Page-1.drawio (1).png (80.5 kB)

Serverless Stackとは

AWSで簡単にサーバーレスアプリを構築できるフレームワークでAWS CDKのラッパーです。
特徴としては、後述するLive Lambda DevelopmentというLocal環境で開発体験を向上する機能が含まれています。

Serverless Stackでプロジェクト作る

早速手を動かします。

npx create-serverless-stack@latest --language typescript kkhs-test
cd kkhs-test

プロジェクト初期生成時のディレクトリ構成はこのようになりました。

├── README.md
├── node_modules
├── package-lock.json
├── package.json
├── src
│   └── lambda.ts
├── sst.json
├── stacks
│   ├── MyStack.ts
│   └── index.ts
├── test
│   └── MyStack.test.ts
└── tsconfig.json

sst.jsonを修正

regionを ap-northeast-1 に修正

{
  "name": "kkhs-test",
  "region": "ap-northeast-1",
  "main": "stacks/index.ts"
}

Apollo Server Lambdaの準備

Apollo GraphQL APIの利用

自動で生成された stacks/MyStack.ts はHTTP APIを利用するスタックになっています。 これをApollo GraphQL APIが利用できるように書き換えます。

import * as sst from "@serverless-stack/resources";

export default class MyStack extends sst.Stack {
  constructor(scope: sst.App, id: string, props?: sst.StackProps) {
    super(scope, id, props);

    const api = new sst.ApolloApi(this, "ApolloApi", {
      server: "src/lambda.handler",
    });

    this.addOutputs({
      "ApiEndpoint": api.url,
    });
  }
}

apollo-server-lambdaのインストール

今回はLambda上でApollo Serverを起動するのでapollo-server-lambdaをインストールしておきます。

npm install apollo-server-lambda

インストールが完了したら、 src/lambda.ts を編集してHello Worldが表示できるようにスキーマを定義します。
process.env.IS_LOCAL はServerless Stackのデバッグモードで起動すると自動的にtrueが入ってきます。
以下ではデバッグモード時にGraphQLの定義情報を閲覧できるようにするため、イントロスペクションを有効化しています。

import { gql, ApolloServer } from "apollo-server-lambda";

const IS_LOCAL = !!process.env.IS_LOCAL;

const typeDefs = gql`
  type Query {
    hello: String
  }
`;

const resolvers = {
  Query: {
    hello: () => "Hello, World!",
  },
};

const server = new ApolloServer({
  typeDefs,
  resolvers,
  introspection: IS_LOCAL,
});

export const handler = server.createHandler();

デバッグモードで動作確認

デバッグモード起動する前にtsconfig.jsonのcompilerOptionsにesModuleInteropを追加します。

{
  "compilerOptions": {
    ...
    "esModuleInterop": true  // ←追加
  },
  "include": ["lib","src"]
}

以下のServerless Stackコマンドを実行するとdevというステージ名でAWS環境にデプロイが開始されます。

npx sst start --stage dev

=======================
 Deploying debug stack
=======================

Deploying stacks
Checking deploy status...
dev-kkhs-test-debug-stack | CREATE_IN_PROGRESS | AWS::CloudFormation::Stack | dev-kkhs-test-debug-stack
Checking deploy status...

()

dev-kkhs-test-my-stack | CREATE_COMPLETE | AWS::CloudFormation::Stack | dev-kkhs-test-my-stack

 ✅  dev-kkhs-test-my-stack


Stack dev-kkhs-test-my-stack
  Status: deployed
  Outputs:
    ApiEndpoint: https://b465pys10l.execute-api.ap-northeast-1.amazonaws.com


==========================
 Starting Live Lambda Dev
==========================

Transpiling Lambda code...
Debug session started. Listening for requests...

デプロイが無事終了すると、Serverless Stackにより作成されたAPI Gatewayのエンドポイントが表示されます。
また、デバッグ用のセッションが開始されリクエスト受付け待ちの状態になります。

Apollo Serverの動作確認

それでは早速クエリを実行してみます。 今回はAltair GraphQL Clientというツールを使いました。
実行すると無事 Hello, World! が表示されました。

hello-world.png (162.2 kB)

ではこのデバッグモードを維持した状態でLocal環境にある src/lambda.ts を修正してみます。

import { gql, ApolloServer } from "apollo-server-lambda";

const IS_LOCAL = !!process.env.IS_LOCAL;

const typeDefs = gql`
  type Query {
    hello: String
  }
`;

const resolvers = {
  Query: {
    hello: () => "エンジニア絶賛募集中!!",  // 文言修正
  },
};

const server = new ApolloServer({
  typeDefs,
  resolvers,
  introspection: IS_LOCAL,
});

export const handler = server.createHandler();

上記の修正を行い保存をすると、Serverless Stackのデバッグ用のセッションに以下の文字が表示されました。

Rebuilding code...
Done building code

そして再度クエリを実行すると修正が反映されていることが確認できました。

hello-world-2.png (167.8 kB)

Local環境で修正した変更はdeployコマンドを実行することでAWS側に反映されます。

npx sst deploy --stage dev

Live Lambda Developmentについて

Lambdaの修正を逐一デプロイせずとも反映できる仕組みは、非常に強力で開発スピードに大きく貢献しそうです。 一方で、どのような仕組みになっているかですが、本家のドキュメントの概要図から読み解けます。

Live Lambda Developmentのしくみ

sst startで起動するとAWS側のAPI Gateway配下に作成されたLambdaリソース(図中のapi.jsやsns.js)はLocal環境のものを参照するようになるとのことです。

デバッグモードで起動すると、WebSocket APIを持つデバッグスタックがデプロイされます。また、LambdaをスタブLambdaに置き換わります。
そして、Local環境からはWebSocketクライアントを起動してこのデバッグスタックに接続するとのこと。

実際にAWSコンソールを覗いてみると、API Gateway側にはWebSocket APIがデプロイされており apigateway.png (26.3 kB)

Lambda側には上記のAPI Gatewayに紐付けられるLambdaがデプロイされていました。 debug-stack.png (81.2 kB)

API GatewayとLmabdaの紐付けも下図のようにそれぞれ紐付けされていました。 apigateway-lambda.png (97.0 kB)

リクエストの流れをまとめると以下のようになります。

  1. API Gatewayにリクエストが届く
  2. スタブLambdaが起動される場合、WebSocket APIにメッセージが送信される
  3. WebSocket APIはLocal環境のクライアントにメッセージを送信
  4. LocalのクライアントはLocal環境のLambdaを実行し結果をWebSocket APIに返信
  5. スタブLambdaが受け取りレスポンスとして返す

(余談)API GatewayにLambdaオーサライザーを追加してみる

このままだと誰でもアクセスできてしまうので、Lambdaオーサライザーを追加して認証済みのリクエストのみがApollo Serverと通信できるように改良してみます。 イメージとしてはこのようになります。

serverless-stack-apollo-Page-2.drawio.png (69.2 kB)

Lambdaオーサライザー追加のために必要なパッケージのインストール

Serverless StackはCDKのラッパーなのでCDKのリソースを利用できます。

npm install @aws-cdk/aws-lambda-nodejs @aws-cdk/aws-apigatewayv2-authorizers

Lambdaオーサライザー用のLambdaファイルを追加

認証ヘッダーがあった場合こちらのLambdaが呼ばれます。
isAuthorizedは認証成功フラグになります。
今回は動作検証のためこのLambdaが呼ばれた場合は認証成功とみなします。

exports.handler = async (event: any) => {
  let response = {
    isAuthorized: true,
    context: {},
  };
  return response;
};

スタックファイルの修正

API GatewayにLambdaオーサライザーを紐付けします。
分かりやすいようにヘッダーにHogeAuthorizationが入っていた場合はLambdaオーサーライザーが呼ばれるように設定します。

import * as sst from "@serverless-stack/resources";
import { HttpLambdaAuthorizer, HttpLambdaResponseType } from "@aws-cdk/aws-apigatewayv2-authorizers";
import { NodejsFunction } from "@aws-cdk/aws-lambda-nodejs";

export default class MyStack extends sst.Stack {
  constructor(scope: sst.App, id: string, props?: sst.StackProps) {
    super(scope, id, props);

    const lambdaAuthorizer = new NodejsFunction(
      this,
      "LambdaAuthorizer",
      {
        functionName: "kkhs-test-lambda-authorizer",
        entry: "src/authorizer.ts",
        handler: "handler",
      }
    );

    const authorizer = new HttpLambdaAuthorizer({
      authorizerName: "hogehoge_authorizer",
      identitySource: ["$request.header.HogeAuthorization"],
      responseTypes: [HttpLambdaResponseType.SIMPLE],
      handler: lambdaAuthorizer,
    });

    const api = new sst.ApolloApi(this, "ApolloApi", {
      defaultAuthorizationType: sst.ApiAuthorizationType.CUSTOM,
      defaultAuthorizer: authorizer,
      server: {
        handler: "src/lambda.handler",
      },
    });

    this.addOutputs({
      "ApiEndpoint": api.url,
    });
  }
}

Apollo Clientツールから動作確認

左側メニューから Set Headers を選択し、HogeAuthorizationに適当な値を入寮します。 header.png (17.2 kB)

クエリを実行するとリクエストの成功が確認できるかと思います。

まとめ

今回はServerless Stackを使ってAWS Lambda上でApollo Serverを立ち上げて動作確認をすることができました。
また、API GatewayにLambdaオーサライザーを追加し特定リクエストのみ処理されるように改良しました。
ApolloApiのコンストラクトはApolloServerを作る上で便利だと思う一方で、Propsの使い方が見慣れないため実装時にドキュメントと向き合う時間は長かったように思います。
しかし、Live Lambda Developmentは機能として非常に魅力的で開発スピードは良くなる感触がありました。