Jsonnet のハッシュ関数を Kubernetes で活用する裏技

こんにちは。SRE/データストアチーム の飯塚です。

私たちのチームでは自社製の Kubernetes Operator である MOCO を用いて 800 インスタンス近くの MySQL を Kubernetes 上にデプロイしており、すでに cybozu.com のかなりの割合のお客様のワークロードを処理しています。MySQL on Kubernetes の経緯や苦労についてはまた別の機会にお話しさせていただくとして、今回は大量の MySQL を Kubernetes にデプロイする際に Jsonnet のハッシュ関数を活用している話をさせてください。

この記事は、CYBOZU SUMMER BLOG FES '24(クラウド基盤本部 Stage)DAY 8 の記事です。

そもそも Jsonnet を使っているのはなぜ?

前述したように、我々は大量の MySQL インスタンスを自社製の Kubernetes Operator である MOCO を用いてデプロイしています。MOCO では MySQLCluster というカスタムリソースのマニフェストを書き、それを kubectl apply することでコンテナ、ストレージ、ロードバランサーが構成され、フェイルオーバーやオートヒーリングを行うコントローラーの設定が行われます。MOCO の機能については以下の記事や発表もご覧ください。

blog.cybozu.io

speakerdeck.com

800 インスタンス近くの MySQL をデプロイしているということは、800 インスタンス分の MySQLCluster のマニフェストを作成する必要があるということです。MOCO は README に記載されている程度の単純な MySQLCluster であればマニフェストの分量も小さいですが、サイボウズ特有の事情を盛り込むとそれなりの分量になります。また 800 インスタンスある MySQLCluster の設定やスペック(CPU やメモリの割り当て量)が全く同一であればよかったものの、実際にはさまざまな事情で設定やスペックが異なります。

そこで Jsonnet の出番です。Jsonnet の関数の引数としてインスタンスごとに可変な設定やスペックを取れるようにしておき、関数を評価した結果として MySQLCluster のマニフェストを生成するようにしています。これにより MySQLCluster ごとの重要な差分に着目しつつ大量のマニフェストを管理できるようにしています。

// このマニフェストはサンプルであり、実際に cybozu.com で使われている MySQL のスペックとは一切関係ありません。
{
    apiVersion: 'v1',
    kind: 'List',
    items: [
        MySQLCluster(name='stage0-kintone-1', cpu='6', memory='64Gi', initialDisk='500Gi'),
        MySQLCluster(name='stage0-kintone-2', cpu='8', memory='128Gi', initialDisk='800Gi'),
        // (以下略)
    ],
}

大量のリソースを管理するうえで困っていることは?

我々の仕事は MySQL を使えるようにして終わりではありません。データベースを安定的に運用するためや、サイボウズのサービスに付加価値を付与するために、それぞれの MySQLCluster に対してさまざまな種類の CronJob が設定されています。

ここで MySQLCluster と同様に、CronJob も処理対象の MySQLCluster によって設定が微妙に異なる場合があります。そこで CronJob についても MySQLCluster と似たような方法で Jsonnet を使ってマニフェストを生成しています。

{
    apiVersion: 'v1',
    kind: 'List',
    items: [
        FooCronJob(name='stage0-kintone-1', target='stage0-kintone-1'),
        FooCronJob(name='stage0-kintone-2', target='stage0-kintone-2'),
        // (以下略)
    ],
}

Kubernetes の CronJob は schedule フィールドに cron の書式 で記載することによってスケジュールを指定することができます。ここで全ての CronJob についてスケジュールが全く同じでも何も問題がないのであれば schedule フィールドを固定にしておけばよいだけです。しかしながら CronJob の規模が数百になってくるとそういうわけにもいきません。数百の CronJob が同時に動き出すと CPU、メモリ、ネットワーク帯域などのリソースが足りなくなったり、CronJob が依存している別のサービスに負荷が集中したりしてしまいます。これを避けるにはどうすればよいでしょうか?

愚直な方法としては CronJob のマニフェストを生成する関数(上記の例では FooCronJob())の引数として schedule を取れるようにしておき、人手でそれぞれの CronJob のスケジュールを指定して分散させることで同時に実行されるジョブの数を調整する方法があります。この方法は確かに問題を解決しますが、人手でまともに管理できるのはせいぜい数十個程度まででしょう。

