はじめに
最近ingress-nginxを使ってトラフィックの管理をすることが増えました。
一般的にコンテナプラットフォームのフロントエンドとして用いられ、主にクラスタ内のアプリケーションを公開するのに役立ちます。
製品などをインストールすると、その中でTLSを実装していることがあったりなんだりで、証明書関連の管理が煩雑になる時があります。そんな時に使いたくなるのがTLS passthroughの機能です。
製品マネージドな部分はなるべく触らずに通信を確立したい時に、TLSをそのままリレーしてくれるとこちらが意識する部分が減り大変便利です。
ただ、使っていくうちに以下の部分が気になるようになってきました。
- ログが出力されない
- rate limitなどが有効にならない
- proxy protocolによってクライアントIPが保存されない
- L4レベルでpassthroughしていると思われるのに、smtp等の他のTCP通信が通らない
そこでingress nginxの中のソースを見て、どのようにしてTLS passthroughを実装しているのか見て`技術的な課題点を把握しようというのが今回の趣旨です。
ingress nginxって何?とかそもそもkubernetesって何?という内容は先人が含んでくれていましたのでそちら参照で
通信確立まで
nginx.confを見る限り、TLSpassthroughを行なっていたところで(IngressリソースにAnnotationsを含めていたところで)内容は特に差異はありません。
ドキュメント参照すると
This feature is implemented by intercepting all traffic on the configured HTTPS port (default: 443) and handing it over to a local TCP proxy.
とあります。またnginx.confでListenしているのは442となっていました。
つまりは
- nginxの方ではなく、Goで書かれたnginx-ingress-controllerで443を受け止める
- nginxで開いている442のProxyへとリクエストを飛ばす
- passthroughをAnnotationsで有効にしていた場合、このリクエストを飛ばすまでに分岐している
という流れが想像できます。
見てみたところ、バックエンドにたどり着くまでにちゃんと
passthrough対象ホスト → nginx-ingress-controllerから直接forwarding
非対象のホスト → 127.0.0.1:442(nginxでListenしている)にforwarding
という処理を辿っています。
ここからは実際にコードで見てみます。
なお、当記事はkubernetes.ioが提供するOSSのコードを基に話を進めます。
コードフローチャート
このPassthrough昨日に特に関係する部分を抜き出して書くと、下図のような流れで処理されていることが読み取れます。
大元のmain.goから順に追っていくと、通信Passthrough通信に直接関係してくる大きな処理が3つあることがわかります。
大まかな処理の流れは以下の通りです。
1. Dial
ingress-contollerの起動オプションに --enable-ssl-passthrough
フラグをつけると、全ての443番portへの通信はlocalhostのProxy経由になるとドキュメントにありましたが、そのProxy、ひいてはPassthrough対象のホストへのTCP通信処理を行う部分です。
#1-1: ingress-controllerのインスタンス生成
#1-2: ingress-controllerのメインの処理
#1-3: 全ての通信を経由させるというProxyプロセス組み立て。
#1-4: Proxyプロセスの中で、クライアントからの通信をハンドリングするプロセスの組み立て。
#1-5: TCPProxyオブジェクトのGetメソッドを使い、ServerListというiterableなオブジェクトの中で合致するもの(なければDefault=(127.0.0.1:442))を接続先として返却
#1-6: #1-5_で得られたホストに向けてコネクションを確立
#1-7: 元のクライアントから受け取ったデータをそのまま#1-6_で確立したコネクションに流す
こうしてみてみると、ingressリソースにおいてAnnotationsで--ssl-passthrough
を付加したホストは_#1-5_でServerListに含まれるのであろうという見当がつきます。
ではこのServerListにどのようなエントリが含まれているのか見てみます
2. SverListへの登録
#2-1: NGINXControllerオブジェクトの参照に対し実装しているsyncIngressメソッドをQueueに追加。具体的にはQueueオブジェクトのsyncフィールドに登録し、そのQueueオブジェクトをNGINXControllerオブジェクトのsyncQueueフィールドに設定しています。
#2-2: Queueに入れたsyncIngressメソッドをgo routineで実行。Run -> k8s.io/apimachinery/pkg/util/wait.Untilという関数を呼んで、一定時間ごとにsyncIngressが実行されるようにしています
#2-3: #3-1で取得されるkubernetesリソース上の設定と現状の設定を比較し、十分な差分があれsyncIngressの中からOnUpdateメソッドをコール
#2-4: OnUpdateの中からgenerateTemplateメソッドをコール。generateTemplateメソッドの中でn.cfg.EnableSSLPassthroughという真偽値がありますが(*1で示した部分)、これがcontrollerを起動する際に渡した --enable-ssl-passthrough
引数が示すものとなります。
#2-5: generateTemplateメソッドの中の引数であるpcfgのPassthroughBackendsフィールドの値を取得し、ServerListとして値を設定しています。
つまりServerListにはpcfg.PassthroughBackendsに登録されている値が入ることがわかりました。
これで、_#3-1_で示していた getConfiguration
メソッドの戻り値であるpcfgに、pcfg.PassthroughBackendsとしてingressリソースのAnnotationsで--ssl-passthrough
を付加したホストが追加される処理が書かれていれば見当があっていることがわかります。
では最後にその部分の処理を追ってみます。
3. PassthroughBackendsへの登録
#3-1: getConfigurationメソッドをコール
#3-2: getBackendServersメソッドをコール。
#3-3: createServersメソッドをコール。ここで初めてingressリソースのAnnotationsが出てきます。
#3-4: createServersメソッドの返り値であるserversにSSLPassthroughのフィールドを代入していきます
#3-5: _#3-4_で代入されたSSLPassthroughをみて、trueならその後の処理に進んでpassUpstreamsにserver.Hostnameを追加していきます。
上の_#3-5_で作られたpassUpstreamsがgetConfigurationメソッドの返り値のpcfgとして返却されています。つまり最初に立てた見当があっており、「ingressリソースにおいてAnnotationsで--ssl-passthrough
を付加したホストはProxyプロセスで直接Dialされる対象に含まれるため、SSL Passthroughを可能にしている」ということがわかりました。
raw code
生のコードで上の処理に合致する部分をマーキングしてみました。
ちょっと長くなるので時間のある方は追ってみるとより納得いくかなと思います。
ngx := controller.NewNGINXController(conf, mc) //#1-1 ->
...
go ngx.Start()//#1-2 ->
func NewNGINXController(config *Configuration, mc metric.Collector) *NGINXController { //#1-1 <-
...
n.syncQueue = task.NewTaskQueue(n.syncIngress) //#2-1
...
}
...
// Start starts a new NGINX master process running in the foreground.
func (n *NGINXController) Start() { //#1-2 <-
...
if n.cfg.EnableSSLPassthrough {
n.setupSSLProxy() //#1-3 ->
}
...
go n.syncQueue.Run(time.Second, n.stopCh) //#2-2 ->
...
}
...
func (n NGINXController) generateTemplate(cfg ngx_config.Configuration, ingressCfg ingress.Configuration) ([]byte, error) { //#2-4 <-
if n.cfg.EnableSSLPassthrough { //# *1
servers := []*TCPServer{}
for _, pb := range ingressCfg.PassthroughBackends { //#3-4 <-
svc := pb.Service
...
// TODO: Allow PassthroughBackends to specify they support proxy-protocol
servers = append(servers, &TCPServer{
Hostname: pb.Hostname,
IP: svc.Spec.ClusterIP,
Port: port,
ProxyProtocol: false,
})
}
n.Proxy.ServerList = servers //#2-5 ->
}
...
}
...
// OnUpdate is called by the synchronization loop whenever configuration
// changes were detected. The received backend Configuration is merged with the
// configuration ConfigMap before generating the final configuration file.
// Returns nil in case the backend was successfully reloaded.
func (n *NGINXController) OnUpdate(ingressCfg ingress.Configuration) error { //#2-3 <-
...
content, err := n.generateTemplate(cfg, ingressCfg) //#2-4 ->
...
}
...
func (n *NGINXController) setupSSLProxy() { //#1-3 <-
...
sslPort := n.cfg.ListenPorts.HTTPS //443
proxyPort := n.cfg.ListenPorts.SSLProxy //442
n.Proxy = &TCPProxy{ // *3
Default: &TCPServer{
Hostname: "localhost",
IP: "127.0.0.1",
Port: proxyPort,
ProxyProtocol: true,
},
}
...
}
...
// accept TCP connections on the configured HTTPS port
go func() {
for {
...
go n.Proxy.Handle(conn) //#1-4 ->
}
}()
}
// Get returns the TCPServer to use for a given host.
func (p *TCPProxy) Get(host string) *TCPServer { //#1-5 <-
if p.ServerList == nil {
return p.Default
}
for _, s := range p.ServerList { //#2-5 <-
if s.Hostname == host {
return s
}
}
return p.Default
}
...
func (p *TCPProxy) Handle(conn net.Conn) { //#1-4 <-
...
data := make([]byte, 4096)
length, err := conn.Read(data)
...
proxy := p.Default
hostname, err := parser.GetHostname(data[:])
if err == nil {
klog.V(4).InfoS("TLS Client Hello", "host", hostname)
proxy = p.Get(hostname) //#1-5 ->
}
...
hostPort := net.JoinHostPort(proxy.IP, fmt.Sprintf("%v", proxy.Port))
clientConn, err := net.Dial("tcp", hostPort) //#1-6
...
if err != nil {
...
} else {
_, err = clientConn.Write(data[:length]) //#1-7
...
}
}
// syncIngress collects all the pieces required to assemble the NGINX
// configuration file and passes the resulting data structures to the backend
// (OnUpdate) when a reload is deemed necessary.
func (n *NGINXController) syncIngress(interface{}) error { //#2-2 <-
...
ings := n.store.ListIngresses()
hosts, servers, pcfg := n.getConfiguration(ings) //#3-1 ->
...
if !n.IsDynamicConfigurationEnough(pcfg) {
...
err := n.OnUpdate(*pcfg) //#2-3 ->
...
}
...
n.runningConfig = pcfg
return nil
}
...
// getConfiguration returns the configuration matching the standard kubernetes ingress
func (n *NGINXController) getConfiguration(ingresses []*ingress.Ingress) (sets.String, []*ingress.Server, *ingress.Configuration) { //#3-1 <-
upstreams, servers := n.getBackendServers(ingresses) //#3-2 ->
...
for _, server := range servers {
if !server.SSLPassthrough { //#3-4 <-
continue
}
...
for _, loc := range server.Locations {
...
passUpstreams = append(passUpstreams, &ingress.SSLPassthroughBackend{
Backend: loc.Backend,
Hostname: server.Hostname,
Service: loc.Service,
Port: loc.Port,
})
break
}
}
return hosts, servers, &ingress.Configuration{
Backends: upstreams,
Servers: servers,
TCPEndpoints: n.getStreamServices(n.cfg.TCPConfigMapName, apiv1.ProtocolTCP),
UDPEndpoints: n.getStreamServices(n.cfg.UDPConfigMapName, apiv1.ProtocolUDP),
PassthroughBackends: passUpstreams, //#3-5
BackendConfigChecksum: n.store.GetBackendConfiguration().Checksum,
DefaultSSLCertificate: n.getDefaultSSLCertificate(),
}
}
...
// getBackendServers returns a list of Upstream and Server to be used by the
// backend. An upstream can be used in multiple servers if the namespace,
// service name and port are the same.
func (n *NGINXController) getBackendServers(ingresses []*ingress.Ingress) ([]*ingress.Backend, []*ingress.Server) { //#3-2 <-
du := n.getDefaultUpstream()
upstreams := n.createUpstreams(ingresses, du)
servers := n.createServers(ingresses, upstreams, du) //#3-3 ->
...
aServers := make([]*ingress.Server, 0, len(servers))
for _, value := range servers {
...
aServers = append(aServers, value)
...
}
...
return aUpstreams, aServers //#3-4
}
...
// createServers builds a map of host name to Server structs from a map of
// already computed Upstream structs. Each Server is configured with at least
// one root location, which uses a default backend if left unspecified.
func (n *NGINXController) createServers(data []*ingress.Ingress,
upstreams map[string]*ingress.Backend,
du *ingress.Backend) map[string]*ingress.Server { //#3-3 <-
...
for _, ing := range data {
...
anns := ing.ParsedAnnotations
...
for _, rule := range ing.Spec.Rules {
...
host := rule.Host
...
servers[host] = &ingress.Server{
Hostname: host,
Locations: []*ingress.Location{
loc,
},
SSLPassthrough: anns.SSLPassthrough, //#3-4 ->
SSLCiphers: anns.SSLCipher.SSLCiphers,
SSLPreferServerCiphers: anns.SSLCipher.SSLPreferServerCiphers,
}
}
}
...
return servers
}
補足
SSL Passthroughを有効にしたホストでは、
- ログが出力されない
- rate limitなどが有効にならない
- proxy protocolによってクライアントIPが保存されない
- L4レベルでpassthroughしているはずなのに、smtp等の他のTCP通信が通らない
という点が自分の中で課題となっていました。
このうち最後のもの以外はgoのプロセスから直接forwardしているからということで説明がつきます。
最後の、HTTP通信以外が通らないという話ですが、ペイロードの中からホスト名を取得する#1-5付近の parser.GetHostname(data[:])
に秘密がありそうです。
そこでこちらの中身をみてみると、TLS通信の中身までは自分の方ではわかりませんがどうやら生のTLS Client Helloにしか対応していないようです。
つまりTLS以外の通信ではホスト名が取得できないため、それをingress nginxで行おうと思ったらnginxのTCP Passthroughを行うしか無いよねということで納得がいきました。
最後に
以外に何かと制約あるなとは思っていましたが、中身をみたら納得です。
開発が進められており便利なOSSであることに変わりはないので、制約を理解しつつシステム構築できればなと思います。