koukiblog

たぶんweb系の話題

GKEリージョンクラスタの制約

GKEにはリージョンクラスタという設定があり、この設定を追加すると、リージョン内の複数のゾーンにノードを複製し、可用性を高めることが出来ます

Regional clusters  |  Kubernetes Engine Documentation  |  Google Cloud

可用性を高める必要があり、インフラ費用に余裕があるならonにしておけばよいと思っていたのだけど、思わぬ制約がありました

前述のとおり、リージョンクラスタはリージョン内の複数のゾーンにノードを複製し、複製するノードはゾーン間で同じスペックである必要があります。(これはおそらくリージョンクラスタというかノードプールの制約)

そのため、ノードのマシンタイプは複製されるすべてのゾーンで利用できる必要があり、複製されるゾーンに利用できないマシンタイプが指定されているとエラーになります。

具体的には、東京リージョン(asia-northeast1)でリージョンクラスタを設定した場合、ノードのmachine-typeにc2マシンタイプを指定することはできなません。なぜなら、asia-northeast1-bではc2マシンタイプに必要なCascade Lakeがサポートされていないからです。 リージョンクラスタは後から取り消すことができないので、リージョンクラスタを作成したあとでマシンタイプをc2インスタンスにしたくなった場合、クラスタ再作成が必要になってしまいます。

これを知らずに既存のGKEクラスタにノードプールを追加しようとしたら、asia-northeast1-bにc2-standard-4というマシンタイプは存在しない、というエラーが返ってきてびっくりしたのでまとめておきました。

RailsアプリをGKEに移植するときにやったこと

GAEで稼働していたアプリをGKE用に移植したので、そのときやったことを自分用にまとめておく

まずベースコンテナを作る

まずはRailsアプリを動作するためのコンテナを作成します。ベースとなるコンテナにアプリケーションをコピーすればアプリが動作するようにするのが目的です。

Railsアプリが必要とするベースコンテナの要件は以下のようになると思います。

  • Ruby
  • native extensionを利用しているgemが依存している様々なライブラリ
  • Node.js

ライブラリは、nokogiri、mysql2のようなgemのための対応です。コンテナサイズを最小にするには、利用しているgemが依存しているライブラリだけをインストールすればよいのですが、今回はメジャーなgemが依存しているライブラリは全部入りにすることにしました。

Node.jsは、Asset Pipeline、Webpackerを利用する場合必要になります。rubyracerのようなgemでも対応可能ですが、Node.jsをインストールしてしまう方がよいと判断しました。

上記の要件を満たすコンテナがDockerHubにあればよかったのですが、探してもなかったので作成しました。

https://github.com/k0uki/docker-ruby-node

カレントディレクトリにアプリケーションが配置されているとして、下記のようなDockerfileでRailsアプリをコンテナ上で動作させることができるようになります。

FROM k0uki/ruby-node:2.6.5-10-slim

ENV RACK_ENV=production \
    RAILS_ENV=production \
    NODE_ENV=production \
    APP_ENV=production \
    RAILS_SERVE_STATIC_FILES=true \
    RAILS_LOG_TO_STDOUT=true \
    NOKOGIRI_USE_SYSTEM_LIBRARIES=1

COPY . /app/
WORKDIR /app
RUN bundle install --without test development && yarn install && yarn cache clean

RUN bundle exec rails assets:clobber assets:precompile

CMD ["bundle", "exec", "rackup", "--port=8080", "-o", "0.0.0.0"]

ログを標準出力に出力する

ログは標準出力に出力するのが一般的です。Railsデフォルトではファイルに出力されるので、これを標準出力に変更します。 Rails5以降であれば、RAILS_LOG_TO_STDOUTという環境変数をtrueにすると標準出力に変更できます。 このとき、

STDOUT.sync = true

をinitializersかenvironmentsに追加しないと、出力がバッファリングされるので注意が必要です。

GKEにデプロイ

Dockernizeさえ出来てしまえば、GKEへのデプロイは何も特殊なことが必要ありません。

DBのmigrationのタイミングは、色々なやり方があると思いますが、僕はアプリの起動時にdb:migrateしてしまえばよいと思っています。今回移行したアプリはDBがなかったのでこの考慮は不要でした。

kubernetesにRailsをデプロイするときには、l.gcr.io/google/rubyは使わない方がいい

運用しているGKE上にコンテナ化されていないRailsアプリをデプロイすることになり、コンテナ化を進めていたときに気づいた話。

デプロイしたいアプリはRailsアプリはGAE上で動いていて、GAEで動作するときにどうもコンテナ化されているらしい。

そこでたどり着いたのが、このレポジトリ。

github.com

RubyとNode.jsがインストールされていて、Railsのための設定もすでに行われているのでそのまま使えそうです。 ruby-baseディレクトリのREADMEにもGKEで動作させるため記載がありました。

# Use the Ruby base image
FROM l.gcr.io/google/ruby:latest

# Copy application files and install the bundle
COPY . /app/
RUN bundle install && rbenv rehash

