尋常でないもふもふ

a software engineer blog

ECS + Fargate + gRPCを使ったマイクロサービス構成

バックエンドサーバは機能毎にマイクロサービスとして分割し、サーバ間通信には gRPC を使ってモダンな感じにしたい。

最初のインフラ構想

まず Node.js アプリケーションをコンテナベースにして、Code Pipeline + CodeBuild + ECR + ECS + Fargate で継続的デプロイ&オートスケールする仕組みを作った。

f:id:jnst:20181115172020p:plain

だがうまくいかなかった。バックエンドの 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 としての通信が失敗してしまう。

f:id:jnst:20181115172030p:plain

じゃあ ALB を諦めて CLB にするしかないか…と変更しようとしたが、ECS + Fargate を使う場合、ECS のネットワークモードが awsvpc 固定となり、ロードバランサーには ALB 以外を選ぶことができない。
つまり gRPC の負荷分散のために CLB を使うなら Fargate は諦めて ECS + EC2 で運用するということになり、インスタンス管理コストの低下という大きなメリットを享受できなくなってしまう。
それだったら gRPC は諦めて Swagger(OpenAPI) で REST API にするしかないかなぁという雰囲気になりつつあった。

ECS サービスディスカバリを使うことで解決

いろいろ検討した結果、ECS サービスディスカバリが使えるのではないかと教えてもらった。

ECS でクラスターとサービスを作ると、サービス名が Route53 で自動的に 複数値回答レコードとしてドメイン登録され、ECS タスクの数だけ IP アドレスが割り振られる。

f:id:jnst:20181115181645p:plain

例えば 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 つのアドレスが返る。

試しに同じ VPCAWS 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 が含まれていないと参照できないので注意。

あとはクライアント実装次第となるが、通常はラウンドロビンされた値が使われることになる。curlwget だと 1 番目の値しか使われなかったが HTTP Client ライブラリの axiosラウンドロビンされて使われていた。

ECS サービスディスカバリとヘルスチェック

ECS のタスク定義で Docker コンテナの HEALTHCHECK を指定できる。
ヘルスチェックが失敗するとタスクは異常と判定し、ECS はタスクを終了してから新しいタスクを自動起動してくれる。
これが ECS サービスディスカバリとも連携してくれるので、タスクが終了すると同時に、タスクの内部 IP アドレスに割り当てられた game-server.local も削除され、ルーティングされなくなるのでとても便利。

Nest.jsでgRPCを使ったマイクロサービス

Nest.js ではアプリケーションサーバとして動かすだけでなく、同時に gRPC として動かし、別のマイクロサービスと通信ができる設計になっている。

docs.nestjs.com

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);
}

疎通テスト

疎通確認には golanggRPCurl を使うと便利。

$ 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 さん

  • 元 Alibaba のエンジニア
  • Redis の GUI ツール Medis も開発

使い方

$ 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で取得することもできる。

フルソースコードはこちら => GitHub

ES6 以降の数値⇒文字列変換

ちなみに memberscore も文字列で格納しなければいけないけど、数値変換する場合は Template String 使うのが一番高速。

const s1 = `${score}`; // Fast!!
const s2 = score + '';
const s3 = String(score);
const s4 = score.toString(); // 暗黙の型変換が行われるので一番遅い

まぁ for 文で 10,000 回転とかしなければ差異ないけど。