839の日記

業務とは関係ない趣味の話を書くブログです。

Ruby 2.7.0に上げてFrozenErrorを踏んだ

最初に結論ですが自分のミスなので言語やライブラリの問題ではないです。

自分のサービスを全部2.6.5から2.7.0にあげていったときにFrozenErrorを踏んでしまったのですが、 該当箇所のコードは以下のようなものでした。

    endpoint = 'https://hoge.com/api/v1/hoge'
    resp = HTTP.get(hoge)
    JSON.parse(resp.body.to_s)

最後の JSON.parseFrozenError can't modify frozen String: "" が出ます。 Railsのlibとして配置した自作httpクライアントで、特にrequireなしで使っていました。

他の箇所は Net::HTTP で通信していたのでそちらで書き直すとちゃんと動いたのですが、 HTTP って標準ライブラリじゃないんだっけ?と思って調べてみると普通にgemでした。
refs: GitHub - httprb/http: HTTP (The Gem! a.k.a. http.rb) - a fast Ruby HTTP client with a chainable API, streaming support, and timeouts

しかも他のライブラリに依存して入っていたものを無意識に使っていたせいで、バージョンが低く2.7に未対応のバージョンを使っていました。 requireも書かずに副作用でコードを書いてしまっていて、かなり微妙な理由で例外を出してしまっていた。。

ちなみにライブラリ側では以下のようなコードの部分で例外が出ていました。

          @contents   = String.new("").force_encoding(@encoding)

          while (chunk = @stream.readpartial)
            @contents << chunk.force_encoding(@encoding) # raise
            chunk.clear # deallocate string
          end

@contentsString.new("") で定義されているので一見 FrozenError の原因にならなさそうですが、 raiseしているのは @contents への追加部分ではなく chunk に対する #force_encoding です。
この問題はちょうど以下のコミットで直されていました。

-      chunk.to_s
+      chunk || "".b

refs: https://github.com/httprb/http/commit/8b1477a9fefc5879f7c864a38eabbfed92b7663d#diff-b31c2c9884d039f633a34d10a344d68b

String#b を使うことでメソッドが返す文字列 == 変更可能として返すという副作用に相乗りしている模様。 refs: instance method String#b (Ruby 2.7.0 リファレンスマニュアル)

今回のケースは恐らくライブラリの後続処理で文字列操作を行わないのでパフォーマンス影響はないという判断なのでしょうか。 もしライブラリの中で文字列を再度扱うようなケースがある場合はここもちゃんとfrozenしておくほうが良さそうな気はします。

初心者のようなミスをしてしまいましたが、今後踏みそうなやつを早めに踏んでおけてよかったという気持ちです。

趣味のGKEクラスタを安価に運用する

元々VPSサーバ2台で運用していたものをGKEに移した際に色々やったことをざっくりと書く。

GKEノード構成

確約利用割引  |  Compute Engine ドキュメント  |  Google Cloud の3年契約ノードを1台、preemptive VM2台の合計3台構成にしている。 3台ともマシンタイプはn1-standard-1でリージョンはus-west1を選択している。

ノードの使用料金は以下のようになる。

  • 確約利用割引のn1-standard-1
    • $0.014225 / vCPU hour, $0.001907 / GB hour
    • 1CPU、3.75GBメモリなので0.014225+0.001907*3=$0.02137625/hour
    • 月に約$15.4
  • preemptive VMのn1-standard-1
    • $0.006655 / vCPU hour, $0.000892 / GB hour
    • 1CPU、3.75GBメモリなので0.006655+0.000892*3.75=$0.009331/hour
    • 2台なので月に約$13.4
  • 2つ合わせて合計$28.8/month

元々VPS2台で3500円ぐらいだったのでノードだけ見れば少し安くなっている。 とはいえ他にも料金はかかるのでトータルとしてはGKEに移行した後のほうが高い。 具体的にかかる料金はストレージとネットワークのegress料金で、この辺を足すと1000円弱は高くなった。

GCPのマネージドLB、RDSなどを使わない

