koukiblog

たぶんweb系の話題

Kubernetesで完了済みのJobをまとめて削除する

Kubernetesの完了済みJobリソースをまとめて削除するには、field-selectorに"status.successful=1"を指定することで削除できます。

kubectl delete jobs  --field-selector status.successful=1

field-selector に指定できるフィールドはリソース毎に定義されていて、Jobの場合は、metadataまたは、 status.successfulのみが指定できます。どこにもドキュメンテーションされていないようです。

指定できるフィールドは、kubernetesのコード内で見ることができます。 kubectl get で取得しても、status.successfulというフィールドは存在しないのでかなりわかりにくいですね。。

func addConversionFuncs(scheme *runtime.Scheme) error {
    return scheme.AddFieldLabelConversionFunc(SchemeGroupVersion.WithKind("Job"),
        func(label, value string) (string, string, error) {
            switch label {
            case "metadata.name", "metadata.namespace", "status.successful":
                return label, value, nil
            default:
                return "", "", fmt.Errorf("field label %q not supported for batchv1.Job", label)
            }
        },
    )
}

https://github.com/kubernetes/kubernetes/blob/master/pkg/apis/batch/v1/conversion.go#L34

完了済みジョブを削除するときの条件が複雑な場合は、go-clientを利用したプログラミングが必要になりますが、大量に完了済みのジョブが残っているのが気になるくらいであれば、kubectl delete で解決してしまうのもよいんじゃないかなと思います。(まだAlphaですがTTLコントローラを利用する方法もあります )

1日1回CronJobを利用して完了済みのジョブを削除するだけであれば、下記のようにシンプルに済ませることができます。

Delete complete k8s jobs everyday · GitHub

備考

status.successfulはこの投稿で知りました

kubernetes - How can I find the list of field selectors supported by kubectl for a given resource type? - Stack Overflow

GolangでGKE用(StackdriverLogging)のログを出力する

GKE(Google Kubernetes Engine)でアプリケーションを開発する場合、ログは標準出力に出力し、GKEデフォルトでインストールされるfluentdでStackdriverLoggingに出力することが多いと思います。

その場合、特定のJSONフォーマットで出力することでStackdriverLogging上で扱いやすくなります。フォーマットの詳細はこちらです。 https://cloud.google.com/logging/docs/agent/configuration#special-fields

Golangの場合、デフォルトのLoggerでは

という問題があったので、どうすればよいのか調べてみました。

まず、ログ用のライブラリですが、logrusというライブラリを使うのがよさそうでした。 https://github.com/sirupsen/logrus

構造化したログを扱うことができ、フォーマットも指定することができます。

JSONのFormatterは標準で用意されているので下記のように利用することでJSON形式でログを出力することが可能です。init() でformatterを指定している箇所で環境変数を使った分岐を使えば、手元ではテキスト形式、サーバではJSONという分岐も可能です。

import (
    "os"
    log "github.com/sirupsen/logrus"
)

func init() {
    // Log as JSON instead of the default ASCII formatter.
    formater := log.JSONFormatter{
        FieldMap: log.FieldMap{
            log.FieldKeyLevel: "severity",
        },
    }

    log.SetFormatter(&formater)

    // Output to stdout instead of the default stderr
    log.SetOutput(os.Stdout)

    // Only log the warning severity or above.
    log.SetLevel(log.InfoLevel)
}

func main() {
  log.Info('hello') // => {"msg": "hello", "level":info, "time":"2019-11-24T18:06:23+09:00"}
}

StackdriverLoggingに連携するにあたって、levelは、"severity"というkeyで連携する必要があります。これは、JSONFormaterにFieldMapを指定することで実現できます。

func init() {
    // Log as JSON instead of the default ASCII formatter.
    formater := log.JSONFormatter{
        FieldMap: log.FieldMap{
            log.FieldKeyLevel: "severity",
        },
    }

    log.SetFormatter(&formater)
}

この解決策は、下記Issueで紹介されていました

github.com

また、logrusで用意されているレベルとStackdriverLoggingで定義されているレベルには差異があるので、カスタムレベルを定義する方法があれば、それを利用したいところですが、logrusには用意されていないようでした。info, warning,errorは利用できますが、それ以外を利用するとStackdriverLoggingでは正しく扱えないので注意が必要です。 それぞれがサポートしているレベルは下記です。

StackdriverLogging: https://cloud.google.com/logging/docs/reference/v2/rest/v2/LogEntry#LogSeverity

logrus: https://github.com/sirupsen/logrus/blob/39a5ad12948d094ddd5d5a6a4a4281f453d77562/logrus.go#L27

Custom Log Levelについては、Issueで要望があがっていますが、いまのところ実装される見込みは薄そうです。

github.com

ということで手軽にJSON形式のログを出力する場合はlogrusがおすすめです。Golang標準のloggerインターフェースを実装しているので、後から差し替えるのも簡単だと思います。

gorilla/sessionでメモリリークが起きる原因を調べた

はじめに