# Default container command invokes rackup to start the server.
CMD ["bundle", "exec", "rackup", "--port=8080"]

https://github.com/GoogleCloudPlatform/ruby-docker/tree/master/ruby-ubuntu16

ところが、この通りDockerfileを記述してビルドすると、bundlerが存在しないエラーになります。

この事象は下記Issueで報告されていました。 github.com

Issueを読むと、こんな記述があります

Hmm. Looks like that readme is incorrect: I don't think gcr.io/google/ruby is the correct image. Generally, we should probably remove that section on GKE from the readme, as this docker image has generally evolved to be pretty app engine specific. If you want to roll your own docker image, I recommend using the canonical ruby images from DockerHub.

要約すると、gcr.io/google/rubyを使うこと自体が間違い。READMEから削除すべき。gcr.io/google/rubyは、GAE独自の仕様にあわせたコンテナになっている。ということっぽい。

google/rubyを仕様を調べれば対応できるとは思うけど、このコンテナ自体汎用的な用途を想定しているわけではなく、GAE用のランタイムとして設計されているようなので、使うべきではないと判断し、DockerHubにあるrubyのオフィシャルイメージ使って自前で構築することにした。

Pumaはあるサイズ以上のデータをPOSTされると一時ファイルを作成する

表題の通り。Pumaはあるサイズ以上のデータをPOSTされると一時ファイルを作成します。

puma/server.rb at 482ea5a24abaccf33c49dc9238a22e2a9affe288 · puma/puma · GitHub

      # Use a Tempfile if there is a lot of data left
      if remain > MAX_BODY
        stream = Tempfile.new(Const::PUMA_TMP_BASE)
        stream.binmode
      else
        # The body[0,0] trick is to get an empty string in the same
        # encoding as body.
        stream = StringIO.new body[0,0]
      end

GKE上で書き込み禁止にして運用していたところ、ある条件下でファイル作成しようとしてエラーになったので調べてみたらこれが原因でした。

Node.jsでsleep相当のことをする

非同期処理が意図通り動いているのか確認するときに便利です。 resolveAfter2Secondsという関数を定義し、完了を待ちます。

async function slowSomething(){
   await resolveAfter2Seconds()
}

function resolveAfter2Seconds() {
  console.log('starting slow promise')
  return new Promise(resolve => {
    setTimeout(function() {
      resolve('slow')
      console.log('starting slow promise')
    }, 2000)
  })
}

GKEでスティッキーセッション(Session Affinity)を利用する

GKE環境でスティッキーセッションを利用するときの方法です。WebSocketなど利用していて、ブラウザからPodと接続を確立したあとはそのPodと通信し続けたい場合に必要になると思います。

k8s環境でスティッキーセッションってそもそもできるのか?と最初不安だったのですが、思ったより簡単にできました。 GCPやそのほかのドキュメントではSessionAffinityと呼ばれていたので以降SessionAffinityにします。

解決策

GKEデフォルトのIngress Controllerには、SessionAffinityの機能があり、この機能を有効にすることで実現することができます。接続元の判断は、クライアントIP、Cookieのどちらかの方法を選ぶことができます。

Configuring a backend service through Ingress  |  Kubernetes Engine Documentation  |  Google Cloud

ドキュメントに書いてある通りなのですが、まずbackend configを作成し

apiVersion: cloud.google.com/v1beta1
kind: BackendConfig
metadata:
  name: my-bsc-backendconfig
spec:
  timeoutSec: 40
  connectionDraining:
    drainingTimeoutSec: 60
  sessionAffinity:
    affinityType: "CLIENT_IP"

サービス側でそのbackend configを利用することで実現できます。

ind: Service
metadata:
  name: my-bsc-service
  labels:
    purpose: bsc-config-demo
  annotations:
    cloud.google.com/neg: '{"ingress": true}'
    beta.cloud.google.com/backend-config: '{"ports": {"80":"my-bsc-backendconfig"}}'
...

サービス側で指定するNEGは必須です。 また、ServiceがNEGを利用していなかった場合、NEGに切り替わるまでの間通信断が発生するので本番環境に反映する場合は注意が必要です。

仕組み

GCPのLoadBalncerにSession Affinity ( https://cloud.google.com/load-balancing/docs/backend-service#generated_cookie_affinity )という仕組みがあり、これをそのままGKEでも利用する形です。

通常のk8s環境の場合、Ingressとして作成されたLBのバックエンドはGCEインスタンスであり、GCEインスタンスに来たリクエストをk8sがPodに転送するので一見SessionAffinityの実現は困難なように思えます。そこで、必要になるのがNEG( https://cloud.google.com/kubernetes-engine/docs/how-to/container-native-load-balancing )です。 

NEGを利用すると、LBのバックエンドをGCEインスタンスではなく、Podにすることが可能になります。こうすることで、LoadBalancerのSession Affinityの機能をk8s環境でもそのまま利用することができます。

NEGはGKE独自の機能なので、GKE以外の環境では、Nginx Ingress Controllerを利用する必要がありそうでした