Unyablog.

のにれんのブログ

docker-compose を Docker で動かす

docker-compose を Docker 内で実行するメモ。

背景

docker-compose は便利なツールだが、Linux サーバー上で動かすときは入れ方に悩む。

Docker engine は Docker 公式 apt リポジトリから入れることができるけど、docker-compose はレポジトリには存在しない。

公式ドキュメント では curl でインストールする方法が書いてある。一度インストールする分にはこれで良いものの、更新を考えるとイマイチ。

また、DebianUbuntu の公式リポジトリには docker-compose パッケージが存在しているけど、新しくなかったりする。

そこで、docker-compose の Docker image を使って動かしてみる。

やり方

公式ドキュメント の "Alternative install options" にはラッパー(run.sh)を使ったやり方が書いてあり、 run.sh/usr/local/bin/docker-compose に置くよう案内している。

github.com

ただ、できれば docker コマンドのみでシンプルにやりたい。

run.sh を使わずにやる

run.sh はそこまで特殊なことはしておらず、

  1. Docker UNIX socket の mount
  2. ホスト側の Working directory の mount
  3. コンテナの Working directory をホスト側と同じに
  4. ホスト側の Home directory の mount
  5. 環境変数の維持

などを行いつつ docker/compose image を起動している(詳細は run.sh のコメント参照)*1

多くの場合*2は、1-3 があれば十分なはず。これをふまえて、docker コマンドで docker-compose を最小限使うには↓のコマンドになる:

$ docker run --rm -it -v /var/run/docker.sock:/var/run/docker.sock -v `pwd`:`pwd` -w `pwd` docker/compose:{version} --help
Define and run multi-container applications with Docker.

Usage:
  docker-compose [-f <arg>...] [--profile <name>...] [options] [--] [COMMAND] [ARGS...]
  docker-compose -h|--help

Options:
...

これで、docker-compose をダウンロードしなくても Docker があれば compose を立ち上げることができる。

*1:Volume などで Working directory 外からの参照がいるのであれば自分でフラグを追加してねというスタンス

*2:混み入ったことをせず、環境変数や Home directory 配下の config なども使わない場合

replicaCount: 1 と Drain とダウンタイム

Kubernetes で Node を Drain するとき、replicaCount: 1 な ReplicaSet (Service) はある程度のダウンダイムを許容せざるを得ない。

関連 issue は以下。

github.com

問題

  • ReplicaSet X があり、その replicaCount は 1 にしている
    • また、同じ Selector で Service X を作成している
  • Node A に ReplicaSet X の Pod X1 が 1つある

状況で Node A を Drain すると、

  • Pod X1 が Terminate される
  • ReplicaSet X に設定した replicaCount に従って、新しい Pod X2 が別の Node に立つ

といった挙動となり、Pod X1 が Terminate されてから Pod X2 が Ready になるまでは Service X に属する Ready な Pod がない状態になる。その結果、ダウンタイムが生じる。

Terminate されてから Pod が Scheduling されるまではほとんど同時なので、ダウンタイムは Container のセットアップが終わるまでの時間と大体同じになる。

PDB は無力

このような問題の対策として Pod Disruption Budget (PDB) で minAvailable を設定することが考えられるが、残念ながら replicaCount: 1 の場合は仕様上うまく動かない。

PDB は Drain の抑制を行うものの、レプリカ数を良い感じに調整してくれるものではない。なので、上の条件からさらに PDB で minAvailable を 1 に設定した状態で Node A を Drain すると、

  • Pod X1 は PDB があるので Terminate されない
  • ReplicaSet X から見ると Pod X1 が Healthy な状態で存在しているので、新たな Pod を別 Node にスケジューリングを行うこともしない

ということで単に Pod X1 が消えずに Node A が一生 Drain されないことになる。

どうするか?

Issue にある通り replicaCount が1つである限りどうしようもないので、replicaCount を増やすことになる。

replicaCount を増やす

replicaCount を増やすと 1 つの Node が死んでも別の Pod 生きており、ダウンタイムは生じない。PDB があると複数ノードが同時に Drain されることも抑制されるのでなお良い。

Drain 前に rollup して Drain 後に rolldown する

要するに replicaCount を一時的に増やす。

