この記事を書いている背景
ステージング環境上でgRPCサーバの動作確認をしている中で、踏み台サーバからgrpcurlを実行するとリクエスト成功するのですが、システム間のgRPCリクエストが通らなくて困りました。
その原因自体は大したものではないのですが、もう少し突き詰めていくと面白い内容だったので、その時調べたことなどを残しておきます。
主にインフラ寄りの話になります。
インフラ前提
ECS/FargateでgRPCクライアントとgRPCサーバは別クラスタです。
システム(クラスタ)間の負荷分散はNLBを使用しています。
なぜ、NLBかというと、ALBはリスナーしかHTTP2対応しておらず、ターゲットはHTTP1.1のため、負荷分散できません。
NLBならL4なので負荷分散できます。
下はインフラ構成概要図となります。
System1はRESTの受け口を持ち、System2のgRPCクライアントでもあります。
踏み台サーバからNLB経由でgrpcurlなどのクライアントツールを利用し、System2のgRPCサーバのAPIを実行することもできます。
システム間連携の失敗原因
単なるチーム間のコミュニケーションミスです。
バックエンドチームは、平文通信を想定して実装していました。
以下の通り、WithInsecure関数を利用。言語はGoです。
grpc.Dial(fmt.Sprintf("%s:%s", os.Getenv(gRPCHostEnvKey), os.Getenv(gRPCPortEnvKey)), grpc.WithInsecure())
一方SREチームは、暗号化通信を想定し、NLBのリスナーをssl/tlsのみに絞っていました。
平文用のリスナーに変更したところ、システム間でgRPCリクエストが成功しました。
内部通信なので平文でもセーフです。
本題(ALPNを巡る)
ここからが面白いところです。
上記の原因を発見するまでに、色々寄り道をして、「ALPN」という単語を知りました。
今回は上述の通り、ALPNは直接的な問題になり得なかったのですが、今後のことも考えて色々調べました。
ALPNとは
ALPN(Application-Layer Protocol Negotiation)とは、プロトコルネゴシエーションを行うためのTLS拡張です。
クライアントは自身が使用可能なプロトコル一覧をサーバに渡し(ClientHello)、サーバ側はその中から選択し(ServerHello)、TLSハンドシェイクが完了。あとはその上で、通信します。
TLS上でのプロトコルネゴシエーションの仕組み、NPNとALPN
HTTP2でSSL/TLSを使う場合は、NPNかALPNのいずれかを使用します。
HTTP/2 プロトコルネゴシエーション方法と ATS での実装
ただ、NPNよりもALPNの方が本命のようです。
理由はこちらです。
NPNではクライアントがプロトコルを選択するが、ALPNではサーバがプロトコルを選択する。サーバ側が選択権を持つのは他のセキュリティ技術(暗号種類の決定等)で行われているやり方なので、そのポリシーに従う。
NPMは3回クライアント、サーバ間でやり取りが発生するのに対し、ALPNは2回で済むので効率的である。
HTTP/2.0のALPN利用に伴うSSL負荷分散装置の不具合にご注意下さい
NLBはALPNに対応していない?
NLBはALPN未対応のようです。
gRPC と HTTP/2 と ALPN
別プロジェクトで実際に検証した結果からも、NLBがALPN未対応であることが分かりました。
grpc-goはALPNに対応していない?
仮にNLBのリスナーがssl/tlsのみだとして、grpc-goのWithTransportCredentials関数を利用してtls有効でgRPCリクエストを実行するとどうなるのでしょうか?
// WithTransportCredentials returns a DialOption which configures a connection
// level security credentials (e.g., TLS/SSL). This should not be used together
// with WithCredentialsBundle.
func WithTransportCredentials(creds credentials.TransportCredentials) DialOption {
return newFuncDialOption(func(o *dialOptions) {
o.copts.TransportCredentials = creds
})
}
時間が足らず、試せていなくて恐縮なのですが、おそらく成功します。
理由は、grpc-goがALPNに未対応だからです。
issueはこちら。
https://github.com/grpc/grpc-go/issues
java-grpcの開発者がissueを立てているあたり、JavaはALPN対応済みなのでしょう。
また、Node.jsもALPN対応済みのようですね。
grpcurlはALPNに対応していない?
grpcurlのオプション
grpcurlにはtls関連で3つ選択肢があります。
- plaintext(tls無効方式)
- insecure(形式としてはtls有効だけど、証明書の中身は検証しない方式)
- オプション無し(tls有効方式)
-plaintext
Use plain-text HTTP/2 when connecting to server (no TLS).
-insecure
Skip server certificate and domain verification. (NOT SECURE!) Not
valid with -plaintext option.
蛇足ですが、grpcurlの「insecure」はtls有効だけど、go-grpcの「grpc.WithInsecure」はtls無効で、同じ「Insecure」でも意味がことなるため、少し混乱します。
開発団体が全然違うからしょうがないけど。
実行結果
insecureオプションあるいはオプション無しのどちらも(つまりtls有効)リクエスト成功しました。
NLBがALPN未対応で、grpcurlがALPN対応ならば、これらは成功しないはずです。
つまり、grpcurlはALPN未対応なのかと考えました。
ところが、issueあげてみたところ、grpcurlはALPN対応とのことでした。
grpcurl(クライアント)はALPNを使うように要求するが、grpc-go(サーバ)がALPN未対応のため、ALPN自体は成功しない。
ただし、grpc-goの仕様によりTLSハンドシェイクは成功するため、tlsでお話ができるようになるようです。
https://github.com/fullstorydev/grpcurl/issues/125
振り返り
今回、内部通信なのでssl/tlsでなくても良いし、たとえssl/tls有効必要な外部通信だとしても、grpc-goならALPN未対応だからNLBでも問題なさそうだということがわかりました。
しかしながら、ALPN対応のgrpcライブラリが用意されているJavaやNode.jsなどの言語で、かつ外部通信用にgRPCのAPIを公開(NLB使う)する方式だと苦しいなと思いました。
この場合は、EnvoyやNginxにTLS終端させて、ACMは諦めるという方向になるでしょうか。。。
通信通ったからいいやではなく、気になったところをどんどん調べていくことで勉強になりました。
最後に、この問題に付き合って色々調べてくれた弊社SREメンバのいっちーさんに感謝です。