開発中のGoで書かれたアプリケーションがメモリリークを起こしていて、gorilla/sessions ( https://github.com/gorilla/sessions ) というライブラリをバージョンアップしたら解決した。

メモリリークを解決したPRはこれ。

github.com

gorilla/contextが原因らしく、Contextが存在するよりも前にContext相当のことをしていたライブラリなので、併用するとよくないことが起こりそうだなーとは思うものの、なぜメモリリークするのかが理解できなかったので調べてみた。

調べた結果

Go 1.7で追加されたContextとgorilla/contextの競合によりメモリリークが発生するようになったのかと思い、少し混乱したのだが、

はそれぞれ独立した事象だった。が、後者のContextの競合は、メモリリークが発生する可能性をより高めてしまっている。 具体的には、 gorilla/contextと http.request.WithContextを併用すると、メモリリークに対する既知の対応を入れた場合でも、gorilla/contextで保存した内容を削除できずにメモリリークが起きる。

メモリリーク

gorilla/session を使う場合は、リクエストの最後にClearを行わないとcontextに保存した内容が削除されず、メモリリークが起きる。 これはgorilla/contextの仕様であり、GoのContextとは関係ない問題だった。 gorilla/muxを利用する場合はライブラリが面倒を見てくれるので利用者は気にしなくて良くて、gorila/session または gorilla/contextを単独で利用している場合に限り、利用者側が気をつける必要がある。

gorilla/contextとGoのContextの競合

gorilla/contextは、http.Requestのポインタをkeyにしたmapにデータを保存している。

var (
    mutex sync.RWMutex
    data  = make(map[*http.Request]map[interface{}]interface{})
    datat = make(map[*http.Request]int64)
)

https://github.com/gorilla/context/blob/master/context.go#L13..L17

一方、http.Request.WithContextは、requestをshallow copyした上でcontextをセットするので、引数として渡したrequestと戻り値のhttp.requestのポインタが変わってしまう。

WithContextの定義はこれ。

func (r *Request) WithContext(ctx context.Context) *Request {
    if ctx == nil {
        panic("nil context")
    }
    r2 := new(Request)
    *r2 = *r
    r2.ctx = ctx
    r2.URL = cloneURL(r.URL)
    return r2
}

go/request.go at 3409ce39bfd7584523b7a8c150a310cea92d879d · golang/go · GitHub

WithContextを利用すると、gorilla/contextで保存する先のmapが変わってしまう。 コードにすると以下のようなことが起こる。

package main

import (
    "context"
    "fmt"
    "net/http"

    gcontext "github.com/gorilla/context"
)

func main() {
    r := new(http.Request)
    fmt.Printf("%v\n", &r) // => 0xc000010020

    gcontext.Set(r, "key", "value") 
    fmt.Printf("gorilla/context: %v\n", gcontext.Get(r, "key")) // => gorilla/context: value

    r = r.WithContext(context.Background())
    fmt.Printf("%v\n", &r) // => 0xc00009c010

    fmt.Printf("gorilla/context: %v\n", gcontext.Get(r, "key")) // => gorilla/context: <nil>
}

上記の例では省略したが、context.Clear(r) を行ってもセットした値を消すことはできない

ライブラリ側の解決策

gorilla/sessionが、1.6 以前のサポートを切ったので、gorilla/contextを利用しなくなった。 上記の回避策が不要になった。 なので、最新のgorilla/sessionを使えばメモリリークは発生しない。

再掲だが、PRはこちら

github.com

gorilla/contextを直接利用すると変わらずメモリリークの可能性はあるので注意

備考

調べたときに利用したページを自分の備忘のため残しておく

そもそもContextとは

ちゃんと理解できてなかったので改めて調べた。こちらのページに日本語訳があって助かった www.ymotongpoo.com

なぜgorilla/muxを利用するとメモリリークしないのか

gorilla/mux ( https://github.com/gorilla/mux ) の mux.go の旧バージョンを参照すると、ServeHTTPメソッドに下記の記述があった。

func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {

       ~~~~

    if !r.KeepContext {
        defer contextClear(req)
    }

       handler.ServeHTTP(w, req)
}

実行されているcontextClearの定義は gorila/context の context.Clear。muxではライブラリ側で、gorilla/contextのcontextClearが実行されていたことが確認できた

ちなみに、gorilla/sessions でメモリリークの回避策で紹介されていた ClearHandlerの定義はこちら

func ClearHandler(h http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        defer Clear(r)
        h.ServeHTTP(w, r)
    })
}

ほぼ同じ効果が期待できそう。

muxのGoのContextへの対応

前述の通り、メモリリークとGoのContextへの対応はまた別の問題。gorilla/muxでは、Goのバージョンによって挙動を変更していた。

Store vars and route in context.Context when go1.7+ is used by ejholmes · Pull Request #169 · gorilla/mux · GitHub

修正のきっかけはこれっぽい

mux.Vars breaks with Go 1.7 · Issue #168 · gorilla/mux · GitHub

メモリリークが報告されている様子

https://groups.google.com/forum/#!msg/gorilla-web/clJfCzenuWY/N_Xj9-5Lk6wJ

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