UZABASE Tech Blog

株式会社ユーザベースの技術チームブログです。 主に週次の持ち回りLTやセミナー・イベント情報について書きます。

マルチホストでのDocker Container間通信 第3回: Kubernetesのネットワーク(CNI, kube-proxy, kube-dns)

こんにちは。SPEEDA開発チームの鈴木です。

これまでマルチホストでのContainer間通信について、

  • Dockerのネットワークの基礎(前々回)
  • マルチホストでのContainer間通信を実現する手段の一つとしてのOverlayNetwork(前回)

といった話をしてきましたが、3回目となる今回はこれまでの内容を踏まえた上でKubernetesのネットワークについてお話します。内容としては大きく次の2つになります。

  1. どうやってマルチホストでのContainer間通信を実現しているか
  2. Service名でPodと通信できるようするための仕組み

では早速1つ目の話をはじめましょう。Kubernetesを利用する場合、基本的には複数のノード上にKubernetesクラスタを構築することになります。 (minikubeを使って単一ノードからなるKubernetesクラスタを構築するような例外はあります)
つまり何らかの方法でホストをまたいでContainer間通信を行うわけですが、そのための方法は3つあります。

マルチホストでContainer間通信を行う3つの方法

  1. 自前でOverlayNetworkを構築し、それをKubernetesと連携させる
  2. CNI(Container Networking Interface)を使う
  3. kubenetを使う

それではこれから上記3つについて少し詳しく説明していきます。

1.自前でOverlayNetworkを構築してKubernetesと連携させる

前回説明した flannel のようなツールを用いてOverlayNetworkを構築する方法です。
私が2016年にCoreOSクラスタ上でKubernetesクラスタをマニュアルで構築したとき、CoreOSの公式ドキュメントに記載されていた方法がこれです(CoreOSがContainer Linuxに改名される前の話です)。
ちなみにCoreOSの場合はflannelを用います(flannelはCoreOS社が開発しています)。
この場合、CoreOSのcloud-configにflannelの設定とflannelに合わせたDockerの設定が必要になります。
当然すべてのノードに対して設定が必要になるのでなかなか面倒です。

2.CNI(Container Network Interface)を使う

CNIは、Cloud Native Computing Foundation※のプロジェクトで、
Container内のNetwork Interfaceを構成するためのプラグインを開発するための仕様とライブラリなどから構成されています。
ネットワーク層をプラガブルにしたいという考えは、多くのContainer RuntimeとOrchestration Toolが共通して持っている考えのため、重複を避けるために定義されました。

※Cloud Native Computing Foundationは、Kubernetesの開発主体です。 DockerやCoreOS、Red Hat、Google、IBM、Mesosphere、シスコ、インテルなどが主導して2015年7月に発足しました。

KubernetesはこのCNIプラグインを利用することができます。 ちなみにDocker自体もCNIとは別にネットワークプラグインを利用できる仕組みを持っています。

CNIを利用できるContainer Runtime

公式ページによると現在(2017年9月)CNIを利用できるContainer Runtimeは以下のようなものがあるようです。
Kubernetesだけではなく、MesosなんかもCNIを利用できるみたいですね。



3rd Party Plugins

以下はCNIの仕様に則って作成されたCNIプラグインです(2017年9月現在)。
注意点として、上述したようにCNIはKubernetesのためだけのインタフェースではないので、Kubernetes用ではないプラグインもあることを挙げておきます。


  • Project Calico - L3の仮想ネットワーク
  • Weave - 仮想ネットワーク
  • Contiv Networking - 仮想ネットワーク + 柔軟なネットワークポリシー制御を提供
  • SR-IOV
  • Cilium - Container用 BPF & XDP
  • Infoblox - ContainerのIPアドレスを管理するサービス(IPAM)
  • Multus - Kubernetesのマルチプラグインとして動作。複数のプラグインのグループ化(他のプラグインをdelegateして呼び出すらしい)。
  • Romana - KubernetesのためのL3の仮想ネットワーク + ネットワークポリシー制御
  • CNI-Genie - Kubernetesのマルチプラグインとして動作。一つのContainerに複数のネットワークインタフェースを割り当て、インタフェースごとにCNIプラグインを割り当てられる。例えばeth0はCalico、eth1はFlannel、eth2はWeaveなど。つまりContainerが複数のネットワークにまたがることができる。
  • Nuage CNI - Kubernetesのための仮想ネットワーク + ネットワークポリシー制御
  • Silk - Cloud Foundryのためのプラグイン。flannelに触発されたらしい。
  • Linen - Open vSwitchでOverlayNetworkを構築するプラグイン

これらを見ると、CalicoやWeaveといった単体でContainerの仮想ネットワークを構築できるツールがCNIプラグインとしても提供されていることがわかります。
プラグインの要件を満たすように既存の機能をうまく使っているのでしょうね。
余談ですが、flannelもそうですが、Calico, Weave, Silk, Linenというように「生地」に関連した名前のものが多いですね。

Kubernetesから利用するのは当然Calico, Weave, Contivといった仮想ネットワークのためのCNIプラグインになります。

CNIプラグインの動作

続いてCNIプラグインはどう動作するのか、具体的にどんなものなのかをご理解いただくために、少々ソースコードを読みながら解説します。

CNIプラグインのソースコード

CNIプロジェクトは、CNIプラグインを簡単に開発できるようにスケルトンを用意しています。
プラグインの動作は、このスケルトンと実際にスケルトンを使って作られたプラグインの両方を見ると理解できると思います。
では順番に見ていきましょう。まずスケルトンからです。軽く眺めて次にいきましょう。
(注:抜粋です。コードすべてではありません。またあくまでこれは今現在のバージョンのコードです。)

cni/pkg/skel/skel.go

type CmdArgs struct {
    ContainerID string
    Netns       string
    IfName      string
    Args        string
    Path        string
    StdinData   []byte
}