GCPのLBは使うと一気に月額料金が跳ね上がるので使っていない。 代わりにEIPを1つ確保し、そのEIPをCloudDNSでサービスのドメインに紐づけている。

1台のノードに確保したEIPを紐付け、そのノードが入口となってクラスタ内の各サービスにアクセスがproxyされる。 このEIPを紐付けるVMに確約利用割引VMを利用している。 元々はこのLB役もpreemptive VMで運用できるか検討したけど、LB役のVMが落ちるとダウンタイムが発生する問題があった。 1日数分程度なので許容かなと思ったけど24hごとに1回きれいに落ちるわけでもなく *1、1日に数回落ちることもあったのであまりUXがよくなさそうだった。 1ヶ月ほどpreemptive VMで検証していた頃は外形監視によるSLAは98%程度だったはず。

EIPが紐付いているpreemptive VMが死んで復旧した後も勝手にEIPがつくわけではないので自前でつけ直す処理を書いていた。

  1. クラスタ内のノードに指定されたEIPが存在しているか
  2. 存在していなかったら3台のノードから自分を除いてランダムに1台選出*2
  3. 選出されたノードに対してGCPAPIを叩いてEIPをattach
  4. 選出されたノードに対してkubeclientでnodeにlabelを付与してnginx-ingress-controller podがそのノードで動くようにする

このあたりをgoで書いて、k8sのCronJobとして1分に1回走らせていた。 この頻度で復旧に数分程度はかかる。 ボトルネックとしてはGCPのEIP attachがまぁまぁ遅いのと、付与したラベルがk8sに気づかれるまでのタイムラグが目立った。

このような背景で確約利用割引VMを使うことに決め、LBの役割はそのVMに任せることにした。 LB以外にもDBやalertmanager、prometheus-exporterなどの全サービスに関わる重要目なpodは確約利用割引VMに配置するようにしている。

preemptive VMが落ちたときに503が返る問題

podの冗長化

確約利用割引VMを使うことでnginx-ingress-controllerのpodは安定して稼働するようになったけど、まだ503が返るケースがあった。 最初から想定したものとしては2台あるpreemptive VMのうち1台にappのpodが偏ってしまって1ノード落ちるとサービスが503になるケース。

このケースの対策としては GitHub - kubernetes-sigs/descheduler: Descheduler for Kubernetes の機能を使うことで解決した。 具体的には同じノードに同じdeploymentのpodが複数個ある場合に他のノードに再配置してくれる機能を使った。 これで2ノードにpodが冗長化されたので1ノードが落ちてもサービスにアクセス不可ということはなくなったので万事解決…。

preemptive VMのプリエンプト通知hook

と思ったらそれでも503が1日に1、2回出るケースがあった。 原因はpreemptive VMのshutdownでk8sのdrain処理はせずにぶちっと切られてしまうので、 ユーザのアクセス時にノードが落ちると503が返るというもの。

この辺の挙動は プリエンプティブル VM インスタンス  |  Compute Engine ドキュメント  |  Google Cloud に詳しく書いてある。

  1. Compute Engine は、プリエンプト通知を ACPI G2 ソフトオフ信号の形式でインスタンスに送信します。シャットダウン スクリプトを使用して、プリエンプト通知を処理し、インスタンスが停止する前にクリーンアップ操作を完了できます。
  2. インスタンスが 30 秒後に停止しない場合、Compute Engine は ACPI G3 メカニカルオフ信号をオペレーティング システムに送信します。
  3. Compute Engine は、インスタンスを TERMINATED 状態に移行させます。

G2シグナルをハンドルして自分が対象だったらdrain処理を開始するといったことが必要になり、プリエンプト通知時にhookして何か処理をするのはサンプルが公式で紹介されている。
Creating and starting a preemptible VM instance  |  Compute Engine Documentation  |  Google Cloud

そのまま採用しても良かったけど、もう少し調べてみるとGCPのorgにそれ用のツールがあったのでそちらを使うことにした。
GitHub - GoogleCloudPlatform/k8s-node-termination-handler: A solution to gracefully handle GCE VM terminations in kubernetes clusters

