ぷろじぇくと、みすじら。

Real World .NET Core on Kubernetes という話をしました

Created at:

10月18日に AWS .NET Developer User Group 勉強会 #2 にて Real World .NET Core on Kubernetes というセッションで話させていただきました。このエントリーはそのフォローアップです。

Lifebear のサーバーアプリケーション (.NET Core) を Amazon EKS / Kubernetes 環境を構築してリリースするまでにメモしていたポイントまとめた形です。

「EKS/AKS/GKE で Kubernetes 立てて、.NET Core アプリをデプロイしてみた(使ってみた)」より一歩進んだ話が欲しいと思っていたのでそれがテーマでした。

.NET Core のアプリケーションを Kubernetes の上で動かしたという国内の実例というのは少なくて、自分で構築する際にも不安になったので、今後 Kubernetes でシステムを構築しようという人の参考になってほしいなと。…と思って書いたらちょっと盛りすぎてしまったので、Kuberentes 寄りの話と .NET Core 寄りの話を分ける方がよかったかなというのが反省点ですね。

知見いろいろ

ネタ帳にはあったものの時間の関係上話せなかったスライドに書いた以外のこともこの際なので書いておきます(主に Kubernetes)。

Docker build は BuildKit 使おう

BuildKit でビルドすると高速かつキャッシュできるので有効にするのがおすすめです。特に NuGet パッケージのリストアに効果があります。

環境変数で DOCKER_BUILDKIT=1 を指定して Dockerfile でおまじないを追加するだけです。

# syntax = docker/dockerfile:experimental
...

RUN --mount=type=cache,target=/root/.nuget/ \
    dotnet restore "WebApplication1.csproj" -c Release -o /app

Kuberentes のバージョンアップは大変

Kubernetes のクラスターのバージョンは1マイナーバージョンアップごとに上げることになります。これはバージョンジャンプが大変なので日々追いかけておくつもりが必要ということです。

kubectl や kubelet のバージョンポリシーもあり、例えば kubelet は api-server に対して前2マイナーバージョン、kubectl はプラスマイナス1マイナーバージョンといった制約がありますのでどのみち大ジャンプが難しいということです。

ノードにはアプリケーション以外のものも動いている

通常ノードを落とす時には kubectl drain するのが普通です。ある時 kubectl drain しなくても kubectl cordon してアプリを再デプロイして Pod を別なところに移せばそのままノードを落とせるのではと思い、やってみると CoreDNS 等のサービスがいなくなって死にました(もちろん対象のノードにいなければ無傷)。それはそう…。

Amazon EKS 用のノードは AMI は作らない

基本カスタマイズは UserData で起動時に何とかします。AMI作っても絶対管理できないですし、ノードはコンテナーを動かすためだけの代物なので。

/etc/eks/bootstrap.sh というものに kubelet のパラメータなどを渡せるのでそこでやりましょう。

デプロイ時にサービスから外れるのを待つ

Pod のシャットダウンと Service から外れるタイミングが異なるため、先に Pod がシャットダウンすると接続しようとしてしまうという話。preStop フックで数秒待って外れてからシャットダウンへ進むようにします。

apiVersion: apps/v1
kind: Deployment
metadata:
  name: myapp
spec:
  template:
    spec:
      containers:
      - name: myapp
        lifecycle:
          # シャットダウン前に少し待たないと Service から削除されるより先に Pod が終了してしまうことがある
          # https://qiita.com/superbrothers/items/3ac78daba3560ea406b2
          preStop:
            exec:
              command: ["sh", "-c", "sleep 5"]

Amazon EKS では Private Endpoint を使う

Kubernetes API のエンドポイントをインターネット側からアクセスできないようにする設定が追加されたので特にプロダクション環境などでは有効にするのがおすすめです。

ただし、当然外部サービスから Kubernetes API を呼び出す難易度が上がるのでその点は考慮する必要があります(その制約で Azure Pipelines の Environments を使えなかった)。

VPC のプライベートアドレスが枯渇

Amazon EKS では Pod に対してプライベートIPを割り当てるのですが、普通の EC2 インスタンスの気持ちで小さめのサブネットにしていると Pod がぽこぽこ増えた時に困ったことになります。

アドレス空間はある程度余裕を持った形にしておくのがよいです。

