あさのひとりごと

3日坊主にならないように、全力を尽くします。 記事は個人のひとりごとです。所属する組織の意見を代表するほど、仕事熱心じゃないです。

Kubernetesで実際のメモリを超えるコンテナアプリを動かすと、どうなるか?

Kubernetesは、コンテナアプリケーションをデプロイするためのオーケストレーションツールです。Kuberenetesは分散環境におけるスケーラブルなコンテナ実行環境をつくるための、さまざまな機能が提供されています。

もともとはGoogleが開発したBorgをもとにOSS化したものですが、今日ではマイクロソフトや(ry

Kubernetesをつかうとステートレスでマイクロサービス的なアプリケーションを1日に何度もデプロイでき、スパイクアクセスがきても水平スケールが容易なので、大規模Webシステムでスケーラブルな基盤を作れる、というのは広く知られています。

一方、Kubernetesには「Resource Requests」という機能があり、これはPodをデプロイする時に必要とするリソース(CPU/メモリ)を指定できるものです。これにより、Kubernetesクラスタのリソースの使用率を高め、効率よくアプリを稼働させることができるのも、うれしさの一つです。

Kubernetesではコンテナが使用するコンピューティングリソースを明示的に要求したり制限をかけたりすることができます。

リソース要求(Resource Request)
  • コンテナに対して指定したリソースが使えるように保証するしくみ
  • アプリを動かすのに最低限必要なリソースを指定
リソース制限(Resurce Limits)
  • コンテナのリソース使用量の上限を決めるしくみ
  • アプリケーションが使用する可能性のある最大リソースを指定

ということで、、、まずはメモリのリソース要求(Resource Request)ついて、

  • コンテナアプリが使うメモリを明示的に要求するとクラスタ上でどういう動きをするのか?
  • メモリをたくさん使う別アプリが邪魔してきたとき、きちんとシステムは動作するのか?

が気になり、簡単な検証をしてみました。

前提読者

実験環境

AzureのKubernetesのフルマネージドサービスであるAKSを使います。公式サイトをもとにKubernetesクラスタを構築しました。

クイック スタート:Azure Kubernetes Service クラスターをデプロイする | Microsoft Docs

ここでは、次の構成のクラスタで実験します。 f:id:dr_asa:20180410130928p:plain:w500

MasterもNodeもkubernetes1.9.6で検証しています。

コンテナアプリが使うメモリを明示的に要求するとクラスタ上でどういう動きをするのか?

Kubernetesがシステムで使用するメモリ量を確認

今回のクラスタはAzure VMの「Standard_D1_v2」をNodeにつかっています。Standard_D1_v2は1台当たり約3.3GBのメモリが使えます。

念のため、次のコマンドで確認します。

$ kubectl describe node

Name:               aks-nodepool1-12354740-1
Roles:              agent
Labels:             agentpool=nodepool1
~中略~
Capacity:
 memory:                          3501592Ki
Allocatable:
 memory:                          3399192Ki

~中略~
Name:               aks-nodepool1-12354740-2
Roles:              agent
Labels:             agentpool=nodepool1
Capacity:
 memory:                          3501592Ki
Allocatable:
 memory:                          3399192Ki

Node上ではkube-proxy等も動作しているため、Podを1つも動作させていない状態でも、223M/499Mのメモリを使用しています。

$ kubectl top node
NAME                       CPU(cores)   CPU%      MEMORY(bytes)   MEMORY%
aks-nodepool1-12354740-1   28m          2%        223Mi           6%
aks-nodepool1-12354740-2   128m         12%       499Mi           15%

アプリを動かすのに必要なメモリを要求してPodを動かす

まずは、Resource Requestの動きを確認します。コンテナに制限をかけるには[resources:]-[requests:]で値を指定します。 次の例(resource-pod.yaml)は、「nginx」の公式イメージをもとに動かしたコンテナが1つ含まれるPodをテンプレートとして定義し、それを「replicas: 2」つまり2つのPodがKubernetesクラスタ上にスケジューリングされる定義になります。

その際、メモリのリソース要求として[memory: 1.5Gi]を指定します。つまりこれはKubernetes に対して「このPodにあるNginxコンテナは1.5Giのメモリ必要だからよろしく!」を宣言しています。

apiVersion: apps/v1
kind: ReplicaSet
metadata:
  name: reqest-exp
spec:
  replicas: 2
  selector: 
      matchLabels:
        app: replicaset-temp
  template:
    metadata:
      labels:
        app: replicaset-temp
    spec:
      containers:
      - name: pod-sample
        image: nginx
        resources:
          requests:
            memory: 1.5Gi

これを次のコマンドを実行してクラスタ上でリソースを作ります。

$ kubectl create -f resource-pod.yaml
replicaset.apps "reqest-exp" created

PodがそれぞれNode1とNode2で1つずつ動いています。

$ kubectl get po -o wide
NAME               READY     STATUS    RESTARTS   AGE       IP             NODE
reqest-exp-9kztw   1/1       Running   0          55s       10.244.2.153   aks-nodepool1-12354740-2
reqest-exp-b4t8h   1/1       Running   0          55s       10.244.3.184   aks-nodepool1-12354740-1

絵で描くとこんな感じ。

f:id:dr_asa:20180410112208p:plain:w600

ただ、起動しているNginxは何も仕事をしていないので、実際のメモリ使用量はほとんど増えていません。

$ kubectl top node
NAME                       CPU(cores)   CPU%      MEMORY(bytes)   MEMORY%
aks-nodepool1-12354740-1   33m          3%        251Mi           7%
aks-nodepool1-12354740-2   133m         13%       505Mi           15%

要求されたメモリが実際のメモリを超えるとどうなるか

次のコマンドを実行して、Podのレプリカ数を2から3に増やします。どうなるかをみてみます。

$ kubectl scale --replicas=3 -f resource-pod.yaml
replicaset.apps "reqest-exp" scaled

Node1に2つのPod、Node2に1つのPodが動いています。まだ定義ファイルで要求した総メモリ量は、実際のクラスタのメモリ量を超えていません。

$ kubectl get pod -o wide
NAME               READY     STATUS    RESTARTS   AGE       IP             NODE
reqest-exp-9kztw   1/1       Running   0          8m        10.244.2.153   aks-nodepool1-12354740-2
reqest-exp-b4t8h   1/1       Running   0          8m        10.244.3.184   aks-nodepool1-12354740-1
reqest-exp-wzg7t   1/1       Running   0          43s       10.244.3.185   aks-nodepool1-12354740-1

絵で描くとこんな感じです。

f:id:dr_asa:20180410112253p:plain:w600

次はレプリカ数を3から4に変更します。

$ kubectl scale --replicas=4 -f resource-pod.yaml
replicaset.apps "reqest-exp" scaled

4つ目のPodである「reqest-exp-fgrpn」はどこのNodeでも起動せず「Pending」になっているのが分かります。

$ kubectl get pod -o wide
NAME               READY     STATUS    RESTARTS   AGE       IP             NODE
reqest-exp-9kztw   1/1       Running   0          13m       10.244.2.153   aks-nodepool1-12354740-2
reqest-exp-b4t8h   1/1       Running   0          13m       10.244.3.184   aks-nodepool1-12354740-1
reqest-exp-fgrpn   0/1       Pending   0          12s       <none>         <none>
reqest-exp-wzg7t   1/1       Running   0          5m        10.244.3.185   aks-nodepool1-12354740-1

またまた絵で描くとこんな感じ。

f:id:dr_asa:20180410112324p:plain:w600

しつこいですが、実際のメモリ使用量はほとんど増えていません。つまり実際はアイドル状態なのに4つ目のPodはスケジューリングされていないということをいっています。これは、4つ目のPodを配置しようとしても、メモリの空きがマニュフェストで定義した「1.5GB」を確実に確保できるスペースがNode上にないからこのような状態になっています。

$ kubectl top node
NAME                       CPU(cores)   CPU%      MEMORY(bytes)   MEMORY%
aks-nodepool1-12354740-1   30m          3%        259Mi           7%
aks-nodepool1-12354740-2   133m         13%       507Mi           15%

なぜ、4つ目のPodはスケジューリングされなかったのか

KubernetesのResource Requestsは、Podに対して指定したリソースが使えるように保証する仕組みです。

この値はPodをNodeにスケジューリングするときに使われます。Resource Requestsを指定したコンテナを含むPodを作成すると、KubernetesのスケジューラーはNodeを選択して実行します。その際、各Nodeには、各リソースタイプの最大容量をもっていてスケジューラは、各リソースタイプに対して、スケジュールされたコンテナのリソース要求の合計がノードの容量よりも少ないことをチェックします。たとえ実際のメモリ使用率は非常に低くても、スケジューラはチェックが失敗した場合はNodeにPodを配置しません。

Managing Compute Resources for Containers - Kubernetes

なおこのResource Requestsの設定はPodではなく、コンテナに対して行われます。今回指定したメモリはバイト単位で設定でき、E/P/T/G/M/K(Ei/Pi/Ti/Gi/Mi/Ki)のいずれかを使用して、メモリを整数または固定小数点整数として指定します。

今回のケースでは、実際のメモリがたくさん空いているにもかかわらずPodは配置されませんでした。Nodeのメモリの最大容量は分かっているので、あらかじめ基盤の方式設計段階でNodeにぎゅうぎゅうにPodがスケジューリングされることを避けることができます。

ただ、、、今回は同じサイズのNodeで検証しているのできちんと2台でスケジューリングされそうな気もしますが、なにか見落としているのかもわかりません。というわけで、ソースコードをきちんと読みたいと思います。 (理由をご存知の人がいればおしえてください)

 追記:まかべ@マイクロソフトさんから教えてもらいました!ご指摘通り、Node1だけheapsterが動いておりました。すっきり理解しました。 (以下原文ママ)前半のMemory Requestsについてですが、片方のNodeだけheapsterが動いていて非対称なので、その分メモリが296Mi足りなくて4つめのPodが行儀よく待っちゃってるかも。heapster分を考慮してPodごとのRequestを1.3Giにしたら、4つ動きました。

メモリをたくさん使う別アプリが邪魔してきたとき、きちんとシステムが動作するのか?

Kubernetesでは「Resource Requests」で宣言した分のメモリをきっちり確保してPodをスケジューリングしているのが分かりました。このResource Requestsは、実際のメモリの使用量ではなく、クラスタに割り当てられているリソースに基づいていることもわかりました。それでは、メモリリソースが余っているクラスタ上に、「Resource Requests」の宣言なしにメモリをたくさん使う野良Podががんがんデプロイされたらどうなるでしょうか?

まず実験のため、次のようなメモリを1.0GBつかうDockerイメージを作ります。これをビルドしてmem-stressという名前のDockerイメージを作ります。

FROM ubuntu:latest

RUN apt-get -y update && apt-get -y upgrade
RUN apt-get -y install stress

# 1つのstressプロセスが起動し、プロセスあたり1GBのメモリの負荷をかける
CMD ["stress", "-m","1", "--vm-bytes" ,"1G","--vm-hang","0","-q"]

これをDocker HubやAzure Container Registryなどのレジストリで公開します。

次にこのDockerイメージを動かすPodを起動するReplicaSetのマニュフェスト(mem-stress.yaml)を作ります。この例では上記のDockerfileをビルドした「image: asashiho/mem-stress:latest」から生成するコンテナが1つ含まれたPodをテンプレートで定義し、そのPodを2つ動かすため「replicas: 5」と定義しています。クラスタには理論上各Nodeに3.5GBずつメモリがありますので、ざっくり5つくらいの野良Podを起動してみます。

このマニュフェストのポイントは、上のNginxのPodと異なりメモリに対するリソース要求(Resource Request)を指定していないことです。 このコンテナを作った張本人である私はアプリがメモリを1.0G使うことを知っていますが、Kubernetes はどれだけリソースを使うコンテナが入ったPodなのか一切知らないのです。

apiVersion: apps/v1
kind: ReplicaSet
metadata:
  name: mem-exp
spec:
  replicas: 5
  selector: 
      matchLabels:
        app: mem-stress-temp
  template:
    metadata:
      labels:
        app: mem-stress-temp
    spec:
      containers:
      - name: pod-sample
        image: asashiho/mem-stress:latest

次のコマンドを実行すると、新しく1Gのメモリを使うPodが5つ動く、、、、、はずです

$ kubectl create -f mem-stress.yaml
replicaset.apps "mem-exp" created

なるほど。

「mem-exp-xxx」のPodは4つしか起動せず、うちいずれかの1つはCrashLoopBackOff―Errorで再起動を繰り返しています。

$ kubectl get pod -o wide
NAME               READY     STATUS             RESTARTS   AGE       IP             NODE
mem-exp-6dcgh      0/1       CrashLoopBackOff   2          1m        10.244.3.188   aks-nodepool1-12354740-1
mem-exp-dnwjk      1/1       Running            1          1m        10.244.3.186   aks-nodepool1-12354740-1
mem-exp-grxvz      1/1       Running            0          1m        10.244.2.155   aks-nodepool1-12354740-2
mem-exp-v5j29      1/1       Running            0          1m        10.244.2.154   aks-nodepool1-12354740-2
mem-exp-x9gxl      1/1       Running            2          1m        10.244.3.187   aks-nodepool1-12354740-1
reqest-exp-9kztw   1/1       Running            0          26m       10.244.2.153   aks-nodepool1-12354740-2
reqest-exp-b4t8h   1/1       Running            0          26m       10.244.3.184   aks-nodepool1-12354740-1
reqest-exp-fgrpn   0/1       Pending            0          13m       <none>         <none>
reqest-exp-wzg7t   1/1       Running            0          18m       10.244.3.185   aks-nodepool1-12354740-1

絵にかくと次の感じです。

f:id:dr_asa:20180410131054p:plain:w500

Podのログを確認するとエラーが出ているのが分かります。

$ kubectl logs mem-exp-6dcgh
stress: FAIL: [1] (415) <-- worker 5 got signal 9
stress: FAIL: [1] (421) kill error: No such process
stress: FAIL: [1] (451) failed run completed in 39s

システム全体のメモリ使用量はきちんと上がっています。

$ kubectl top node
NAME                       CPU(cores)   CPU%      MEMORY(bytes)   MEMORY%
aks-nodepool1-12354740-1   33m          3%        2275Mi          68%
aks-nodepool1-12354740-2   127m         12%       2573Mi          77%

これを、40分ほど放置します。その間に夕飯のしたくを。

$ kubectl get po -o wide
NAME               READY     STATUS             RESTARTS   AGE       IP             NODE
mem-exp-6dcgh      0/1       CrashLoopBackOff   11         44m       10.244.3.188   aks-nodepool1-12354740-1
mem-exp-dnwjk      1/1       Running            6          44m       10.244.3.186   aks-nodepool1-12354740-1
mem-exp-grxvz      1/1       Running            0          44m       10.244.2.155   aks-nodepool1-12354740-2
mem-exp-v5j29      1/1       Running            0          44m       10.244.2.154   aks-nodepool1-12354740-2
mem-exp-x9gxl      1/1       Running            6          44m       10.244.3.187   aks-nodepool1-12354740-1
reqest-exp-9kztw   1/1       Running            0          1h        10.244.2.153   aks-nodepool1-12354740-2
reqest-exp-b4t8h   1/1       Running            0          1h        10.244.3.184   aks-nodepool1-12354740-1
reqest-exp-fgrpn   0/1       Pending            0          56m       <none>         <none>
reqest-exp-wzg7t   1/1       Running            0          1h        10.244.3.185   aks-nodepool1-12354740-1

何が起こっているか、よーく見てみます。

1.Node1にスケジューリングされた野良Pod3つ

「mem-exp-6dcgh」と「mem-exp-dnwjk」と「mem-exp-x9gxl」はPodを稼働させたくても実際のメモリが足りないため、うち1つのいずれかが再起動を繰り返し「CrashLoopBackOff」のステータスになっています。

2.Node2にスケジューリングされた野良Pod2つ

「mem-exp-grxvz」と「mem-exp-v5j29」は再起動されることもなく順調に稼働しています。

3.マニュフェストでメモリの使用量をあらかじめ宣言したPod4つ

「reqest-exp-9kztw」は再起動されることなく3つが稼働中、そして空き容量待ち「reqest-exp-fgrpn」はPenddingでお行儀よく待っていること分かります。

なるほど!

どのようなしくみでPodがスケジューリングされたのか

KubernetesのResource Requestsでメモリを記述しないと、「Podがいつメモリ不足で終了されても構わない」という意味になります。なにがそうさせているかというと、、、、、

Pod QoSについて

KubernetesはPodに対して3つのQuality of Service (QoS)クラスを提供しています。このQoSは、今回検証したResource Requestsとリソースの上限を決めるResource Limitsの2つから次の条件でclassが決まります。

BestEffort

pod内のどのコンテナにもResource RequestsとResource Limitsが設定されていない時に設定される

Burstable

BestEffortもGuaranteedも設定されていないときに設定される

Guaranteed

CPUとメモリの両方に、Resouce RequestsとResource Limitsがセットされていること/Pod内のそれぞれのコンテナにセットされていること/Resouce RequestsとResource Limitsの値がそれぞれ同じであること、で設定される

このQoSの確認は、kubectl describeでできます。例えば「mem-exp-96vdn」のQoSを確認するときは次のコマンドになります。

$ kubectl describe pod  mem-exp-96vdn |grep QoS
QoS Class:       BestEffort

ちなみにpod内に複数のコンテナが存在する場合はまず各コンテナに上記のルールに従ってQoSを割り当てます。全てのコンテナがBestEffortならPodのQoSはBestEffortとなり、全てのコンテナがGuaranteedならPodのQoSはGuaranteedになるとのことです。そして、いずれの条件にも当てはまらない場合BestEffortになるとのことです。

今回の検証では、1.5GのResouce Requestsを指定したPodのQoSは「Burstable」、なにも指定しない野良Podは「BestEffort」が設定されていました。

Nodeのメモリ上限に達した場合、どういった判断でkillされるか

Kubernetesクラスタは魔法の箱ではないので、実際のメモリを超えるアプリは実行できません。今回の検証のNode1でなにがおきていたかというと。。。

Kubernetesでは、QoSに従ってどのPodのコンテナがkillされるか決まります。最も優先度が低く最初にkillされるのはBestEffort、つぎにはBurstable、最後にGuaranteedがkillされます。このGuaranteedはsystem processがメモリを必要とした場合のみkillされます。

もし同じQoSクラスだった場合、OutOfMemory (OOM) scoreのよってどのプロセスをkillするかを比較して決めているようです。決して、適当にkillしているわけではなく、Kubernetesはちゃんと忖度しながらkillしています。

今回の検証では、メモリが足りていないNode0上で、優先度の低い野良Podがkillされ、再起動されていました。この再起動のルールも、実はとても興味深い処理方式で動いています。こちらは後日別ブログで。

まとめ

Kubernetesでは、アプリケーション(コンテナ)が使うメモリに制限をかけると、クラスタ上の理論的なリソースが配分されてPodがスケジューリングされることがわかりました。また、Podの優先度が高くなり、クラスタの実際の使用メモリが多くなったときも、killされる可能性が低くなることが分かりました。

検証していませんが、ServiveやIngressでサービスディスカバリーを定義しておけば、今回のケースではいずれかのPodは動いているはずですので、クラスタ外部からのアクセスをみてみると、サービスが正しく動作している状態を維持できているはずです。

すごい。

なお、今回は設定しませんでしたがメモリにRequest Limitsを与えたときの動きや、CPUに関しても後日ひまを見つけて検証してまとめてみたいと思います。

というわけで、クラウドKubernetesマネージドサービスを使うと、クラスタそのものの構築やMasterのメンテはお任せできますので、分散基盤のめんどうがみられるレベルのインフラエンジニアがいなくても、快適なデプロイ環境を作れますが、一方、、、知識としてのインフラ技術は、ある程度勉強しておかないと、障害時に中でなにがおこっているのか、だけでなく適切な基盤方式設計ができない、ということにもなります。難しいですが勉強だいじ。

おわり

© 2017 ASA.