手軽にWebサイトの負荷試験をする

概要

シナリオをきっちり作ってクラスタ立ててやるほどでもなく、でも単一URLにだけリクエストおくるほど単純でもなく、 Webページに同時にn人接続した場合の負荷を調べる程度のテストをしたいときにやったことです。

手順

1. Siegeのインストール

Siegeというベンチマークツールを使ってテストを行います。これは

It can stress test a single URL with a user defined number of simulated users, or it can read many URLs into memory and stress them simultaneously. とあるように、複数のURLに対して複数のユーザーによる同時接続をシュミレートでき、今回の用途に適しています。また、コマンドラインから実行できるのも便利です。 他に手軽なベンチマークツールとしてはApache abもよく知られていますが、こちらは複数URLへのリクエストが行えないため、今回は選択しませんでした。

公式ページからダウンロードできますが、今回のテストはMacから実行したので

brew install siege

でインストールしました。

2. テスト対象ページで発生するリクエスト先URL一覧を取得する

Webページにアクセスした場合、通常はそのページ自体のURLだけではなく、 画像、CSS、JSファイルなど複数のリソースに対するリクエストが発生します。 今回はWebページへのアクセスをシミュレートしたいので、すべてのリクエスト先URL一覧を作成します。

Chromeブラウザで該当ページにアクセスし、DevToolsのNetworkタブを開いてSave all as HAR with contentを選択してHARファイルをダウンロードします。 これはJSONファイルになっているので、jqコマンド等でパースしリクエストURLの一覧を取得することができます。

cat /path/to/file.har | jq '.log.entries[].request.url' | sed -e 's/"//g' > /path/to/url_list.txt

で、URL一覧を記述したファイルを作成できます。もし一覧内にテスト対象外サーバー上のリソースが含まれている場合は、適宜削除します。

3. Siegeでのテスト実行

siegeコマンドで上記URL一覧を指定することで、テストの実行が可能です。 例えば同時実行数を10とし、5分間テストを継続する場合、

siege -c 10 --time=5M -f /path/to/url_list.txt

でテストを実行できます。その他ベーシック認証のIDパスワードの指定、ヘッダーの指定、リクエスト数調整などの各種オプションも指定できます。

APIを雑にモックするサービスを作った

概要

EASY API MOCKER f:id:ropupu-ropupu:20190414141410p:plain

開発をしてるときにAPIのモックがほしいな〜というときが時々あると思いますが、その時に『このパスにこのメソッドとこのパラメーター投げたらこのステータスコードとレスポンス返してね』という設定をしたらそのとおりに動いてくれるやつです。

技術

バックエンド

バックエンドはTypeScriptで作成しました。TypeScriptを使うのはこれが2回目くらいですが、やはりインターフェースがあると作りやすいです。 テストにはjest、フレームワークはexpressを使いました。またDBとしてFirestoreを利用しています。

フロントエンド

フロントはVue.jsで作りました。UIはElementuiを使いました。

サーバー

バックエンドはDocker内で開発していたので、それをそのままherokuで公開しています。雑にコンテナを公開するのはherokuが一番ラクでした。しかし最近GCPのCloud Runが使えるようになったので、そちらもよさそうだしどこかでいじろうかなと思ってます。 フロントはNetlifyを使ってます。

TypeScriptでfirebase-admin sdkを使う

Node.jsだと公式ドキュメントの通り

const admin = require('firebase-admin');

var serviceAccount = require('path/to/serviceAccountKey.json');

admin.initializeApp({
  credential: admin.credential.cert(serviceAccount)
});

var db = admin.firestore()

で使えるのですが、TypeScriptだとちょっとやり方を変えないといけません。

事前にJSONファイルを読み込むのにtsconfig.tscompilerOptions.resolveJsonModuletrueにしておいて、

import * as firebase from 'firebase-admin'
import * as serviceAccount from '/path/to/firestore-service-account.json'

const params = {
  type: serviceAccount.type,
  projectId: serviceAccount.project_id,
  privateKeyId: serviceAccount.private_key_id,
  privateKey: serviceAccount.private_key,
  clientEmail: serviceAccount.client_email,
  clientId: serviceAccount.client_id,
  authUri: serviceAccount.auth_uri,
  tokenUri: serviceAccount.token_uri,
  authProviderX509CertUrl: serviceAccount.auth_provider_x509_cert_url,
  clientC509CertUrl: serviceAccount.client_x509_cert_url
}
   