次に Kueue などの高度なジョブスケジューラーを導入する方法も考えられます。同時に利用できるリソースの上限を Quota として設定すれば、それを守るようにジョブをスケジューリングしてくれそうです。この方法ならジョブの数が増えていってもスケールしそうですが、Kueue などの高度なジョブスケジューラーを未導入の我々にとっては大げさすぎます。

もう少し手軽な方法はないのでしょうか?

Jsonnet を使ってスケジュールを分散させる

ここで手軽な方法として Jsonnet のハッシュ関数を使ってスケジュールを分散させる方法を紹介します。Jsonnet の標準ライブラリには std.md5(s) があり文字列のハッシュ値を計算することができます。リファレンスには std.sha1(s), std.sha256(s), std.sha512(s), std.sha3(s) も載っていますが、これらは #699 で導入された関数であり投稿時点での go-jsonnet の最新リリースである v0.20.0 でも利用できません。我々の用途は暗号用途ではないので MD5 でも十分でしょう。

std.md5(s) の戻り値として得られるハッシュ文字列を16進数と見立て、m で割ったあまりを計算する関数 hashModulo(s, m) を定義します。

local hashModulo(s, m) =
  local h = std.md5(s);
  local xs = [std.parseHex(c) for c in std.stringChars(h)];
  std.foldl(function(x, y) (x * 16 + y) % m, xs, 0);

[
  'hashModulo("CYBOZU SUMMER BLOG FES", 1000)', hashModulo('CYBOZU SUMMER BLOG FES', 1000),
  'hashModulo("CYBOZU SUMMER BLOG FES", 100)', hashModulo('CYBOZU SUMMER BLOG FES', 100),
  'hashModulo("Cybozu Inside Out", 1000)', hashModulo('Cybozu Inside Out', 1000),
  'hashModulo("Cybozu Inside Out", 100)', hashModulo('Cybozu Inside Out', 100),
]

これを実行すると以下のような出力が得られます。

[
   "hashModulo(\"CYBOZU SUMMER BLOG FES\", 1000)",
   657,
   "hashModulo(\"CYBOZU SUMMER BLOG FES\", 100)",
   57,
   "hashModulo(\"Cybozu Inside Out\", 1000)",
   663,
   "hashModulo(\"Cybozu Inside Out\", 100)",
   63
]

この関数を使うことで、たとえば毎週どこかの曜日の 0:00, 3:00, 6:00, 9:00, 12:00, 15:00, 18:00, 21:00 のうちのどれかの時間に始まるようなスケジュールを生成することができます。

local dayOfWeeks = ['SUN', 'MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT'];

// 週1回
local schedules = std.flattenArrays([
  [
    '0 0 * * ' + dow,
    '0 3 * * ' + dow,
    '0 6 * * ' + dow,
    '0 9 * * ' + dow,
    '0 12 * * ' + dow,
    '0 15 * * ' + dow,
    '0 18 * * ' + dow,
    '0 21 * * ' + dow,
  ]
  for dow in dayOfWeeks
]);

local getSchedule(name) =
  schedules[hashModulo(name, std.length(schedules))];

[
  'getSchedule("stage0-kintone-1")', getSchedule('stage0-kintone-1'),
  'getSchedule("stage0-kintone-2")', getSchedule('stage0-kintone-2'),
  'getSchedule("stage0-kintone-3")', getSchedule('stage0-kintone-3'),
  'getSchedule("stage0-kintone-4")', getSchedule('stage0-kintone-4'),
  'getSchedule("stage0-kintone-5")', getSchedule('stage0-kintone-5'),
]

これを実行すると以下のような出力が得られます。

[
   "getSchedule(\"stage0-kintone-1\")",
   "0 9 * * SUN",
   "getSchedule(\"stage0-kintone-2\")",
   "0 18 * * SUN",
   "getSchedule(\"stage0-kintone-3\")",
   "0 18 * * THU",
   "getSchedule(\"stage0-kintone-4\")",
   "0 15 * * FRI",
   "getSchedule(\"stage0-kintone-5\")",
   "0 6 * * MON"
]

だいたい分散していることが分かりますね。あとはこれを CronJob の schedule に設定するだけです。

まとめ

Kubernetes 上に大量のリソースをデプロイする際に生じる課題に対して Jsonnet を使うことで手軽に解決することができる例を紹介しました。我々はこのほかにも Jsonnet を使ったさまざまなハックにより運用課題を解決しています。また機会があれば紹介したいと思います。