GKE/Kubernetes でなぜ Pod と通信できるのか

  • 31
    いいね
  • 0
    コメント

Google によるフルマネージドサービスである GKE では Kubernetes のマスタやノードのことは理解しなくとも Kubernetes の API を使って実現したいことに集中できる。
しかし、本番運用をはじめてから「なぜ動いているのかもなぜ動かないのかも分からない」というような状態ではいざという時に困る。
特に Service がどのようにクラスタの外から Pod へのアクセスを提供しているのかはブラックボックスになっていたため、今回は GKE での Service の L3(〜L4) の挙動について Kubernetes の内外を調査した。

(後で図や参照情報へのリンクを追加予定)

追記(2017/3/22)

この記事が書かれた当時は GKE での LoadBalancer Service の動作についてドキュメントがなく、私を含めて動作を疑問に思っている人が多かった。よって、ドキュメントとソースコードを照らし合わせつつ挙動を確認する必要があった。
現在は Google Cloud Next 2017 にてこの記事の内容の概要をカバーする Kubernetes の Co-founder & Tech-lead の Googler である Tim Hockin と Michael Rubin
による下記の発表が公開されているので、これを観ることが正しくかつ近道である。この記事で触れていない localOnly 等も解説されている。

The ins and outs of networking in Google Container Engine and Kubernetes (Google Cloud Next '17)
https://www.youtube.com/watch?v=y2bhV81MfKQ

kube-proxy によって出力される iptables の中身を把握したい人には参考になるかもしれない。

環境

Kubernetes ver. 1.5.2(Master & Node)

GKE クラスタの初期設定

今回はネットワークを作ってその中で作業する。

$ gcloud compute networks create kube-sandbox
(省略)
$ gcloud compute firewall-rules create default-allow-ssh --network kube-sandbox --allow tcp:22
(省略)

上で作った kube-sandbox ネットワーク内に GKE クラスタを作成する。今回は単純化のためノードは1とする。

$ gcloud beta container clusters create --machine-type=g1-small --num-nodes=1 --network=kube-sandbox kube-sandbox
(省略)
NAME          ZONE          MASTER_VERSION  MASTER_IP       MACHINE_TYPE  NODE_VERSION  NUM_NODES  STATUS
kube-sandbox  asia-east1-a  1.5.2           104.199.251.51  g1-small      1.5.2         1          RUNNING

現時点で kube-sandbox ネットワークには下記のルートが自動的に設定されている。具体的なノード名等は環境ごとに違うので読み替えてほしい。

$ gcloud compute routes list --filter=network=kube-sandbox
NAME                                                            NETWORK       DEST_RANGE     NEXT_HOP                                                            PRIORITY
default-route-0febf5d565eb04b9                                  kube-sandbox  10.146.0.0/20                                                                      1000
default-route-61d48fa98847c47f                                  kube-sandbox  10.138.0.0/20                                                                      1000
default-route-73c1d7230a9f1444                                  kube-sandbox  10.128.0.0/20                                                                      1000
default-route-7cc26e9102aa6708                                  kube-sandbox  10.142.0.0/20                                                                      1000
default-route-7de16d017205e5de                                  kube-sandbox  10.132.0.0/20                                                                      1000
default-route-e3201908a50d2b01                                  kube-sandbox  0.0.0.0/0      default-internet-gateway                                            1000
default-route-fccedbefa32ce294                                  kube-sandbox  10.140.0.0/20                                                                      1000
gke-kube-sandbox-5bb78bc1-3ca8c4dd-edb8-11e6-90da-42010af00188  kube-sandbox  10.76.0.0/24   asia-east1-a/instances/gke-kube-sandbox-default-pool-88bb8656-26d1  1000

いかにも GKE な感じの gke-kube-sandbox-5bb78bc1-3ca8c4dd-edb8-11e6-90da-42010af00188 というルートができており NEXT_HOP に指定されている GCE インスタンスが作成した GKE クラスタのノードであることがわかる。
該当するインスタンスを確認する。
(表示内容の調整のため format 内でprojection を使っている。)

$ gcloud compute instances describe gke-kube-sandbox-default-pool-88bb8656-26d1 \
         --format='table(name,networkInterfaces[0].networkIP, networkInterfaces[0].accessConfigs[0].natIP, tags.items.map(0).join(","):label=TAGS)'
NAME                                         NETWORK_IP  NAT_IP           TAGS
gke-kube-sandbox-default-pool-88bb8656-26d1  10.140.0.2  104.199.254.163  gke-kube-sandbox-5bb78bc1-node,goog-gke-node

対応する Kubernetes 側の Node 情報を確認する。
(デフォルトでは表示されないフィールドを抜き出すために custom-columns の中で JSONPath を使う。)

$ kubectl get node -o custom-columns='NAME:.metadata.name,EXTERNAL-IP:.status.addresses[?(@.type=="ExternalIP")].address,INTERNAL-IP:.status.addresses[?(@.type=="InternalIP")].address,POD-CIDR:.spec.podCIDR'
NAME                                          EXTERNAL-IP       INTERNAL-IP   POD-CIDR
gke-kube-sandbox-default-pool-88bb8656-26d1   104.199.254.163   10.140.0.2    10.76.0.0/24

GCE ルートの DEST_RANGE と Kubernetes Node の POD-CIDR, GCE ルートの NEXT_HOP と Kubernetes Node の NAME が対応していることが確認できた。
一応 Node の中の設定も見てみると、 POD-CIDR のアドレス範囲は cbr0 ブリッジに割り当てられていることがわかる。

$ gcloud compute ssh user@gke-kube-sandbox-default-pool-88bb8656-26d1 -- ip address show cbr0
4: cbr0: <BROADCAST,MULTICAST,PROMISC,UP,LOWER_UP> mtu 1460 qdisc htb state UP group default qlen 1000
    link/ether 0a:58:0a:4c:00:01 brd ff:ff:ff:ff:ff:ff
    inet 10.76.0.1/24 scope global cbr0
       valid_lft forever preferred_lft forever
    inet6 fe80::a42a:16ff:fe06:38a5/64 scope link
       valid_lft forever preferred_lft forever

この記事では L2 レイヤのコンテナネットワークについては説明しないが、このブリッジにそれぞれの Pod の veth がアタッチされる。
(/24 ということは同時にIP アドレスが割り当てられる Pod は1 Node につき254個ということを意味する。 GKE に限って言えば nodeIpv4CidrSize フィールドは変更不可能なようだから、他のリソースに余裕があっても Pod の数に応じて Node をスケールする必要があるように見える。)

ここまでで Pod の IP アドレスから対象の GCE インスタンスへのルーティングが行われていることが分かる。

これらの設定は何を意味するのだろうか。
Networking によると Kubernetes のコンテナ間ネットワークの基本要件は下記の3つがある。

  • 全てのコンテナは別の全てのコンテナと NAT なしで通信できる
  • 全てのノードは全てのコンテナと NAT なしで通信できる
    • 逆も同様(全てのコンテナは全てのノードと NAT なしで通信できる)
  • コンテナは自分自身の IP アドレスを他のコンテナから見えるのと同じ IP アドレスとして見ることができる

flannel 等のオーバレイネットワークでこれらの要件に対応している環境もあるが、 GCE ではオーバレイネットワークが不要としているのは、この GCE ルートの設定によるものであるようだ。

Kubernetes の世界

ここからは Kubernetes の Service がどのように動いているかについて「Debugging Services - Is the kube-proxy working?」を参考に確認していく。

kube-proxy

Service は kube-proxy による仮想 IP アドレスと kube-dns による名前解決によって実現されているが、この記事では L3(〜L4) にフォーカスするため kube-dns については特に説明しない。
kube-proxy は Kubernetes 1.2 以降は動的に iptables のルールを操作 (Proxy-mode: iptables) することで、 Linux カーネルの netfilter を使って動い、仮想 IP アドレスへのアクセスを適切に Pod に転送する。
なお、過去は名前通りユーザスペースのプロキシ (Proxy-mode: userspace) として動いていたらしい。

SSH 経由で GKE ノードの iptables のルール全体を取得するには下記のようにする。

$ gcloud compute ssh user@gke-kube-sandbox-default-pool-88bb8656-26d1 -- sudo iptables-save

操作ごとに差分のみ示す。なお著者は iptables に詳しくないので、 iptables 自体の解釈については他の記事も参考にしてほしい。

(注: Kubernetes の外に転送する ExternalName Service と 複数 IP アドレスを A レコードで返す Headless Service は DNS ベースで仮想 IP アドレスを持たないのでこの記事では触れない。)

Service なし

まずは kubectl run で作った Deployment から Pod を起動する。

$ kubectl run hostnames --image=gcr.io/google_containers/serve_hostname \
                        --labels=app=hostnames \
                        --port=9376 \
                        --replicas=1
deployment "hostnames" created

下記のような IP アドレスを持った Pod が作られた。この時点だと特に iptables には変化がない。

$ kubectl get pod -o wide -l app=hostnames
NAME                         READY     STATUS    RESTARTS   AGE       IP          NODE
hostnames-3799501552-jpmdq   1/1       Running   0          6m        10.76.0.9   gke-kube-sandbox-default-pool-88bb8656-26d1

ClusterIP Service

先程の Pod に対応する の ClusterIP Service を作ってみよう。

$ kubectl expose deployment hostnames --port=80 --target-port=9376
service "hostnames" exposed

作られたものを確認してみる。 Service だけでなく Endpoints ができていることが分かる。

$ kubectl get pod,endpoints,service -o wide -l app=hostnames
NAME                            READY     STATUS    RESTARTS   AGE       IP          NODE
po/hostnames-3799501552-jpmdq   1/1       Running   0          42m       10.76.0.9   gke-kube-sandbox-default-pool-88bb8656-26d1

NAME           ENDPOINTS        AGE
ep/hostnames   10.76.0.9:9376   33m

NAME            CLUSTER-IP    EXTERNAL-IP   PORT(S)   AGE       SELECTOR
svc/hostnames   10.79.255.4   <none>        80/TCP    33m       app=hostnames

この時点で iptables には次のルールが追加されていた。

-A KUBE-SERVICES ! -s 10.76.0.0/14 -d 10.79.255.4/32 -p tcp -m comment --comment "default/hostnames: cluster IP" -m tcp --dport 80 -j KUBE-MARK-MASQ
-A KUBE-SERVICES -d 10.79.255.4/32 -p tcp -m comment --comment "default/hostnames: cluster IP" -m tcp --dport 80 -j KUBE-SVC-NWV5X2332I4OT4T3
-A KUBE-SVC-NWV5X2332I4OT4T3 -m comment --comment "default/hostnames:" -j KUBE-SEP-EV2NQKURMPKCP5UI
-A KUBE-SEP-EV2NQKURMPKCP5UI -s 10.76.0.9/32 -m comment --comment "default/hostnames:" -j KUBE-MARK-MASQ
-A KUBE-SEP-EV2NQKURMPKCP5UI -p tcp -m comment --comment "default/hostnames:" -m tcp -j DNAT --to-destination 10.76.0.9:9376

部分ごとに見ていこう。

-A KUBE-SERVICES ! -s 10.76.0.0/14 -d 10.79.255.4/32 -p tcp -m comment --comment "default/hostnames: cluster IP" -m tcp --dport 80 -j KUBE-MARK-MASQ

10.76.0.0/14 の中以外から ClusterIP への通信は KUBE-MARK-MASQ に回されるようだ。上で確認した cbr0 のアドレス空間は 10.76.0.0/24 だが、等しくないのは何のためで、どこで設定されているのか?
設定箇所は kube-proxy の起動オプションが答えだった。ノードごとの Pod のアドレス空間の上にクラスタの Pod のアドレス空間があることが分かる。

$ gcloud compute ssh user@gke-kube-sandbox-default-pool-88bb8656-26d1 -- ps ax | grep kube-proxy
 1625 ?        Sl     1:48 kube-proxy --master=https://104.199.251.51 --kubeconfig=/var/lib/kube-proxy/kubeconfig --cluster-cidr=10.76.0.0/14 --resource-container= --v=2

KUBE-MARK-MASQ は次のように定義されている一連のルールを読むと POSTROUTING を経由して KUBE-POSTROUTING からマスカレード(NAT)するかどうかの判断のためのフラグをつける設定だとわかる。
よって、クラスタ境界を跨ぐ通信は NAT されることがわかった。

-A KUBE-MARK-MASQ -j MARK --set-xmark 0x4000/0x4000
-A KUBE-POSTROUTING -m comment --comment "kubernetes service traffic requiring SNAT" -m mark --mark 0x4000/0x4000 -j MASQUERADE
-A POSTROUTING -m comment --comment "kubernetes postrouting rules" -j KUBE-POSTROUTING

次のルールを見てみよう。Cluster IP への通信は KUBE-SVC-* チェインに飛ぶことが分かる。

-A KUBE-SERVICES -d 10.79.255.4/32 -p tcp -m comment --comment "default/hostnames: cluster IP" -m tcp --dport 80 -j KUBE-SVC-NWV5X2332I4OT4T3

KUBE-SVC-* チェインは Endpoints に対応する KUBE-SEP-* に飛ぶ。

-A KUBE-SVC-NWV5X2332I4OT4T3 -m comment --comment "default/hostnames:" -j KUBE-SEP-EV2NQKURMPKCP5UI

KUBE-SEP-* は対象の Pod の ContainerPort に宛先の IP アドレスとポート番号を転送する。

-A KUBE-SEP-EV2NQKURMPKCP5UI -s 10.76.0.9/32 -m comment --comment "default/hostnames:" -j KUBE-MARK-MASQ
-A KUBE-SEP-EV2NQKURMPKCP5UI -p tcp -m comment --comment "default/hostnames:" -m tcp -j DNAT --to-destination 10.76.0.9:9376

ここまでで、 Cluster IP へのアクセスが Service を構成する Pod へと転送されることがわかった。

Pod をスケールする

Pod をスケールした場合の変化を確認する。

$ kubectl scale deployment hostnames --replicas 2
deployment "hostnames" scaled

複数の Pod に対応する Endpoints が出来ていることがわかる。

$ kubectl get pod,endpoints,service -o wide -l app=hostnames
NAME                            READY     STATUS    RESTARTS   AGE       IP           NODE
po/hostnames-3799501552-jpmdq   1/1       Running   0          39m       10.76.0.9    gke-kube-sandbox-default-pool-88bb8656-26d1
po/hostnames-3799501552-nw2gz   1/1       Running   0          21m       10.76.0.10   gke-kube-sandbox-default-pool-88bb8656-26d1

NAME           ENDPOINTS                        AGE
ep/hostnames   10.76.0.10:9376,10.76.0.9:9376   30m

NAME            CLUSTER-IP    EXTERNAL-IP   PORT(S)   AGE       SELECTOR
svc/hostnames   10.79.255.4   <none>        80/TCP    30m       app=hostnames

この時点でできた差分は下記のようになる。

-A KUBE-SEP-BWZ646KDVNVKXICT -s 10.76.0.10/32 -m comment --comment "default/hostnames:" -j KUBE-MARK-MASQ
-A KUBE-SEP-BWZ646KDVNVKXICT -p tcp -m comment --comment "default/hostnames:" -m tcp -j DNAT --to-destination 10.76.0.10:9376
-A KUBE-SVC-NWV5X2332I4OT4T3 -m comment --comment "default/hostnames:" -m statistic --mode random --probability 0.50000000000 -j KUBE-SEP-BWZ646KDVNVKXICT

KUBE-SEP については、新しく追加された Pod に対しても同様にチェインを追加している。

-A KUBE-SEP-BWZ646KDVNVKXICT -s 10.76.0.10/32 -m comment --comment "default/hostnames:" -j KUBE-MARK-MASQ
-A KUBE-SEP-BWZ646KDVNVKXICT -p tcp -m comment --comment "default/hostnames:" -m tcp -j DNAT --to-destination 10.76.0.10:9376

注目すべきは statistic module の random モードを使って 50% ずつ振り分けを行っているところだ。これにより ClusterIP はロードバランサとして働く。

-A KUBE-SVC-NWV5X2332I4OT4T3 -m comment --comment "default/hostnames:" -m statistic --mode random --probability 0.50000000000 -j KUBE-SEP-BWZ646KDVNVKXICT
-A KUBE-SVC-NWV5X2332I4OT4T3 -m comment --comment "default/hostnames:" -j KUBE-SEP-EV2NQKURMPKCP5UI

単純化のためにレプリカ数は1に戻しておこう。

$ kubectl scale deployment hostnames --replicas 1
deployment "hostnames" scaled

NodePort Service

これから外に公開する Service を作ってみる。まずは Node であるインスタンスのポートを通して Service にアクセスできるようにする NodePort Service だ。
前回作った ClusterIP Service を消してから作成する。

$ kubectl delete service hostnames
service "hostnames" deleted
$ kubectl expose deployment hostnames --port=80 --target-port=9376 --type=NodePort
service "hostnames" exposed

作られたリソースを見てみる。 Service のポート 80 がノードのポート 31358 に割り当てられていることが分かる。

$ kubectl get pod,endpoints,service -o wide -l app=hostnames
NAME                            READY     STATUS    RESTARTS   AGE       IP          NODE
po/hostnames-3799501552-jpmdq   1/1       Running   0          50m       10.76.0.9   gke-kube-sandbox-default-pool-88bb8656-26d1

NAME           ENDPOINTS        AGE
ep/hostnames   10.76.0.9:9376   1m

NAME            CLUSTER-IP      EXTERNAL-IP   PORT(S)        AGE       SELECTOR
svc/hostnames   10.79.245.174   <nodes>       80:31358/TCP   1m        app=hostnames

CLUSTER-IP が設定されていることからも、 ClusterIP Service の拡張であることがわかる。
iptables には下記のようなルールが追加された。
KUBE-NODEPORTS に来たもので NodePort に割り当てられたポートあてのパケットを対応する KUBE-SVC-* で処理するルールだとわかる。

-A KUBE-NODEPORTS -p tcp -m comment --comment "default/hostnames:" -m tcp --dport 31358 -j KUBE-MARK-MASQ
-A KUBE-NODEPORTS -p tcp -m comment --comment "default/hostnames:" -m tcp --dport 31358 -j KUBE-SVC-NWV5X2332I4OT4T3

KUBE-NODEPORTS チェインとは何かというと、 KUBE-SERVICES に来たものでノードのローカルアドレス宛てのパケットだ。

-A KUBE-SERVICES -m comment --comment "kubernetes service nodeports; NOTE: this must be the last rule in this chain" -m addrtype --dst-type LOCAL -j KUBE-NODEPORTS

これにより、ノードの NodePort に来たものが Service の Endpoints を構成する Pod の適切なポートに転送されることが分かる。

LoadBalancer Service (GKE 依存)

最後に LoadBalancer Service がどのように動くのかを見る。

$ kubectl delete service hostnames
service "hostnames" deleted
$ kubectl
kubectl expose deployment hostnames --port=80 --target-port=9376 --type=LoadBalancer

作られたリソースを確認する。 EXTERNAL-IP が設定された。

% kubectl get pod,endpoints,service -o wide -l app=hostnames
NAME                            READY     STATUS    RESTARTS   AGE       IP          NODE
po/hostnames-3799501552-jpmdq   1/1       Running   0          55m       10.76.0.9   gke-kube-sandbox-default-pool-88bb8656-26d1

NAME           ENDPOINTS        AGE
ep/hostnames   10.76.0.9:9376   1m

NAME            CLUSTER-IP      EXTERNAL-IP       PORT(S)        AGE       SELECTOR
svc/hostnames   10.79.250.127   104.199.232.159   80:30675/TCP   1m        app=hostnames

PORT を見ると、 NodePort Service の上に実装されていることが分かる。
iptables には次のようなルールが追加された。

-A KUBE-SERVICES -d 104.199.232.159/32 -p tcp -m comment --comment "default/hostnames: loadbalancer IP" -m tcp --dport 80 -j KUBE-FW-NWV5X2332I4OT4T3
-A KUBE-FW-NWV5X2332I4OT4T3 -m comment --comment "default/hostnames: loadbalancer IP" -j KUBE-MARK-MASQ
-A KUBE-FW-NWV5X2332I4OT4T3 -m comment --comment "default/hostnames: loadbalancer IP" -j KUBE-SVC-NWV5X2332I4OT4T3
-A KUBE-FW-NWV5X2332I4OT4T3 -m comment --comment "default/hostnames: loadbalancer IP" -j KUBE-MARK-DROP

まず、下記のルールから、EXTERNAL-IP の対象宛ての Service のポート宛てのパケットは全て KUBE-FW-* で処理されることが分かる。

-A KUBE-SERVICES -d 104.199.232.159/32 -p tcp -m comment --comment "default/hostnames: loadbalancer IP" -m tcp --dport 80 -j KUBE-FW-NWV5X2332I4OT4T3

次のルールから KUBE-FW-* に来たパケットはは対応する KUBE-SVC-* で処理されることが分かる。 KUBE-MARK-DROP は何らかの理由で KUBE-SVC-* がない場合にパケットを捨てるためのものだろう。

-A KUBE-FW-NWV5X2332I4OT4T3 -m comment --comment "default/hostnames: loadbalancer IP" -j KUBE-MARK-MASQ
-A KUBE-FW-NWV5X2332I4OT4T3 -m comment --comment "default/hostnames: loadbalancer IP" -j KUBE-SVC-NWV5X2332I4OT4T3
-A KUBE-FW-NWV5X2332I4OT4T3 -m comment --comment "default/hostnames: loadbalancer IP" -j KUBE-MARK-DROP

KUBE-SVC-* に来たら今までに見てきた Service と同様なので、 EXTERNAL-IP に来たパケットは結果的には Pod に転送されることが分かる。

LoadBalancer Service の GCE 側

GCE 側も確認する。最初に確認した通り現在存在する GKE ノードの GCE インスタンスには下記のようなタグが設定されている。

$ gcloud compute instances list --format='table(name,tags.items.map(0).join(","):label=tags)'
NAME                                         tags
gke-kube-sandbox-default-pool-88bb8656-26d1  gke-kube-sandbox-5bb78bc1-node,goog-gke-node

GCE ファイアウォールを確認してみるとノードのタグに対して Service が export しているポートへの通信が許可されている。

$ gcloud compute firewall-rules list --filter=network=kube-sandbox
NAME                                     NETWORK       SRC_RANGES         RULES                         SRC_TAGS  TARGET_TAGS
k8s-fw-a0d5edc74edc111e690da42010af0018  kube-sandbox  0.0.0.0/0          tcp:80                                  gke-kube-sandbox-5bb78bc1-node

更に Service の EXTERNAL-IP の IP アドレスに対応する転送ルールが作られている。

$ gcloud compute forwarding-rules list --filter IPAddress=104.199.232.159
NAME                              REGION      IP_ADDRESS       IP_PROTOCOL  TARGET
a0d5edc74edc111e690da42010af0018  asia-east1  104.199.232.159  TCP          asia-east1/targetPools/a0d5edc74edc111e690da42010af0018

転送ルールの転送先は上で確認したインスタンスを含むターゲットプールに向いていることが確認できる。

$ gcloud compute target-pools list a0d5edc74edc111e690da42010af0018 --format='table(name,region,instances.map().basename().join(","))'
NAME                              REGION      INSTANCES
a0d5edc74edc111e690da42010af0018  asia-east1  gke-kube-sandbox-default-pool-88bb8656-26d1

なおこれらの転送ルール及びターゲットプールの生成は GCE Cloud Provider Addon が行っている。

ターゲットプールを指す転送ルールは GCE の L4 ロードバランサである Network LoadBalancing として機能するので、転送ルールに設定された外部 IP アドレスへのパケットが Node に転送されることが分かる。

なお、GCE の Network LoadBalancing は Maglev として知られる DSR 型のロードバランサなので、クライアントが送信した際の送信先 IP アドレスがそのまま iptables で処理される。(参考)

まとめ

GKE/GCE/Kubernetes を通して、下記のように Service から作られたロードバランサからどのように Pod までパケットが届くかを確認した。

  • GKE のノード間のネットワークは GCE のルートでルーティングするためオーバレイネットワークは不要
  • Kubernetes のコア機能である Service の IP レイヤは kube-proxy が動的に生成した iptables のルールを使って Linux カーネルで処理される
  • GCE Network Load Balancing 等との連携部分については Cloud Provider Addon が処理する

なお、 HTTP ロードバランサに対応する Ingress の実装は今回説明した LoadBalancer Service のものとは別になっている。
(具体的には現在 NodePort Service で公開されるポートを使って実現されている。GCE Ingress Controllerの実装を参照してほしい。)

参考

Service: https://kubernetes.io/docs/user-guide/services/
ネットワークモデル全般: https://kubernetes.io/docs/admin/networking/
kube-proxy の iptables 処理部分の実装: https://github.com/kubernetes/kubernetes/blob/v1.5.2/pkg/proxy/iptables/proxier.go
GKE に頼らずに GCE 及び AWS でクラスタを構成するチュートリアル: https://github.com/kelseyhightower/kubernetes-the-hard-way