ECS + Fargate + gRPCを使ったマイクロサービス構成
バックエンドサーバは機能毎にマイクロサービスとして分割し、サーバ間通信には gRPC を使ってモダンな感じにしたい。
最初のインフラ構想
まず Node.js アプリケーションをコンテナベースにして、Code Pipeline + CodeBuild + ECR + ECS + Fargate で継続的デプロイ&オートスケールする仕組みを作った。
だがうまくいかなかった。バックエンドの gRPC でサーバ間通信を行う部分で Unavailable
, transport is closing
というエラーになってしまう。
この ALB を使って ECS タスクに対して負荷分散させる構成は gRPC を使わずに REST API でならうまくいった。
HTTP/2 の gRPC は ALB と相性が悪い
gRPC は HTTP/2 が必須となり、ALB も HTTP/2 に対応しているが、それはフロントエンドのみ。ALB によってバックエンドに転送する場合は強制的に HTTP/1.1 になってしまい、gRPC としての通信が失敗してしまう。
じゃあ ALB を諦めて CLB にするしかないか…と変更しようとしたが、ECS + Fargate を使う場合、ECS のネットワークモードが awsvpc
固定となり、ロードバランサーには ALB 以外を選ぶことができない。
つまり gRPC の負荷分散のために CLB を使うなら Fargate は諦めて ECS + EC2 で運用するということになり、インスタンス管理コストの低下という大きなメリットを享受できなくなってしまう。
それだったら gRPC は諦めて Swagger(OpenAPI) で REST API にするしかないかなぁという雰囲気になりつつあった。
ECS サービスディスカバリを使うことで解決
いろいろ検討した結果、ECS サービスディスカバリが使えるのではないかと教えてもらった。
ECS でクラスターとサービスを作ると、サービス名が Route53 で自動的に 複数値回答レコードとしてドメイン登録され、ECS タスクの数だけ IP アドレスが割り振られる。
例えば game-server
というサービス名にし、3 つのタスク(コンテナ)を起動した場合は 3 つの内部 IP アドレスそれぞれに game-server.local
というドメイン名が割り当てられ、同一 VPC 内であればアクセスできるようになる。
Route 53
Name | Type | Value |
---|---|---|
game-server.local. | A | 10.0.0.1 |
game-server.local. | A | 10.0.0.2 |
game-server.local. | A | 10.0.0.3 |
複数値回答レコードはドメイン game-service.local
にアクセスすると、最大 8 つの IP アドレスをクライアントに返す。上記の場合は 10.0.0.1
, 10.0.0.2
, 10.0.0.3
の 3 つのアドレスが返る。
試しに同じ VPC に AWS Cloud9 を立ち上げ、ターミナルから dig
コマンドを試してみると次のような結果となる。
$ dig +short game-service.local 10.0.0.1 10.0.0.2 10.0.0.3
なお、Route53 側で Hosted zones の local.
にチェックをいれ、Associated VPC に対象の VPC が含まれていないと参照できないので注意。
あとはクライアント実装次第となるが、通常はラウンドロビンされた値が使われることになる。curl
や wget
だと 1 番目の値しか使われなかったが HTTP Client ライブラリの axios はラウンドロビンされて使われていた。
ECS サービスディスカバリとヘルスチェック
ECS のタスク定義で Docker コンテナの HEALTHCHECK を指定できる。
ヘルスチェックが失敗するとタスクは異常と判定し、ECS はタスクを終了してから新しいタスクを自動起動してくれる。
これが ECS サービスディスカバリとも連携してくれるので、タスクが終了すると同時に、タスクの内部 IP アドレスに割り当てられた game-server.local
も削除され、ルーティングされなくなるのでとても便利。
Nest.jsでgRPCを使ったマイクロサービス
Nest.js ではアプリケーションサーバとして動かすだけでなく、同時に gRPC として動かし、別のマイクロサービスと通信ができる設計になっている。
main.ts
(async () => { const app = await NestFactory.create(AppModule); const protoDir = join(__dirname, '..', 'protos'); app.connectMicroservice({ transport: Transport.GRPC, options: { url: '0.0.0.0:5000', package: 'rpc', protoPath: '/rpc/rpc.proto', loader: { keepCase: true, longs: Number, defaults: false, arrays: true, objects: true, includeDirs: [protoDir], }, }, }); await app.startAllMicroservicesAsync(); await app.listen(3000); });
普通のアプリケーションサーバとして動かす必要がないなら await app.listen(3000);
をコメントアウトする。
マイクロサービスなので単一の .proto
ファイルを読み込むレベルのシンプルなもので事足りると思うが、複数 proto を読み込みたい場合は includeDirs
でディレクトリを指定すればよい。ここは Nest.js 公式のサンプルになく、Issue あげたら教えてもらえた。この部分は @grpc/proto-loader が使われている。
rpc.controller.ts
Controller はデコレーターで gRPC のサービス名とメソッド名を指定する方式。
リクエスト/レスポンスの型情報は公式の grpc-tools
を使うより protobuf.js
で生成した方が JavaScript オブジェクトをそのまま型変換できて便利。
@Controller() export class RpcController { constructor(private readonly championService: ChampionService, private readonly battleFieldService: BattleFieldService) {} @GrpcMethod('Rpc', 'GetChampion') async getChampion(req: GetChampionRequest): Promise<GetChampionResponse> { const obj = this.championService.getChampion(req.champion_id); return GetChampionResponse.create({champion: obj}); } @GrpcMethod('Rpc', 'ListChampions') async listChampions(req: IEmpty): Promise<ListChampionsResponse> { const champions = this.championService.listChampions(); return ListChampionsResponse.create({champions}); } @GrpcMethod('Rpc', 'GetBattleField') async getBattleField(req: IEmpty): Promise<rpc.GetBattleFieldResponse> { const battleField = this.battleFieldService.getBattleField(); return GetBattleFieldResponse.create({battle_field: battleField}); } }
ここで必要なのは単純な型情報だけなので、自分で get-champion-request.dto.ts
みたいな interface を定義して使ってもいい。リクエストに含まれるパラメータが Nest.js が自動で格納するので、req.champion_id
のように参照することができる(keepCase: false
にするとキャメルケースになる)。
rpc.proto
protobuf のサービス定義はこんな感じ。サンプルでしかないけど League of Legends のチャンピオン取得ができる API みたいなのを想定している。
service Rpc { rpc GetChampion (GetChampionRequest) returns (GetChampionResponse); rpc ListChampions (Empty) returns (ListChampionsResponse); rpc GetBattleField (Empty) returns (GetBattleFieldResponse); }
疎通テスト
疎通確認には golang の gRPCurl を使うと便利。
$ grpcurl -d '{"champion_id": 1}' -plaintext -proto ./rpc/rpc.proto -import-path ./protos 127.0.0.1:5000 rpc.Rpc/GetChampion { "champion": { "championId": 1, "type": "ASSASSIN", "name": "Akali", "message": "If you look dangerous, you better be dangerous." } }
フルソースコード
Node.js の gRPC サンプルはあまりないので参考になるとよいです。
Node.jsでRedis使うならioredisがおすすめ
TL;DR
- まだ node_redis を使ってる人が多いけど標準で Promise 対応してなくてレガシー
- ioredis
- 使い方はほぼいっしょ
- 標準で Promise 対応してるので async/await でそのまま書ける
- Cluster, Sentinel, LuaScripting 含めたフル機能が使える
開発者の Luin さん
使い方
$ yarn install ioredis
async/await
もちろん従来の callback 方式でも書けるんだけど、async/await 覚えちゃうと callback には戻りたくない。
const Redis = require('ioredis'); (async () => { const redis = new Redis(); const pong = await redis.ping(); console.log(pong); // => PONG redis.disconnect(); })();
ちなみに node_redis で async/await する場合
公式から引用。promisify
をかまさないといけないのが非常にだるい。
const {promisify} = require('util'); const redis = require("redis"); const client = redis.createClient(); const getAsync = promisify(client.get).bind(client); (async () => { const res = await getAsync('foo'); console.log(res); })();
TypeScript で使う場合
$ yarn install ioredis $ yarn install -D @types/ioredis
型情報が必要になるので IORedis
として import したほうがわかりやすいと思う。
import * as IORedis from 'ioredis'; export class Sample { private readonly redis: IORedis.Redis; constructor(options?: IORedis.RedisOptions) { this.redis = new IORedis(options); } async echo(message: string): string { return await this.redis.echo(message); } }
ランキングを実装してみる
ioredis を使ったデイリーランキングの実装例。
import * as IORedis from 'ioredis'; import {DateTime} from 'luxon'; import {RankingUser} from './ranking-user'; import {UserDto} from './user-dto'; import {RankingUtil} from './ranking-util'; export class DailyRanking { private readonly redis: IORedis.Redis; constructor(options?: IORedis.RedisOptions) { this.redis = new IORedis(options); } // e.g. RANKING_DAILY_20181016 static createKey(): string { return DateTime.utc().toFormat("'RANKING_DAILY_'yyyyMMdd"); } update(user: RankingUser, score: number): void { const key = DailyRanking.createKey(); const dto: UserDto = {name: user.name, grade: user.grade}; // ignore userId, score const json = JSON.stringify(dto); this.redis.zadd(key, `${score}`, `${user.userId}:${json}`); } async listByHighScore(limit: number): Promise<RankingUser[]> { const key = DailyRanking.createKey(); const max = '+inf'; const min = '-inf'; const args = ['LIMIT', '0', `${limit}`, 'WITHSCORES']; const result = await this.redis.zrevrangebyscore(key, max, min, ...args); const users: RankingUser[] = []; for (let i = 0, len = result.length; i < len; i++) { if (i % 2 === 1) { const member = result[i - 1]; const score = result[i]; const user = RankingUtil.createRankingUser(member, score); users.push(user) } } return users; } async getByUserId(userId: number): Promise<RankingUser> { const key = DailyRanking.createKey(); const args = ['MATCH', `${userId}:*`]; const [cursor, result] = await this.redis.zscan(key, 0, ...args); const [member, score] = result; return RankingUtil.createRankingUser(member, score); } close(): void { this.redis.disconnect(); } }
Redis の SortedSet では key
, member
, score
の 3 つを格納してスコアを元に順位付けする。
一般的には member
にユーザIDだけ入れて取得した後で RDB から最新データを取ってくる実装が多いと思うけど、上記コードではユーザ情報を JSON 文字列として Redis のなかに格納してしまう方式。
member
の接頭辞を <user_id>:
としておくことで zscan
使えばユーザIDで取得することもできる。
ES6 以降の数値⇒文字列変換
ちなみに member
も score
も文字列で格納しなければいけないけど、数値変換する場合は Template String 使うのが一番高速。
const s1 = `${score}`; // Fast!! const s2 = score + ''; const s3 = String(score); const s4 = score.toString(); // 暗黙の型変換が行われるので一番遅い
まぁ for 文で 10,000 回転とかしなければ差異ないけど。