ISUCON11予選に「Railsへの執着はもはや煩悩(ry」で参加したけどダメでした

今年も@cnosukeさんと@aibouさんと「Railsへの執着はもはや煩悩の域であり、開発者一同は瞑想したほうがいいと思います。」というチームでISUCONの予選に出ました。

3人とも別の会社にいるので、去年同様に前の週にリハーサルを行ってイメージを掴みました。

去年と同じようになんとなくの役割分担として、cnosukeさんがAPIのコード変更を行い、aibouさんがDB・nginx周りのチューニングを行い、自分はデプロイとか設定のgit管理したりと雑多な対応をしていました。

なんとなくのスケジュール

昼食を食べずにぶっ続けでやっていたのでかなり疲れてしまって、あんまり当日の記憶も残っていないのですごく雑です。

時刻 やったこと メモ
09:40 集合
10:00 競技開始
10:11 webapp以下をgitに突っ込む
10:29 1号機をnginx, アプリ、3号機をmariadbにする これだけでちょっとスコアがった
10:49 いくつかmariadbにインデックスを貼った 24,648点出て暫定2位になったのでテンションが上がった
11:52 /assets 以下はnginxが直接返すようにした
11:59 秘蔵のmy.cnfを投入した 実際は 設定ファイルのパーミッションがおかしかった せいで投入できていなかった…
12:39 /api/initialize 以外は全部nginxが直接返すようにした
13:05 アプリのリファクタリングが終わった
13:23 1号機のCPUが辛いので /api/trend だけ2号機に流してみた
16:02 バルクインサートするようにアプリを書き換えた 38,324点まで上がった
16:24 アプリを書き換えたり、更にmariadbにインデックスを張ったりした この頃に最高得点の54,690点が出た
17:13 アプリを直したりmariadbにインデックスを貼ったりトラフィックを1号機と2号機で混ぜるようにしたりした 36,926点までしか出なくて伸び悩む
17:30 ポータルがメンテに入ったので マシンを再起動 してみたり、細かなアプリケーションの改善とかをしてた
18:10 ポータルがメンテ明けになったのでベンチを回してみたところ、なんと1,000点しか出なくなってしまった とても焦った
18:44 nginxの設定がおかしいことに気づいたがもう時間がなくなってしまったので、1号機にnginxとアプリ、3号機にmariadbの構成で出した。 13,982点になってしまった

競技中のトラブル

mariadbの設定が反映されていなかった件

/etc/mysql 以下をすべてgitリポジトリに突っ込んで、 /etc/mysql 自体はそのリポジトリシンボリックリンクにしていたのですが、どうやらパーミッションがおかしい(本来はroot:rootが所有しているのが、isucon:isuconになっちゃった)のが原因で、実は設定が反映されていなかったことに途中で気づいて、気づいたものの対応するのにとても時間がかかってしまいました。

結局、AMIがあったので別のインスタンスを立てて、それの /etc/mysql の構成を真似て構築し直して、gitリポジトリに追加しないようにしたところ使えるように戻りました。

nginxの設定がおかしかった件

ポータルのメンテ後くらいからスコアが1,000点しか出なくなって、なんでだろう…とずっと考えていたのですが気づけず、ISUの「状態」が1行も出ず空っぽになっているのがおかしい?と気づいて、

f:id:rkmathii:20210822224313p:plain

見てみたところ、自分が入れてしまったnginxの設定でIPアドレスを間違っていたのが原因でした。。。

f:id:rkmathii:20210822224124p:plain

リクエストを飛ばす先がないのでエラーになるかなと思ったのですが、今回のルールでは減点にならなかったので、加点もされず減点もされず、どこがおかしいのか気づくのに時間がかかってしまったのがもったいなかったです。。。


予選突破には11万点くらい必要だったので届かなかったかもしれませんが、途中でトラブってしまったのがもったいなかったです(´;ω;`)

問題はとても面白かったし、ポータルも途中不具合があったもののそれ以外は快適に使えましたし、すごく楽しかったです。

来年こそ予選突破したい…!

Kustomizeでvarsの代わりにreplacementsを使う

Kustomize v3.8までなら、varsを使うことで次のように特定の箇所の値を別の箇所に上書きすることができ、例えば

# test.yaml
---
apiVersion: v1
kind: Service
metadata:
  name: test
  labels:
    app.kubernetes.io/name: THIS-IS-TEST-SERVICE-NAME # ☆この値を上書きに使いたい
spec:
  type: NodePort
  ports:
    - nodePort: 32000
      port: 80
  selector:
    run: test
---
apiVersion: v1
kind: Pod
metadata:
  name: test
  labels:
    app.kubernetes.io/name: $(TEST_SERVICE_NAME) # ★ここを上書きする
spec:
  containers:
    - name: nginx
      image: nginx
      env:
        - name: testenv
          value: $(TEST_SERVICE_NAME) # ★ここも上書きする
# kustomization.yaml
resources:
  - test.yml

vars:
  # ☆上書きに使う値の情報
  - name: TEST_SERVICE_NAME
    objref:
      apiVersion: v1
      kind: Service
      name: test
    fieldref:
      fieldpath: metadata.labels.[app.kubernetes.io/name]

というマニフェストファイル群に対してkustomize build .すると、次のように上書きすることができていた。

apiVersion: v1
kind: Service
metadata:
  labels:
    app.kubernetes.io/name: THIS-IS-TEST-SERVICE-NAME # ☆この値を上書きに使う
  name: test
spec:
  ports:
  - nodePort: 32000
    port: 80
  selector:
    run: test
  type: NodePort
---
apiVersion: v1
kind: Pod
metadata:
  labels:
    app.kubernetes.io/name: THIS-IS-TEST-SERVICE-NAME # ★上書きされた
  name: test
spec:
  containers:
  - env:
    - name: testenv
      value: THIS-IS-TEST-SERVICE-NAME # ★上書きされた
    image: nginx
    name: nginx

しかし、Kustomize v3.9以降からはエラーになるようになってしまった。

Error: field specified in var '{TEST_SERVICE_NAME ~G_v1_Service {metadata.labels.[app.kubernetes.io/name]}}' not found in corresponding resource

どうやら、varsで指定するfieldpathに . が含まれているような場合について、解釈できなくなってしまったようだった。

もともと2020年1月くらいからvarsをこれ以上使い続けるのは…みたいなIssueもたっていて、

github.com

最近のKustomizeでは代替になるreplacementsという機能が実装されていたので、それを使ってみることにした。


※以降はKustomize v4.2.0で動作確認した

上に書いたような例をreplacementsで実現しようとすると、次のようになる。

# test.yaml
---
apiVersion: v1
kind: Service
metadata:
  name: test
  labels:
    app.kubernetes.io/name: THIS-IS-TEST-SERVICE-NAME # ☆この値を上書きに使う
spec:
  type: NodePort
  ports:
    - nodePort: 32000
      port: 80
  selector:
    run: test
---
apiVersion: v1
kind: Pod
metadata:
  name: test
  labels:
    app.kubernetes.io/name: TO_BE_REPLACED # ★ここを上書きする (TO_BE_REPLACEDの部分は何でも良い)
spec:
  containers:
    - name: nginx
      image: nginx
      env:
        - name: testenv
          value: TO_BE_REPLACED # ★ここも上書きする (TO_BE_REPLACEDの部分は何でも良い)
# kustomization.yaml
resources:
  - test.yml

replacements:
  - source:
      # ☆上書きに使う値の情報
      version: v1
      kind: Service
      name: test
      fieldPath: metadata.labels.[app.kubernetes.io/name]
    targets:
      # ★上書きされる対象の情報
      - select:
          version: v1
          kind: Pod
          name: test
        fieldPaths:
          - metadata.labels.[app.kubernetes.io/name]
          - spec.containers.0.env.0.value

こうしてkustomize build .を実行すると、冒頭に書いた例と同様に上書きされたマニフェストファイルが生成される。

varsの場合と異なり、「上書きに使う値」「上書きされる対象」のどちらのパスも明記する必要があるので若干めんどくさくなったように思ったが、よく言えば「どの部分が上書きされてしまうのか?」が一覧できるようになったので、慣れればreplacementsのほうが管理しやすくなるのかもしれない。

おうちKubernetesクラスタを組む(後編)

rkmathi.hatenablog.com

の続き。

ノートPC↔デスクトップマシンの通信があんまり安定してくれなくて、たまにパケットが通らなくなったりしてよく分からない感じになっちゃったので、VMからまるっと作り直した。 ただ、作り直してもたま~にパケットが通らなくなってしまうのが起きてしまったので、ルータをちゃんとしたヤツに買い換えないといけないのかもしれない。

Serviceの公開ができるようにする

k8sクラスタ上のPodに対してノートPCからどうやってアクセスするのがいいかな~と考えていたが、Serviceをtype: NodePortで作成するのが一番ラクだと思ったのでそうすることにした。

# nginxのPodをたてる
$ kubectl run nginx --image=nginx

# type: NodePortなServiceを作成する
$ kubectl expose po nginx --name=nginx --type=NodePort --port=80

# 作成したPodがどのNodeで動いているか確認する
$ kubectl get po -o wide
NAME     READY   STATUS    RESTARTS   AGE   IP               NODE    NOMINATED NODE   READINESS GATES
nginx    1/1     Running   0          13h   172.16.168.135   node1   <none>           <none>

# 作成したServiceのnodePortを確認する
$ kubectl get svc
NAME         TYPE        CLUSTER-IP      EXTERNAL-IP   PORT(S)        AGE
nginx        NodePort    10.104.230.33   <none>        80:30953/TCP   11m

これで「今回作成したnginxはnode1の30953ポートで動いている」ということが分かるので、そこにアクセスすれば見えた。

# node1のIPは192.168.10.191
$ curl -is http://192.168.10.191:30953 | head -2
HTTP/1.1 200 OK
Server: nginx/1.21.0

しかしtype: NodePort(かつexternalTrafficPolicy: Cluster)の場合は、PodがいないNodeに対してアクセスしてしまっても正しいNodeに転送してくれるはずだったのに、なぜか転送されなかった。

# node2のIPは192.168.10.192
$ curl -is http://192.168.10.192:30953 | head -2
(返事がない)

調べてみると、Calicoを使ってかつ、KubernetesクラスタのCIDRをVMのと別のにしてしまうと、うまく転送してくれないとというようなIssueがあったので、その現象にあたってしまったのかもしれない?

github.com

Calicoではなく別のCNIプラグインなら大丈夫らしいので、試しにCiliumに切り替えてみたところ、うまく動いてくれたのでそっちを使うことにした。

Tailscaleで家の外からもk8sクラスタにアクセスできるようにする

次は家の外からでもアクセスしたいな~と思ったので、またまたsuperbrothersさんのを参考にして、Tailscaleを導入してみた。

tailscale.com

アカウントを作成したら、クライアントを各マシンにインストールしただけで、本当にあっさり導入できてしまった。

f:id:rkmathii:20210620004816p:plain

iPhoneにTailscaleのクライアントを入れたら、4G回線からも普通に接続できてしまった。

f:id:rkmathii:20210620012959:plain

TailscaleのMagic DNSを有効にする

Magic DNSを有効にすれば、「<マシン名>.<ユーザ名>.beta.tailscale.net」という名前で引けるようになるので、IPアドレスを覚えたり/etc/hostsに書いたりをせずに済むようになったため更に便利になった。

f:id:rkmathii:20210620004926p:plain

さっきはIPアドレスでアクセスしていたnginxのNodePortの例も、

$ curl http://node0.<アカウント名>.beta.tailscale.net:30953/
$ curl http://node1.<アカウント名>.beta.tailscale.net:30953/
$ curl http://node2.<アカウント名>.beta.tailscale.net:30953/

のようにFQDNでアクセスできるようになった。

また、kubernetes-dashboardについても

apiVersion: v1
kind: Service
metadata:
  name: kubernetes-dashboard-expose
  namespace: kubernetes-dashboard
spec:
  type: NodePort
  selector:
     k8s-app: kubernetes-dashboard
  ports:
    - nodePort: 30000
      port: 443
      targetPort: 8443
      protocol: TCP

というようなtype: NodePortなServiceを作ることで、

$ curl http://node0.<アカウント名>.beta.tailscale.net:30000/
$ curl http://node1.<アカウント名>.beta.tailscale.net:30000/
$ curl http://node2.<アカウント名>.beta.tailscale.net:30000/

でアクセスできるようになった。

f:id:rkmathii:20210620010036p:plain

証明書を設定できていないので警告は出てしまうが。。。


最終的にはこういう感じになった。

f:id:rkmathii:20210619230304p:plain

これで一通り準備ができてそれっぽく使えるようになったので、Kubernetesクラスタで色々実験するのが捗りそう😺

あとTailscaleはすごい!!

おうちKubernetesクラスタを組む(前編)

いままで手元でKubernetes関連のなにかを試そうとすると、基本的にはThinkPad X390(Core i7-8665U 4コア8スレッド, メモリ16GB)のWindowsのWSL2上でkindを起動していたが、WSL2にメモリを12GB当ててしまうとWindowsが使う分が足りなくなってきたり、ノートPCなのでスリープしたりすると復帰時にたまにヘンになったりとあまり快適な状態ではなかったため、superbrothersさんのを参考に小さいデスクトップマシンを買ってみた。

デスクトップマシンの構成

小さめのマシンでいくつか迷ったけど、折角(?)なので使ったことがないRyzenを積んでるASUS Mini PC PN51にしてみた。 CPUはRyzen 5 5500UなのでノートPC用のだけど、基本電源はつけっぱなしにするつもりだったので消費電力が小さそうだし筐体サイズも小さいのでこれで十分かなということにした。

これだけではSSDもメモリもないので、Western Digital WD Blue SN550 NVMe SSD (1TB)と、Crucial CT16G4SFD832A x 2本を買ったので、構成はこうなった。

項目 中身
CPU Ryzen 5 5500U (6コア12スレッド)
メモリ 32GB
SSD 1TB
OS Ubuntu Desktop 20.04

メモリは32GBか64GBかで迷ったけど、VMはいくつか立ち上げるつもりはあるものの、ゲームをするわけでもなく基本SSHかRDP経由で軽くGUIを使くらいの予定なので、32GBでいいやということにした。

消費税と送料込みで、

商品 値段
PN51 47,700円
WDS100T2B0C 12,248円 (セールしてた)
CT16G4SFD832A x 2本 19,040円
合計 78,988円

と結構安く済んでよかった。 (価格ドットコムの価格推移をみた感じだと、もうちょっと前ならメモリもSSDも更に安く買えたかもしれないが…)

とりあえずKubernetesを動かす

家で使っているのは普通の家庭用Wi-fiルータなのであまり機能がないが、DHCP固定割当設定はあるのでそれでIPを固定することにして、SSHやRDPするときに迷わないようにした。

まずはvirt-managerを使って、Control Plane用に1つ(2core, 2GB)、Worker Node用に2つ(2core, 4GB)、Ubuntu ServerのVMを立ち上げた。

f:id:rkmathii:20210616235249p:plain

(いままで使ったことはないが)containerdをインストールして、kubeadmを使ってクラスタを起動して、CalicoをインストールしてReadyにするところまでできた。

f:id:rkmathii:20210616235918p:plain

その後、kubernetes-dashboardを起動させてみたが、ノートPCのWebブラウザから見ようとすると

  • kubernetes-dashboard--kubelet-insecure-tls オプション付きで起動させる。
  • デスクトップマシン上で kubectl proxy --address 0.0.0.0 --accept-hosts='^*$' を実行する。
  • ノートPC上で ssh -L 18001:<デスクトップマシンのIP>:8001 rkmathi@<デスクトップマシンのIP> を実行する。
  • ノートPC上のWebブラウザhttp://localhost:18001/api/v1/namespaces/kubernetes-dashboard/services/https:kubernetes-dashboard:/proxy/#/login にアクセスする。

というように面倒かつ安全じゃない感じの手順が必要になってしまったので、今まであまり触れてこなかったネットワーク周りを勉強しながらいい感じに直したい。

f:id:rkmathii:20210617001050p:plain

まずは↓のように家の中のノートPC経由でKubernetes上で動かしているWebアプリにアクセスできるようにしたいが、DNSとかMetalLBとかTLS周りとかがよくわかってないので色々試行錯誤中なので、後編に続く。。。

f:id:rkmathii:20210616234717p:plain

これができたら家の外からもつなげるようにしたい。


続き

rkmathi.hatenablog.com

Podのresourcesで指定するcpuの値について

Podのresourcesで指定する値で、cpuは具体的に何がどう制限されるのかがよく分かってなかったので調べてみた。

よく分からない部分

resources:
  requests:
    cpu: 100m # 100ミリってことは多分0.1コア以上確保できないWorker Nodeにデプロイされないんだろう
    memory: 256Mi # メモリを256Mi以上確保できないWorker Nodeにデプロイされないんだろう
  limits:
    cpu: 1000m # 1000ミリってことは1コア分ってことなんだろうけど、それを超えてCPUを使うとどうなるの?
    memory: 512Mi # メモリを512Mi以上消費したらコンテナがOOMKillerに殺される

という感じで resources.limits.cpu が意味する値の意味がよく分かっていなかった。

resources.requests.cpuも同様によく分かっていなかったが、requestsは「デプロイ先のWorker Nodeにはこれくらいの余裕が必要ですというよ指定」なんだろうなという理解だったので、あまり気にしていなかった。

resources.requests.cpuとresources.limits.cpuの単位の意味

resources.requests.cpuとresources.limits.cpuで指定する値は、「CPUのミリコア」で間違っていなさそうだった。

例えばキャパシティが4コアのWorker Nodeに対しては、合計で4000mよりも多くのresources.requests.cpuをスケジュールすることができない。

Worker Nodeの物理的なCPU1コア(ハイパースレッディングが有効なら論理1コア)が1000mに対応するので、新しいCPUだろうと古いCPUだろうと、1000mはおなじ量だということが分かった。

ということは、Worker Nodeに複数種類のCPUを混ぜてしまうとややこしいことになりそう?

resources.limits.cpuの値の意味

これは、そのコンテナが1秒間に専有できる最大のCPUの量だった。

つまり、1000mなら1コアを1秒間すべて専有するくらいCPUを消費することができ、500mなら1コアを1秒間のうち500ミリ秒までしか専有できない。

2500mなら、1コアを1秒間で2500ミリ秒だけ専有できるので、大体2.5コア分くらいのCPUを専有することができる。

また、resources.limits.cpuはresources.limits.memoryとは異なり、圧縮可能(compressible)なリミットであることが分かった。

そのため、キャパシティを超えてCPUを消費してしまったとしてもコンテナが殺されるようなことはなく、CPUの使用量が減らされるだけだった。

(今回の話とは全く関係ないが、)resources.requestsやresources.limitsで指定する値は普通に使う場合はcpuかmemoryだけしか指定しないが、Device Pluginという仕組みを使うと、たとえばGPUなど特殊なリソースのリクエストやリミットを制限するための仕組みを用意することができるらしい。

CPUを空回しするコンテナをresources.limits.cpuを変えて比較してみた

すごく単純だけど、次のような「ひたすらカウンタをインクリメントし続けて、1秒に1回だけ現在のカウント値を出力する」というプログラムを書いて、resources.limits.cpuを変えたPodで実行して性能を比較してみた。

package main

import (
        "log"
        "runtime"
        "sync"
        "time"
)

const tryTimes = 10

var mux sync.Mutex

func main() {
        log.SetFlags(log.Lmicroseconds)

        log.Println("app start")

        log.Printf("runtime.NumCPU(): %d\n", runtime.NumCPU())

        var cnt, old uint64
        var wg sync.WaitGroup
        wg.Add(tryTimes)
        go func(wg *sync.WaitGroup) {
                for {
                        time.Sleep(time.Second)

                        mux.Lock()
                        log.Printf("diff: %d\n", cnt-old)
                        old = cnt
                        mux.Unlock()

                        wg.Done()
                }
        }(&wg)

        go func() {
                for {
                        mux.Lock()
                        cnt += 1
                        mux.Unlock()
                }
        }()

        wg.Wait()

        log.Printf("avg : %d\n", cnt/tryTimes)

        log.Println("app done")
}

1コアだけを空回しするプログラムなので、1000m以下では徐々に性能が上がっていって、1000m以上は指定しても変化がないと予想していた。

次のような結果になった。 グラフには省略したが、 resources.limits.cpuを無指定にした場合も1000mと同程度の値になった。

f:id:rkmathii:20210613230103p:plain

f:id:rkmathii:20210613230142p:plain

確かに予想通りの傾向にはなったものの、500mと1000mや、400mと800mのようにちょうど2倍だけ割り当てるようなケースでキレイに性能が2倍になるわけではなかった。

1000m未満の場合は「あえてCPUの使用量を制限する」という処理をしないといけないはずだから、その分がオーバーヘッドになってしまい性能が出ないのかなとも思ったが、ちゃんと調べていないので実際の理由は分かっていない。

この傾向が本当にあるとなると、resources.limits.cpuを1000m未満で指定してしまうと一時的にCPUを使いたいようなケースでも思ったよりも性能が出ないということになりそうなので、1000m以上を指定するのが無難そうな気がした。 それか、そもそもlimitsは指定しないのでもいい気がする。

参考にした資料

kubernetes.io

kubernetes.io

cloud.google.com