freeeでSREをしている河村(at-k)です。
freeeでは、既存・新規サービスのマイクロサービス化を推進しており、効率的なマイクロサービスの運用を実現するためにKubernetesを積極活用しています。Kubernetesはコンテナのオーケストレーションツールであり、コンテナ化されたマイクロサービスを管理・運用していく上で大きな効果が期待されます。
Kubernetesでは、複数のノード(例えばAWS EC2 Instance)を組み合わせてクラスタを構成し、そのクラスタ上にコンテナが指定された構成(manifest)で配置されます。Kubernetesはコンテナ構成を自律的に維持する機能を持ち、運用コストや耐障害率を改善します。また、クラスタに配置されたサービスに対しては、具体的などのノードにコンテナをスケールするか、といった詳細にとらわれることなく、宣言的にサービスを定義し、細かな運用はKubernetesにまかせるといったことができます。本稿では後者の、Kubernetes内サービスを外部に公開する手法について行った検証を解説します。
Kubernetesクラスタ内のサービスを公開する方法はいくつかありますが、最近社内で検証を行ったIngress Controller、特にNGINX Ingress Controllerについて紹介し、導入方法及び、カスタマイズ方法としてgRPC通信を有効にする手順を解説します。他の方法としては、例えばIstioや、envoyを利用する、ということも考えられましたが、freeeではNGINXの本番運用経験が豊富なことと、Ingress以外でもKubernetes上でNGINXを利用するあてがあったこと、envoyでないとできないというクリティカルな要件が「まだ」なかったこと、Ingressの実装としてはNGINX Ingress Controllerが一番こなれている(機能の豊富さとメンテナのアクティブさ、本番事例)といった点を考慮しました。
Kubernetesについてある程度の基本的知識を想定していますが、公式が提供しているTutorials - Kubernetesがよくまとまっており、1時間程度でキャッチアップ出来ますので、興味があればこの機会にいかがでしょうか。
Minikubeを使ったサービスの公開
まず例として、Minikubeを使った公式チュートリアル(Hello Minikube - Kubernetes)をベースに、サービスの外部公開まで実施します。MinikubeのインストールとImageの作成までは省略しますが、以下のコマンドで指定しているhello-nodeは、上記の公式チュートリアルで作成した、”Hello World!” を返すWeb ServerのImageです。
> kubectl run hello-node --image=hello-node:v1 --port=8080 > kubectl expose deployment hello-node --type=LoadBalancer > kubectl get svc NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE hello-node LoadBalancer 10.96.19.86 <none> 8080:32623/TCP 1m > minikube service hello-node --url http://192.168.99.100:32623 > curl http://192.168.99.100:32623 Hello World!
LoadBalancer Type(NodePort Typeでも可)のServiceを作成し、Service毎に割り当てられたポート(例では32623)を通してアクセスします。この様な方法は、サービスがどこで動いているのか一見してわかりやすく感じます。しかし、サービスを追加するたびに、未使用portを払い出したり、portへのネットワーク疎通を確保するなどのKubernetesの外側のインフラを別途整備する必要があります。公開するサービスが少なければ良いですが、数が増えてくると煩雑になります。
参考
Kubernetes NodePort vs LoadBalancer vs Ingress? When should I use what?
Services - Kubernetes Ingress - Kubernetes
Nginx Ingress Controllerを用いたサービス公開
そこで、次に紹介するIngressとIngress Controllerを用いて、それらの課題の解決を図ります。Serviceの前段にIngressを配置する構成で、それらのIngressに対し、ルーティングを行うIngress Controllerがクラスタ内に最低1つ置かれます。Ingress ControllerはInboundトラフィックに対し、要求ホスト名とIngressで公開されるホスト名を突き合わせてルーティングを行います。先程の例と比較すると以下の図の様になります。
詳しくは実際の動作を見ていただくほうがわかりやすいかと思うので、ここからIngress Controllerの導入と動作例を見ていきます。Ingress Controllerとして利用できるものはいくつかあるようですが、ここではNGINX Ingress Controllerを試しました。Kubernetesが公式に開発していること、NGINXベースで高いスケーラビリティが期待できること、また、gRPCに対応していること、が採用理由です。
標準設定でのインストール
helm chartが用意されているので、標準的設定であればクラスタへのインストールは比較的容易です。helmはKubernetesにおけるパッケージ管理ツールで、パッケージの検索、インストール、標準設定からのカスタマイズ、設定のrevision管理、などの便利な機能が提供されています。ここでは詳細は省略します。
まずはinstall。
> helm search nginx-ingress NAME CHART VERSION APP VERSION DESCRIPTION stable/nginx-ingress 0.20.1 0.14.0 An nginx Ingress controller that uses ConfigMap... > helm upgrade --install nginx-ingress stable/nginx-ingress > helm list NAME REVISION UPDATED STATUS CHART NAMESPACE nginx-ingress 1 Sun Jun 17 22:44:48 2018 DEPLOYED nginx-ingress-0.20.1 default
helm installで何が行われるかは、以下のコマンドを実行することで、具体的にどういったmanifestが生成・適用されるかを確認することが出来ます。
> helm upgrade --install nginx-ingress stable/nginx-ingress --dry-run --debug | grep Source ... # Source: nginx-ingress/templates/controller-service.yaml # Source: nginx-ingress/templates/default-backend-service.yaml # Source: nginx-ingress/templates/controller-deployment.yaml # Source: nginx-ingress/templates/default-backend-deployment.yaml ...
Service/Deploymentがそれぞれ2つ宣言されていることがわかります。1つはIngress Controller本体、もう一つのdefault backendは、定義されていないhost nameでアクセスされた場合に用いられるServiceです。kubectlで配置されたServiceを見てみます。
> kubectl get svc NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE nginx-ingress-controller LoadBalancer 10.103.136.223 <pending> 80:30774/TCP,443:31153/TCP 35m nginx-ingress-default-backend ClusterIP 10.106.62.223 <none> 80/TCP 35m
動作検証
続いて、ingressの設定を行います。
> kubectl expose deployment hello-node --type=ClusterIP --name hello-node-svc --port 8080 service "hello-node-svc" exposed > cat << EOF | kubectl create -f - pipe heredoc> apiVersion: extensions/v1beta1 kind: Ingress metadata: name: hello-world spec: rules: - host: hello-world http: paths: - path: / backend: serviceName: hello-node-svc servicePort: 8080 EOF > kubectl get ingress NAME HOSTS ADDRESS PORTS AGE hello-world hello-world 80 1m
このとき、ingress controllerのlogを見ていると以下のようにingressの追加が自動で検出され、設定が反映されたことがわかります。
> kubectl logs nginx-ingress-controller-786dc4f648-zlcqz -f I0617 14:14:29.430140 6 event.go:218] Event(v1.ObjectReference{Kind:"Ingress", Namespace:"default", Name:"hello-world", UID:"bfa92545-7238-11e8-b043-080027992c84", APIVersion:"extensions", ResourceVersion:"995305", FieldPath:""}): type: 'Normal' reason: 'CREATE' Ingress default/hello-world I0617 14:14:29.546514 6 controller.go:177] ingress backend successfully reloaded...
それではpodにアクセスします。
> minikube service nginx-ingress-controller --url http://192.168.99.100:30774 http://192.168.99.100:31153 > sudo sh -c 'echo 192.168.99.100 hello-world >> /etc/hosts' > curl http://hello-world:30774 Hello World!
Ingressに設定したhost nameを使うことで、hello world podにアクセス出来ました。
同じ要領で、"Hello World!"の代わりに"Hello Ingress!"と返すServiceを作ります。ImageとDeploymentの作成は省略、hello-ingress-nodeというDeploymentを作成したとします。
> kubectl expose deployment hello-ingress-node --type=ClusterIP --name hello-ing-node-svc --port 8080 service "hello-ing-node-svc" exposed > cat << EOF | kubectl create -f - pipe heredoc> apiVersion: extensions/v1beta1 kind: Ingress metadata: name: hello-ingress spec: rules: - host: hello-ingress http: paths: - path: / backend: serviceName: hello-ing-node-svc servicePort: 8080 EOF > sudo sh -c 'echo 192.168.99.100 hello-ingress >> /etc/hosts' > curl http://hello-ingress:30774 Hello Ingress!
hello-worldとhello-ingressは、host nameは異なりますが、どちらも同じIPを指しており、portも同じなので、ネットワーク的には同一のものを参照しています。host nameを元にIngress Controllerが振り分けを行っています。
この様に、Ingress Controllerを配置しておけば、新しいサービスが追加される際に、公開ホスト名を定義したIngressを設定することで自動でサービスまでの動線を整備してくれます。これらの作業はKubernetes内で完結しているため、クラスタ内・外での責任範囲が明確化され、運用の簡易化・権限委譲を効率的に進めることが出来ます。 なお、ホスト名をDNSに登録する必要がありますが(例では/etc/hostsに登録)、こちらについてもexternal-dnsを使うことで、例えばAWSではIngressの追加時にRoute 53に自動設定することが出来ます(今回はMinikubeでの検証なので省略します)。
参考
Kubernetesのexternal-dnsでRoute53 RecordSetを自動作成する - Qiita
external-dns/aws.md at master · kubernetes-incubator/external-dns
カスタマイズ - gRPC用追加設定
デフォルトの設定でもおおよその目的は達せられましたが、実際の運用では設定をカスタマイズしたくなることもあると思います。ここでは、NGINX Ingress ControllerのBackendにgRPCアプリケーションを配置するための手順を説明します。 先にも書いたとおり、NGINX Ingress ControllerはgRPCに対応していますが、いくつか追加設定が必要になります。対象は、Ingress、Ingress Controller、場合によってはapplicationにも手を入れる必要があります。
検証環境として、grpc/grpc-goで配布されているgreeter_serverを配置した以下のDockerfileのコンテナを用いました。同様にgreeter_clientを使って疎通検証を行います。
FROM grpc/go:1.0 EXPOSE 50051 RUN go get -u google.golang.org/grpc/examples/helloworld/greeter_server RUN go get -u google.golang.org/grpc/examples/helloworld/greeter_client CMD greeter_server
Ingressの設定
gRPCサービスであることをAnnotationに記載する必要があります。metadataにannotationsとして、grpc-backendであることを明示します。
cat << EOF | kubectl create -f - apiVersion: extensions/v1beta1 kind: Ingress metadata: annotations: nginx.ingress.kubernetes.io/grpc-backend: "true" name: hello-grpc spec: rules: - host: hello-grpc http: paths: - path: / backend: serviceName: hello-grpc-svc servicePort: 8080 EOF
Ingress Controllerの設定
NGINX Ingress ControllerのHTTPポートのデフォルトの設定では、HTTP2が有効になっていません。gRPCをHTTPで公開する場合は、追加の設定が必要です(HTTPSの場合は不要)。デフォルト設定で、HTTP2でIngress ControllerのHTTPポートにアクセスすると、次のようなログが出力されます。
172.17.0.1 - [172.17.0.1] - - [17/Jun/2018:16:26:34 +0000] "PRI * HTTP/2.0" 400 174 "-" "-" 0 0.001 [] - - - -
修正が必要な設定箇所は以下のあたりです。listen 443はhttp2が設定されていますが、listen 80にはされていません。
minikube > k exec nginx-ingress-controller-786dc4f648-2k4hh cat /etc/nginx/nginx.conf | grep listen listen 80 default_server backlog=511; listen [::]:80 default_server backlog=511; listen 443 default_server backlog=511 ssl http2; listen [::]:443 default_server backlog=511 ssl http2;
そこで、修正を入れた設定ファイルをロードするように、helm chartsを補正します。NGINX Ingress Controllerは、カスタムテンプレートからnginx.confを生成する仕組みになっているため、このテンプレートを自前のものに差し替えます。
まず、オリジナルのテンプレートを取ってきます。
> kubectl exec nginx-ingress-controller-786dc4f648-2k4hh cat /etc/nginx/template/nginx.tmpl > nginx.tmpl.org
これに対し、以下のように変更を加えます。わかりにくいですが、listen 80 にhttp2を追加しています。
> diff nginx.tmpl.org nginx.tmpl 702c696 < listen {{ $address }}:{{ $all.ListenPorts.HTTP }}{{ if $all.Cfg.UseProxyProtocol }} proxy_protocol{{ end }}{{ if eq $server.Hostname "_"}} default_server {{ if $all.Cfg.ReusePort }}reuseport{{ end }} backlog={{ $all.BacklogSize }}{{end}}; --- > listen {{ $address }}:{{ $all.ListenPorts.HTTP }}{{ if $all.Cfg.UseProxyProtocol }} proxy_protocol{{ end }}{{ if eq $server.Hostname "_"}} default_server {{ if $all.Cfg.ReusePort }}reuseport{{ end }} backlog={{ $all.BacklogSize }} http2{{end}}; 704c698 < listen {{ $all.ListenPorts.HTTP }}{{ if $all.Cfg.UseProxyProtocol }} proxy_protocol{{ end }}{{ if eq $server.Hostname "_"}} default_server {{ if $all.Cfg.ReusePort }}reuseport{{ end }} backlog={{ $all.BacklogSize }}{{end}}; --- > listen {{ $all.ListenPorts.HTTP }}{{ if $all.Cfg.UseProxyProtocol }} proxy_protocol{{ end }}{{ if eq $server.Hostname "_"}} default_server {{ if $all.Cfg.ReusePort }}reuseport{{ end }} backlog={{ $all.BacklogSize }} http2{{end}}; 708c702 < listen {{ $address }}:{{ $all.ListenPorts.HTTP }}{{ if $all.Cfg.UseProxyProtocol }} proxy_protocol{{ end }}{{ if eq $server.Hostname "_"}} default_server {{ if $all.Cfg.ReusePort }}reuseport{{ end }} backlog={{ $all.BacklogSize }}{{ end }}; --- > listen {{ $address }}:{{ $all.ListenPorts.HTTP }}{{ if $all.Cfg.UseProxyProtocol }} proxy_protocol{{ end }}{{ if eq $server.Hostname "_"}} default_server {{ if $all.Cfg.ReusePort }}reuseport{{ end }} backlog={{ $all.BacklogSize }} http2{{ end }}; 710c704 < listen [::]:{{ $all.ListenPorts.HTTP }}{{ if $all.Cfg.UseProxyProtocol }} proxy_protocol{{ end }}{{ if eq $server.Hostname "_"}} default_server {{ if $all.Cfg.ReusePort }}reuseport{{ end }} backlog={{ $all.BacklogSize }}{{ end }}; --- > listen [::]:{{ $all.ListenPorts.HTTP }}{{ if $all.Cfg.UseProxyProtocol }} proxy_protocol{{ end }}{{ if eq $server.Hostname "_"}} default_server {{ if $all.Cfg.ReusePort }}reuseport{{ end }} backlog={{ $all.BacklogSize }} http2{{ end }};
これを元にconfigmapを作成します。
> kubectl create configmap nginx-custom-tmpl --from-file=nginx.tmpl configmap "nginx-custom-tmpl" created
configmapをマウントするようにvalues.yamlを書き換え、helm upgradeします。
> cat <<EOF > values.yaml controller: name: controller ## Override NGINX template customTemplate: configMapName: nginx-custom-tmpl configMapKey: nginx.tmpl EOF > helm upgrade --install nginx-ingress stable/nginx-ingress -f values.yaml
参考
ingress-nginx version 0.13 for grpc not work · Issue #2444 · Kubernetes/ingress-nginx
動作確認
アクセス先を、Nginx Ingress Controllerに変更したgreeter_clientを実行します。
> go run greeter_client/main.go 2018/06/18 02:01:40 Greeting: Hello world
うまく疎通したようです。
アプリケーション側対応(必要であれば)
ここまででIngress ControllerのgRPC対応は完了ですが、アプリケーション側に修正が必要なケースもあります。gRPC通信が時々うまくいかず(全部失敗するわけではない)、NGINX Ingress Controllerのログに
upstream sent invalid http2 table index: 64 while reading response header from upstream
といったエラーが出ている場合は、go-grpcの既知のバグが原因である可能性があり、その場合は、修正PRが取り込まれたバージョンが必要です。詳しくは下記のIssueを参考にしてください。
gRPC servers cannot use dynamic HPACK · Issue #2405 · kubernetes/ingress-nginx
終わりに
Kubernetesのサービス公開方法として、Nginx Ingress Controllerを使った方法を紹介しました。個別にNodePort設定をするよりも、シンプルかつ容易にサービスの外部公開が可能になります。
Kubernetesはコンテナ管理のための新しい基盤であり、日々多くの機能が提案・追加されています。そのため、情報が不足していたり、まだデファクトが定まっていなかったりする部分が多く、学習・導入コストが高くつく可能性があります。一方で、Kubernetesの提供するフレームワークは非常に強力であり、複雑化しがちなマイクロサービス構成を、集約・統合的にまとめることで、サービスの運用を大幅に効率化できる可能性を持っています。本稿が、読者の皆様の何かしらのご助力になれば幸いです。