firebase.initializeApp({
  credential: firebase.credential.cert(params),
})

でいけます。

Lambda(Node.js)の開発、テスト、デプロイ

概要

Lambda(Node.js)関数を作成するには、手元でコードを書き、zipにまとめてコンソールでアップするとドキュメントにも書かれていますが、実際の開発のときにはもうちょっと手間のかからないデプロイのやり方が必要です。また、デプロイの前には手元で動作確認やテストも実行したくなります。

今回は、Dockerを使用して手元でLambda関数の開発、テスト、デプロイを行う方法を紹介します。

開発

開発環境構築

開発環境構築はDockerで行います。 Lambdaの実行環境はAWSドキュメントにありますが、この環境になるべく近い開発環境を立てたいので、ドキュメントを参考にDockerfileを作成します。 また、このDockerにデプロイツールのapex、テスト用ライブラリもインストールします。

docker-lambdaというLambda用のDockerイメージもありますが、今回は使用しません。docker-lambdaにはaws-cliなど今回使用しない機能がはいっているのと、作成するDockerfileがそう複雑ではないためです。
Serverless Frameworkというものもありますが、今回は同じく利用しません。リソース管理は別にCloudFormationで行いたいからです。

作成したDockerfileはこちらです。 これをdocker-compose.ymlを使って立ち上げます。

git clone https://github.com/ropupu/lambda-dev-test-deploy-sample.git
cd lambda-dev-test-deploy-sample/
docker-compose build
docker-compose up -d
docker ps

これで、開発用のlambdaコンテナが立ち上がります。

AWSリソース構築

AWSリソースの構築を行います。リソースはCloudFormationを使用して管理します。 今回は例として、図のようにLambdaとSNSを使い、Lambda->SNSにpublishするためのリソースを作成します。 f:id:ropupu-ropupu:20190120121728p:plain

これを構築するCloudFormationファイルはこちらです。

また、このCloudFormationを使ってリソースを構築すると、パラメーター(SNSトピックをサブスクライブするメールアドレス) の入力を求められます。その後リソース構築が完了すると、入力したメールアドレスに『AWS Notification - Subscription Confirmation』というタイトルで確認メールが届くので、本文中の確認リンクを押しておいてください。

コードを書く

.
├── cloudformation.yml
├── docker
│   └── Dockerfile
├── docker-compose.yml
└── lambda
    └── functions
       └── publishSNSMessage
           └── index.js

このようなディレクトリ構成を作り、lambda/functions/publishSNSMessage/index.jsにファイルを作成します。このindex.jsがLambda関数に対応します。コードの中身は以下のようになります。

const AWS = require('aws-sdk');
const sns = new AWS.SNS({apiVersion: '2010-03-31'});

exports.handler = async function(event, context) {
  const snsTopicArn = process.env['SNS_TOPIC_ARN'];
  const subject = "hello";
  const message = "hello from Lambda!";

  const params = {
    TopicArn: snsTopicArn,
    Subject: subject,
    Message: message
  };

  try {
    await sns.publish(params).promise();
  } catch (err) {
    console.log(err);
    throw err;
  }

  return "success!";
};

環境変数からSNSトピックのARNを取得し、そのトピックに『hello』というメッセージを送信する関数です。

初回デプロイ

apex設定

apexを利用してLambdaをデプロイする前に、いくつか設定を行う必要があります。 まず、docker-compose.ymlと同階層に.envファイルを作成します(.env.exampleファイルをコピーして使ってください)。.envファイルには、以下のパラメーターを入力します。

AWS_ACCESS_KEY_ID=AKIAXXXXXXXXX
AWS_SECRET_ACCESS_KEY=xxxxxx
AWS_REGION=ap-northeast-1

ここで、AWS_ACCESS_KEY_ID、AWS_SECRET_ACCESS_KEYは、CloudFormationで作成したIAMユーザー『lambda-deploy-user』のアクセスキーIDとシークレットアクセスキーをAWSコンソールから取得して入力します。入力が終わったら、この設定を反映させるため、docker-composeコマンドを利用してdockerコンテナを再起動させてください。

