Node.js
DynamoDB
lambda
JAWS
APIGateway

JAWSフレームワークで、サーバレスな分散ロックサービス「Joumae」をつくった

More than 3 years have passed since last update.

Joumae(錠前)というサーバレスの分散ロックサービスをOSSとして公開したので、ご紹介させていただきます :bow:
AWS LambdaとAPI Gateway、DynamoDB、JAWSフレームワークなどを利用していて、小規模な社内利用であれば月々数十円で運用できます。

Joumaeとは

Joumaeはサーバレスの分散ロックサービス(Distributed Locking Service, Distributed Lock Manager)です。
任意のシェルコマンドや、プログラム中の任意のコードブロックを、クラスタ全体で排他的に実行するために使うことが出来ます。

Joumaeの利用例

例えば、クラウドワークスではデプロイロックのために活用しています。

アプリとインフラのBlue-Greenデプロイ

クラウドワークスでは、

  • アプリの更新はHubot、Rake、Capistrano、AWS SDK for Rubyなどを利用して
  • インフラの更新はChefやaws-cliやCapistrano、CodeDeployなどを利用して

それぞれBlue-Greenデプロイで行っています。

サーバワイドなロックとクラスタワイドなロック

Capistranoでデプロイロックを実現したいのであれば、

などを使えばよいのでは?と思われるかもしれませんが、実は私たちのようにBlue-Greenデプロイを、しかもアプリとインフラの更新にわけて行えるようにしている場合、不十分です。一方の更新中に、もう一方の更新を始めてしまって、

  • アプリのデプロイ中に、Chef CookbookをChef Serverに上げて、各サーバでChef Clientを実行してインフラの更新を初めてしまったり、
  • アプリのデプロイ中に、EC2インスタンスの停止・起動してしまったり

する可能性があるからです。

caplockなどを使っている場合、どちらのケースでもデプロイのどこかの段階で、同じサーバにCapistranoによるデプロイを走らせようとしたときにやっとエラーとなります。もっと早い段階でエラーにしたいし、そもそも同一クラスタに対して行われるアプリの更新とインフラの更新を同時に行おうとするところが問題です。では、同時に行わなければよいのではないか、ということで、Slack等で人間が「アプリのデプロイしますので、インフラのデプロイ(の引き金になるChef CookbookのGitHubレポジトリへぷるりマージ)はしばらくしないでください」のように調整をする…のは面倒ですね。クラスタ単位でのデプロイロックがかけられれば、自動化できます。

その他のソリューションと導入・運用の手間

クラスタ単位のデプロイロックを実現したいのであれば色々な選択肢があると思いますが、ZooKeeperは構築や運用の手間がやりたいことに対して大げさ、Redisベースのソリューションだと単体では可用性が気になりますし、Sentinelでクラスタを組むと、小規模な社内利用なら恐らく問題にはならないでしょうか、余計にDurabilityが気になります。また、運用の手間がそれなりにあります。時間は運用よりサービス開発に使いたい。

分散ロックを簡単に

Joumaeを利用すると、このようなクラスタ単位でのデプロイロックを、難しいプログラミングや面倒なインフラ構築なしで実現することができます。

Joumaeの構成

Joumaeは、

  • Webアプリ
  • クライアント

の二つで構成されています。

Webサービスの実装にあたってJAWSというフレームワークを利用しているところが特徴です。

Webアプリ

AWS API GatewayAWS Lambda上で動作するNode.jsとJAWSフレームワーク製のWebアプリです。
それを実現する、二つのJAWSフレームワーク向けモジュール

をOSSとして公開しています。

joumae-usersは、JoumaeのAPIクライアント(user)それぞれにAPIキーを払い出したり、それによる認証機能を提供します。

joumae-resourcesは、joumae-usersを利用して、認証済みのAPIクライアントにリソースの作成、ロック、ロック更新、ロック解除の機能を提供します。

JAWSの思想的に、異なる機能は異なるモジュールとして提供し、かつ1モジュール1 GitHubレポジトリという思想なので、joumae-resourcesとjoumae-usersの二つそれぞれ別のGitレポジトリに置いています。

