1クール続けるブログ

とりあえず1クール続けるソフトウェアエンジニアの備忘録

44smkn Week in Review - November 28, 2022

AWS Week in ReviewとSRE Weekly読んでて、自分の備忘録用のメモもこんな感じにまとまっていると良いだろうかと思い、試してみることにしました
44smkn Week in Review - November 14, 2022 - 1クール続けるブログ

今週はRe:Inventなのでそのへんを追いかけたいけど、そんなに時間がないような気もしてきた。
また現地のRe:Inventに参加する機会があれば行きたいなあ。

Article

CEO Raj Dutt Interview: The Grafana Experience Will Change - New Stack

業務でGrafana使うことがなくなり、あまり追えてなかったので、Grafanaの過去/現在/未来の話を大雑把に知れて良かった。

Grafanaファミリーがまた増えていた。まさか Mimir というメトリクスのプロダクトも作っているとは。加えて、プロファイリングツールである Phlare というのもあるらしい。
Loki(ログ)Grafana(可視化)Tempo(トレース)Mimir(メトリクス)で LGTM とのこと。

少しだけどどうやって収益あげているか?とかどの製品の売上がどのくらい占めるかとかの言及あって面白い。
OSSの企業ちゃんと儲かっていて欲しいけれど、難しい面が結構ありそうだなと思っている。

(Prometheusのメンテナの44%以上がGrafana Labs出身ってすごいな。Prometheusで集めたデータ可視化にGrafana使うことへの安心感がすごい。)

Argo Rollouts at scale: Bringing Automated Rollbacks to 2,100+ services at Monzo - monzo

Monzoというオンライン銀行のサービスがArgo Rolloutを使いPrometheusのクエリベースのRollbackを実装した記事。Monzoさんって結構名前聞く気がする。

Ephemeral Metadata という機能を使って、新旧のサービスに対応するメトリクスへのクエリを分けているらしい。

サービス数がめちゃくちゃ多いからだと思うけど、移行用にサービスを作ってしまうの凄い。
あと、めっちゃ良いなと思ったのは、各サービスに重要度を示す tier というのがあるらしい。これを使って移行する順番を自動的に決めることが出来たとのこと。

Enabling Collaborative K8s Troubleshooting with ChatOps - New Stack

botkube.io は良いぞという話だった。
あまりChatOpsに関して調べたことはないので知識なかったんですが、Chat上でのペアワークをするのに便利という点やモバイルからでも作業ができるというのは、確かに補助的にそういうツールがあると便利かもなあと思った。

色々試して行き着いた読書方法 - iwashi

「高速で読み流しながらマークし、後でまとめる。全部読まなくていい。」
めっちゃ良い読書法と思うし、凄い試したいのに、どうしてもじっくり読みがち。しかも全部読まなきゃという感覚に襲われてしまう。
今少しずつ練習している…。

The state of OpenTelemetry - cncf

OpenTelemetryの各言語の実装状況がまとまっていて良かった。
てっきりGoとかが成熟しているのかなと思いきや、一番成熟しているのはJavaだった。

記事内でも言及がありますが、experimental は、α / β / rc をカバーするらしく、更にstableに移行する時に既存のユーザをbreakするようなことはしないらしいので本格的に採用しても良いレベルなのかも知れない。

ついでにDatadogのOpentelemetryのサポート状況を見てみたけど、2つの方法でサポートしているらしい。
https://docs.datadoghq.com/tracing/trace_collection/open_standards/

1. app(otel sdk) -> opentelemetry-collector with datadog-exporter -> datadog-agent -> datadog backend
2. app(otel sdk) -> datadog-agent -> datadog backend

Strategies and Tools for Performing Migrations on Platform - spotify