…このように replicaCount を増やすのが正攻法だと思うが、今回は個人のどうでもいいクラスタなので、コンテナが立つまでの十数秒のダウンタイムは許容することにした。

k8s: 手元で削除したリソースをクラスタからも消したい

k8syaml を kustomize で管理している。ここからリソースを消しても kustomize はそれを感知しないためクラスタからは削除されない。

どうするか

kpt を使う。これを見つけたのは deprecated になった inventory からリンクが貼られていたため。

kpt はいくつかの機能で構成されているが、そのうち live を使う(それ以外の機能はあまり知らない *1)。

kpt live では apply 時に適用対象のリソースを列挙して ConfigMap に保存する。これによって、次回以降の apply 時に適用するリソースと以前に適用したリソースを比較することができ、不要になったリソースが何であるかを判別することができる。

googlecontainertools.github.io

kpt を使う

kpt をインストールしたあとに、まず ConfigMap を作るためのリソースを作成する。

$ kpt live init .

これで inventory-template.yaml ができる。これを kustomize.yaml に含めておく。

resources:
  - ...
  - inventory-template.yaml

そして build したものを kpt live apply で適用する。

$ kustomize build | kpt live apply

その結果、2回目からは不要なリソースを消してくれるようになる。

以下おまけ

kpt を使わない方法

Kustomize で CommonLabels を使うことで kubectl apply --prune と組み合わせることもできる。

commonLabels:
  app.kubernetes.io/managed-by: kustomize

として

$ kustomize build | kubectl apply -f - --prune -l app.kubernetes.io/managed-by=kustomize

すれば二回目以降は prune される。ただ、

のでやめた。

その他の方法

GitOps 的な方面で色々ある。

  • terraform

    Kustomize integraion がある。ドキュメントを読むとできそう。

    ただそのために terraform を導入するのも面倒なのでやめた。

  • flux2

    Git レポジトリ経由ではなく手元から実行できる方法が分からなかったのでやめた。削除してくれるかまでは見ていない。

  • Argo CD

    Argo CD はパージしてくれる機能がありそう。ローカル実行もできそうだったけど、kpt のほうが(ほぼ)手元で完結してシンプルで良さそうに見えたのでやめた*2

*1:別のコマンドとしてくれたほうが分かりやすいと思ったり

*2:CD がほしいわけではないのです

Vertical Pod Autoscaler の limits 周りの挙動について

Kubernetes で memory の requests を管理するのに Vertical Pod Autoscaler (VPA) を使っている。

github.com

VPA はリソースの使用量の実績に基づいて良い感じに limits と requests を調整してくれるものだが、 limits の設定に関してちょっと困ったのでメモ。

参考: https://github.com/kubernetes/autoscaler/tree/master/vertical-pod-autoscaler#limits-control

VPA がどう limits を設定するか

VPA が limits を設定するときは、 LimitResources などの制約がない場合、以下のようなアルゴリズムで設定される。

  1. VPA が requests を算出する
  2. 算出した requests に、元々設定されていた requests と limits の比をかけて新しい limits とする

すなわち、メモリの requests を 500Mi, limits を 1000Mi とした Pod に対して、VPA が新しい requests を 700Mi とすれば、新たな limits は 1400Mi となる。

requests を設定していないとき問題

ここで、Pod に limits のみを設定し、requests を設定していない場合はどうなるか。このとき、k8s は requests を limits と同じ量に設定するので、VPA は requests と同量の limits を推奨するようになる *1

requests は普段の使用量ベースで自動設定されるので、limits に同じ値が設定されるとほとんど burst できない Pod となってしまう。 起動時ちょっとだけメモリを多めに使う、みたいな Pod がある場合に OOM killer で落ち続けるようになってしまった。

解決策

解決策としては以下が考えられる。

  • limits だけではなく request を設定する

    request を設定する際には VPA の挙動を考慮する必要がある

  • limits を設定せず request のみを設定する

    こうすると VPA によって limits が減ることはない

今回はめんどくさかったので後者にした。

limits がない結果ノードのメモリが不足する可能性があるが、 requests は普段調整されるためそれほど起こらないはず。もし頻繁に起きるなら、普段は問題ないが Burst したときにメモリが足りないということであり、それは各ノードの余剰メモリが足りないということであるので、そういった問題として対処することにした *2