このツールはGCPのmetadata APIを叩いて自分が終了対象になっていたらdrainなどの処理を行ってくれる。 実際に試してみたところちゃんと終了通知をハンドルしてdrain処理を行ってくれていて、外形監視の503アラートも出なくなった。

f:id:husq:20200103003240p:plain
検知してpodをevictするログ

まとめ

  • 確約利用割引VM1台とpreemptive VM2台を運用してノード費用は月額$28.8程度
  • preemptive VMを運用していて発生した503の対策にはdeschedulerとk8s-node-termination-handlerを利用した

おまけ

主題からは外れているけど今回書いた記事とテーマが近い話。

deschedulerのリソース再配置

元々はリソースの再配置も含めてdeschedulerの利用を検討していたけど、 QoSの設定が割と難しく最優先にしても再配置を対象外にしたいpod*3が選ばれて503になってしまう問題があり採用を見送った。 確約利用割引VMは余っていても重要なpodしか配置しない制約を課して、preemptiveは24hに1回落ちるのでそれを再配置扱いにしている。

またdeschedulerはemptyDirやhostPathのvolumeを利用していると再配置の対象外となる。 自分の場合はpodでnginxとrailsのcontainerが存在し、emptyDirを使ってrails側のassetsをnginx側に渡している。 再配置の対象外としないためにはdeschedulerの実行時に --evict-local-storage-pods フラグを付けておく必要がある。
refs: https://github.com/kubernetes-sigs/descheduler/blob/0.9.0/pkg/descheduler/pod/pods.go#L57 *4

f:id:husq:20200104031116p:plain
deschedulerによって冗長なpodがevictされるログ

k8s-termination-handlerの実装やissue

そんなに量が多くなさそうだったので実装は一通り軽く読んだ。 preemptive VM以外も対象になっているようだったけど、preemptive VM以外は興味がなかったので読み飛ばしている。

DaemonSetとして動作しているけど、デプロイしたところpreemptive VMのノードにだけ*5k8s-termination-handlerのpodが動作していた。 実装的にはmetadata APIをpollingしていて、終了対象になっていたらpodのevictやtaintラベルの付与を行った後にdrain処理を行っている。 evictするときに自分自身をちゃんと対象外にしていて少し親近感を感じた*6

issueを見てて面白いなーと思ったのは以下。

[Question] Handles simulated termination events? · Issue #8 · GoogleCloudPlatform/k8s-node-termination-handler · GitHub
Q. terminationの挙動をsimulateできる?
A. gcloudコマンドでできるよ

試してみた、ちゃんとできた。

Any plans to add a slack notifications? · Issue #10 · GoogleCloudPlatform/k8s-node-termination-handler · GitHub
Q. slack通知の予定ある?
A. 回答なし

どういうpayloadにするか議論になると面倒そうだけど自分もほしい...。
通知が飛んできたタイミングと何か別のアラートがきたタイミングが一緒ならpreemptiveが落ちたのか、みたいな安心感がある。

ということでPRを出した。
Added a feature to notify slack by 8398a7 · Pull Request #21 · GoogleCloudPlatform/k8s-node-termination-handler · GitHub

f:id:husq:20200104031459p:plain
通知結果

ひとまず自分はforkしたimageで運用しているが特に問題はない。 落ちたときにアラートが上がらないとクラスタの正常稼働が安心できるけど、そのうち鬱陶しくなるかもしれない。 SLACK_WEBHOOK_URL が未指定なら通知処理はスキップするようにしているので、邪魔になったら切るかも。

Q: Is this tool discontinued or ready and production safe? · Issue #11 · GoogleCloudPlatform/k8s-node-termination-handler · GitHub
Q. productionで使ってもいい?
A. 正気の沙汰じゃないので、testやstaging環境とかの節約だけにしといて

I would question your sanity considering using preemptive nodes on live environments

[Propose] Call removing a node from a cluster for a preemptible VM. · Issue #15 · GoogleCloudPlatform/k8s-node-termination-handler · GitHub
Q. ノードの削除を明示的に実行したらcontrol planeが早くハンドリングできるんじゃない?(という意味...?)
A. とちるとノードが減ったまま戻ってこなくなるからやめとこ