Spotifyが大規模なmigrationを行った経験から、「症状(When), 避けるべきこと(Don't), 推奨すること(Do)」を各challengeでまとめている記事。
この記事内でのSpotifyの事例は、mobile appのビルドをbazelに移行することだったが、多くの大規模な移行に共通する学びだと思った。

好きなDoがいくつかあるので挙げておく。

Challenge 1: Do

  • Address your audience. Understand their mental models so that you can talk about what’s relevant, connect where their needs are, and find proxies to expand your reach.

Challenge 2: Do

  • Communicate. Keep your audience engaged by sharing the progress through newsletters and workplace posts.
  • Look to automation. Simplify the migration process by investing in automation upfront.
  • Make time for spike weeks. Partner with squads and tribes to jointly dedicate a week to work on the migration.

Challenge 4: Do

  • Use dashboards. Metrics and dashboards will communicate the progress and impact, as well as help prioritize your work at scale over time.

Podcast

53. 時系列データベースエンジン w/ nakabonne - fukabori.fm

OpenTelemetryやらGrafanaやらの記事を読んでいたので、久しぶりに聞いた。
時系列データの特徴とそれを生かした時系列データベースの設計が分かりやすく語られててやっぱり良かった。
Prometheusがパーティション切り替わる時に、通信遅延や時間のズレによってデータを取り逃すことがあるかも(かつ後発のDBではそうならないようになっている)という話は興味深かった。

WEB+DB PRESSのRustで作るRDBMSの写経をやったからか、昔より解像度高めに聞けた気がする。

Rebuild: 348: Stop Digging Up The Past (higepon)

前半のTwitterの話は聞いてて胸が苦しくなった。
ソフトウェアの品質が高いからこそ、大量にソフトウェアエンジニアがいなくなっても動き続けるんだよね。
次のラピュタの放送で壊れるんじゃないか…?という話があって確かにと頷いた(これrebuildで聞いた話じゃないかな…?)

44smkn Week in Review - November 21, 2022

AWS Week in ReviewとSRE Weekly読んでて、自分の備忘録用のメモもこんな感じにまとまっていると良いだろうかと思い、試してみることにしました 44smkn Week in Review - November 14, 2022 - 1クール続けるブログ

これ始めて2週目で既に遅延している
まあでもゆるくやっていくのを目標としているのでそれでもヨシ

はてなブログでもmermaidレンダリングできると嬉しい

Article

メルカリShopsの注文システム安定化の歩み - mercari

マイクロサービス群のorchestrationをGCP Workflowsでやっているんですね。Workflows使ったことないですが、AWSで言うStepFunctionsという認識をしています。
冒頭読んで、もしかしてSagaの補償トランザクションの話かなと思ったんですが違ったようです。
「不整合の検知をできるようにする → 運用の中でカバーしきれなかったエラーのパターンやAPIが見つかる → 地道に改善を続けてほぼすべてのパターンがハンドリングできるようになる」という流れでした素敵。

Kubecost Open Sources OpenCost: an Open Source Standard for Kubernetes Cost Monitoring

kubecost自体はオープンソースではなくて、kubecostの中のコスト配分エンジンをオープンソース化したものがOpenCostみたいですね。
この記事に加えて OpenCost Product Comparison – Kubecost を見ると分かりやすいです。
OpenCostにはDashboardは含まれないそうです、prometheusのメトリクスを吐いてくれそうです。そう考えるとDatadogも使えそう。

それを踏まえて、AWS and Kubecost collaborate to deliver cost monitoring for EKS customersの記事でインストールされているのは kubecostの方であると。
tierを上げたかったらmarketplaceで買えますよってことですかね多分。
Freeだと15日間のretentionしかないのでちゃんと使うには課金が必要そうな雰囲気。ただ最初の30日間は無料とのこと。
https://www.kubecost.com/pricing/

All-in-One, Integrated Front-End Toolchain Rome Released V10, Dubbed First Stable Release - infoq

JaveScript周りのツールチェインは種類とその中での選択肢が多くてややこしいなと思っていました。
それを1つにまとめようとする壮大なツールとして、romeというのがあるんですね。
Rust書き換えて初めてのstable releaseということで、ここから盛り上がってくるんでしょうか?

Amazon EKS now supports Kubernetes version 1.24

v1.23 と v1.24 はほとんど期間開かなかったですね。確認したらおよそ3ヶ月間でした。
Topology Aware HintsでAZ跨ぎの通信量が減るのが期待できそうですね。

  • dockershimとお別れ
  • v1.24以降新しく追加される BetaAPI は今までと違いデフォルトで有効にはならない
  • Topology Aware Hints
  • more...

Composite availability: calculating the overall availability of cloud infrastructure

アプリケーションを構成する全ての異なるサービスを組み合わせた可用性 - composite availability を計算する必要があるよね、という話。これを意識すると、よりresilientなシステムを設計することができて、その結果ユーザ体験が良くなるんではとのことでした。

自分たちで運用するサービスのSLOを決めるときも、この composite availability 以上には出来ないと思うので、プロダクトの要求する可用性がそれよりも高いのであれば、設計自体も見直す必要があるのだよなと思いました。

この記事の例だと、Google Cloudのサービスが説明に使われていたので、AWSでも例を一つ取って計算してみます。記事に書かれているSerialのパターンで。

SLAはここを参考: https://aws.amazon.com/legal/service-level-agreements/

.9999 * .9999 * .999 * .999 = 0.9978... ≒ 99.78%

Podcast / Radio

内山昂輝の1クール!#408

冒頭にあった休んだ時にストック使うべきかどうかという話。
内山さんが新型コロナウイルスに感染してしまったため、昔に収録したストックが放送された先週。「ピンチヒッターで人が来たほうが面白いんじゃないか」と話されていた。
それ聞いて思い出したのは、おぎやはぎのメガネびいきにて、おぎやはぎのお二人が欠席してゲスト予定のchelmicoトンツカタン森本、アルピーでやってた回。
そう考えるとスリリングで面白いのかもしれないけど、ピンチヒッター側のプレッシャーがすごそう。

普段テレビでニュース観ないので、サッカーの情報はほぼ内山さんからしか入ってこない。
今回もワールドカップの話をしてくれていた。「いつもと時期違うんですよね」の一本槍で、この前美容師さんとの会話乗り切った。ありがとう。
ちょろいのリスナーも美容師さんから振られたうたプリの話を3枚くらいの手札で乗り切ってた気がする。

44smkn Week in Review - November 14, 2022

AWS Week in ReviewとSRE Weekly読んでて、自分の備忘録用のメモもこんな感じにまとまっていると良いだろうかと思い、試してみることにしました。
raindrop.io にブックマークが溜まっていくだけで見返す機会がないのは良くない。
ゆるくやっていこう。古い記事も今週読んだらここに載ります。

Article

GitHub Universe 2022における新発表のすべて - github

TasklistsはGitHub Projectsと深く統合されており、”tracked by”や”tracks”といった新しいフィールドを用いて、親と子の課題を俯瞰的に確認できます。 RoadmapとTasklistは近日公開予定です。ウェイティングリストに登録し、準備が整いましたら、お試しください。

Issueの依存関係を表現関係を表現したいというのは仕事上でも需要があるし、OSSのIssueを眺めるときにも役に立ちそう。
Issues / About Tasklists に詳細あり。waitlistに登録しないと。

GitHub Codespaces with JetBrains IDEs (Public Beta) - github

GitHub now supports the use of GitHub Codespaces with JetBrains IDEs via the JetBrains Gateway. After downloading the JetBrains Gateway and installing the GitHub Codespaces plugin, users will be able to connect to their codespaces with the JetBrains IDE of their choice.

JetBrains Gatewayという「リモートサーバにSSH接続し、そこで動いているIDEバックエンドサービスにアクセスする仕組みを提供するクライアントのアプリケーション」を利用しているとのこと。
CodeSpacesでIntelliJとか動いたらすごいな。本格的にiPadで開発できそう。

Introducing GitHub Actions Importer - github

We’re excited to announce a public preview of GitHub Actions Importer, which helps you forecast, plan, and facilitate migrations from your current CI/CD tool to GitHub Actions. The user interface for GitHub Actions Importer is an extension to the official GitHub CLI, which delegates to a Docker container.

jenkins の例が載っているが、 https://github.com/github/gh-actions-importer#supported-platforms を見ると、他にcircle-ciやgitlab等がサポートされている様子。
ジョブの移行って不確実性が高い上に関係者が多くなりがちなので、 gh actions-importer audit でどのくらいの数のジョブが自動変換できるかとかが見れるのは嬉しいだろうなあという感じがする。

The GitHub Actions Importer IssueOps template repository provides the foundational functionality required to run GitHub Actions Importer commands through GitHub Actions and Issues.

GHA workflowでimporterが動かせるテンプレートリポジトリもあるらしい。

Spotify’s Vulnerability Management Platform - spotify

Spotify脆弱性管理プラットフォームの名前は Kitsune らしい。
脆弱性のライフサイクルを管理するバックエンドAPIサーバをそう名付けたのにはどういう理由があるんだろう。
backstage.io で可視化してユーザとの接点を作っているらしい。独自のpluginとかも作れるんだなあ知らなかった。New Stackのplatform engineeringの記事とかでも取り上げられているので結構気になる。

スキルマップを使ったSPOF可視化と改善について - mercari

スキルマップは具体的な項目かつシートに書いていくのが分かりやすそうだなと思いました。 この項目を作っていくのが結構大変だと思うんですが、どういうふうに作っていったのか気になりました。

やること

  • チームとして特定のメンバーにスキルが偏っている項目(カバレッジが低いもの)を見つけ、ドキュメント作成や勉強会開催、ペアプログラミング等を通して偏りをなくす
  • エンジニア個人のOKR設定やオンボーディングのために参照する

やらないこと

  • カバレッジをメンバー間で相対比較する
  • Managerが評価の際にカバレッジを使う
  • そのスキルを身に着けたいと思わないメンバーに無理やりスキルを身に着けさせる

スキルマップ活用のポリシーをちゃんと定めているのが良いですね。なにか取り組むときには、どう使い、どう使わないのかを定義するべきだなと思いました。

Introducing the Docker+Wasm Technical Preview - docker

previewではありますが、dockerからwasmが扱えるようになったんですね。
wasm動かすときには、--runtime--platformを指定するそう。
ここで使われているイメージは https://hub.docker.com/r/michaelirwin244/wasm-example/tags で、OS/ARCHの表示がwasi/wasm32になっていることが分かります。

docker run -dp 8080:8080 --name=wasm-example --runtime=io.containerd.wasmedge.v1 --platform=wasi/wasm32 michaelirwin244/wasm-example

wasm moduleのbuild/pushに関しての記載: https://docs.docker.com/desktop/wasm/#building-and-pushing-a-wasm-module

Introducing Amazon EventBridge Scheduler - AWS

class methodの方の解説記事も出ていた: [新機能] タイムゾーン指定でスケジュール起動できるAmazon EventBridge Schedulerがリリースされました
EventBridge Ruleのquotaにぶち当たったことは今のところないけど、1アカウント内1regionで300までとは中々しぶい。
quotaはまず嬉しいしタイムゾーン指定ができるのもありがたい。
targetが豊富になったのも気になり。この感じだとEKS Jobとか呼べそうである。定期実行しているterraform moduleをこっちに書き換えるのはあり。

terraform-provider-awsの方で既にissueは作られていて、PRは既にマージされていそうなので、あとはリリースを待つのみである。
https://github.com/hashicorp/terraform-provider-aws/issues/27760

Fluent BitのLua PluginとLuaの単体テスト含むCIを試してみた

記事一覧はこちら

今回使ったコードはここにまとめてあります。

github.com

背景・モチベーション

fluent-bitにはmodifyrecord_modifierというFilterが用意されており、Record/Eventsの変更ができるようになっていますが、複雑なことをやらせようと思うとlua filterが必要になってくると思います。
例えば、kubernetes filterを利用し、metadataを付与した後に、namespacelabelscontainer_nameなどを組み合わせて文字列を作り更に条件分岐も組み合わせたい、となることがありました。

Fluent BitのLua Pluginを試す

実現したい処理

stackdriver pluginで送信するときに logNameフィールドとして抽出される値をLua pluginを利用しrecordに追加する

  • logNameフィールドとして抽出されるときにデフォルトで参照されるkey名はlogging.googleapis.com/logNameである
  • valueとしては <namespace>_<appラベル or pod_nameからrandom stringを除いたもの>_<container> としたい
  • kubernetes filterで付与されたmetadataを利用する

今回はKuberntesクラスタで動かさずローカルで試すことを目標とします。
そのため、tail pluginやkubernetes filterは利用せず、dummy pluginを利用して、metadataが付与された状態のrecordを注入することとします。

dummyに設定したrecord

{
   "timestamp":"2022-05-09T23:56:33.044423373Z",
   "stream":"stderr",
   "log":"some messages",
   "kubernetes":{
      "pod_name":"test-server-75675f5897-7ci7o",
      "namespace_name":"test-ns",
      "pod_id":"60578e5f-e5bb-4388-be57-9de01c8a4b79",
      "labels":{
         "apps":"test"
      },
      "annotations":{
         "kubernetes.io/psp":"default"
      },
      "host":"some.host",
      "container_name":"test-server",
      "docker_id":"1d79200d4e60bb7f58b2e464e22a82d5d3bf694ebf334b3757bbdb0ce25353aa",
      "container_hash":"container.registry/test-server/test-server@sha256:bfd1a73b6d342a9dd5325c88ebd5b61b7eaeb1c8a3327c3d7342028a33b89fe0",
      "container_image":"container.registry/test-server/test-server:0.0.82"
   }
}

Luaで実装しFilterから呼び出す

https://docs.fluentbit.io/manual/pipeline/filters/lua#callback-prototype

引数として、tag, timestamp, recordの3つを取り、かならず3つの値をretrunする必要があります。それが code, timestamp, recordであり、codeの値によって後ろ2つの返却値の扱いが変わってきます。1のときには2つとも利用されますが、それ以外のときは timestampは利用されませんし、record1に加えて2のときしか利用されません。

  • -1: recordはdropされる
  • 0: recordは変更されない
  • 1: timestampとrecordが変更される
  • 2: recordのみ変更される

それも踏まえて処理を実装したのが下記です。

function append_k8s_logname(tag, timestamp, record)
    local new_record = record

    local app = record["kubernetes"]["labels"]["app"]
    if app == nil then
        local pod_name = record["kubernetes"]["pod_name"]
        _, _, app = string.find(pod_name, "([%w-]+)%-%w+%-%w+")
    end
    local namespace = record["kubernetes"]["namespace_name"]
    local container_name = record["kubernetes"]["container_name"]

    local log_name = string.format("%s_%s_%s", namespace, app, container_name)
    new_record["logging.googleapis.com/logName"] = log_name

    return 2, 0, new_record
end

Luaは初めて書いたのですが下記が参考になりました。
正規表現POSIX準拠ではないようでしたが、機能としては十分でハマることはほぼありませんでした。

では、Filterから呼び出してみたいと思います。最低限、必要なのはscriptへのパスと呼び出す関数名になります。パスはmainの設定ファイルからの相対パスもサポートされているようです。productionでは絶対パスで指定したほうが良いと思いますが、今回はテストなので相対パスで書いています。
stdoutをOUTPUTに指定し動作確認したところ、期待通りの出力を得ることができました。

[FILTER]
    Name    lua
    Match   *
    script  ./append_k8s_logname.lua
    call    append_k8s_logname

[OUTPUT]
    Name stdout
$ fluent-bit -c fluent-bit.conf
[0] kube.var.log.containers.test-server_test-ns_test-server-aeeccc7a9f00f6e4e066aeff0434cf80621215071f1b20a51e8340aa7c35eac6.log: [1653143473.074878000, {"kubernetes"=>{"labels"=>{"app"=>"test"}, "pod_name"=>"test-server-75675f5897-7ci7o", "annotations"=>{"kubernetes.io/psp"=>"default"}, "namespace_name"=>"test-ns", "container_name"=>"test-server", "docker_id"=>"1d79200d4e60bb7f58b2e464e22a82d5d3bf694ebf334b3757bbdb0ce25353aa", "container_hash"=>"container.registry/test-server/test-server@sha256:bfd1a73b6d342a9dd5325c88ebd5b61b7eaeb1c8a3327c3d7342028a33b89fe0", "host"=>"some.host", "container_image"=>"container.registry/test-server/test-server:0.0.82", "pod_id"=>"60578e5f-e5bb-4388-be57-9de01c8a4b79"}, "log"=>"some messages", "logging.googleapis.com/logName"=>"test-ns_test_test-server", "timestamp"=>"2022-05-09T23:56:33.044423373Z", "stream"=>"stderr"}]

Fluent Bitの設定に関するCIを作成する

Fluent Bitの運用を行っていく上で不安になる要素として2つあります。これらを解消するためのCIパイプラインを作成していきます。

  • Fluent Bitの設定ミス
  • Luaのコード変更によるデグレ

Fluent Bitの設定をvalidate

github.com

上記のIssueで設定のvalidateが入ったようです。--dry-runというoptionがあるようなのでそれを利用することで解決。

LuaのUnit Testを書く

Luaのunit testのツールに関しては、lua-users wiki: Unit TestingWhat unit test frameworks are people using? : lua を参考にし、fluent-bit内で動くLuaJITで動きそう かつ 導入が簡単なものとして luaunit というツールを選定しました。これは、 luaunit.lua というファイルを配置するだけで動くようになります。

Luaのdocやサンプルコードを見ている限り、関数や変数にはsnake_caseが用いられているように見えていたのですが、luaunitではcamelCaseやPascalCaseが使われていて、ちょっと違和感があります。 ざっと書いてみたのはこんな感じです。Luaっぽく書くにはどうすれば良いんだ…(頭抱え)。

local lu = require('luaunit')
local akl = require('append_k8s_logname')

TestAppendK8sLogname = {}
    function TestAppendK8sLogname:setUp()
        create_record = function(labels)
            return {
                kubernetes = {
                    pod_name = "test-server-75675f5897-7ci7o",
                    container_name = "envoy",
                    namespace_name = "test-ns",
                    labels = labels
                }
            }
        end
        self.create_record = create_record
        self.logname_key = "logging.googleapis.com/logName"
    end

    function TestAppendK8sLogname:testAppLabelExists()
        local record = self.create_record({ app = "app" })
        local _, _, got = akl.append_k8s_logname(nil, nil, record)
        lu.assertEquals(got[self.logname_key], "test-ns_app_envoy")
    end

    function TestAppendK8sLogname:testAppLabelNotExists()
        local record = self.create_record({ dummy = "dummy" })
        local _, _, got = akl.append_k8s_logname(nil, nil, record)
        lu.assertEquals(got[self.logname_key], "test-ns_test-server_envoy")
    end
-- end of table TestAppendK8sLogname

local runner = lu.LuaUnit.new()
runner:setOutputType("text")
os.exit( runner:runSuite() )

テスト対象の関数をテスト用のファイルから呼び出すためにexportする処理を追加してあげる必要がある。

local M = {}
M.append_k8s_logname = append_k8s_logname
return M

実行してみてテストが通ることを確認する。

$ luajit append_k8s_logname_test.lua
..
Ran 2 tests in 0.001 seconds, 2 successes, 0 failures
OK

GHAに実装する

Fluent Bitとluajitをもっといい感じにインストールしたい…と思いつつ下記のように実装。
後はrenovateを設定すればいい感じになるはず。

fluent-bit-lua-example/test-fluent-bit-config.yaml at main · 44smkn/fluent-bit-lua-example · GitHub

まとめ

Luaを触ったこと無かったこともあり、Lua Filterは食わず嫌いをしていたけれど触ってみると意外となんとかなるかもなという所感を持ちました。

karpenterのOD Fallbackを試してみた

記事一覧はこちら

こちらの記事は、2022/3/13に大幅に修正いたしました。
EC2 OnDemand Fallback at the Provisioner level · Issue #714 · aws/karpenter · GitHub のIssueから、OD Fallbackを行う方法は、nodeAffinityのprefferredを利用しか無いと思っていたのですが、v0.6.0からFAQs | Karpenterにて下記のように記載されるようになり、より良い方法があることがわかりました。
そのため書き直しました。

Karpenter will fallback to on-demand, if your provisioner specifies both spot and on-demand.

背景・モチベーション

aws.amazon.com

karpenterのGAがアナウンスされて、クラスメソッドさんの記事スタディサプリENGLISHのSREさんが書いた記事を読んで、とても良さそうだし業務にも活かせそうと思ったので触りたくなりました。

本番環境で運用する上では、スポットが起動しなくなったときにオンデマンドを起動する(OD Fallback)仕組みを考えておかねばと思っています。
多様なインスタンスタイプを起動する候補にしていれば、昨今の安定したスポットインスタンス供給でそのような自体はあんまり考えられませんが、備えあれば憂いなしとも言いますし。

Karpenterの概要

karpenter.sh

個人的には公式Docの Concept のページにある Kubernetes cluster autoscalerという項目に書いてある3つが非常にKarpenterの特徴がわかりやすい記述になっているのではないかと思っています。

  • Designed to handle the full flexibility of the cloud
  • Group-less node provisioning
  • Scheduling enforcement

特によく言及されるスケジューリングの速さに関しては、下2つの項目が関わっていると思います。
AutoScalingGroupやManagedNodeGroupといったGroupのメカニズムを使用せず直接インスタンスを起動していること。EC2Fleetを利用して必要なcapasityを満たすようにEC2インスタンスを起動する仕組みになっているようです。
また、Podスケジューリングをkube-schedulerに頼らず、karpenterが作成したノードにpodをbindするようです。そのためkubeletはノードの起動やkube-schedulerを待つ必要がなく、コンテナイメージのPullなどコンテナランタイムの準備をすぐに行うことが可能なようです。

Karpenterの環境構築

https://karpenter.sh/docs/getting-started-with-terraform/karpenter.sh

通常のEKSクラスタ構築に加えて行う必要があるのは下記かと思います。

  • PrivateサブネットとSecurityGroupに"karpenter.sh/discovery" = var.cluster_nameとタグ付与して、karpenterがdiscoveryできるようにする
  • Karpenterが起動するノードに紐付けるInstanceProfileの作成
    • defaultのInstanceProfileをHelm経由で設定する or ProvisionerというCRD内で宣言する必要がある
  • IRSAでkarpenterのcontrollerのpodが利用するIAMロール

ちなみにeksのmoduleをv1.18に設定したら、やたらとハマったのでこちらのIssueが役に立ちました:Error when creating provisioner - failed calling webhook · Issue #1165 · aws/karpenter · GitHub
EKSクラスタの構築が完了したら、下記のようにHelmを利用してインストールしていきました。

$ helm repo add karpenter https://charts.karpenter.sh
$ helm repo update
$ helm upgrade --install karpenter karpenter/karpenter --namespace karpenter \
  --create-namespace --version 0.6.5 \
  --set clusterName=${CLUSTER_NAME} \
  --set clusterEndpoint=$(aws eks describe-cluster --name ${CLUSTER_NAME} --query "cluster.endpoint" --output json) \
  --set serviceAccount.annotations."eks\.amazonaws\.com/role-arn"=${KARPENTER_IAM_ROLE_ARN} \
  --set aws.defaultInstanceProfile=KarpenterNodeInstanceProfile-${CLUSTER_NAME} \
  --wait

OD Fallbackを行うためのマニフェスト指定

Karpenter will fallback to on-demand, if your provisioner specifies both spot and on-demand.

More specifically, Karpenter maintains a concept of “offerings” for each instance type, which is a combination of zone and capacity type (equivalent in the AWS cloud provider to an EC2 purchase option).

Spot offerings are prioritized, if they’re available. Whenever the Fleet API returns an insufficient capacity error for Spot instances, those particular offerings are temporarily removed from consideration (across the entire provisioner) so that Karpenter can make forward progress through fallback. The retry will happen immediately within milliseconds.

https://karpenter.sh/v0.6.5/faq/#what-if-there-is-no-spot-capacity-will-karpenter-fallback-to-on-demand

冒頭で紹介したとおり、OD Fallbackする方法はProvisionerの .spec. requirements 内の karpenter.sh/capacity-type keyに対して on-demandspotの両方を指定すれば良いようです。
基本的にはspotを優先的に起動し、もし不足していたらon-demandをprovisionするようです。 ということで、defaultという名称のproviderを用意します。defaultという名称のproviderは faq#if-multiple-provisioners-are-defined-which-will-my-pod-use にあるように特別扱いされます。
ちなみに後続のテストのために、インスタンスタイプを c4.xlarge 絞っています。

apiVersion: karpenter.sh/v1alpha5
kind: Provisioner
metadata:
  name: default
  namespace: karpenter
spec:
  requirements:
    - key: "node.kubernetes.io/instance-type"
      operator: In
      values: ["c4.xlarge"]
    - key: karpenter.sh/capacity-type
      operator: In
      values: ["spot", "on-demand"] # ここで双方を指定する
  provider:
    subnetSelector:
      karpenter.sh/discovery/44smkn-test: "*"
    securityGroupSelector:
      karpenter.sh/discovery/44smkn-test: "*"
  ttlSecondsAfterEmpty: 30

OD Fallbackのテスト

この後のセクションで触れますが、karpneterはFallbackするときの条件としてEC2 Fleet作成リクエストのエラーコードが InsufficientInstanceCapacity である必要があります。
これを自分で再現するのは難しいので、エラーコードが SpotMaxPriceTooLow も同じような挙動を取るように変更してイメージを作り直します。

func (p *InstanceProvider) updateUnavailableOfferingsCache(ctx context.Context, errors []*ec2.CreateFleetError, capacityType string) {
    for _, err := range errors {
        if InsufficientCapacityErrorCode == aws.StringValue(err.ErrorCode) || "SpotMaxPriceTooLow" == aws.StringValue(err.ErrorCode) {
            p.instanceTypeProvider.CacheUnavailable(ctx, aws.StringValue(err.LaunchTemplateAndOverrides.Overrides.InstanceType), aws.StringValue(err.LaunchTemplateAndOverrides.Overrides.AvailabilityZone), capacityType)
        }
    }
}

f:id:jrywm121:20220313221459p:plain

# karpenter's root dir
$ GOFLAGS=-tags=aws ko build -L ./cmd/controller
$ docker tag <loaded image> ${ACCOUNT_ID}.dkr.ecr.ap-northeast-1.amazonaws.com/karpenter/controller:latest
$ docker push ${ACCOUNT_ID}.dkr.ecr.ap-northeast-1.amazonaws.com/karpenter/controller:latest

$ kubectl edit deploy karpenter -n karpenter  # update container image and imagePullPolicy

SpotMaxPriceTooLowを起こすように、spotのmax-priceを下げてスポットインスタンスが起動できないというシチュエーションを作ります。
karpenterはLaunch Templateを生成し、それをインスタンス起動するためのEC2Fleet作成リクエスト時に渡しています。なので、作成されたLaunch Templateを直接マネジメントコンソールから編集してmax-priceを変更しちゃいます。直接Launch TemplateをProvisionerに指定することも出来るのですが、編集して対応することにしました。
というのも、karpenterはkarpenterが持ちうるLaunch Templateの設定値セットのHashを取って同一であれば、同じLaunch Templateを再利用します。そのため編集してしまったほうが手間が少なく済んだのです。

c4.xlargeのスポット価格が 0.0634くらいだったので 0.06 に設定して、ノードのprovisionを試みます。
すると、下記のようにInsufficientInstanceCapacity for offeringというログが発生して一度ERRORとなった後に、再試行し on-demandのノードが起動することが分かりました。
秒単位でFallbackしていて非常に速いですね。次のセクションで仕組みについて見ていきたいと思います。

2022-03-13T13:02:18.067Z  INFO    controller.provisioning Waiting for unschedulable pods  {"commit": "6180dc3", "provisioner": "default"}
2022-03-13T13:02:35.367Z    INFO    controller.provisioning Batched 2 pods in 1.022647416s  {"commit": "6180dc3", "provisioner": "default"}
2022-03-13T13:02:35.374Z    INFO    controller.provisioning Computed packing of 1 node(s) for 2 pod(s) with instance type option(s) [c4.xlarge] {"commit": "6180dc3", "provisioner": "default"}
2022-03-13T13:02:39.916Z    DEBUG   controller.provisioning InsufficientInstanceCapacity for offering { instanceType: c4.xlarge, zone: ap-northeast-1a, capacityType: spot }, avoiding for 45s  {"commit": "6180dc3", "provisioner": "default"}
2022-03-13T13:02:39.916Z    DEBUG   controller.provisioning InsufficientInstanceCapacity for offering { instanceType: c4.xlarge, zone: ap-northeast-1c, capacityType: spot }, avoiding for 45s  {"commit": "6180dc3", "provisioner": "default"}
2022-03-13T13:02:39.916Z    DEBUG   controller.provisioning InsufficientInstanceCapacity for offering { instanceType: c4.xlarge, zone: ap-northeast-1d, capacityType: spot }, avoiding for 45s  {"commit": "6180dc3", "provisioner": "default"}
2022-03-13T13:02:39.916Z    ERROR   controller.provisioning Could not launch node, launching instances, with fleet error(s), SpotMaxPriceTooLow: Your Spot request price of 0.06 is lower than the minimum required Spot request fulfillment price of 0.0634.; SpotMaxPriceTooLow: Your Spot request price of 0.06 is lower than the minimum required Spot request fulfillment price of 0.0647. {"commit": "6180dc3", "provisioner": "default"}

2022-03-13T13:02:39.916Z    INFO    controller.provisioning Waiting for unschedulable pods  {"commit": "6180dc3", "provisioner": "default"}
2022-03-13T13:02:46.150Z    DEBUG   controller.provisioning Created launch template, Karpenter-44smkn-test-9056194203411996147  {"commit": "6180dc3", "provisioner": "default"}
2022-03-13T13:02:48.361Z    INFO    controller.provisioning Launched instance: i-05c437e761fec9383, hostname: ip-10-0-1-223.ap-northeast-1.compute.internal, type: c4.xlarge, zone: ap-northeast-1a, capacityType: on-demand    {"commit": "6180dc3", "provisioner": "default"}
2022-03-13T13:02:48.391Z    INFO    controller.provisioning Bound 2 pod(s) to node ip-10-0-1-223.ap-northeast-1.compute.internal    {"commit": "6180dc3", "provisioner": "default"}

Fallbackの仕組み

一部コードを載せていますが、ここでの説明に不要な部分は省略させていただいております。またインライン展開している箇所もあります。

UnschdulableなPodをスケジューリングする際のエントリポイントから順を追ってみていきます。
インスタンス作成の失敗などでPodのスケジューリングに失敗した場合には、この関数の単位でループすると認識しています。

pkg/controllers/provisioning/provisioner.go#L85-L127

func (p *Provisioner) provision(ctx context.Context) error {
    logging.FromContext(ctx).Infof("Batched %d pods in %s", len(items), window)

    // Get instance type options
    vendorConstraints, err := v1alpha1.Deserialize(&v1alpha5.Constraints{Provider: p.Spec.Provider})
    if err != nil {
        return nil, apis.ErrGeneric(err.Error())
    }
    instanceTypes, err := p.cloudProvider.instanceTypeProvider.Get(ctx, vendorConstraints.AWS)

    // Launch capacity and bind pods
    workqueue.ParallelizeUntil(ctx, len(schedules), len(schedules), func(i int) { /* request ec2 fleet */ }
}

instanceTypeProvider.Get(ctx, vendorConstraints.AWS)での処理が重要です。以下の処理を呼び出しています。
Offeringとは、インスタンスタイプ毎のcapacityTypeとzoneの組み合わせのことを指します。
ちなみに、ここではProviderのrequimentsなどを考慮していないので、ほとんどのインスタンスタイプが返却されます。実際は、binpacking の処理にて考慮がされます。

pkg/cloudprovider/aws/instancetypes.go#L63-L110

// Get all instance type options (the constraints are only used for tag filtering on subnets, not for Requirements filtering)
func (p *InstanceTypeProvider) Get(ctx context.Context, provider *v1alpha1.AWS) ([]cloudprovider.InstanceType, error) {
    for _, instanceType := range instanceTypes {
        offerings := []cloudprovider.Offering{}
                 for zone := range subnetZones.Intersection(availableZones) {
        // while usage classes should be a distinct set, there's no guarantee of that
        for capacityType := range sets.NewString(aws.StringValueSlice(instanceType.SupportedUsageClasses)...) {
            // exclude any offerings that have recently seen an insufficient capacity error from EC2  →  ここでInsufficientInstanceCapacityのエラーコードが返ってきたOfferingを候補から外す
            if _, isUnavailable := p.unavailableOfferings.Get(UnavailableOfferingsCacheKey(capacityType, instanceType.Name(), zone)); !isUnavailable {
                offerings = append(offerings, cloudprovider.Offering{Zone: zone, CapacityType: capacityType}) 
            }
        }
    }
}

p.unavailableOfferings.Get(UnavailableOfferingsCacheKey(capacityType, instanceType.Name(), zone)) で返ってくる値はどのように決定されるのでしょうか。
EC2 Fleet作成を試みた後にキャッシュに保持する処理があります。現状は45秒キャッシュするようです。

一回目の処理では失敗したOfferingをcacheし、エラーログを出力して処理を終了します。呼び出し元はループしているので、その後に再度この処理が行われます。
候補からspotは省かれています。候補のOfferingにspotが1つでもあれば、capacityTypeにはspotとなりますが、今回はないのでon-demandになります。
capacityTypeはノードのlabelに付与されるため、userDataがspotのときと異なることになります。そのため、既存のLaunchTemplateを利用できず新しくLaunchTemplateを作成します。ログを見ると作成されていることが確認できます。

そしてCreateFleetInputにもon-demandのOptionが追加されることで on-demand のノードが起動するようです。

pkg/cloudprovider/aws/instance.go#L147

func (p *InstanceProvider) launchInstances(ctx context.Context, constraints *v1alpha1.Constraints, instanceTypes []cloudprovider.InstanceType, quantity int) ([]*string, error) {
    capacityType := p.getCapacityType(constraints, instanceTypes)

    // Get Launch Template Configs, which may differ due to GPU or Architecture requirements
    launchTemplateConfigs, err := p.getLaunchTemplateConfigs(ctx, constraints, instanceTypes, capacityType)

    createFleetInput := &ec2.CreateFleetInput{ /* ... */ }
    if capacityType == v1alpha1.CapacityTypeSpot {
        createFleetInput.SpotOptions = &ec2.SpotOptionsRequest{AllocationStrategy: aws.String(ec2.SpotAllocationStrategyCapacityOptimizedPrioritized)}
    } else {
        createFleetInput.OnDemandOptions = &ec2.OnDemandOptionsRequest{AllocationStrategy: aws.String(ec2.FleetOnDemandAllocationStrategyLowestPrice)}
    }
    createFleetOutput, err := p.ec2api.CreateFleetWithContext(ctx, createFleetInput)

         // ここで InsufficientInstanceCapacity だったOfferingをcacheしています。現状は45秒キャッシュするようです。
    for _, err := range errors {
        if InsufficientCapacityErrorCode == aws.StringValue(err.ErrorCode) {
            p.instanceTypeProvider.CacheUnavailable(ctx, aws.StringValue(err.LaunchTemplateAndOverrides.Overrides.InstanceType), aws.StringValue(err.LaunchTemplateAndOverrides.Overrides.AvailabilityZone), capacityType)
        }
    }
}

まとめ

一度、2021年12月に書いた内容が間違っていたため書き直したのですが、当時のv0.5.1と細部が違っていて非常に学べることが多かったです。
eks moduleのv18でとてもハマったのは想定外でしたが…。

下記のようにBlockDeviceMappingのサポートが次回リリースのバージョンが入りそうなので、やっと20GiBのRoot volumeの制限から抜け出せそうですね。

t.co

静的ウェブサイトホスティングにおけるCloudFront+S3 vs S3単体で比較

記事一覧はこちら

背景・モチベーション

「S3の静的ウェブサイトホスティングするなら、CloudFront使ったほうが色んな面で良い」という何となくの意識はありつつも、コストはどのくらい違うんだっけ?という点や利用者にとって何が嬉しいんだっけという部分を明確に説明できない自分がいることに気が付きました。

もやもやを解消したくて調べてまとめてみました。
コストなどは2021年12月末時点のものです。

CloudFront+S3 vs S3 Only

まず前提を置きましょう。
S3はともかく、CloudFront + S3側の構成は下記とします。

Back to Basics: Hosting a Static Website on AWS - YouTube から引用させていただきました。めっちゃ分かりやすくて良い動画です。

f:id:jrywm121:20211223234307p:plain

これから4つの観点で比較していきますが、背景で書いたように「CloudFront使ったほうが色んな面で良い」というなんとなくの意識が自分の中にあるせいで、若干CloudFront+S3構成に寄った取り上げ方になってしまっている可能性があります。

コスト

リージョンはap-northeast-1(Tokyo)で、S3のtierはStandardとします。
また、CloudFrontですべてのファイルをデフォルトTTLが1ヶ月でキャッシュすることにします。
CloudFront Security Savings Bundle を利用すると更にCloudFrontは安くなることが見込まれますが、今回は利用しないことを前提とします。

結論としては、CloudFront+S3の方が1000リクエストあたりの価格がS3単体に比べて0.0012/0.00037=3.24倍ほどと高価なためにコストは上がります。
しかしながら、すべてキャッシュ可能なコンテンツと前提を置いているので、CloudFront+S3におけるS3へのGETリクエストをほぼ無視できることを考えると、差はその部分でしか生まれません。データ通信量に関しては、500TBまでは同じレートですが、そこを超えるとCloudFrontのGBあたりの通信料は減っていくのでアクセス量が多いサイトであるほど価格差は縮まっていく傾向にあるようです。

そのため、ファイルが細かく分割されていてファイルのfetch数がやたら多いサイトでない限りは、大きい価格差は生まれないように思えます。

S3単体にかかるコスト

S3において静的ウェブサイトホスティングでファイルの配信にかかるコストで考えると、GETリクエストの料金とS3からインターネットに出ていくときのデータ転送料がかかってきます。
S3のGETリクエスト料金はstandard tierだと 0.00037USD/1,000reqです。データ転送量はどこまで使ったかによって料金が変わってきます。

データ転送量/月 USD/GB
10TBまで 0.114
次の40TB 0.089
次の100TB 0.086
150TBから 0.084

S3+CloudFrontにかかるコスト

CloudFrontとS3の間に通信コストはかからないので、通信コストはCloudFrontからインターネットに出ていくときのデータ転送量のみです。
CloudFrontのリクエスト料金は0.0120USD/10,000reqです。 S3は1,000reqごとの課金だったので桁が違うことに注意です。
あとはオリジンのFetchでS3のGETリクエスト料金も払うことになる。

データ転送量/月 USD/GB
10TBまで 0.114
次の40TB 0.089
次の100TB 0.086
次の350TB 0.084
次の524TB 0.080
次の4PB 0.070
5PBから 0.060

ユースケースごとの課金

Case1,2に関してはなんとなく肌感でこのくらいのリクエスト量と通信量が妥当かなという感覚がありますが、Case3に関しては完全に想像です。
CloudFront+S3構成の方は厳密には、originのFetchでS3のGETリクエスト料金も払うことになりますが、全ファイルをキャッシュすることを考えると微々たるものと想定し計算から外しています。

Case1. 100GB/月の通信量・100万件/月のHTTPSリクエス

S3onlyに比べ、CF+S3 の方が0.83USD(7%)高い結果となった

(データ通信) 100 * 0.114USD + (リクエスト) 0.00037USD / 1000req * 1,000,000req = 11.77 USD/月
---
CF+S3
(データ通信) 100 * 0.114USD + (リクエスト)0.0120USD / 10,000req * 1,000,000req = 12.6 USD/月

Case2. 15TB/月の通信量・1億件/月のHTTPSリクエス

S3onlyに比べ、CF+S3 の方が83USD(5%)高い結果となった
10TB/月を超えているとカスタム料金設定ができるとあったので、もっと安くなるのかも

S3only
(データ通信) 10000 * 0.114USD + 5000 * 0.089USD + (リクエスト) 0.00037USD / 1000req * 100,000,000req = 1622 USD/月
---
CF+S3
(データ通信) 10000 * 0.114USD + 5000 * 0.089USD + (リクエスト)0.0120USD / 10,000req * 100,000,000req = 1705 USD/月

Case3. 2PB/月の通信量・150億件/月のHTTPSリクエス

S3onlyに比べ、CF+S3 の方が2610 USD(1.5%)安い結果となった

S3only
(データ通信) 10000 * 0.114USD + 40000 * 0.089USD + 100,000 * 0.086USD + 1,850,000 * 0.084USD +(リクエスト) 0.00037USD / 1000req * 15,000,000,000req = 174250 USD/月
---
CF+S3
(データ通信) 10000 * 0.114USD + 40000 * 0.089USD + 100,000 * 0.086USD + 350,000 * 0.086USD + 524,000 * 0.080USD + 976,000 * 0.070USD +(リクエスト)0.0120USD / 10,000req * 15,000,000,000req = 171640 USD/月

セキュリティ

ここでは、CloudFrontはOrigin Access Identity(OAI)を利用してアクセスするものとします。

もしS3静的ウェブサイトホスティング特有の機能であるサブディレクトリでのインデックスドキュメントの設定とOAIを併用したい場合には、Lambda@EdgeもしくはCloudFront Functionsを利用する必要があり、その場合には追加で課金が必要になります。

CloudFrontからキャッシュを返すようにするためには、origin requestをtriggerにしてリクエストパスの末尾に index.html を付けてあげるのが良さそうです(参考)。なお、CloudFront Functionsはorigin requestのtriggerでは動かないので、ここではLambda@Edgeを使うのが推奨されそう。

Origin Access Identityを利用したS3コンテンツへのアクセス制限を利用することで、S3バケットへのアクセスをCloudFrontからの通信に限定することも出来る。
CloudFrontを介さないS3バケットへのアクセスを禁止することでoriginを保護できますし、CloudFrontはWAFと統合できるのでIPアクセスを指定したブロックなどを行うことができるようになります。

パフォーマンス

CloudFrontはEdgeロケーションからキャッシュされたレスポンスを配信するので当然のことながらパフォーマンスは良くなります。
ちなみに、ap-northeast-1にエッジロケーションは22個あるらしいです。なんだか前に見たときよりめっちゃ増えている気がします。

キャッシュが無い場合にも、originがAWSにある場合にはAWS global networkを利用して高速になるらしい…。

S3ウェブサイトホスティングではコンテンツを圧縮して配信することができませんが、CloudFrontではオブジェクト圧縮が可能なので、そういった面でもパフォーマンス面においてCloudFront+S3の方が優れると言えます。

Maintenability

この言葉が当てはまるのかは少し微妙ですが、主にどちらが構成の変更に強いかというニュアンスで用いています。
例えば、今は静的なコンテンツを返していますが、同じリクエストパスで動的なレスポンスを生成して返したくなるケースや、S3バケットを2つに分割してそれぞれにownerを持たせたくなるケースが考えられると思います。あとはメンテナンス時間にメンテ対象のパスに対して固定レスポンスを返したり。

例で出したようなケースは、やはりパスごとにoriginを変更できるCloudFrontを利用しないと難しい面があると思います。
また、カスタムドメインを設定できるのも魅力的ですね。あんまり無さそうですが、完全にGCPに移行してGCSとCloud CDNが利用するというふうに構成を変えても、ドメインを変更する必要がありません。

保守性という意味で言うと、例えば多言語化であったり別の理由でキャッシュキーの考慮がCloudFront+S3では必要になってしまったり、キャッシュを管理するCache-Controlヘッダだったりで変わる挙動について認識しておく必要性があるので、複雑さは増すとも言える気がします。シンプルに保つという部分ではS3でのウェブサイトホスティングが良いのかもしれません。

デプロイ

どちらの方法でもS3へのアップロードが必要になりますが、キャッシュポリシーの設定次第かもしれませんが、CloudFront+S3の場合には、Invalidationが必要になります。
デプロイ時の考慮はS3単体時に比べて増えます。

まとめ

「一時的なサイトなのでシンプルに作りたい」「コストをなるべく切り詰めたい」というようなケースである場合には、S3単体でウェブサイトホスティングするのが良さそうという印象を受けましたが、そうでない場合にはCloudFront+S3構成にするのが色んな観点でのメリットが得られやすいという感覚を持ちました。
CloudFront+S3構成が自分が思っていた感覚よりも安いなというのが大きな驚きでした。ここではキャッシュの条件などを安くなる方に倒しているので当たり前といえば当たり前なのですが、予想以上にS3単体でのコストに近づいたので…。

MongoDBのReplicaSetでInitial Syncを実行するか判断している処理を追う

記事一覧はこちら

背景・モチベーション

MongoDBサポートの方から、MongoDB ReplicaSetのSecondaryをリストアする方法は2つあると聞きました。
1つはInitial Syncを利用してSecondayをseedする方法、もう1つはファイルを直接コピーしてSecondaryをseedする方法です。
後者はネットワークを介さずに直接データをコピーしてくるので非常に高速です。AWSであれば、EBSスナップショットからボリュームを作りattachするだけで良いので楽ですしね。
後者はInitial Syncが走らないわけですが、どのようにInitial Syncを行わない判定をしているのか非常に気になりました。
雰囲気でしかC++を読めないですが、処理を追っていこうと思います。

docs.mongodb.com

ちなみにMongoDB Universityを利用してMongoDBに入門した記事は下記です。

44smkn.hatenadiary.com

Initial Syncとは

docs.mongodb.com

データセットを最新の状態に維持するために、ReplicaSetのSecondaryメンバは他のメンバからデータを同期・複製する必要があります。
MongoDBでのデータの同期は2つの形式があります。1つが今回取り上げるInitial Syncで、もう一つがReplicationです。
前者は、新しいメンバに対してすべてのデータセットを与えます。そしてデータセットに対して継続的に変更を適用するのが後者です。

Initial Syncの実装方法は、Logical Initial SyncFile Copy Based Initial Syncの2つです。
後者は、SERVER-57803 を見る限り、v5.1.0からEnterprise Serverのみに実装されたようです。feature flagがtrueになるのもSERVER-52337から察するにv5.2.0からみたいなので、かなり新しい機能のようです。

Initial Sync自体の処理に関しては、かなりドキュメントが整備されています。

github.com

今回のInitial Syncを行うかという判断に関しては、実装方法に関わらない共通の処理でしたので処理が非常に追いやすいはずでした。
C++をあまりに雰囲気で読みすぎて時間がかかってしまった…。

データをコピーしてSecondaryのメンバをseedする処理

Restore a Replica Set from MongoDB Backups — MongoDB Manual

  1. ファイルシステムのスナップショットからデータベースのファイル群を取得する
  2. Standaloneでmongodを起動する
  3. Local DBを削除して一度シャットダウンする
  4. シングルノードの ReplicaSetとして起動する
  5. PrimaryのdbPath配下のファイルをSecondaryにコピーする、つまりLocalDBを一度削除して新しく作成されたReplicaSetのメタデータを保持している状態
  6. Secondaryを ReplicaSetに追加する

Initial Syncを実行するか判断する処理を追う

コードは読みやすいようにいくつか改変を加えています。

Initial Syncを実行するかどうかを明示的に判断している処理

Initial Syncを実行するかを判断しているのはreplication_coordinator_impl.cpp#L836-L852の中にある const auto needsInitialSync = lastOpTime.isNull() || _externalState->isInitialSyncFlagSet(opCtx); という条件ですが後者は実行時の設定次第で変わるようなので、lastOpTime.isNull()の結果が肝要ではと推測します。
ではどのようにこの値を取得しているのかが気になってきます。

// replication_coordinator_impl.cpp#L836-L852
void ReplicationCoordinatorImpl::_startDataReplication(OperationContext* opCtx) {
    // Check to see if we need to do an initial sync.
    const auto lastOpTime = getMyLastAppliedOpTime();
    const auto needsInitialSync =
        lastOpTime.isNull() || _externalState->isInitialSyncFlagSet(opCtx);
    if (!needsInitialSync) {
        LOGV2(4280512, "No initial sync required. Attempting to begin steady replication");
        // Start steady replication, since we already have data.
        // (omitted by author...)
        return;
    }
}

エントリポイントから追っていき、どのようにlastOpTimeが設定されているか探る

まず mongodのエントリポイントを確認します。
mongod.cppで呼ばれている関数を追っていくと、mongod_main.cpp#L707にて、replCoord->startup(startupOpCtx.get(), lastShutdownState); という処理が見つかります。
どうやらここで ReplicaSet関連の処理が呼ばれているようです。

// mongod.cpp
int main(int argc, char* argv[]) {
    mongo::quickExit(mongo::mongod_main(argc, argv));
}
---
// mongod_main.cpp#L707
ExitCode _initAndListen(ServiceContext* serviceContext, int listenPort) {
   if (!storageGlobalParams.readOnly) {
        auto replCoord = repl::ReplicationCoordinator::get(startupOpCtx.get());
        replCoord->startup(startupOpCtx.get(), lastShutdownState);
    }
}

呼ばれているstartup関数の中ではreplication_coordinator_impl.cpp#L929にてローカルストレージからレプリケーション設定の読み込みが宣言されているようです。
もし有効な設定であれば、replication_coordinator_impl.cpp#L683のようにコールバックでスケジュールされる _finishLoadLocalConfig 関数の中で、_setMyLastAppliedOpTimeAndWallTime()が呼ばれてoptimeを設定しています。
ただし、これが設定されるのはoptimeのエントリがLocalDBにある場合のみです。
replication_coordinator_external_state_impl.cpp#L786-L813 の関数が呼ばれていて、それを見るとLocalDBのoplog.rsの最新のエントリを取得していることが分かります。

// replication_coordinator_impl.cpp#L866-L867
void ReplicationCoordinatorImpl::startup(OperationContext* opCtx,
                                         StorageEngine::LastShutdownState lastShutdownState) {
    bool doneLoadingConfig = _startLoadLocalConfig(opCtx, lastShutdownState);
}
---
// replication_coordinator_impl.cpp#L683
void ReplicationCoordinatorImpl::_finishLoadLocalConfig(
    const executor::TaskExecutor::CallbackArgs& cbData,
    const ReplSetConfig& localConfig,
    const StatusWith<OpTimeAndWallTime>& lastOpTimeAndWallTimeStatus,
    const StatusWith<LastVote>& lastVoteStatus) {

    OpTimeAndWallTime lastOpTimeAndWallTime = OpTimeAndWallTime();
    if (!isArbiter) {
        if (lastOpTimeAndWallTimeStatus.isOK()) {
            lastOpTimeAndWallTime = lastOpTimeAndWallTimeStatus.getValue();
        }
    }

    const auto lastOpTime = lastOpTimeAndWallTime.opTime;
    // Set our last applied and durable optimes to the top of the oplog, if we have one.
    if (!lastOpTime.isNull()) {
        _setMyLastAppliedOpTimeAndWallTime(lock, lastOpTimeAndWallTime, isRollbackAllowed);
    } 
}
---
// replication_coordinator_external_state_impl.cpp#L786-L813
StatusWith<OpTimeAndWallTime> ReplicationCoordinatorExternalStateImpl::loadLastOpTimeAndWallTime(
    OperationContext* opCtx) {
    try {
if (!writeConflictRetry(
                opCtx, "Load last opTime", NamespaceString::kRsOplogNamespace.ns().c_str(), [&] {
                    return Helpers::getLast(
                        opCtx, NamespaceString::kRsOplogNamespace.ns().c_str(), oplogEntry);
                })) { /* ... */ }
    }
}
---
// namespace_string.cpp
const NamespaceString NamespaceString::kRsOplogNamespace(NamespaceString::kLocalDb, "oplog.rs");
---
// namespace_string.h
class NamespaceString {
    // Namespace for the local database
    static constexpr StringData kLocalDb = "local"_sd;
}

まとめ

実際にコードを追っていくと、ドキュメントに書いてある手順も非常に腑に落ちていいですね。
ファイルの読み込みをしてオブジェクトにmapして、そのメンバの値によって処理を行うという一連の流れを追えたのも良かったです。