次に、lambdaディレクトリ内にproject.jsonを作成します。これはapexによるデプロイを行う際の全体設定のためのファイルです。内容はこちらです。

最後に、index.jsと同階層にfunction.jsonを作成します(function.example.jsonファイルをコピーして使ってください)。これはapexによるデプロイを行う際の関数ごとの設定ファイルです。

{
    "description": "publish SNS topic",
    "handler": "index.handler",
    "role": "arn:aws:iam::xxxxxx",
    "environment": {
        "SNS_TOPIC_ARN": "arn:aws:sns:xxxxxx"
    }
}

この『role』と『SNS_TOPIC_ARN』にあたるものは、それぞれCloudFormationで作成したIAMロールとSNSトピックのARNです。コンソールなどで確認し、入力してください。

これらの設定が終わると、プロジェクトのディレクトリ全体はこのようになっています。

├── .env
├── cloudformation.yml
├── docker
│   └── Dockerfile
├── docker-compose.yml
└── lambda
    ├── functions
    │   └── publishSNSMessage
    │       ├── function.json
    │       └── index.js
    └── project.json

デプロイ

docker execを使ってdockerコンテナの中でapexコマンドを実行します。

$ docker exec -it lambda bash
bash-4.2# apex deploy
   • updating config           env= function=publishSNSMessage
   • updating function         env= function=publishSNSMessage
   • created alias current     env= function=publishSNSMessage version=1
   • function updated          env= function=publishSNSMessage name=publishSNSMessage version=1

これでLambda関数がデプロイできました。デプロイした後、apexで関数の実行もできます。

bash-4.2# apex invoke publishSNSMessage
"success!"

"success!"と表示された後、CloudFormationでのリソース構築時に入力したメールアドレス宛メールをチェックしてください。Lambdaから送信されたメールが届いているはずです。

f:id:ropupu-ropupu:20190120155712p:plain

テスト

一旦Lambdaのコード作成、デプロイまでが完了しましたが、コードのテストを行っていません。 ここではテストフレームワークmochaアサーションツールとしてchai、ライブラリのスタブ化を行ってくれるproxyquireを利用してテストを行います。

テスト作成

まず、index.jsと同階層にpackage.jsonを作成します。

{
  "name": "publishsnsmessage",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "devDependencies": {
    "aws-sdk": "^2.290.0"
  }
}

そして、Dockerコンテナに入り、package.jsonをもとにnpm installを行います。

$ docker exec -it lambda bash
bash-4.2# cd functions/publishSNSMessage
bash-4.2# npm install

これは、proxyquireによるライブラリのスタブ化を行う場合、そのライブラリがインストールされている必要があるためです。

次に、index.jsと同階層にtestディレクトリを作成します。その中にtest.jsファイルを作成し、ここにテストコードを書いていきます。 また、test.jsと同じ階層にtest.envファイルも作成します。今回テスト対象であるindex.jsはprocess.envを使用しているため、テスト時に環境変数のセットを行うためです。 test.jsの内容はこちら、test.envの内容はこちらです。

すべての設定が終わると、プロジェクトのディレクトリ全体はこのようになっています。

├── .env
├── cloudformation.yml
├── docker
│   └── Dockerfile
├── docker-compose.yml
└── lambda
    ├── functions
    │   └── publishSNSMessage
    │       ├── function.json
    │       └── index.js
    │       ├── node_modules
    │       ├── package-lock.json
    │       ├── package.json
    │       └── test
    │           ├── test.env
    │           └── test.js
    └── project.json

テスト実行

Dockerコンテナに入り、テスト対象関数(今回はpublishSNSMessage)ディレクトリ内に移動します。そして、mochaコマンドでテストを実行します。

$ docker exec -it lambda bash
bash-4.2# cd functions/publishSNSMessage
bash-4.2# mocha


  publishSNSMessage
    ✓ should return success! when sns publishing succeeds (3526ms)