What is the API that it uses to know that the node will be down after 30 seconds? · Issue #17 · GoogleCloudPlatform/k8s-node-termination-handler · GitHub
Q. どうやってG2シグナルのハンドルしたらいい?
A. metadata APIでできるよ
Q. metadata APIで取れるのはG3だけのように思える

metadata APIとプリエンプト通知に関する公式ソースを見つけられなかったのでコメントが真なのかわからないけど、もしも真なら割とギリギリにdrain処理をしていることになる。
simulateで何度かnodeをterminateしてみると適切にdeschedulerでpodが分散された後なら問題なさそうだった。

*1:もしもきれいに24hに1回なら落ちるタイミングを朝5時とかにしてやろうと思っていた時期もあった

*2:自分自身を選択するとつけ直す際にpublic IPがなくなるシーンでGCPAPIと疎通できなくなる

*3:DBやingress-nginx-controllerが選ばれると即ダウンタイム

*4:このifはやばいでしょ…と思った

*5:これがどこで制御されているかは読んでない

*6:EIPをつけ外しするpodで自分自身を対象にしたら外した段階でネットワークに疎通できなくなる問題でハマったことがあったので

じぶん ReleaseNote v0.2

839.hateblo.jp

11月と比べると全体のトピックは少なく、やってることが重たかったなぁという印象。

Tech寄り

  • O'Reilly Japan - Go言語でつくるインタプリタ を読んだ
    • 軽く一通り読んだ程度なので復習したい…
  • CKADを取得した
  • 課題管理をGitHub IssueからJIRAへ移行する準備をした
  • 本番環境でやらかしちゃった人 Advent Calendar 2019に投稿した
  • AWS Advent Calendar 2019に投稿した
  • macOSのpostgresqlを更新する - 839の日記
    • 定期的に調べるので記事にした
  • action-slackのリリース作業を簡略化した
    • actions/create-release というライブラリを使って手動オペレーションを減らした
  • action-slackのテンプレートを更新した
    • typescript用のtemplateが結構変わっていたので追従した
    • @zeit/ncc を使ってbundleしたjsを全ブランチに配置する方針になってたのに驚いた
  • action-slackのテストを書いた
    • 機能追加のたびに実際にslack通知して期待通りか調べるのが手間なのでテスト環境を揃えた
    • slackにsendするときのobjectが期待したフォーマットになっているかをテストする
    • Actionsのテストはローカル/CIの両方で動くように書くノウハウがあんまりない気がする
  • 各種脆弱性対応
    • 放置していた諸々のセキュリティアラートを修正した(Rails 6.0.2.1とかserialized-javascriptとか)
  • actions/checkout@master だとタグ情報が取れなくなっていたので既存のActionsを修正した
    • v2でも取れるようだけど、exampleのやり方では期待した挙動でなかったのでv1で固定するようにした
    • タグ情報が不要なところはv2を使っている

ガジェット寄り

  • 家のルータをTP-Link C3150に買い替えた
    • ASUSのRT-AC88Uが大幅値下げでほぼ同値段だったが、TP-Linkに興味があったのでC3150を購入した
    • I-O DATAのWN-AC733GR3を使っていたがオンラインゲームの合流ができないという問題*1もあった
    • Web管理ツール、iPhoneアプリのUXはとても良く、速度も速くなったので変えてよかった
  • fitbit versa2の初期不良対応
    • 画面タップが反応しない、勝手に1日10回近く再起動するなどの不具合症状が出ていた
    • サポートにYouTube限定公開URLをつけて連絡したら代替品を発送してくれる*2とのこと
    • 以前もベルトが壊れたとき*3に代替品を送ってくれていてサポートは毎回感謝

*1:結局原因はあんまりわかってないけど、プライベートセパレーターが怪しい

*2:あくまで今回自分が相手に説明したケースの場合は、という前提

*3:最近のはベルトだけ交換できるけど、昔は本体ごとしか変えられなかった