ECSでService Discoveryを使ってみよう


皆さんこんにちは!
シュークリーム大好きエンジニアのくろちゃんです。

こちらの記事は、Applibot Advent Calendar 2020の23日目の記事です!
昨日は @h_km さんのUnityプロジェクトにおけるSmartBeatの活用例紹介でした。是非チェックしてみてくださいね!

背景

11月と12月の2ヶ月間、株式会社アプリボットさんで内定者アルバイトをしていました。
そのうち、初めの1ヶ月間はゲームの新規タイトル開発プロジェクトのサーバサイド開発チームでお世話になっていました。

そこで一任していただいたタスクを実装した経験を元に、ECSでサービスディスカバリを使うための実装方法についてアウトプットしようと思います!

プロジェクトが抱えていた課題について

僕がジョインしたプロダクトでは、Terraformを使ってECS on EC2で構築されていました。

構成としては、1つのService内にAPP側のコンテナとRedisコンテナが共存しているような構成になっており、下記の画像のような構成でAPIサーバが稼働していました。

このような構成では下記のような課題が生まれてしまいます。

  • RedisがEFSからデータをフェッチするのに遅延が発生すると、タイミングによってはApp側がNot Ready状態のRedisにアクセスしようとしてしまうため、タスク自体がエラーとして処理され再起動されてしまう
  • そもそもApp側のデプロイがされる度に新しいRedisを立ち上げる必要がない

どうしたか?

今回の場合は、データストア領域であるRedisコンテナがApp側のコンテナと同じタスク(もっというと同じサービス)内に存在している事が大きな問題となってしまっていました。

そこで単純ではありますが、RedisコンテナをApp側と完全に分離して別サービスに切り出すという対応をしました。
イメージとしては、こんな感じになります。

このような構成にする事で、Redis単体でヘルスチェックができるようになり、App側がデプロイされる度に新しいRedisが立ち上がるという大きなコストを払わなくて済むようになります。

ECS Service Discoveryの導入

ここからが本題になるのですが、今回のようにECSサービス間で通信をする場合にはService Discoveryという機能を使う必要があります。

Service Discoveryを使う事で、ECSのサービス単位に他サービスがアクセスするためのDNSが登録され、その名の通りサービス間の通信をよしなにやってくれるようになります。

ただし、対象となるECSサービスのタスク定義でネットワークモードがhostまたはbridgeを使用している場合はAWSドキュメントにも書かれている通り、AレコードではなくSRVレコードが登録されます。

サービスタスクで指定されたタスク定義が bridge または host ネットワークモードを使用する場合、SRV のレコードのみがサポートされる DNS レコードタイプです。各サービスタスクの SRV レコードを作成します。SRV レコードのコンテナ名とコンテナポートの組み合わせをタスク定義から指定する必要があります。

こちらのSRVレコードの扱い方が特殊で、アプリケーションの実装側で対応しなければならないという事実に気づかず、てんやわんやしてしまいましたw

SRVレコードというもの自体初めて触れた概念だったので、苦戦を強いられましたが最終的にはアプリケーション側でどのように実装すれば良いのか理解して実装する事ができたので、具体的なコード例を示しながら説明していきたいと思います!

SRVレコードを元にサービス検出する実装方法

まず前提知識をお伝えします。
今回僕が働いていたプロジェクトでは、開発言語にGolangを用いており、Dev環境・Product環境のように、環境ごとにRedisへの接続情報を切り替えて実装しているという状況でした。

そのため、今から出てくるコード例もあくまでGo言語の場合はこうやって実装するというモノになっていますので予めご了承ください。

SRVレコードはアプリケーション側でホスト解決をしてあげる必要があり、MongoDBなどのようにパッケージ側でSRVレコード検出を自動で行ってくれるモノも多く存在しています。

mongo-example.go
clientOpts := options.Client().ApplyURI("mongodb+srv://mongodb.example.com")
client, err := mongo.Connect(context.TODO(), clientOpts)
if err != nil {
    log.Fatal(err)
}
_ = client

しかし今回改修対象となったRedisにはそのような機能は組み込まれておらず、下記のように自前でDialerを設定してあげる必要がありました。

redis-srv.go
&redis.RedisConfig{
    UniversalOptions: GoRedis.UniversalOptions{
        Addrs:  []string{fmt.Sprintf("%s-redis.dev-ecs", cfg.Env)},
        Dialer: createDevRedisDialer,
        ...(中略
    },
}

// ----

func createDevRedisDialer(ctx context.Context, network, addr string) (net.Conn, error) {
    _, addrs, err := net.LookupSRV("", "", addr)
    if err != nil {
        return nil, exception.Stack(err)
    }
    if len(addrs) == 0 {
        return nil, exception.New(exception.UnExpected, "SRVレコードが1件も見つかりませんでした")
    }

    // サービスディスカバリに登録するのはRedisサービスのみであるためインデックスは0で固定
    const redisSrvRecordIndex = 0
    return net.Dial(network, fmt.Sprintf("%s:%d", addrs[redisSrvRecordIndex].Target, addrs[redisSrvRecordIndex].Port))
}

まずは、RedisのOptionsで設定しているAddrsについて説明します。
Service Discoveryを使う場合は、

[Service Discoveryで設定した名前].[DNS名前空間名]

というようなフォーマットで文字列を渡してあげる必要があります。

Addrsで指定したアドレスが、Dialerに指定しているcreateDevRedisDialer()addr(第3引数)にコールバック関数のように代入されます。(ちなみにnetworkには何が入ってくるかというと、"tcp"などのような使用したプロトコル情報が入ってきます)

addrを受け取ったcreateDevRedisDialer()では何をしているのかというと、

  1. netパッケージのLookupSRV()に受け取ったaddrを受け渡す事で、登録されているSRVレコードの一覧を取得している
  2. 取得したSRVレコードから該当のレコードを抽出(今回はRedisサービス1つのみを登録しているので必ずIndex番号=0)
  3. net.Dial()を使ってSRVレコードから抽出したTarget(ホスト):Port(ポート番号)に対してコネクションを張りに行く

こんなことをしています。

今回はRedisを取り上げてSRVレコードへの接続方法について見てきましたが、基本的な流れは一緒になると思うので、net.LookupSRV()を使ってSRVレコードを取得し、該当のサービスのレコードを取得してnet.Dial()するという実装を自前で用意してみてくださいね!

おわりに

ここまで読んでいただきまして、ありがとうございました!

ECSでService Discoveryを使う上で、SRVレコードというものを避けて通れない場面が今後も出てくるかと思いますが、自分自身今回実装した経験を忘れないようにしたいなと思ったので今回記事にできてよかったなと感じています。

Applibot Advent Calendar 2020 次回は @Sigsiguma さんです!お楽しみに!!!

それでは、良いクリスマスとお年をお迎えください!ではっ