*1:requests が何らかの理由で設定されない場合も VPA によって同量になる https://github.com/kubernetes/autoscaler/blob/2542e8c884a8e25634d3b8f43243fa8706007f30/vertical-pod-autoscaler/pkg/utils/vpa/limit_and_request_scaling.go#L69-L72

*2:LimitRanges ぐらいは設定してもいいかもしれない

Kubernetes ヒヤリハット ― デフォルトに戻すときはちゃんと書く

Kubernetes で Service Account を特定のものから default に戻そうとして、Pod の Template から serviceAccountName を消して apply した。

         spec:
-          serviceAccountName: old-service-account
           restartPolicy: Never
           containers:
           - ...

しかし、適用しても作られる Pod の serviceAccountNameold-service-account のままだった。

serviceAccountName を消すだけでは apply しても unchanged となり、 default に戻るわけではないらしい。

ちゃんと default って書き直しましょう。

         spec:
-          serviceAccountName: old-service-account
+          serviceAccountName: default
           restartPolicy: Never
           containers:
           - ...

教訓: 冪等性があると思ってはいけない!

SQLAlchemy で MySQL の JSON_CONTAINS をやる

SQLAlchemy で MySQLJSON_CONTAINS をやるには func.json_contains を用いれば良いのだけど、 JSON_CONTAINS のドキュメント にあるように candidateJSON ドキュメントである必要がある。

以下のようにすると hogeJSON ドキュメントではないためエラーが出る。

from sqlalchemy import func

# Foo.values は JSON の配列である想定

session.query(Foo).filter(
    func.json_contains(Foo.values, 'hoge')
).all()
sqlalchemy.exc.OperationalError: (MySQLdb._exceptions.OperationalError) (3141, 'Invalid JSON text in argument 1 to function json_contains: "Invalid value." at position 1.')

JSON ドキュメントにするには JSON_QUOTE を使うと良い。JSON_QUOTE すると foo"foo" になる。

SQLAlchemy でやるには func.json_quote を挟む。

from sqlalchemy import func

# Foo.values は JSON の配列である想定

session.query(Foo).filter(
    func.json_contains(Foo.values, func.json_quote('hoge'))
).all()

ISUCON 10 本戦に出場した(16位)

チーム 😇😇😇 として ISUCON 10 本戦に参加した。

isucon.net

結果は 14206 点で 16位。くやしい!

レポジトリは以下。よく分からない Author になってるコミットは大体自分によるもの。

github.com

以下はチームメイトのブログ。コードの改善などは基本的にそちらを見てください。

blog.utgw.net

やったことなど

自分はいつもどおりサーバーを触る人だった。恒例になってきた一連の動作とパフォーマンスを除いて、今回特別にやったことや所感を書いていこうと思う。

Envoy

Envoy が入っていて「自分は Envoy の YAML を手で書いたことがあるぞ!!任せろ!!!」って張り切ったんだけど、実際には Envoy らしいことは特にしておらず普通の Reverse Proxy だった。さっさと Nginx に載せ替えても良かったと思う。 Envoy の設定って手で書くものじゃないしね…。

実際静的ファイルの配信をするために Nginx を導入したけど、当日は全てを Nginx に載せ替えることはしなかった。Envoy の設定には困っていなかったし、 ISUCON で使うと面白そうだったので😈。

気になった点としては、

  • 最後のほうで LimitNOFILE に到達して突然死した

    その時は時間がなくて雑に Restart することで解決したけど、後で調査した結果 LimitNOFILE 到達が原因だった。そういえば上げ忘れてたね…。それでも突然死はやめてほしいが、そういう思想らしい。

  • ベンチマーク中 CPU 使用率が 40% と高かった

    今まで ISUCON で Nginx を使ってたときは困ったことがなかったので意外だった。TLS 終端してるとそんなもんだったっけ?

手で設定しにくいということを除いても、普通の Reverse Proxy として使うなら Nginx のほうが扱いやすいなあと思った。

サーバー構成

今回はサーバースペックが3台とも違っていて、

  1. CPU 2コア メモリ 1GB
  2. CPU 2コア メモリ 2GB
  3. CPU 4コア メモリ 1GB