OOMKilled や CrashLoopBackOff を監視する

Kubernetes 上で発生したエラーイベントを監視しておきましょう。Datadog では Kubernetes のイベントも監視できるのでひっかけて通知するようにしていると異変に気付きやすくなります。

CrashLoopBackOff は発生していても、以前の Pod にはアクセスできるので他のきっかけがないと気づきにくくなります。

コンテナーの中で docker を動かす

Docker イメージを CI でビルドするのに CI のエージェントが Docker で動いているとそのままでは Docker 動かない問題にあたります。そこで俗にいう Docker in Docker (dind) です。

イメージをビルドするだけなので、ほかにも img とか Rootless Docker とか kaniko とか検討できるものはあるのですが無理しない方針で無難に Docker in Docker にしました。

privileged: true にしないといけないのでそこは微妙なのですが、今ならもしかすると Rootless でうまいこと privileged: true しなくてもいけるかもしれません。

Kubernetes の API を試すときは Pod に入って curl するのがオススメ

Kubernetes の API にアクセスする場合 ServiceAccount の設定をミスっていたりするとうまくアクセスできなくなったりするのですが、これをアプリデプロイして試すのは大変なので kubectl exec podname -it /bin/bash で入って curl で叩くのがおすすめです。

Pod が偏る

通常、Pod をデプロイするときには Kubernetes がリソースの具合を見て適度にバランスしてスケジューリングします。一方でデプロイ後に再スケジュールはしてくれません。

例えば、2つあるうちの1つのノードが死んだ場合はそのノードで動いていた分の Pod が生きているノードで起動されなおします。そして、その後ノードが復活してきたとしても Pod たちは元のノードに帰っていったりはしません。偏ったままです。

これを解決する手っ取り早い方法としては再デプロイかレプリカ数を上げたり下げたり、要するに Pod の再作成です。

他には Kubernetes の拡張として Descheduler という再スケジュールしなおすものが開発されているのでこちらの利用を検討してもいいかもしれません(が、鋭意開発中っぽい雰囲気があります)。

まとめ

振り返ってみると大変なのはアプリそのものよりは中小規模の Kubernetes クラスターを構築、運用するノウハウというところが大きい気がしました。

繰り返しにはなりますが、.NET Core を Kubernetes で実際に動かしてリリースまでもっていっているという事例の参考になって、事例がもっと出てきてほしいですね。