type dispatcher struct {
    Getenv func(string) string
    Stdin  io.Reader
    Stdout io.Writer
    Stderr io.Writer

    ConfVersionDecoder version.ConfigDecoder
    VersionReconciler  version.Reconciler
}

func PluginMain(cmdAdd, cmdDel func(_ *CmdArgs) error, 
  versionInfo version.PluginInfo) {
    if e := PluginMainWithError(cmdAdd, cmdDel, versionInfo); e != nil {
        if err := e.Print(); err != nil {
            log.Print("Error writing error JSON to stdout: ", err)
        }
        os.Exit(1)
    }
}

func PluginMainWithError(cmdAdd, cmdDel func(_ *CmdArgs) error, 
  versionInfo version.PluginInfo) *types.Error {
    return (&dispatcher{
        Getenv: os.Getenv,
        Stdin:  os.Stdin,
        Stdout: os.Stdout,
        Stderr: os.Stderr,
    }).pluginMain(cmdAdd, cmdDel, versionInfo)
}

func (t *dispatcher) pluginMain(cmdAdd, cmdDel func(_ *CmdArgs) error, 
  versionInfo version.PluginInfo) *types.Error {
    cmd, cmdArgs, err := t.getCmdArgsFromEnv()
    if err != nil {
        return createTypedError(err.Error())
    }

    switch cmd {
    case "ADD":
        err = t.checkVersionAndCall(cmdArgs, versionInfo, cmdAdd)
    case "DEL":
        err = t.checkVersionAndCall(cmdArgs, versionInfo, cmdDel)
    case "VERSION":
        err = versionInfo.Encode(t.Stdout)
    default:
        return createTypedError("unknown CNI_COMMAND: %v", cmd)
    }

    if err != nil {
        if e, ok := err.(*types.Error); ok {
            // don't wrap Error in Error
            return e
        }
        return createTypedError(err.Error())
    }
    return nil
}

はい、続いてはスケルトンを利用した実際のプラグインです。
これはCNIプロジェクトがサンプルとして用意している単純なプラグインです。

plugins/plugins/main/loopback/loopback.go

func main() {
    skel.PluginMain(cmdAdd, cmdDel, version.All)
}

func cmdAdd(args *skel.CmdArgs) error {
    args.IfName = "lo" // ignore config, this only works for loopback
    err := ns.WithNetNSPath(args.Netns, func(_ ns.NetNS) error {
        link, err := netlink.LinkByName(args.IfName)
        if err != nil {
            return err // not tested
        }

        err = netlink.LinkSetUp(link)
        if err != nil {
            return err // not tested
        }

        return nil
    })
    if err != nil {
        return err // not tested
    }

    result := current.Result{}
    return result.Print()
}

func cmdDel(args *skel.CmdArgs) error {
    args.IfName = "lo" // ignore config, this only works for loopback
    err := ns.WithNetNSPath(args.Netns, func(ns.NetNS) error {
        link, err := netlink.LinkByName(args.IfName)
        if err != nil {
            return err // not tested
        }

        err = netlink.LinkSetDown(link)
        if err != nil {
            return err // not tested
        }

        return nil
    })
    if err != nil {
        return err // not tested
    }

    return nil
}

CNIプラグインのソースコード解説

それではいよいよ解説に入ります。このサンプルのmain関数を見てください。CmdArgsを引数にとる関数2つを、スケルトンのPluginMain関数に渡しています。

func main() {
    skel.PluginMain(cmdAdd, cmdDel, version.All)
}

このcmdAddcmdDelはContainerが追加・削除されたタイミングで(CNIプラグインを利用するプログラム)から呼び出される関数です。
引数のCmdArgsは、スケルトンを見ると次のようになっています。

type CmdArgs struct {
    ContainerID string // ContainerのID
    Netns       string // ネットワーク名前空間名
    IfName      string // インタフェース名
    Args        string // プラグイン実行時の引数
    Path        string // CNIプラグインを検索するパスのリスト(パスは:で区切る)
    StdinData   []byte // 標準入力の値
}

つまり、プラグインはContainerの追加・削除を契機として、上記構造体の情報を元に任意の処理を行います。
ちなみにこのサンプルはContainerの追加時にループバックインタフェースをContainerのネットワーク名前空間内に作成し、Containerの削除時にループバックインタフェースをネットワーク名前空間から削除しています。