Error: aws sdk error
    at Object.promise (/var/lambda/functions/publishSNSMessage/test/test.js:25:17)
    at Object.exports.handler (/var/lambda/functions/publishSNSMessage/index.js:16:31)
    at Context.it (/var/lambda/functions/publishSNSMessage/test/test.js:49:25)
    at callFn (/opt/node-v8.10.0-linux-x64/lib/node_modules/mocha/lib/runnable.js:372:21)
    at Test.Runnable.run (/opt/node-v8.10.0-linux-x64/lib/node_modules/mocha/lib/runnable.js:364:7)
    at Runner.runTest (/opt/node-v8.10.0-linux-x64/lib/node_modules/mocha/lib/runner.js:455:10)
    at /opt/node-v8.10.0-linux-x64/lib/node_modules/mocha/lib/runner.js:573:12
    at next (/opt/node-v8.10.0-linux-x64/lib/node_modules/mocha/lib/runner.js:369:14)
    at /opt/node-v8.10.0-linux-x64/lib/node_modules/mocha/lib/runner.js:379:7
    at next (/opt/node-v8.10.0-linux-x64/lib/node_modules/mocha/lib/runner.js:303:14)
    at Immediate._onImmediate (/opt/node-v8.10.0-linux-x64/lib/node_modules/mocha/lib/runner.js:347:5)
    at runCallback (timers.js:794:20)
    at tryOnImmediate (timers.js:752:5)
    at processImmediate [as _immediateCallback] (timers.js:729:5)
    ✓ should throw error when sns publishing fails


  2 passing (4s)

これで、手元でLambda関数の開発、テスト、デプロイを行えるようになりました。

2018年にやったこと[ソフトウェアエンジニアリング関連]

2019年の年始の雰囲気も薄れてきた今になってようやく書く、2018年にやったこととそこから学んだことです。

リストアップしてみるとこうなります。

  • Lambda(Node.js)

    • Lambdaの概要
    • test(mocha, chai, proxyquire)
  • Deploy

    • to S3(CodeBuild, CodePipeline, API Gateway, Microsoft Flow)
    • to Lambda(apex)
    • to DB(db-migrate)
  • E2E test

    • puppeteer
  • Docker

    • ECS
  • Coursera


Lambda

既存のEC系サービスのバックエンドにLambda(Node.js v6)が使われていて、途中からプロジェクトに入り、開発や運用を引き取りました。 Lambdaをちゃんと使ったことがなかったので、Lambdaへのデプロイ方法、ライフサイクル、バージョニングとエイリアス、等Lambdaに関する知識を学びました。

コードについては、最初の段階では処理が共通化されていなかったり、直にSQL文が書かれていたりしたので、モデルを作成してそちらに処理を移すようにしました。 また、テストをmocha, chai, proxyquireを使って書きました。

Deploy

to S3

上記のプロジェクトでは、フロントエンドのコードを手元でwebpackを使ってビルドし、awsコマンドを使って必要なファイルだけをアップ、その後CloudFrontのキャッシュを削除、という手順でデプロイが行われていたので、この手順をCodeBuildを使って自動化しました。 また、このCodeBuildとCodePipeline、それにAPIGatewayとLambdaを組み合わせて、HTTPリクエストを投げるとデプロイが始まるという状態を作りました。これにより、今までエンジニアサイドでのみ可能だったデプロイが他の人でもできるようになりました。デザインのみの修正を行う場合、デザイナーがデザイン・コーディングを行い、その後ボタン(今回はMicrosoft Flowを使っています)を押せば変更が開発環境に反映されるようになりました。また開発環境上で動作の確認をした後、ステージング、本番への反映も可能です。これにより、素早い開発が可能となりました。

to Lambda

Lambdaもコードを手元でzip化してコンソールでアップロードという状態だったので、Apexを使ってデプロイするようにしました。

to DB

データベースの変更はmysqlに接続して直にSQL文を流す状態だったので、db-migrateを導入しました。ライブラリとしては薄めですが、今回のケースには合っていて必要十分という感じです。

E2E test

puppeteerを使ったE2Eテストを作成しました。サンプルコードはこちら

Docker

Dockerを本格的に使い始めました。こちらの本で一通りを学習できてよかったです

Docker/Kubernetes 実践コンテナ開発入門

Docker/Kubernetes 実践コンテナ開発入門

また、ECSを現在進行系で使用中です。Fargateの料金が下がって嬉しい。今はここらへんのデプロイ周りのところに悩んでいます。

Coursera