というわけでライフベアではほとんど好き勝手構築させていただいたので大変感謝しています。もし .NET Core (C#) + Kubernetes な環境ににご興味のある方はご一報いただくか、直接コンタクトしていただくと良いかと思います。

そして Cysharp では C# (サーバー、クライアント) に関するご相談を受け付けておりますのでお困りのことがあればぜひお問い合わせください

C# で null 許容型あれこれ

Created at:

.NET Core 3.0 のリリースとともに C# 8.0 がきて、null 許容型 (nullable reference types) を使えるようになったので使ってみたメモです。

未初期化だけど後で初期化するパターン

コンストラクターではまだ初期化しないのだけど、いずれフレームワークから初期化するので API を利用する側はほぼ null を扱わない、けど内部の初期状態は null なのを許してほしいというケースはまあよくあります。Kotlin の lateinitTypeScript の definite assignment assertion みたいなやつですね。

C# ではズバリそれっぽいものはないので default! を初期値として代入しておけばよいです。

lateinit とかもそうですが適当に使うと治安が最高に悪くなるので気を付けたほうがいいやつです。

ジェネリクス

class 制約のないジェネリクスの場合 ? をつけて null 許容型であると宣言できません。そのため null を扱うかもしれない、というケースを宣言するには属性を使用することになります。

逆を言えば where T: classwhere T: unmanaged のような制約をつけておけば T? と null 許容型を素直に書くことができるので書けるときは書いてしまうのが手です。

AllowNull 属性と DisallowNull 属性 (事前条件)

例えば次のようなメソッドの引数で参照型のときは null 渡したいケースがあります。

そのままでは非許容なので null が来てもよいということをコンパイラーに伝える必要があるので、 AllowNull 属性を使用します。

逆に null が入ってきてほしくないというのを明示することもできます。例えば先ほどのコードをもう一度。

このような場合は DisallowNull 属性を使用すると null を許さないということを明示できます。

DisallowNull 属性は .NET Core のコードであれば IEqualityComparer<T>.GetHashCode などに見られます。

MaybeNull 属性と NotNull 属性 (事後条件)

次は値型ならデフォルト値、参照型なら null が返るようなメソッドを扱うケースです。GetValueOrDefaultDefaultIfEmpty のようなものでよくありますね。

このようなケースをカバーするには MaybeNull 属性を使うと戻り値が null になる可能性があることを表せます。なお、戻り値に対しての属性なので return: です。

逆に絶対 null を返さないということがわかっている場合には NotNull 属性で宣言できます。例えば次のようなケースです。

プロパティ

AllowNull 属性と DisallowNull 属性 (事前条件)

プロパティの setter で null を許可したかったり拒否したかったりというケースがあるかもしれません。例えば setter で null はセットできるけれども null は返ってこないような API です(というのはあまり想像しづらいですが…)。

ちなみにコンパイラーが中途半端に賢いので Value = null; の後に Value を使用しようとすると怒られます(が、実際は null じゃないはずなのでなんか変な気がします)。

逆は例によって DisallowNull です。null が返るかもしれないけれど null をセットすることは許さないようなケースです。Microsoft のドキュメントのサンプルではこんな感じです

初期化時は null が返るけど、ユーザーがセットすることは許さないみたいな感じですかね。

その他のケース

NotNullWhen 属性 と MaybeNullWhen 属性 (事後条件)

TryGetValueTryParse のような、成功時には out 引数で値を返すがそれ以外では null を返し、成否は戻り値にするというケースがあります。例えば次のようなコードがあるとします。

この MyDictionary を使う場合は次のようなコードになるわけですが、その際 null の扱いも上手く処理されてほしいわけです。

そこで戻り値によって nullable かどうかを伝える NotNullWhen という属性が用意されています。この属性を付けると「nullable な型が場合によってはその後 non-nullable 確定できるかも」という情報をコンパイラーに伝えられます。

他の使い道としては String.IsNullOrEmpty みたいなもので使えます(使われています)。 IsNullOrEmpty も後のフロー解析に影響を与えてほしい側面を持っているのでピッタリですね。

逆の意味を持つのが MaybeNullWhen 属性で non-nullable につけた MaybeNullWhen(false)NotNullWhen(true) と同じになります。

これらの属性の使い分けですが

例えば TryGetValue のようなもので MaybeNullWhen 属性を使った場合、前提として non-nullable になると out var で宣言したローカル変数が non-nullable になり、if のようなフロー解析から外れたときに non-nullable (ただし null が入っている可能性がある) という状況になります。

同様に IsNullOrEmpty のようなもので MaybeNullWhen 属性を使った場合、引数は前提として non-nullable なので String? な値を引数に渡すたびに ! を付けてあげないといけないことになります。

…と、ここまで読むと MaybeNullWhen 属性を使わなくてもほとんどのケースでは NotNullWhen 属性で事足りるのではと思うのですが、制約なしジェネリクスの型パラメータに ? を付けて null 許容型とすることはできないのでその場合には MaybeNullWhen を使う必要があります。

NotNullIfNotNull 属性 (事後条件)

入力が null ではなかったら絶対に null ではないということがわかっていて、コンパイラーに伝えたいというケースに使えるのが NotNullIfNotNull 属性です。何を言っているのかみたいな名前…。

具体的なケースであれば例えばエスケープ処理のようなもので、文字列を受け取るけれども null が来たら null を返すが、それ以外は絶対値が返るようなメソッドです(その仕様がいいかどうかはさておき)。

まとめ

属性を書き始めると書き味が悪くなるのと IntelliSense などでシグネチャヒントを見ただけではわからないという問題が起きやすくなるので、新規に書き起こすものは極力属性を書かなくて済むような API にするほうが望ましいように感じました。

例えば NotNullIfNotNull 属性のようなものなどはそもそも引数で null を受けない、TryGetValue などは戻り値と out を使わないで null 許容型をそのまま返すといった形にできます。

属性は既存のコードを壊さず null 許容型との相互運用のためにアノテーションをつけていくものというぐらいの認識がいいかもしれません。まあジェネリクスが厳しいのですが…。