CmdArgsについて補足
main関数ではcmdAdd,cmdDelの両方を渡しているが、addとdelのどちらを使うかどう決定されるのでしょう。 これはスケルトンのコードを見ればわかるのですが、CNI_COMMANDという環境変数の値により決定されます。
値がADDの場合cmdAddが呼び出され、値がDELの場合cmdDelが呼び出されます。
またcmdAdd,cmdDelに渡ってくる`CmdArgsの値はどのようにセットされているのでしょうか。
これも環境変数が使われていて、単純に以下の環境変数の値が構造体の対応する変数にセットされています。

環境変数 CmdArgsの変数
CNI_CONTAINERID ContainerID
CNI_NETNS Netns
CNI_IFNAME IfName
CNI_ARGS Args
CNI_PATH Path

つまりCNIプラグインの処理を呼び出すプログラムは、予めこれらの環境変数をセットしておく必要があります。

ここまで簡単にコードを見てきましたが、どうでしょう。結構シンプルな仕組みではないでしょうか。

KubernetesでCNIを使うには

KubernetesでCNIを使う場合は、公式ドキュメントに書いてありますが、
kubelet の引数に --network-plugin=cni の指定と、
CNIの設定を配置するディレクトリ--cni-conf-dir(defaultは /etc/cni/net.d)の指定が必要になります。
またCNIプラグイン自体は--cni-bin-dir(defaultは/opt/cni/bin)に存在する必要があります。

例えばCNIプラグインとして、Weaveを使用する場合、このような感じになります。

$ view /etc/cni/net.d/10-weave.conf
{
    "name": "weave",
    "type": "weave-net",
    "hairpinMode": true
}
$ ls -lat /opt/cni/bin/weave*
lrwxrwxrwx 1 root root       18 Aug 10 14:51 weave-ipam -> weave-plugin-1.9.7*
lrwxrwxrwx 1 root root       18 Aug 10 14:51 weave-net -> weave-plugin-1.9.7*
-rwxr-xr-x 1 root root  9446280 Jun  7 02:37 weave-plugin-1.9.7*

CNIの仕様について更に詳しく

CNIの仕様について更に詳しく知りたい場合は、公式のこちらをご覧ください。
CNIの設定ファイルについての説明なども書いてあります。

3.kubenetを使う

これはGKEでKubernetesクラスタを構築した場合に利用されている方法です。
kubenetが使用されていることは、GKEのクラスタに接続して ps ax | grep kubeletなどとしてkubeletコマンドの引数を見ることで分かります。

$ ps ax | grep kubelet
 3461 ?        Sl   1218:07 /usr/local/bin/kubelet
 --api-servers=https://xx.xxx.xxx.xxx
 --enable-debugging-handlers=true
 --cloud-provider=gce
 --pod-manifest-path=/etc/kubernetes/manifests
 --allow-privileged=True
 --v=2 --cluster-dns=10.11.240.10
 --cluster-domain=cluster.local
 --cgroup-root=/
 --system-cgroups=/system
 --network-plugin=kubenet # ←ここ
 --runtime-cgroups=/docker-daemon
 --kubelet-cgroups=/kubelet
 --node-labels=beta.kubernetes.io/fluentd-ds-ready=true,cloud.google.com/gkenodepool=default-pool 
 --babysit-daemons=true 
 --eviction-hard=memory.available<100Mi,nodefs.available<10%,nodefs.inodesFree<5% 
 --anonymous-auth=false 
 --authorization-mode=Webhook 
 --client-ca-file=/etc/kubernetes/pki/ca-certificates.crt 
 --feature-gates=ExperimentalCriticalPodAnnotation=true 
 --experimental-allocatable-ignore-eviction

--network-plugin=kubenetという引数が与えられていますね。
ちなみにCNIを使う場合は--network-plugin=cniという指定でした。

kubenetを使用する場合、cbr0という名前のブリッジが作成され、cbr0に接続されたpod用のvethペアも作成されます。
更に言うとkubenetを使用する場合、実はCNIプラグインが使用されているようです(恐らくブリッジへの接続などで)。
例えばGKEの場合、クラスタの/opt/cni/bin配下を見てみると、以下のようなプラグインが配置されています。

/opt/cni/bin$ ls -l
-rwxr-xr-x 1 root root 4026452 Mar 22 20:04 bridge
-rwxr-xr-x 1 root root 2901956 Mar 22 20:03 cnitool
-rwxr-xr-x 1 root root 9636499 Mar 22 20:04 dhcp
-rwxr-xr-x 1 root root 2910884 Mar 22 20:03 flannel
-rwxr-xr-x 1 root root 3102946 Mar 22 20:04 host-local
-rwxr-xr-x 1 root root 3609358 Mar 22 20:04 ipvlan
-rwxr-xr-x 1 root root 3170507 Mar 22 20:04 loopback
-rwxr-xr-x 1 root root 3640336 Mar 22 20:04 macvlan
-rwxr-xr-x 1 root root 2733314 Mar 22 20:04 noop
-rwxr-xr-x 1 root root 4000236 Mar 22 20:04 ptp

まとめ

これまでマルチホストでのContainer間通信を実現するための3つの方法を説明してきたわけですが、自前でやるにしろ、CNIプラグインやkubenetを利用するにせよ、Kubernetes本体の機能はほとんど使われていないことがわかったかと思います。

マルチホストでContainer間通信ができるだけでは解決しない問題

前回の記事で私は「Overlay Networkを構築するだけでは、Containerが起動するホストが動的に変わるようなケースには対応できません。」というようなことを書きました。これはどういうことを言っているのか説明しましょう。

まず、KubernetesのようなオーケストレーションツールでContainerを起動する場合、基本的にはDeployするホストをユーザーが直接指定することはありません。Kubernetesがホストの状況(負荷とか空き容量とか)を見て、適切なホストにDeployしてくれるからです。そう、つまりContainerがどこにDeployされるかは予めわからないのです。
もちろんDeploy後であれば、DeployされたホストのIPを知ることができます。しかしContainer間で連携を行う場合、Deployされるまで連携するContainerのIPが分からないのは不便きわまりないです。この問題は、当然、ホストをまたいでContainer同士が通信できても解決しません。

この問題を解決するには、どのホストにContainerがDeployされてもContainerにアクセスする側が気にしなくていいような仕組みが別に必要になります。
KubernetesではServiceがそのための仕組みを提供しています。

Service

Kubernetesをお使いの方はご存知の通り、Serviceはいわゆるロードバランサーで、名前をつけてPod(Kubernetesのリソースの一つでContainerを管理する)と紐付けておくことで、Service経由でPodとやりとりができるようになります。Podがスケールされて複数のホストに存在する場合は、ロードバランシングしてくれたりもします(Kubernetes Cluster上のPodからのみ名前解決できる)。

もう少し具体的に説明しましょう。まず、Serviceを生成するとServiceには自動的にIPが割り当てられます。
そしてこのIP(とServiceで指定したPort)を介してServiceに紐づくPodとやりとりすることができます。そのため、どのホストにPodがDeployされているか気にする必要がなくなります。

ちなみにこのIPは仮想的なIPで、紐づく物理的あるいは仮想的なNICはどこにも存在しません。
またこのIPはKubernetes Cluster上のPodからのみやりとりできます。

f:id:kenji-suzuki:20170903221229p:plain

しかし何故Podを直接名前解決できるようにせず、Serviceを挟んでいるのでしょうか。
Podのスケールアウトに対応するためでしょうか。でもこれはDNSラウンドロビンで対応できそうですね。
では、仮にPodを直接名前解決できたとしたらどうでしょう。そして、その名前解決の結果がキャッシュされ、キャッシュが有効なうちに何らかの理由でPodがフェールオーバーされて、これまでとは別のホストで起動されたらどうなるでしょうか。困ったことになりますね。このあたりのことは公式のここで言及されています。

さて、ただただServiceの機能について説明するだけでは面白くないので、今回は最後にもう少し突っ込んだ話をしたいと思います。

Serviceの機能はどのように実現されているか

今回は機能を次の2つに絞って「誰」が「どのように」実現しているかを説明します。

  1. 名前解決
  2. ルーティング

ルーティングというのは、ServiceのIP(とPort)にアクセスされたら対応するPodのIP(とPort)にフォワードするという意味で書きました。

担当者は誰だ?

まず、こちらをご覧ください。これはKubernetesのアーキテクチャ図で、これを見るとKubernetesが様々な登場人物(Component)から成るツールだということがわかります。「誰」がという表現をしたのはこのためです。

f:id:kenji-suzuki:20170903184036p:plain

今回の話でいうと、名前解決の部分を担当しているのがkube-dns(上図にはいませんが)で、ルーティングの部分を担当しているのがkube-proxyです。
では、「どのように」それを実現しているのでしょうか。

彼らは一体何をやっているのか?

彼らがやっていることを端的に表すと「ServiceやEndpointリソースを監視して、リソースの状況に合わせたアクションを起こす」ということをやっています(今回はEndpointリソースには言及しません)。

もう少し具体的に説明しましょう。
Kubernetesのkube-apiserverは、自身が管理している各種リソース(ServiceやNode、Pod、Secretなど)について、問い合わせる口を持っています。
そして問い合わせを行った結果、以前の問い合わせの結果との差分から、リソースの「追加」「削除」「更新」といったリソースの変化を検知しイベント通知を行うためのライブラリが用意されています。このライブラリは汎用的に作られていて、「追加」「削除」「更新」のイベント通知を受けた際に処理を行うイベントハンドラを簡単に設定できるようになっています。

kube-dnsやkube-proxyはサーバとして起動され、常駐しながら上記の仕組みを利用してServiceリソースを監視し、状況に応じた処理を行っています。

実際のソースコードを見てみましょう。 次はkube-dnsのパッケージのコードです。
dns.go

func (kd *KubeDNS) setServicesStore() {
    // Returns a cache.ListWatch that gets all changes to services.
    kd.servicesStore, kd.serviceController = kcache.NewInformer(
        kcache.NewListWatchFromClient(
            kd.kubeClient.Core().RESTClient(),
            "services",
            v1.NamespaceAll,
            fields.Everything()),
        &v1.Service{},
        resyncPeriod,
        kcache.ResourceEventHandlerFuncs{
            AddFunc:    kd.newService,
            DeleteFunc: kd.removeService,
            UpdateFunc: kd.updateService,
        },
    )
}

func (kd *KubeDNS) newService(obj interface{}) {
    if service, ok := assertIsService(obj); ok {
        glog.V(3).Infof("New service: %v", service.Name)
        glog.V(4).Infof("Service details: %v", service)

        // ExternalName services are a special kind that return CNAME records
        if service.Spec.Type == v1.ServiceTypeExternalName {
            kd.newExternalNameService(service)
            return
        }
        // if ClusterIP is not set, a DNS entry should not be created
        if !v1.IsServiceIPSet(service) {
            if err := kd.newHeadlessService(service); err != nil {
                glog.Errorf("Could not create new headless service %v: %v", service.Name, err)
            }
            return
        }
        if len(service.Spec.Ports) == 0 {
            glog.Warningf("Service with no ports, this should not have happened: %v",
                service)
        }
        kd.newPortalService(service)
    }
}

ServiceInformerというのが、Serviceリソースの状態を監視し、通知を行ってくれる構造体になります。
このInformerにハンドラを登録している部分が、AddFuncやUpdateFuncなどと書かれている部分です。

続いてkube-proxyのパッケージのコードを見てみましょう。こちらも同じような感じで、Informerにハンドラが登録されています。

config.go

func NewServiceConfig(serviceInformer coreinformers.ServiceInformer, resyncPeriod time.Duration) *ServiceConfig {
    serviceInformer.Informer().AddEventHandlerWithResyncPeriod(
        cache.ResourceEventHandlerFuncs{
            AddFunc:    result.handleAddService,
            UpdateFunc: result.handleUpdateService,
            DeleteFunc: result.handleDeleteService,
        },
        resyncPeriod,
    )
}

func (c *ServiceConfig) handleAddService(obj interface{}) {
    service, ok := obj.(*api.Service)
    if !ok {
        utilruntime.HandleError(fmt.Errorf("unexpected object type: %v", obj))
        return
    }
    for i := range c.eventHandlers {
        glog.V(4).Infof("Calling handler.OnServiceAdd")
        c.eventHandlers[i].OnServiceAdd(service)
    }
}

kube-dnsのイベントハンドラは何をしているか

まずkube-dnsのserverの起動部分を見ると分かるのですが、SkyDNSというDNSサービスを起動しています。

func (server *KubeDNSServer) Run() {
    pflag.VisitAll(func(flag *pflag.Flag) {
        glog.V(0).Infof("FLAG: --%s=%q", flag.Name, flag.Value)
    })
    setupSignalHandlers()
    server.startSkyDNSServer() // ここ
    server.kd.Start()
    server.setupHandlers()

    glog.V(0).Infof("Status HTTP port %v", server.healthzPort)
    if server.nameServers != "" {
        glog.V(0).Infof("Upstream nameservers: %s", server.nameServers)
    }
    glog.Fatal(http.ListenAndServe(fmt.Sprintf(":%d", server.healthzPort), nil))
}

SkyDNSのサーバーには、問い合わせを受けたときに呼び出される「DNSレコードを返す処理」を渡せるようになっており、その部分のみkube-dnsが実装しています。
そしてそのレコードの登録処理が、Serviceの変更の通知を受け取ったときに呼び出される処理なのです。
これによりKubernetes Cluster内で名前解決が可能になります。

kube-proxyのイベントハンドラは何をしているか

まず先に言ってしまうと、ServiceからPodへのルーティングは、iptablesによって行われています。
iptablesの内容を表示させてみると次のようになっています(説明に必要なものだけ抜粋しています)。これはkubernetes-dashboardのルーティングなのですが、Serviceの 10.110.169.150:80 が Pod の 10.42.0.0:9090 にフォワードされていることが分かると思います。

-A KUBE-SEP-4 -s 10.42.0.0/32     -m comment --comment "kube-system/kubernetes-dashboard:"        -j KUBE-MARK-MASQ
-A KUBE-SEP-4 -p tcp              -m comment --comment "kube-system/kubernetes-dashboard:" -m tcp -j DNAT --to-destination 10.42.0.0:9090

-A KUBE-SERVICES -d 10.110.169.150/32 -p tcp -m comment --comment "kube-system/kubernetes-dashboard: cluster IP" -m tcp --dport 80 -j KUBE-SVC-DASH

-A KUBE-SVC-DASH    -m comment --comment "kube-system/kubernetes-dashboard:" -j KUBE-SEP-4

ちなみにこのdashboardをスケールアウトさせてみると
$ kubectl scale deploy kubernetes-dashboard -n kube-system --replicas=3

このようになります。

-A KUBE-SEP-3 -s 10.44.0.1/32 -m comment --comment "kube-system/kubernetes-dashboard:" -j KUBE-MARK-MASQ
-A KUBE-SEP-3 -p tcp -m comment --comment "kube-system/kubernetes-dashboard:" -m tcp -j DNAT --to-destination 10.44.0.1:9090
-A KUBE-SEP-1 -s 10.42.0.0/32 -m comment --comment "kube-system/kubernetes-dashboard:" -j KUBE-MARK-MASQ
-A KUBE-SEP-1 -p tcp -m comment --comment "kube-system/kubernetes-dashboard:" -m tcp -j DNAT --to-destination 10.42.0.0:9090
-A KUBE-SEP-2 -s 10.42.0.1/32 -m comment --comment "kube-system/kubernetes-dashboard:" -j KUBE-MARK-MASQ
-A KUBE-SEP-2 -p tcp -m comment --comment "kube-system/kubernetes-dashboard:" -m tcp -j DNAT --to-destination 10.42.0.1:9090

# -m statisitc でランダムに宛先を変えている
# 1/3の確率で KUBE-SEP-1
-A KUBE-SVC-XGLOHA7QRQ3V22RZ -m comment --comment "kube-system/kubernetes-dashboard:" -m statistic --mode random --probability 0.33332999982 -j KUBE-SEP-1
# 1/2の確率で KUBE-SEP-2
-A KUBE-SVC-XGLOHA7QRQ3V22RZ -m comment --comment "kube-system/kubernetes-dashboard:" -m statistic --mode random --probability 0.50000000000 -j KUBE-SEP-2
# 残りは KUBE-SEP-3
-A KUBE-SVC-XGLOHA7QRQ3V22RZ -m comment --comment "kube-system/kubernetes-dashboard:" -j KUBE-SEP-3

もう分かったかと思いますが、kube-proxyのイベントハンドラがやっているのは、このiptablesの動的な更新です。 これは上記のkube-proxyのコードを追っていくと分かります(興味がありましたらproxier.goのsyncProxyRulesのあたりをご覧ください)。

まとめ

kube-dnsとkube-proxyがServiceを監視しており、Serviceが作成されたとき、kube-dnsによりDNSレコードが登録され名前解決が可能になり、そしてkube-proxyによりiptablesを使ったルーティングが設定されることが分かったかと思います。

図にするとこのような流れになるでしょうか。

f:id:kenji-suzuki:20170906013723p:plain
f:id:kenji-suzuki:20170906015645p:plain
f:id:kenji-suzuki:20170906013926p:plain
f:id:kenji-suzuki:20170906014014p:plain
f:id:kenji-suzuki:20170906014153p:plain

さて、大分長くなりましたがKubernetesのネットワーク話はいかがだったでしょうか。
様々な仕組みは用意しつつ、巧みに自前での実装を回避しているという印象を私は受けました。
Kubernetesは調べていくと感心させられることが多いですね。

シリーズ

第1回: マルチホストでのDocker Container間通信 第1回: Dockerネットワークの基礎
第2回: マルチホストでのDocker Container間通信 第2回: Port Forwarding と Overlay Network
第3回: マルチホストでのDocker Container間通信 第3回: Kubernetesのネットワーク(CNI, kube-proxy, kube-dns) (当記事)

マルチホストでのDocker Container間通信 第2回 Port Forwarding と Overlay Network

こんにちは。SPEEDA開発チームの鈴木です。
前回はマルチホストでのDocker Container間通信の説明の前段として、Dockerのネットワークが次のようになっているという話をしました。  
 
f:id:kenji-suzuki:20170710131809p:plain

今回はいよいよ、マルチホストでどうやってDocker Container同士の通信を実現するのかを説明していきます。

はじめに

マルチホスト間でのDocker Container通信とは、次のようにホストをまたいでDocker Container同士が通信することを指します。

f:id:kenji-suzuki:20170707164743p:plain

図で見ると大分単純ですし、前提条件次第では実際簡単に実現できるのですが、Containerのオーケストレーションを前提におくと色々な仕組みが必要なことがわかってきます。

DockerによるContainerのマルチホスト間通信

例としてA,B,C,Dという4つのDocker Containerを、複数のホストに配置するとこのようになります(Host XとYは同じネットワークに属することとします)。
※以降の説明には不要なため、vethの記載は省略します。

f:id:kenji-suzuki:20170714142929p:plain

これをネットワーク的な視点で見ると、次のような感じです。 f:id:kenji-suzuki:20170714184336p:plain

Container AとContainer Bは当然通信できます。
また、Container AからHost Yまでも問題なく通信できます。 f:id:kenji-suzuki:20170714184907p:plain

しかし、Container Cとは通信できません。なぜならContainer AとContainer Cは異なるPrivate Network上に存在するからです。 f:id:kenji-suzuki:20170714185209p:plain

この問題を解決して、異なるホストのContainerと通信できるようにする方法はいくつかあります。
その1つがDockerのPort Forwarding機能です。

Port Forwarding

Port Forwarding機能を使うとContainerの外部からContainerにアクセスできるようになります。

# Host Yの8080番ポートにアクセスしたら、Container Cの80番ポートにForwardする
host-y: $ docker run --name container-c -d -p 8080:80 nginx

f:id:kenji-suzuki:20170714190341p:plain

iptablesの設定内容を見ると、docker runで指定したようにPort Forwardingが行われる設定がされていることがわかります。

host-y:/$ sudo iptables-save 
*nat
# 略
-A DOCKER ! -i docker0 -p tcp -m tcp --dport 8080 -j DNAT --to-destination 172.17.0.2:80
COMMIT
# 略

この通信方法は「接続元のContainerが接続先のContainerのホストのIPを知っている」という条件のもとに成り立っています。つまりContainerが起動するホストが動的に変わるようなケースには対応できません。
逆に言えばホストが固定されていて、今後も変わらないならばこれで十分です。

Overlay Network

Overlay Networkとは、あるNetworkの上に別のNetworkを構築する技術です。
異なるホストのContainer同士が通信できない原因はContainerが異なるネットワークに属していることにありました。
「じゃあOverlay Network構築して同じネットワークに属するようにしてしまえばいいじゃん」というのがこの方法です。 f:id:kenji-suzuki:20170714200720p:plain

Overlay Networkを使用する場合、Containerは同じネットワークに属していることになるので、異なるホストのContainerと直接通信することができるようになります。
続いてOverlay Networkの構築方法をいくつかご紹介します。

1. Docker Swarm mode

Dockerのオーケストレーション機能であるSwarm modeでContainerのマルチホスト間通信を実現している方法が、まさにこのOverlay Networkを利用する方法です。
Swarm modeでクラスタを構築した後、Docker用のネットワーク一覧を表示するdocker network lsコマンドを実行してみると、Overlay Networkが構築されていることがわかります。

$ docker network ls
NETWORK ID          NAME                DRIVER              SCOPE
d95e8109f33a        bridge              bridge              local
e5430ed50f3d        docker_gwbridge     bridge              local
6f6c8c7f698d        host                host                local
1q38yeph9dnj        ingress             overlay             swarm
2bae9ccfec69        none                null                local

以前のSwarmではService Discoveryとしてconsul, etcd, zookeeperなどの分散KVSが必要でしたが、Swamr modeの登場以降は不要になっています(Dockerに組み込まれたようです)。

2. 分散KVSとdocker network create -d overlayコマンドを使って自前で構築

Swarm modeがやってくれているようなことを自前でやります。いまはSwarm modeがあるので、この方法が使われることはあまりないでしょう。

3. flannelのようなOverlay Network構築ツールを使う

flannelは公式ページの冒頭に「Flannel is a simple and easy to configure layer 3 network fabric designed for Kubernetes.」とあるように、元々Kubernetes向けに作られたものです。
flannelを使用する場合、次のイメージのように各ホストにflanneldとetcdをインストールします。

f:id:kenji-suzuki:20170717220751p:plain

flannelの主な役割は2つです。

  1. etcdを使ってNetwork情報を共有することで、docker0に重複しないSubNetを割り当てる。
    (割り当て済みのSubNetのアドレス帯を共有しているので重複を避けられる)
  2. 各ホストに仮想NICflannel0を作成。Container間でpacketを送受信する際、docker0と接続されたflannel0がVXLANでpacketのカプセル化・非カプセル化を行う。

VXLANでカプセル化されたpacketは次のような構造をしています。

f:id:kenji-suzuki:20170718012341p:plain

元のpacket(Inner MAC Address 〜 payloadまで)の送信先IPはContainer CのIPですが、Container Cは仮想的には同じNetworkに属しているものの物理的には別のNetworkに属しておりルーティングできないので、上記図でルータ的役割を果たしているHost Yのeth0を宛先とする別のHeader(Outer MAC Address 〜 VXLAN Headerまで)を付加しています。このようにあるプロトコルを別のプロトコルで包むことをカプセル化といいます。

packetを受信した側は、付加されたHeaderの情報(VXLAN Headerの情報など)から受信したpacketがVXLANのpacketであることが判別できるので、包んでいるプロトコルを剥がして本来送信したかった宛先に送ることができます。 例えばHost XのContainer AからHost YのContainer Cにpacketを送信すると次のようにpacketが送信されていきます。NICの右側が、そのNICのpacketをキャプチャしたものです(簡略化しています)。

f:id:kenji-suzuki:20170726032055p:plain f:id:kenji-suzuki:20170726032225p:plain

Overlay Networkだけで解決できないこと

Port Forwardingと同様に、Containerが起動するホストが動的に変わるようなケースには対応できません。
同じネットワークに属してはいますが、同じホストに存在するわけではないので、dockerのlink機能やdocker-composeを使っての名前解決ができないからです。
Docker swarmやKubernetesなどはこの問題を解決するための仕組みをもっています。

次回は、KubernetesにおけるContainerのマルチホスト間通信と合わせてこのあたりのことを書きたいと思います。

シリーズ

第1回: マルチホストでのDocker Container間通信 第1回: Dockerネットワークの基礎
第2回: マルチホストでのDocker Container間通信 第2回: Port Forwarding と Overlay Network (当記事)
第3回: マルチホストでのDocker Container間通信 第3回: Kubernetesのネットワーク(CNI, kube-proxy, kube-dns)

マルチホストでのDocker Container間通信 第1回: Dockerネットワークの基礎

こんにちは。SPEEDA開発チームの鈴木です。
調べてみるとなかなか興味深い技術であるマルチホストでのDocker Conainer間通信。
これをどのように実現しているのか説明したいと思います。 が、その前に今回の投稿では、まず基礎知識的な話としてDockerのネットワークについて順を追って説明をします。

Dockerのネットワーク

docker0

Dockerをインストールしたあと、ifconfigip addr showするとdocker0なるものが表示されるようになるので、気になっていた人もいるかと思います。
これは一体何者なのでしょうか。

[kenji@arch ~]$ ifconfig
docker0: flags=4099<UP,BROADCAST,MULTICAST>  mtu 1500
        inet 172.17.0.1  netmask 255.255.0.0  broadcast 0.0.0.0
        ether 02:42:fb:28:ee:2d  txqueuelen 0  (Ethernet)
        RX packets 0  bytes 0 (0.0 B)
        RX errors 0  dropped 0  overruns 0  frame 0
        TX packets 0  bytes 0 (0.0 B)
        TX errors 0  dropped 0 overruns 0  carrier 0  collisions 0

 
このdocker0はDockerによって自動的に作成された仮想ブリッジで、Docker Container同士をつなぐためのものです。

f:id:kenji-suzuki:20170707184308p:plain

docker0に接続されているネットワーク・インタフェース

何かしらDocker Containerを起動した状態で、ブリッジを管理するbrctlコマンドを実行してみると次のようにdocker0に接続されているネットワーク・インタフェースが表示されます。 (brctlbridge-utilsパッケージに含まれています)

[kenji@arch ~]$ brctl show
bridge name     bridge id               STP enabled     interfaces
docker0         8000.0242fb28ee2d       no              veth269de4e
                                                        veth87e3222
                                                        vethbcff718

そしてこれがインタフェースをgrep vethしてみた結果です。たしかにこのネットワーク・インタフェースは存在するようですね。

[kenji@arch ~]$ ip addr show | grep veth
9: vethbcff718@if8: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue master docker0 state UP group default 
11: veth269de4e@if10: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue master docker0 state UP group default 
15: veth87e3222@if14: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue master docker0 state UP group default 

 
ちなみにDocker Containerをすべて落としてみるとこうなります。docker0には何も接続されていないようですね。

[kenji@arch ~]$ brctl show
bridge name bridge id       STP enabled interfaces
docker0     8000.0242fb28ee2d   no  

さきほど存在したネットワーク・インタフェースもいなくなっています。

[kenji@arch ~]$ ifconfig | grep veth
[kenji@arch ~]$ 

 
なるほど。じゃあこのvethXXXがDocker Containerのネットワーク・インタフェースなのか、というとちょっと違うのですが、正確なところを説明する前にそもそもvethって何なのかを説明します。

veth

vethは virtualethernet device、つまり仮想的なネットワーク・インタフェースです。
vethは常にペアで作成され、このペア間で通信が行えます。
(vethペアの片割れをveth peerといいます)

f:id:kenji-suzuki:20170707192057p:plain

 
vethペアは次のようなコマンドで作成できます。

[kenji@arch ~]$ sudo ip link add name veth1 type veth peer name veth1-peer

ip link showで確認してみると、vethのペアが作成されていることが分かります。

[kenji@arch ~]$ ip link show | grep veth
18: veth1-peer@veth1: <BROADCAST,MULTICAST,M-DOWN> mtu 1500 qdisc noop state DOWN mode DEFAULT group default qlen 1000
19: veth1@veth1-peer: <BROADCAST,MULTICAST,M-DOWN> mtu 1500 qdisc noop state DOWN mode DEFAULT group default qlen 1000

 
さて、ここで一つの疑問が生じます。

「Docker Containerを起動したときに作成されるvethって一つじゃね?」

Containerの起動後にネットワーク・インタフェースを表示してみると、たしかにそう見えます。

[kenji@arch ~]$ docker ps --format "table {{.ID}} {{.Names}}"
CONTAINER ID NAMES
b71e096208c2 kong
7add408c9775 kong-database

# Containerが2つなので4つvethが表示されるはず?
[kenji@arch ~]$ ip link show | grep veth
17: vethb96b887@if16: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue master docker0 state UP mode DEFAULT group default 
21: veth9c7e462@if20: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue master docker0 state UP mode DEFAULT group default 

 
この疑問に答えるキーワードがネットワーク名前空間です。

ネットワーク名前空間

Dockerのことを勉強すると必ずといっていいほど説明されていますが、Linuxはカーネルが扱う様々なリソースをある単位でまとめて分離する仕組みを持っています。 この仕組みを名前空間といいます。 分離されたリソースはその名前空間に属するプロセス以外からは直接見えなくなります。

例えばunshareコマンドでUTS名前空間を新たに作成した場合、その名前空間を作成した側と別のホスト名を持つことができます。

# 1. 名前空間のエントリを表示
kenji@test1:~$ sudo ls -l /proc/*/ns | grep uts | awk '{print $11}' | sort | uniq
uts:[4026531838]

# 2. UTS名前空間を新たに作成し、その名前空間でbashを実行
kenji@test1:~$ sudo unshare -u /bin/bash
root@test1:~# 

# 1,2 とは別のシェルで名前空間のエントリを表示すると一つ増えてることが分かる
kenji@test1:~$ sudo ls -l /proc/*/ns | grep uts | awk '{print $11}' | sort | uniq
uts:[4026531838]
uts:[4026532203]

# 1,2 のシェルでホスト名を変更
root@test1:~# hostname updated-host
root@test1:~# hostname
updated-host

# 1,2 とは別のシェルでホスト名を表示すると、こちらには上記の変更が反映されないことが分かる
kenji@test1:~$ hostname
test1

 

この名前空間には色々種類があるのですが、ネットワークに対する名前空間がネットワーク名前空間です。
そして前述のvethペアの片割れは、作成したネットワーク名前空間に移動させることができるのです。
つまりvethペアによってネットワーク名前空間同士を仮想的なケーブルでつなぐことができます。

f:id:kenji-suzuki:20170709234136p:plain

移動させたvethは移動元からは見えなくなります。これがDocker Containerのvethの片割れが見えなかった理由です。
ここまでを踏まえるとDockerのネットワークが次のようになっていることが理解できるでしょう。

f:id:kenji-suzuki:20170710000413p:plain

更に付け加えると、veth(peer)はDocker Container内ではeth0として見えるようになっています。 またDockerによりveth(peer)にIPアドレスが割り当てられます。

f:id:kenji-suzuki:20170710132020p:plain

Docker Containerのネットワーク名前空間を参照する

ネットワーク名前空間の一覧はip netnsコマンドで参照でき、ip netns exec ネットワーク名前空間名コマンドを使うと指定したネットワーク名前空間で任意のコマンドが実行できるのですが、Docker Containerのネットワーク名前空間はDockerにより隠蔽されています。
しかし以下2つの情報を知っていれば、強引に参照することができます。

  1. Docker Containerのネットワーク名前空間は/proc/Docker ContainerのPID/ns/netに割り当てられる。
  2. ip netnsコマンドで表示されるネットワーク名前空間は、/var/run/netnsディレクトリ配下のものである。

どういうことかというと、Docker ContainerのPIDを調べてネットワーク名前空間を見つけて、/var/run/netnsへのリンクを貼るのです。
試してみたいけど面倒くさいという方のためにシェルを用意してあります(github)。
プログラムの性質上、実行にroot権限が必要になるので、気になるようでしたら内容をご確認ください。

git clone https://github.com/pujoheadsoft/docker-netns.git
cd docker-netns
chmod +x docker-netns.sh 

# 実行例
~/docker-netns$ sudo ./docker-netns.sh visible
container's network namespace to visible, you can show container's nemespace by [ip netns] command.
kenji@master:~/docker-netns$ ip netns
bfe34b8a7972
2ec174520fda

# vethとvethに対応するContainerを表示
kenji@test1:~/docker-netns$ sudo ./docker-netns.sh showveth
VETH                 CONTAINER ID         NAMES                         
veth2753b2f          51f82f5e9712         /mywordpress_wordpress_1      
vetha334043          4939c68f1740         /mywordpress_db_1  

# ContainerのIPを表示(リソースはcontainer側のものですが、コマンドはホスト側のものを使えるので、IPを表示するコマンドがないContainerでもIPを表示できます)
kenji@test1:~/docker-netns$ sudo ./docker-netns.sh showip
CONTAINER ID    IP                                       NAMES
51f82f5e9712    inet 172.18.0.3/16 scope global eth0     /mywordpress_wordpress_1
4939c68f1740    inet 172.18.0.2/16 scope global eth0     /mywordpress_db_1

ルーティング

Dockerにより、Docker Containerから外部のネットワークに接続できるようIPマスカレードが行われるようなルーティングになっています。
これはIPテーブルを参照してみると分かります。

[kenji@arch ~]$ ip addr show | grep docker
3: docker0: <NO-CARRIER,BROADCAST,MULTICAST,UP> mtu 1500 qdisc noqueue state DOWN group default 
    inet 172.17.0.1/16 scope global docker0

# iptablesの情報を参照
[kenji@arch ~]$ sudo iptables-save 
# Generated by iptables-save v1.6.1 on Mon Jul 10 01:15:10 2017
*nat
# --中略--
-A PREROUTING -m addrtype --dst-type LOCAL -j DOCKER
-A OUTPUT ! -d 127.0.0.0/8 -m addrtype --dst-type LOCAL -j DOCKER

# これがIPマスカレードの部分。Dockerのネットワーク(172.17.0.0/16)かつ、
# docker0以外のインタフェースからパケットが出力された場合IPマスカレードする
-A POSTROUTING -s 172.17.0.0/16 ! -o docker0 -j MASQUERADE
-A DOCKER -i docker0 -j RETURN

COMMIT
# --中略--

これまでの図に加えると、このような感じになりますね。

f:id:kenji-suzuki:20170710131809p:plain

以上がDockerのネットワークの説明になります。
Docker Composeを使った場合、bridgeやルーティングはまたこれとは少し違ったものになるのですが、そこは割愛します。
次回は、いよいよマルチホストでのDocker Container間通信の話をします。


株式会社ユーザベースでは、DockerやDocker関連技術に興味のあるエンジニアを大募集中です!

シリーズ

第1回: マルチホストでのDocker Container間通信 第1回: Dockerネットワークの基礎 (当記事)
第2回: マルチホストでのDocker Container間通信 第2回: Port Forwarding と Overlay Network
第3回: マルチホストでのDocker Container間通信 第3回: Kubernetesのネットワーク(CNI, kube-proxy, kube-dns)