ちょっと毛色が違いますが、去年からCourseraをはじめました。"Software Design and Architecture"というコースをとりました。これは4つの講座から成り立っていて、"Object-Oriented Design", "Design Patterns", "Software Architecture", "Service-Oriented Architecture"をそれぞれ1ヶ月ずつ使って受けた感じです。 ビデオ講義と、実際に手を動かして提出する課題、それにテストがあり、週末はけっこうこれに費やしてました。 内容はオブジェクト指向デザインパターンアーキテクチャ設計+UMLなどです。 ちゃんとこれらを体系立てて学んでいなかったので、歯抜けになった知識が埋まって、ずーと放っておいた宿題を片付けたような気分です。

去年にやった主なことはこんなかんじですね。もうちょっとちょくちょくまとめていったほうがよさそうです。今年はもうちょいまめにブログを書くことを目標にします。

LeetCode: Binary Tree-3

Solve Tree Problems Recursively

ツリーの問題は、トップダウンもしくはボトムアップ再帰的に解くことができる。 トップダウンのアプローチでは、最初に探索したノードの子ノードを再帰的に探索する。つまり、このようなやり方になる。

  1. 対象ノードが空のノードである場合は終了
  2. 返り値を対象ノードの値で更新する
  3. 左の子ノードに対して同じ関数を適用し、返り値を取得する
  4. 右の子ノードに対して同じ関数を適用し、返り値を取得する
  5. 返り値に3と4の結果を追加し、返り値を返す

例えば、与えられた二分木の深さを知るためには、次のようなやり方で解く。 まず、ルートノードの深さは1である。ノードの深さがわかっていれば、その子ノードの深さもわかる。なので、再帰的に解くとき、親ノードの深さをパラメーターとして渡せば、子ノードの深さもわかり、またその子ノードの深さを知ることができる。深さを知るための関数maximum_depth(root, depth)は以下のような動きをする。

  1. rootが空ならここで終わる
  2. もしrootが空でなければ
  3. answer = このノードの深さとanswerの深い方
  4. maximum_depth(root.left, depth + 1)
  5. maximum_depth(root.right, depth + 1)

ボトムアップのアプローチでは、まず最初にすべての子ノードに対して再帰的に関数を呼び出す。そして返り値と対象ノードの値を比較して答えを導く。つまり、このようなやり方になる。 1. 対象ノードが空のノードである場合は終了 2. 左の子ノードに対して同じ関数を適用し、返り値を取得する 3. 右の子ノードに対して同じ関数を適用し、返り値を取得する 4. 2,3の結果と対象ノードの値から答えを導き、返す

ツリーの深さを知るときには、先程のトップダウンのアプローチとまた違った考え方もできる。左の子ノードからはじまるサブツリーと右の子ノードからはじまるサブツリーの深さがわかっていれば、対象ノードの深さはわかる。これを順々に適用することによって、ツリー全体の深さが得られる。深さを知るための関数maximum_depth(root)は以下のような動きをする。

  1. 対象ノードが空なら0を返す
  2. left_depth = maximum_depth(root.left)
  3. right_depth = maximum_depth(root.right)
  4. 2と3の深さの大きいほうに1を足した値を返す

結論

再起を理解し、またそれを問題に適用するやり方を見つけるのは難しい。 ツリーの問題にあたったとき、次の二つの問を自分に投げかけるといい。 - ノードに対していくつかのパラメーターを与えることで、そのノードから答えを得られるか? - 子ノードに何のパラメーターを渡すのかについて、定義されたパラメーターもしくは対象ノード自身の値から算出することはできるか? 両方を満たすなら、トップダウン・アプローチを使うことができる。

もし、対象ノードの子ノードから導かれる答えがわかっていれば、対象ノードの答えがわかるか?これが満たされるなら、ボトムアップ・アプローチを使うことができる。


コードはこちら

LeetCode: Binary Tree-2

Level-order Traversal 概要

Breadth-First Search(幅優先探索)は、ツリーやグラフのようなデータ構造を探索するアルゴリズム。 探索はルートからはじまり、そのルート自身をまず訪問済みにする。次に、隣接している階層のノードを探索し、訪問済みとする。その階層のノードすべてを訪問済みにしたら、その下の階層のノードを探索し、訪問済みとする。これを続ける。 ノードの探索順は、階層順になる。(ルート→第二階層のノード→第三階層のノード→...となる)

幅優先探索のためにキューを使うのが特徴的である。(訪問済みのノードをキューにためておく)