Weave Fluxを利用してGKE環境のCDを構築した
Weave Flux(https://github.com/weaveworks/flux)を利用して、GKE環境のCDを構築しました。 k8sでアプリケーションの継続的なデプロイを実現しようと思うと意外と考えないといけないことが多く、Fluxはよい解決策に思えました。
解決したかった課題
解決したかった課題としては、
です。
Flux?
k8sクラスタに常駐するタイプのCDツールで、Gitレポジトリをポーリングして定期的にGitレポジトリにあるmanifestsをクラスタに適用してくれます。 指定したレポジトリをgit pullしてその中のmanifestsをkubectl apply するだけ、という非常にシンプルなツールです。 コンテナレジストリを監視してイメージの差し替えを自動化する機能もありますが、利用していないので割愛します。
インストール方法
公式レポジトリにデプロイ用のk8s manifestsがあるので、それを編集して kubectl apply するだけです。 github.com flux-deployment.yamlの --git-url(GitレポジトリのURL), --git-branch(ポーリングするブランチ), --git-path(マニフェストがあるディレクトリ) を変更すればほとんどの場合十分だと思います。
flux用に名前空間を分けることもできます。
kubectl create namespace flux kubectl apply -f deploy -n flux
公式のチュートリアルが充実しています。
flux/get-started.md at master · weaveworks/flux · GitHub
レポジトリの準備
社内ネットワーク内に存在しているGithub Enterpriseを利用していたため、k8sクラスタから接続することはできません。そこで、GCPのCloud Source Repositories ( https://cloud.google.com/source-repositories ) にコードをミラーリングして対応することにしました。
FluxはデフォルトではCloud Source Repositoriesに対応していませんが、公式ドキュメントの"Using a private git"( https://github.com/weaveworks/flux/blob/master/site/standalone-setup.md#using-a-private-git-host )で紹介されている手順を利用すればCloud Source Repositoriesに接続することができます。
kustomize
kustomizeを利用してマニフェストの管理を行なっていたのですが、Fluxはkustomizeに対応していません。
僕の場合は、Cloud Source Repositoriesにマニフェストを持っていく時に kustomize build してしまい、ビルド後のマニフェストだけをコミットするようにしました。
kustomizeサポートは現在議論中のようです。
Kustomize integration support · Issue #1261 · weaveworks/flux · GitHub
出来上がったフロー
社内GHEレポジトリにコミット
↓
CIツールでそれを検知し、kustomize build したものをSource Repositoriesにコミット
↓
クラスタに常駐しているFluxがSource Repositoriesをポーリングし、差分があればkubectl apply
という感じになりました。
履歴も残るし、安定しているのでいまのところ満足しています。いまクラスタに適用されているマニフェストが何なのかが仕組みで保証されているのは安心度高いです。
Fluxのよいところ
シンプルでメンテがほぼ不要なところがよいです。
気をつけないといけないところ
マニフェストの削除にはまだ対応していません。なので、レポジトリからあるリソースを管理するマニフェストを削除しても、そのリソースはずっと残ってしまいます。
レポジトリに存在していないリソースを消していいかというと、そうじゃない場合もありそうなのでなかなか難しいところ。。
KubernetesでPodが終了するときのフロー
ここにかいてあることの個人的なまとめ cloud.google.com
まとめ
アプリケーションはSIGTERMで正常に終了する必要がある
終了フロー
1. PodをTerminating状態にして、Serviceのendpointから削除する
まず、PodはTerminating状態になり、Serviceのendpointから削除されます。コンテナは稼働し続けたまま、新しいトラフィックは入ってこなくなる。
2. preStop Hookの実行
preStop Hookが実行される。外部のアプリケーションを実行しているときなど、SIGTERMで正しく終了出来ない場合はこちらを使う。
3. PodにSIGTERMシグナルの送信
PodにSIGTERMが送信される。k8sは、SIGTERMシグナルで、アプリケーションが正常に終了することを期待している。ここでいう正常終了とは、graceful shutdownのことで、SIGTERMで処理中のプロセスを停止させてしまう場合、処理が途中で打ち切られてしまう可能性が常に発生してしまいます。
4. grace periodを待つ
kubernetesは、2, 3の経過にかかわらず、定められたgrace periodの間、コンテナの停止を待っている。grace periodのデフォルトは30秒で、Pod毎に変更することが可能。
PodのspecにterminationGracePeriodSeconds を指定する
5. SIGKILLを送信し、Podを削除
grace periodの間待ってもコンテナが停止しない場合、SIGKILを送信して強制的に停止させる。そのあと、Podを削除する
まだよくわからないこと
https://kubernetes.io/docs/concepts/workloads/pods/pod/#termination-of-pods 上記ページの記載だと、1のServiceからの削除と、2,3のPod停止処理が同時に行われると書いてある。
つまり、SIGTERMシグナルが送信されたあとに新しいトラフィックが流れる可能性があるが、それはk8sの仕様ということ。ときどき、prestopでsleepしてる対応を見かけるのだが、これが現実的に起きうる頻度なのかが不明。
SIGTERM受け取ったら新しいリクエストは処理しないのが正常なように思えるし、だからといって全てのPodのprestopにsleep仕込んでいくのも違和感があります。。
Stackdriver MonitoringでGKEのPodを監視する場合
Stackdriver MonitoringでGKEのPod,Containerのリソースを監視する場合、アラートが発生したあとにそのPodが削除された場合、そのアラートがずっと残ってしまうという問題があった。 この問題は、Pod, Containerそのもののメトリクスを監視するのではなく、グルーピングすることがで解決できる。 アラートのポリシーを設定するときには、container_nameでグルーピングする方がよさそう。 グルーピングする場合、集約のルールも設定する必要があるが、99 percentile などを設定すれば特定のPodで異常値が発生したとしても見逃すことはない。
Sidecarを利用しているとk8sのjobが終了しない
問題
Sidecarを使っているとkubernetesのJobが正しく終了できない問題がありました。SIdecarコンテナの仕様によっても変わるのですが、cloudsql-proxy, istio-proxyなどよく使われるSidecarではこの問題が発生します。
これはJobを実行するメインのコンテナが処理を完了して終了しても、Sidecarは変わらず動作を続けているからです。
状況
kubernetesにもIstioにもIssueがあがっており、回避策は挙げられているものの、それが利用できるかどうかは状況次第で、解決には至っていないようです。
Better support for sidecar containers in batch jobs · Issue #25908 · kubernetes/kubernetes · GitHub
現状の解決策
現状の解決策は、
- Sidecarコンテナを自分でコントロールして自死する仕組みを作る(emptyDirをmountしておいて、そこに特定のファイルがあったら終了コード0で終わるとか)
- Jobが長引かないことを祈ってconcurrencyPolicy: Replace
の2択のようです。
前者の方法はistio-proxyのように自動で注入されるsidecarには適用しづらいので、後者が適用できるように実行間隔や処理内容を調整する必要があります。。
また、自分でSidecarコンテナを作成する場合は、別コンテナから安全に終了させることができる仕組みを用意しておくとよさそうです。
追記
CronJobの場合は、concurrencyPolicyで対応できるが、Jobの場合はそれがない。
Jobの場合は、activeDeadlineSeconds を設定することで、指定した時間が来たらPodが終了することを保証することができる。
k8sのreadOnlyRootFilesystemを有効にして、Rackアプリケーションでファイルアップロードをするための設定
kubernetesにはreadOnlyRootFilesystemというオプションがあり、このオプションを有効にするとコンテナ内がreadonlyになるためセキュリティの施策の1つとして有効にされることがあります。その状態でRack(Rails,Sinatra,etc)アプリケーションからファイルアップロードを行うときに思ったより手間取ったのでそのときの対応メモ。
Rubyのバージョンは2.6。kuberntesはGKEを利用していて、1.11.7-gke.6でした。
前提とエラーの内容
WebアプリケーションでPOSTのファイルアップロードを受け付けるにはディスクに一時ファイルを書き込みできる必要があります。readOnlyRootFilesystemを有効にした状態でファイル書き込みを許可する場合には、k8sのemptyDirを作成し、それをマウントするのが一般的に取られる設定です。(おそらくですが)
以下のようなyamlになります。
containers: image: rubyapp name: app securityContext: readOnlyRootFilesystem: true volumeMounts: - name: tmp mountPath: /tmp volumes: - name: tmp emptyDir: {}
nginxのpid書き込みなど、この設定で問題ないのですが、Rackの場合はパーミッションエラーがありうまくいきませんでした。
エラーメッセージをよく見てみると一時ファイルを書き込みしようとしてるディレクトリがなぜかRubyを実行しているカレントディクトリになっています。Rubyの問題のようなので調べて見ると、Rackがファイルアップロード時の一時ファイルを保存するのは Dir.tmpdir で得られるディレクトリということでした。Dir.tmpdirを実行してみたところ、たしかにカレントディレクトリが取得できました。ENV['TMPDIR']を"/tmp"にしても変わらずカレントディレクトリのままでした。
原因
そこで Rubyのソースコードを確認してみたところ、原因がわかりました。Dir.tmpdirは以下のようになっています
def self.tmpdir if $SAFE > 0 @@systmpdir.dup else tmp = nil [ENV['TMPDIR'], ENV['TMP'], ENV['TEMP'], @@systmpdir, '/tmp', '.'].each do |dir| next if !dir dir = File.expand_path(dir) if stat = File.stat(dir) and stat.directory? and stat.writable? and (!stat.world_writable? or stat.sticky?) tmp = dir break end rescue nil end raise ArgumentError, "could not find a temporary directory" unless tmp tmp end end
https://github.com/ruby/ruby/blob/1fae154c07b957278fd336b54256d5c57f21e0d5/lib/tmpdir.rb#L26
問題はこの条件文です。
(!stat.world_writable? or stat.sticky?)
tmpdir候補のディレクトリはこのチェックを突破しないとtmpdirになることができないようです。VolumeMountされたディレクトリを確認したところ、world_writable?はtrueかつ、stickyビットは立っていないことが確認できたので、これが原因で間違いなさそうです。
対応
VolumeMountするディレクトリのパーミッションを変更する方法を調べたのですが見つかりませんでした。
Rubyはどのユーザーでも書き込みできるディレクトリの場合はstickyビットを求めているのに対し、k8sはコンテナ前提なので複数のユーザーがいることはあまり想定していないのでしょう。
仕方ないので、RubyのDir.tmpdirを書き換えてしまいます
class Dir class << self alias org_tmpdir tmpdir end def self.tmpdir dir = ENV['TMPDIR'] return File.expand_path(dir) unless dir.nil? org_tmpdir end end
manifestsは以下のようにします。
containers: - image: rubyapp name: app env: - name: TMPDIR value: /tmp securityContext: readOnlyRootFilesystem: true volumeMounts: - name: tmp mountPath: /tmp volumes: - name: tmp emptyDir: {}
これで RubyのDir.tmpdirが"/tmp"になり、/tmpには書き込みができるのでファイルアップロードができるようになりました。
k8sクラスタのsecretsを別のクラスタにコピーしたいとき
secretsといってもbase64encodeされているだけなので、簡単にコピーできます。
kubectl get secrets -o yaml > current_secrets.yaml
で取り出して
kubectl apply -f current_secrets.yaml
でok。
このときTYPE:kubernetes.io/service-account-tokenなど、おそらくコピーすべきでないsecretsも含まれていることに注意が必要です。
アプリケーションで利用するsecretsにラベルを付与しておけば、secretsを取り出すときにラベルでフィルタできるので便利です。次この作業をするかどうかはわからないのですが、念のため僕はsecretsにlabelを付与しておくことにしました。
複数のファイルをまとめてkubectl apply する
kubectl にはその機能がなかったのでfindしてxargsする
find . -name "*.yaml"|xargs -I {} kubectl apply -f {}