JAWSアプリ ではなく モジュール を公開する理由は、以下の2点です。

  1. JAWSアプリにはAWSアカウントIDなどの環境固有の情報が含まれる仕様なので公開したくない
  2. モジュールの再利用が非常に高い(アプリだけでなくインフラもモジュール内にコード化されているから)ので、利用者としてはモジュールさえ公開されていれば簡単に使い始められるから

クライアント

Ruby製のライブラリ、CLIアプリjoumae-rubyをOSSとして公開しています。

JAWSとは

AWSでサーバレスなWebアプリケーションを実装するためのフレームワークです。

https://github.com/jaws-framework/JAWS

JAWSの特徴

インフラとして、

などをフル活用することを前提に設計されており、「Monstrously scalable serveless framework」を謳っています。
個人的なJAWSを採用することによるメリットは、主に

  • dev/prod parity
  • 運用に必要なコストが低い
  • 拡張性が高い

の3点です。

dev/prod parity

http://12factor.net/dev-prod-parity

環境(JAWSのステージ)をjawsコマンド1発で気軽につくることができます。
jawsコマンドは、テンプレートに基づいて環境に必要な

  • API Gateway
  • Lambda
  • DynamoDB

などのAWSリソースを半自動的に作成・変更・破棄してくれます(内部的にはAWS CloudFormationを利用)。
そのため、環境を新しくつくるための心理的障壁が低い、また本番環境と同じ構成のテスト環境をテンプレートに基づいてつくることができるので、結果的に環境差異が原因で本番デプロイしてから判明するバグが減ることが期待されます。

運用に必要なコストが低い

従量課金かつ無料枠も大きいAPI GatewayとLambdaを活用することで、アクセスの少ない社内利用なら数十円/月から運用できます。

再利用性と拡張性が高い

JAWSアプリは1つ以上のモジュールから構成されます。モジュールには、そのアプリに特化した専用モジュール、他のアプリで利用できる再利用可能モジュールの2種類があります。(JAWSのドキュメント上はどちらもモジュールと呼ばれていて、再利用可能モジュールと呼んでいるものは正式にはAWSMといいます)

再利用可能モジュールは、

  • Lambda Functionで動くプログラムの雛形
  • それがrequireするnpmモジュール
  • AWSリソースの構成情報(CloudFormationテンプレの部分)

で構成されています。

雛形はそのモジュールのインストール時に生成され、アプリ毎に自由に改変することができます。
そのため、JAWSでは再利用可能なモジュールを積極的に利用することで、結果的に

  • npmモジュールとAWSリソース構成情報の再利用性
  • 改変可能な雛形や、他のモジュールと組み合わせることによる拡張性

の二つのメリットを得ることが出来ます。

例えば、JAWSアプリにJoumaeのモジュールを組み込むことで、Joumaeが提供する分散ロック機能と独自機能を併せ持ったアプリ(とそのインフラ)をつくることができます。

JAWSの状況

JAWSのREADMEには「v1 (Beta)」と大きく書かれていますが、いつのまにかβを脱したのか、この記事を公開した2015/11/10時点の最新バージョンは 1.3.3 です。

$ jaws --version
1.3.3

この記事でもこのバージョンを前提に説明します。

JoumaeがJAWSを採用した理由

Joumaeは、社内でアクセスが少ない環境での利用を想定していたため、前述の3つの特徴のうち特に「運用に必要なコストが低い」点が採用の決め手になりました。

その他には、未知数なところもありますが、

  • サーバレスなためサーバの運用保守に伴う手間が削減できたり、
  • 今後利用拡大したとしてもインフラ構成を変えずにスケールアウトできたり、

するところにも期待しています。

JAWSアプリの構成

JAWSアプリにはJAWSが定める標準的なファイル構成があります。
皆さんがJoumaeをご自身の環境にデプロイするときには、みなさんの環境固有の情報を含むJAWSアプリを作成する必要があるので、Joumaeを使うなら覚えておいて損はありません!