という構成だった。コンテスト中、構成は以下の変遷をたどった。

  1. サーバー1 単体

    初期状態。

  2. サーバー2: MySQL サーバー3: Web, API, Envoy, Nginx

    MySQL といえば InnoDB バッファプール!ということでメモリの大きい方に載せた。サーバー1は開発用。

  3. サーバー2: Web, API, Envoy, Nginx サーバー3: MySQL

    サーバー2を見ていたらメモリは 30% ぐらいしか使っていないのに CPU がサチってたので載せ替え。サーバー3はメモリも CPU も80%程度で良い感じ。

  4. サーバー1: API, Envoy, Nginx サーバー2: Web サーバー3: MySQL

    サーバー2 を見ていたら意外にも Envoy が食っていたので Nginx と API ごと 1 に載せ替えた。

この状態でリソース不足が起きることはなくなった。謎エラーがなければもっと伸びたよなぁ~~~~。

Protocol Buffers

今回 Web アプリの API には Protocol Buffers が使われていたので、デバッグのために Protocol Buffers を解読し打てるようにする必要があった。

色々考えた結果 Wireshark でパケットキャプチャして直接眺めることに。

ただ、色々難しいことがあり…

  • WiresharkTLS の解読ができない

    そういえば TLS の中身見れる機能があったな、って秘密鍵だけ持ってきたけど解読できなかった。まあ言われてみれば DH だしね…*1。新たな知見を得た。

    これは Envoy とアプリ間の通信を見ることで解決。

  • Wireshark で Protocol Buffers のパースができなかった

    最新の Wireshark だと見れる機能があるはずなのだけど、うまく行かなかった。Proto を Import するパスが間違っていた?それとも HTTP/2 や gRPC じゃないから見れない?*2

結局パケットキャプチャした内容をそのまま bytes で保存して、 protoc に噛ますことで見れるようになった。

$ cat /tmp/foo.bin | protoc --decode=xsuportal.proto.services.admin.InitializeRequest /Users/nonylene/codes/innocent-team/isucon10f/proto/xsuportal/services/admin/initialize.proto --proto_path /Users/nonylene/codes/innocent-team/isucon10f/proto

めっちゃむずかった!普通に Printf デバッグなりすればよかったね。

デバッグ用のリクエストを作るのも色々手間取ってしまった。初めはこれ gRPC だ!って勘違いしていたのでずっと grpcurl でやろうとして使い方がわからん…と言っていた*3。結局 curlprotoc を組み合わせて解決。

github.com

MySQL 8

予選は MySQL 5.7 だったけど本戦は MySQL 8 だった。

ISUCON 8 本戦で MySQL 8 にめちゃくちゃハマった経験があったけど、そのおかげもあって今回は全く困らなかった。 GRANT 文とユーザー作成を分けなきゃいけなくなったんだな~、ぐらい。

全体の感想とか

結局色々やったものの utgw のブログに上げられているエラーが解決できず、サーバーも本領発揮せずに終わってしまった。みんなで色々見てたけど分からず厳しかった!!

感謝

今年のコンテストも楽しかったです!今年は本戦もリモートとなりましたが、Tシャツや名札が事前に送付されたり、当日に YouTube ライブをしていたりとリモートでも盛り上がるように色々施策があって良かったと思います。問題解説ビデオ最高でした。

www.youtube.com

問題も解きごたえがありました。サーバーによってスペックが違うのも新鮮で、「とりあえず3つに分けとこ」だけではなくちゃんとサーバーのリソースを検討することを求められていて楽しかったです。

今年もチームメイトに助けられました。自分だけだと絶対出れてない(学生一人チームすごいよね…)し、バランスの良いチームだなあと思います。来年もやっていきたい!

*1:じゃあ RSA にするか、パフォーマンスも良くなるだろうし、と思ったけど Envoy での設定方法がわからなかっ上、ベンチマーカーが対応してるのかも不明だったのでやめた

*2:Protocol Buffers のライブラリを使っているだけなので、 Envoy とアプリ間の通信は HTTP/2 や gRPC ではなく HTTP 1.1 だった。

*3:途中で息抜きに外に出て考えたら「あっこれ gRPC じゃないじゃん」って気づいた。休憩は大事。