JAWSアプリは以下のような構成になっています。

  • プロジェクトルート
    • aws_modules/
      • <モジュールまたはリソース>/
        • awsm.json … このモジュールに含まれるLambda Functionが共通的に利用するDynamoDBテーブルやIAMポリシーの内容
        • /
          • awsm.json … Lambda Functionにデプロイするzipアーカイブの生成方法や、Lambda FunctionをWeb APIとして呼び出すために利用するAPI Gatewayリソースの内容
          • handler.js … Lambda Functionのエントリポイントとなるjsです。いわゆるhandler。
          • index.js … handlerの処理の実体。基本的にアプリ固有の変更はhandler.jsではなくindex.jsに書きます
          • event.json … このディレクトリ内で jaws run するとLambda Functionをローカルで実行できますが、そのときLambda Functionのhandlerに渡されるeventの内容です
    • cloudformation/*
      • <ステージ>/
        • <リージョン>/
          • lambdas-cf.json* …
          • resources-cf.json* …
    • admin.env* … jawsが呼び出すaws-cliが使うプロファイル名の設定
    • jaws.json* … プロジェクトのメタデータ。各ステージの最新情報を含む
    • lib/ … 後述の通り、使わないで済むなら使わないほうがよいです
    • index.js … 場合によって作る。例えば、各Lambda Functionから共通的に使いたいコードがあるとき
    • package.json
    • node_modules/

JAWSアプリはnpmモジュールでもあるので、package.jsonやnode_modulesがあります。

lib/はプロジェクトを生成すると自動生成されますが、使わなくて済むのであれば使わずに作るのがおすすめです。libを使う前提で各Lambda Functionを実装すると、モジュールやLambda Functionの独立性や再利用が損なわれるからです。JAWSのメリットの一つに、aws_modules直下の「モジュール」をnpmパッケージにして他のJAWSプロジェクトにインストールできる、という再利用性の高さがあるのですが、プロジェクトルートのlibに依存しているモジュールはそれ単体で再利用できないからです。

JoumaeがJAWSアプリでなくモジュールとしてOSS公開されている理由

JAWSアプリの構成で特筆すべきは、名前の右に「*」をつけた、AWSリソース名などの環境固有の情報を含むファイルが含まれることです。
例えば、弊社で運用しているJoumaeアプリのGitレポジトリには、弊社のAWSリソース名などの環境固有の情報が含まれます。それを全世界に公開したくはなかったので、環境情報を含まないモジュール部分だけをOSSとして公開しています。

Joumaeのデプロイ

jawsコマンド

JAWSプロジェクトは、jawsというJAWSのコマンドラインツールを使うことで半自動的にAWSへデプロイすることができます。jawsは内部的にはNode.jsで書かれた

  • Lambda Functionのビルドツール
    • 必要なnpm_modulesや環境変数(を含む.envファイル)、jsファイルをminifyしてzipアーカイブに固めてLambdaにデプロイできるようにする…みたいなちょっと自前で作りこみたくないけどあると嬉しい機能を提供してくれます
  • CloudFormationによるAWSリソースの自動構成ツール
    • それぞれのモジュールに必要なAWSリソースを調べて、その通りCloudFormationテンプレートを書き換えたり、AWSコンソールやaws-cliでAWSを操作したり、ということを自動化してくれます

になっています。

JAWSプロジェクト作成

Joumaeを皆さんの環境にデプロイするためには、まずJAWSプロジェクトを作成します。

JAWSプロジェクトにはAWSアカウントIDなどの環境固有の情報が含まれます。
これを公開Gitレポジトリには残したくなかったため、Joumaeはいくつかの「AWSM(AWSモジュール)*」として提供しています。ここまでの説明では、「JAWS向けモジュール」や単に「モジュール」と読んでいましたが、正式にはAWSMです。

*JAWS正式には、AWSM(Amazon Web Services Module)といいます。「JAWSモジュール」といわないのは、JAWSがフレームワーク名に反して、いずれCloud Agnosticを目指しているからではないかと思います。AWSMはあくまでJAWSの「AWS上で動く、再利用可能なアプリとインフラ一式」を指していて、AWSMをJAWSMとよばないのは、JAWSはAWS専用というわけではないよ、という意思の現れなのではないかということです。

JAWSプロジェクトを作成するためには、以下のコマンドを実行します。

$ jaws project create

jaws project create コマンドを実行したら、いくつかの質問に答えていきます。

jaws project create は気軽に実行できます。実行することによって、何か問題が発生したり、後始末が面倒になったりということはありません。

質問に答え終わると、最終的にはAWS CloudFormationによりLambdaやAPI Gateway、S3などのAWSリソース一式(を含むCloudFormationスタック)が自動的にプロビジョニングされますが、

  • その際に既存のAWSリソースが削除されることはなく、
  • 必要なくなったらCloudFormationスタックを削除するだけで全てのAWSリソースをまとめて削除でできる

からです。

JAWSプロジェクト名

まず、「Enter a project name」はプロジェクト名を決めてくださいということなので、任意のプロジェクト名を入力します。

$ jaws project create
       ____   _____  __      __  _________
      |    | /  _  \/  \    /  \/   _____/
      |    |/  /_\  \   \/\/   /\_____  \
  /\__|    /    |    \        / /        \
  \________\____|__  /\__/\__/ /_________/ v1 (BETA)

       *** The Server-Less Framework ***

JAWS: Enter a project name:  (jaws-Ey2w3iGe)

上記の例ではデフォルトで jaws-Eu2w3iGe という名前になっています。これは自動生成された名前なので、 jaws project create するたびに変わります。ちょっと試すだけであれば、このままで問題ありません。

プロジェクト名はjawsが作成するCloudFormationスタックの名前の一部になるので、jaws-社名サービス名 などとすると、社内の他の人が作ったプロジェクトと被ってしまうので注意してください。

ドメイン名

次に、ドメイン名を入力します。

JAWS: Enter a project domain (You can change this at any time:   (myapp.com)

デフォルトでは、 myapp.com となっています。いまのところ、JAWSアプリが利用する環境変数の保存先となるS3バケットの名前に使われるだけなので、適当に決めましょう。ここではmyapp.comにしておきます。

AWS通知メール送信先アドレス

次に、各種AWSサービスからの通知メールの送信先アドレスを入力します。いまのところ、デフォルトでは特に通知メールは飛んでこないので、なんでもOKです。

jawsはCloudFormationで各AWSサービスのリソースを作ります。CloudFormationは「CloudFormationテンプレート」にしたがってリソースを作りますが、そこにこの「通知メールの送信先アドレス」が入力パラメータとして渡されます。そのため、テンプレートにCloudWatch関連のリソースを書き足してこのパラメータを参照させてやれば、自由に通知をさせることは可能です。ただ、jawsのデフォルトではそのようなテンプレートになっていない、というだけです。

JAWS: Enter an email to use for AWS alarms:  (you@yourapp.com)

JAWSステージ

dev、stg、prodなどのいわゆる環境名です。

JAWS: Enter a stage for this project:  (dev)

デフォルトでは開発環境を意味する dev という名前になっています。ちょっと試すだけであればこのままで問題ありません。いきなり本番環境を作る、ということであれば prod などにしても良いと思いますが、ステージは後で追加作成できるので特に拘る必要はありません。

AWSリージョン

ステージを作成するAWSリージョンを選びます。

JAWS: Select a region for your project:
  > us-east-1
    us-west-2
    eu-west-1
    ap-northeast-1

初期状態ではus-east-1(Northern Virginia)リージョンになっているので、カーソルキーでTokyoリージョンを選びます。

JAWS: Select a region for your project:
    us-east-1
    us-west-2
    eu-west-1
  > ap-northeast-1

aws-cli・aws-sdkのプロファイル

最後にjawsコマンドが利用するaws-cliのプロファイル((~/.aws/config で定義したもの)を選びます。

JAWS: Select an AWS profile for your project:
  > default

CloudFormationによるAWSリソースの作成

全ての質問に答え終わると、jawsコマンドが自動的にCloudFormationスタックの作成を始めます。スタックにはIAMロール、IAMポリシー、S3バケット、API GatewayのAPIなどが含まれます。AWSコンソールやaws-cliを使ったり、CloudFormationテンプレートを都度書かなくても、jawsが必要なAWSリソースを自動的に作成してくれるということです。べんり。

作成には数分かかるので、缶コーヒーでも飲みながら待ちましょう。

Joumaeモジュールのインストール

この状態でJoumaeを構成する各モジュール(JAWSのAWSモジュール)をnpmでインストールします。

$ cd JAWSプロジェクト名
$ npm install joumae-users --save
$ npm install joumae-resources --save

npm installによって、モジュールがインストールされて、aws_modules以下に以下の様な構成でモジュールの設定ファイルやコードが生成されます*。

*「npm installだけで設定ファイルやコードが生成される?」と不思議に思われる方もいるかもしれません。これは、各モジュールのpackage.jsonに書かれたpostinstallスクリプトの設定のおかげです。

  • プロジェクトルート/
    • aws_modules/
      • joumae-users/
        • event.json
        • handler.js
        • index.js
        • awsm.json
      • joumae-resources/
        • event.json
        • handler.js
        • awsm.json

Joumaeを改造したい、という場合を除いて、コードや設定ファイルの変更は不要です。

また、npm install時に、これらのモジュールに含まれるLambda Functionが必要とするDynamoDBやIAMなどのリソースがCloudFormationテンプレートに自動的に追加されます。

具体的には、以下のものが追加されます。

  • 「プロジェクト名-ステージ名-(users|resources)」という名前のDynamoDBのテーブル
  • それを読み書きできるIAMポリシー(Lambda Functionが利用するもの)

(詳細については、joumae-usersのawsm.jsonjoumae-resourcesのawsm.jsonを参照してください。)

npm installするだけで、アプリだけでなくインフラのコードも自動的にセットアップされるイメージです。べんり。

ステージに環境変数の設定

jaws env setコマンドで、Lambda Functionから読める環境変数を設定することができます。

Joumaeが必要とする環境変数

Joumaeを正常に動かすためには、最低限以下の二つの環境変数が必要です。

$ jaws env set dev ap-northeast-1 JOUMAE_USERS_JWT_ISSUER <任意の文字列>
$ jaws env set dev ap-northeast-1 JOUMAE_USERS_JWT_SECRET <任意の文字列>

JOUMAE_USERS_* という環境変数は、joumae-usersモジュールが提供するLambda Functionのコードで使われます。joumae-usersモジュールだけが使う想定のため、利用範囲が明確になるようにJOUMAE_USERS_というプレフィックスをつけてます。JWTはJSON Web Tokenの略で、JoumaeではJWTをAPIキーとして利用しています。(余談:本当はAPIキーとして利用するにあたって、トークンをサーバサイドで無効化するなどの機能も実装しておくべきなのですが、それはまた別の話)

JWT_ISSUER はJWTを払い出した主体(JWTのRFCにあるissまたはIssuer)です。joumae-<社名|サービス名|コードネーム> などが考えられます。JWT_SECRET はJWTの署名・改ざん検知のために使う共通鍵です(参考: JSON Web Signature (JWS)のRFCとjoumae-usersがJWT実装として利用しているnode-jsonwebtokenのREADME)

環境変数の設定内容を確認

jaws env list ステージ リージョン でそのステージに設定してある環境変数を確認することができます。
ここまで手順通りにできていれば、devステージ用の環境変数がS3バケット内のENVファイルに以下のように保存されているはずです。

$ jaws env list dev ap-northeast-1
JAWS: Getting ENV file from S3 bucket: jaws.dev.apnortheast1.myapp-vkco8sqg.com in ap-northeast-1
JAWS: ENV vars for stage dev:
JAWS: ------------------------------
JAWS: ap-northeast-1
JAWS: ------------------------------
JAWS_STAGE=dev
JAWS_DATA_MODEL_STAGE=dev
JOUMAE_USERS_JWT_SECRET=<jaws env setで設定した文字列>
JOUMAE_USERS_JWT_ISSUER=<jaws env setで設定した文字列>

API GatewayとLambdaへのデプロイ

$ jaws dash

対話的UIで全てのAPI GatewayのAPI/Resource/MethodとLambda Functionを選択し、devステージへデプロイします。

jaws dashが起動したら、デプロイするリソース(API GatewayのAPI・Resource・Method、Lambda Function)を選んで、一番下の「Deploy selected」でENTERを押します。L) から始まる名前のものはLambda Function、E から始まる名前のものはAPI Gatewayのリソースです。

JAWS: Dashboard for project "joumae"
 -------------------------------------------
 Project Summary
 -------------------------------------------
    Stages:
       dev ap-northeast-1
       prod ap-northeast-1
    Lambdas: 7
    Endpoints: 7
 -------------------------------------------
 Select Resources To Deploy
 -------------------------------------------
    joumae-users/show
      L) lJoumaeUsersShow
      E) /users/show - POST
    joumae-users/signin
      L) lJoumaeUsersSignin
      E) /users/signin - POST
    joumae-users/signup
      L) lJoumaeUsersSignup
      E) /users/signup - POST
    joumae-resources/acquirelock
      L) lJoumaeResourcesAcquirelock
      E) /resources/{name}/lock/acquire - POST
    joumae-resources/releaselock
      L) lJoumaeResourcesReleaselock
      E) /resources/{name}/lock/release - POST
    joumae-resources/renewlock
      L) lJoumaeResourcesRenewlock
      E) /resources/{name}/lock/renew - POST
    joumae-resources/create
      L) lJoumaeResourcesCreate
      E) /resources - POST
    - - - - -
  >   Deploy Selected -->

次にデプロイ先となるステージを選びます。

JAWS: Choose a stage:
  > 1) dev
    2) prod

ステージを選択すると、後はjawsが自動的に

  • 各Lambda Functionに必要なnodeモジュール、jsファイル等を作業ディレクトリへコピーしたり、
  • browserifyにかけて、Lambda上で高速にロードできるようにサイズを減らしてくれたり、
  • 最終的にLambdaが受け付けられるzipファイルに固めてデプロイしたり、
  • API Gatewayを設定してHTTP経由でLambda Functionを呼び出せるようにしてくれたり、

といった面倒事をやってくれます。手元のMBPでは1分ほどかかります。

JAWS: Endpoint Deployer:  Endpoints for stage "dev" successfully deployed to API Gateway in the region "ap-northeast-1". Access them @ https://<API GatewayのAPI固有のID>.execute-api.ap-northeast-1.amazonaws.com/dev/
JAWS: -------------------------------------------
JAWS:  Dashboard:  Deployments Completed
JAWS: -------------------------------------------

コマンドが実行完了したら、もうWebサービスとして利用できる状態になっているはずです。
jaws dashコマンドの終了直前に出力されるAPI GatewayのEndpoint URL(上記の出力例では https://<API GatewayのAPI固有のID>.execute-api.ap-northeast-1.amazonaws.com/dev/ の部分)は後で利用するので控えておきましょう。

Joumaeクライアントで動作確認

以上でJoumaeのWebサービス側が稼働しているはずなので、その動作確認もかねてJoumaeクライアントから色々な操作を行ってみましょう。

Joumaeクライアントを提供するjoumae-rubyは、「Dockerイメージにシステムワイドでインストールしておいてサクッと使う」というような使い方も想定しています。あえて他のgemに依存せずに、ベタなRuby 2.0.xをターゲットに実装してあるので、単純にgem installして使ってしまいましょう。

# JoumaeのRubyクライアント(CLI込み)をインストール
$ gem install joumae

# Joumae APIのURLを環境変数に設定
$ export JOUMAE_API_ENDPOINT=<jaws dashでデプロイした際に出力されたURL>

ユーザ登録とAPIキーの取得

$ api_key=$(joumae signup --email joumae@example.com --password mypassword | jq -r '.api_key')

# 払い出されたAPIキーを環境変数に設定。以降のコマンドにはこのAPIキーが必要です。
$ export JOUMAE_API_KEY=$api_key

リソースの作成・ロック・アンロック

$ joumae create --resource--name app-servers
$ joumae acquire-lock --resource--name app-serevrs
$ joumae release-lock- --resource--name app-servers

コマンド実行中にリソースをロック

joumae run で、任意のコマンド実行中にJoumaeリソースをロックし、コマンドが終了したときにアンロックさせることができます。

$ joumae run --resource-name app-servers -- bash -c "\"echo Starting deployment; sleep 10; echo Finished deployment\""

例えばこれをCapistranoと組み合わせると、デプロイロックを実装することができます。

$ joumae run --resource-name $APP-$ENV -- bundle exec cap $ENV deploy

冒頭で紹介した弊社のJoumae利用例で、「インフラのデプロイ時にクラスタのロックをとる」という用途で使っている、と書きました。弊社のインフラのデプロイスクリプトをCIから呼び出すエントリポイントは、単一のシェルスクリプトになっています。それとデプロイロックを組み合わせると、以下の様なイメージになります。

$ joumae run --resource-name $APP-$ENV -- scripts/mumo.sh deploy $APP-$ENV

まとめ

サーバレスな分散ロックサービスJoumaeと、そのデプロイや動作確認手順、利用しているJAWSというフレームワークについて紹介しました。
社内向けの分散ロックサービスが必要になったときは、ぜひ使ってみてください!