1クール続けるブログ

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

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して、そのメンバの値によって処理を行うという一連の流れを追えたのも良かったです。

ZenHubのPrometheus Exporter作ってみた

記事一覧はこちら

背景・モチベーション

仕事でexporterを書くときにスッと書けるようにという練習がてら。
オンボーディングの中で自分でマイクロサービスをデプロイしてみるというタスクもあり、そこでも使える気がしたので一石二鳥を狙っている。

プロダクトバックログとかの量とかタスクの見積もりとかをaggregateしたりで可視化出来るのは悪くないような気も。GitHubAPIも利用するとLabelでいろんな属性を付けられるので良さげなんだけれども、とりあえず今回はZenHubのAPIのみを使っている。

ちなみにGrafanaのorgの下には、ほとんど手が付けられていないzenhub-exporterがあったりする。

本編

Exporterの書き方はここに書いてある。公式でツールセットだったりも提供してくれていて非常に手厚い。

prometheus.io

exporterのデフォルトのポート番号を選ぶ

Prometheusのexporterは1つのホスト上で複数動くことが充分に考えられるため、デフォルトのポート番号がwikiで整理されている(詳細)。使われていないポート番号を選ぶことを始めた。今回はポート番号9861を利用することにした。実際に広く使われるexporterを作成する場合には、このwikiを編集する必要がある。

github.com

Exporterを実装する上で気をつけたいこと

前述のWriting Exporterというページを参考に一部抜粋してみる。
📝 のマークが付いているのは自分の感想でドキュメントに書いてあった内容ではありません。

Configuration

YAMLがPrometheusの標準的な設定記述なので、もし設定ファイルを必要とする場合にはYAMLを利用すること。

Metrics

Naming

メトリクスの名前は特定のシステムに詳しくない人でも類推できるものする必要がある。そのため、メトリクス名は、通常 haproxy_up のように、exporter名をprefixに付ける必要がある。

メトリクスはsecondsやbyteなどのbase unitsを使用して、Grafanaなどのgraphing toolsで読みやすいようにしておく。同様に、パーセンテージではなくratioを利用する。
📝 これは自分の中でも印象が強く、node_exporterがいつだったかのバージョンアップの時に今までなかった単位がメトリクスのsuffixに足されたのを覚えている。

Metrics名にはexportされる際のラベルを含めるべきではない。ただし、同じデータを複数のメトリクスで異なるラベルを付けてエクスポートしている場合は例外だ。単一のメトリクスにすべてのラベルを付けてエクスポートするとカーディナリティが高くなりすぎる場合にのみこの例外対応が必要になる。
📝 ここでのカーディナリティはRDBでなく時系列DBの用語を指すので注意(fukabori.fm #53

Prometheus のメトリクスとラベル名はsnake_caseになる。
_sum _count _bucket _total といったsuffixはSummaryやHistograms、Countersに使われるため、それら以外であればこれらのsuffixは利用しないこと。_total はcounter用に予約されているのでCOUNTERタイプのメトリクスであれば利用するべきsuffixである。

process_scrape_ というprefixは予約されているので利用しないこと。ただし、これらに独自のprefixを追加した場合には利用可能となる。たとえば、jmx_scrape_duration_secondsは問題ない。

成功リクエストと失敗リクエストの数がある時に、これを公開する一番良い方法は一つのメトリクスではトータルのリクエスト数、もう一つのメトリクスでは失敗したリクエストを扱うこと。 1つのメトリクスに失敗または成功のラベルを付けないように注意する!
同様に、キャッシュのヒットまたはミスについても、1つのメトリクスを総計に、もう1つのメトリクスをヒット数にするのがよい。
📝 ついついメトリクスを分けるのではなくLabelで対応しがちなので特に気をつけたい項目

Labels

ラベルには関してはこちらのドキュメントにおいても言及されています。

prometheus.io

一般的なガイドラインとしてメトリクスのカーディナリティを10以下にするようにして超えるものは一握りとする。
📝 ここではLabelの取りうる値のことについて言っていると自分は解釈しました。保存するデータ量はLabelの取りうる値の掛け算によって決まるので非常にパフォーマンスに影響が出やすい部分のはずです。cardinality-is-keyというブログを参考にしました。

なにか事象が起こるまで存在しない時系列データは扱いが難しくなるため、事前に存在する可能性があるとわかっている時系列データに関しては0などのデフォルト値を書き出しておくこと。

ターゲットラベルと衝突するラベル名は避けることが推奨されている。例えば、region, zone, cluster, availability_zone, az, datacenter, dc, owner, customer, stage, service, environment, envなど。
📝 ターゲットラベルと言っているのはおそらくExporterごとに付与されるラベルのことと思います。例えば、ec2のService Discoveryを利用されている場合にはmetaラベルでAZが取得できるので、それをavailability_zoneにrelabelするのはよくあるかなあと思います。

Read/writeとsend/receiveはラベルとしてではなく、別々のメトリクスとして使用するのが最適です。これは通常、一度に1つだけを気にするからであり、その方が使いやすいからです。

ラベルの付与は慎重に。ラベルが増えると、ユーザがPromQLを作成するときに考慮しなければならないことが増えます。メトリックに関する追加情報は、infoメトリックを介して追加することができます。例えば、kube_pod_infoとかがそれに当たります。

Types

MetricsのTypeは通常CounterかGaugeだけれども、もしデクリメントのあるCounterの場合には誤解を招かないようにGAUGEではなく、UNTYPEDを利用する。

Help Strings

ヘルプをメトリクスそれぞれを付けておく、メトリクスのSourceが分かると良い。

Collector

ExporterのCollectorを実装する場合には、scrapeごとにメトリクスを更新するという方法を取るべきではない。
毎回新しいメトリクスを作成してください。GoではCollect()メソッドでMustNewConstMetric()を使ってメトリクスの作成を行います。
📝 作成されるメトリクスの構造体は変更できないようにフィールドがunexportedになっています。
新しいメトリクスを毎回作成する理由は2つあります。

  • 2つのscrapeが同時に発生する可能性があり、direct instrumentationの場合にはグローバル変数が競合する可能性がある
  • ラベルの値が消失したときにもexportされてしまう

Elasticsearch などの多くのシステムでは、CPU、メモリ、ファイルシステム情報などのマシン・メトリクスが公開されています。Prometheusのエコシステムではnode_exporterがこれらを提供しているので、このようなメトリクスは削除すべきです。

Deployment

それぞれのexporterは1つのinstanceのアプリケーションを監視すべきで、できれば同じマシン上で隣り合っているのが望ましい。

ただし2つの例外があります。

  1. 監視しているアプリケーションの横で実行することが全く無意味な場合
    • 📝 今回作ったzenhub_exporterblackbox_exporterはこのケースに当てはまる
  2. システムのランダムなインスタンスからいくつかの統計情報を引き出し、どのインスタンスと話しているかを気にしない場合

Scheduling

メトリクスは、Prometheus がscrapeするときにのみアプリケーションから引き出されるべきであり、exporterは独自のタイマーに基づいてscrateを実行すべきでない。

公開するメトリクスにタイムスタンプを設定すべきではなく、Prometheusに任せたほうが良い。

メトリクスの取得に1分以上かかるなど、特にコストがかかる場合はキャッシュしても構いわないが、HELP Stringsにその旨を記載する。
Prometheusのデフォルトのscrape timeoutは10秒です。もしexporterがこれを超えることが予想される場合は、ドキュメントの中で明示的にその旨を伝える必要がある。

Failed scrapes

Scrapeに失敗したときの処理はパターン主に2つ。2つ目はexporterのダウンとアプリケーションのダウンの見分けが付く点で優れる。

  1. 5xxエラーを返す
  2. myexporter_up(例:haproxy_up)という変数を用意して、スクレイプが成功したかどうかに応じて0または1の値を持つ

Landing page

http://yourexporter/ にユーザがアクセスした時に見せるシンプルなHTMLを用意して、そこにはexporterの名称と/metrics ページへのリンクを貼るべし。

f:id:jrywm121:20210920213503p:plain

上記踏まえて ZenHub Exporter 作った

上記を踏まえて、今回作ったアプリケーションのリポジトリはこちらです。

github.com

ビルドやリリースのツールもPrometheusが提供していて、haproxy_exporternode_exporterなどのPrometheusのorgで管理されているリポジトリはだいたいpromuというツールを使ってリリースなどが行われているそうです。実際には、promuを利用したCircleCIのOrbが作られており、それを各リポジトリから利用されているようです。ドラフトのリリースを作ってくれます。

今回はそのpromuというツールでビルドやリリースを行うようにしてみました。便利ではありますし、Officialのexporterとリリース形式が同じになるため良いですが、必ずしも利用する必要性が無さそうなところも見えましたので普通にgoreleaserを使っても良いのではと思います。ただしその際には、ldflagsgithub.com/prometheus/common/version.Versionなどにバージョン設定をしないと、--version実行時に歯抜けで表示されるので注意です。

まとめ(雑記?)

OpenMetricsを有効化したけど、どこからUnitを設定するのかよく分からなかった。スペックを読む限りだとTypeやHelpと同じところに表示されそうな気がしてるんだけど、Prometheusのclientライブラリからは読み取れなかった…。

github.com

MongoDB UniversityのM103を修めた

記事一覧はこちら

Table of Contents

背景・モチベーション

8月1日から中途入社して働いている会社ではRDBだけでなく、いわゆるNoSQL(Not Only SQL)であるMongoDBを利用しています。そのうえshardingも利用しているということで、データ指向アプリケーションデザインを7月に読んでいたこともあり、非常に興味を持ちました。

同じチームの方から、MongoDB Universityが良いよというアドバイスを頂き、早速受講してみたのでまとめたメモをブログに残しておこうと思います。

自分が受講したコースは、レクチャー動画を観て、Quizを解き、更にインタラクティブなターミナルをブラウザ上で動かして実際に構築しているという流れで進んでいきます。動画を観るだけではないので、講義内容が右から左に抜けていきづらくなっていると思います。動画は英語ですが、Transcriptがダウンロード出来るようになっているので、最悪DeepLにお願いすることでなんとかなります。

講師の方の中にスターウォーズが好きな人が居て、時々画面の前の僕らに向かってYoung Padawanと語りかけてくるのが面白いです。

M103

f:id:jrywm121:20210830010153p:plain

Chapter 1: The Mongod

What is mongod

mongodはmongodbのメインのdaemon processです。
It is the core server of the database, handling connections, requests, and most importantly, persisting your data.

mongodはReplicaSetやSharded Clusterなど複数サーバでの構成において、各サーバで動作する

Default Configuration

設定ファイルや起動引数で設定を渡すことになるが、渡さない場合にはデフォルトの設定で動く

  • The port mongod listens on will default to 27017.
  • The default dbpath is /data/db.
    • the data files representing your databases, collections, and indexes are stored so that your data persists after mongod stops running
    • The dbpath also stores journaling information (crash logs…)
  • mongod binds to localhost
    • mongodに接続できるクライアントはlocalhostに存在するものだけ
    • リモートクライアントを受け入れるには設定を変更する必要がある
  • Authentication is turned off

当たり前っちゃ当たり前だけど、リモートからのアクセスを許可するときには、authtrue にしましょうね

Configuration file

コマンドラインでオプション渡す事もできるが、設定ファイルを利用することも可能

設定ファイルの形式はYAMLとなる 階層化されてるので、コマンドオプションより格段readabilityが増して分かりやすいぞ

mongod -f "/etc/mongod.conf"

Configuration File Options — MongoDB Manual

File Structure

MongoDB standalone server のときのファイル構造

root@2a9d0458fdcc:/data/db# tree -L 2 .
.
|-- WiredTiger
|-- WiredTiger.lock
|-- WiredTiger.turtle
|-- WiredTiger.wt
|-- WiredTigerHS.wt
|-- _mdb_catalog.wt
|-- collection-0-7411902203291987629.wt
|-- collection-2-7411902203291987629.wt
|-- collection-4-7411902203291987629.wt
|-- diagnostic.data
|   |-- metrics.2021-08-22T08-52-18Z-00000
|   `-- metrics.interim
|-- index-1-7411902203291987629.wt
|-- index-3-7411902203291987629.wt
|-- index-5-7411902203291987629.wt
|-- index-6-7411902203291987629.wt
|-- journal
|   |-- WiredTigerLog.0000000001
|   |-- WiredTigerPreplog.0000000001
|   `-- WiredTigerPreplog.0000000002
|-- mongod.lock
|-- sizeStorer.wt
`-- storage.bson

WiredTiger (storage engine) がクラスタメタデータやWiredTiger固有の設定などの情報をtrackする用途で使われているファイル群が上記

The WiredTiger.lock file acts as a safety. 2つ目のMongoDBプロセスを同時に実行し、このフォルダを指定した場合、ロックファイルはその2つ目のMongoDBプロセスが起動するのを防いでくれます。 mongod.lock も同じような役割を果たす。

ホストマシンが落ちたりcrashしたときには、このlockファイルのせいでmongodが起動できないことがある。

.wt の拡張子を持つファイルは、collectionとindexのデータ。WiredTigerでは、それぞれ別の構造として保存される。

diagnostic.data ディレクトリはMondoDBサポートエンジニアが診断する目的のみのために利用されるもの。 この診断データは、Full Time Data Capture (FTDC) モジュールによって取得される。

journal ディレクトリ内のファイルはそれぞれWiredTigerのjournaling systemの一部。

WiredTigerでは、書き込み操作はメモリにバッファリングされる。60sごとにflashされてデータのチェックポイントが作成される。

また、WiredTigerでは、ディスク上のjournal fileへの書き込みにWAL(Write Ahead Logging)を採用している。journal entryははまずメモリ上にバッファリングされ、その後WiredTigerは50ミリ秒ごとにjournalをディスクに同期します。Each journal file is limited to 100 megabytes of size.

WiredTigerは、データのディスクへの同期にファイルローテーション方式を採用しています。障害発生時には、WiredTigerはjournalを使用してチェックポイント間に発生したデータを回復することができます。

※ 基本的にMongoDBのdata direcrotyは直接編集を行わないこと!

root@2a9d0458fdcc:/tmp# tree .
.
`-- mongodb-27017.sock

/tmp ディレクトリには、socketファイルがありプロセス間通信に利用される。 このファイルは起動時に作成され、MongoDBサーバーにこのポートを所有させる。

Basic Commands

Shell Helperを利用することでメソッドLikeに操作することができる

  • db - database
    • db.<collection>.createIndex 等…
    • db.runCommand() をwrapしてusabilityを高めたもの
  • rs - replicaset
  • sh - sharded

db.commnadHelp(<Command>) でヘルプが見れる

Logging

db.getLogComponents() でlogLevelを確認できる commandやindexなどのcomponentごとにLoglevelが設定できる

-1 は親の指定を引き継ぐこと。下記でいうと、一番上のレベルで”verbosity”: 0 が宣言されていているので0 になる 0 - 5 まで設定することでき、数字が高いほどverboseとなる。0はinfoレベルのみで、1からdebugレベルの出力を行う。

{
    "verbosity" : 0,
    "accessControl" : {
        "verbosity" : -1
    },
    "command" : {
        "verbosity" : -1
    },
    "control" : {
        "verbosity" : -1
    },
    "//": "omitted..."
}

ログレベルの設定

mongo admin --eval '
  db.setLogLevel(0, "index")
'

ログを確認する手段は2つ

# via Mongo Shell
db.adminCommand({ "getLog": "global" })

# via command line
tail -f /data/db/mongod.log

5 Severity Levels Logエントリの2つ目のフィールド

F - Fatal
E - Error
W - Warning
I - Information(Verbosity Level 0)
D - Debug(Verbosity Level 1-5)

Profilers

ログにはコマンドに関するデータも含まれていますが、クエリの最適化を開始するのに十分なデータはありません。

execution statsやクエリで使用されるインデックス、rejected planなどの情報を取得し、遅いoperationをデバッグするためには、ログではなくProfilerを利用する。

Profilerが取得するデータは下記の3つ

  • CRUD operations
  • Administrative operations
  • Configuration operations

データベースレベルでProfilerを有効にできる。 有効にすると、system.profile というcollectionに、CRUD operationのprofiling dataが格納される。

profilingLevelには3つの段階がある

  • 0: profilingがオフになっている
  • 1: slow operationのみprofilingする
  • 2: 全てをprofilingする

デフォルトでは、100ms 以上の操作を「遅い」と判断するが、自分で設定することも可能

> db.getProfilingLevel()
0
> db.setProfilingLevel( 1, { slowms: 50 } )
{ "was": 0, "slowms": 50, "sampleRate": 1, "ok": 1 }
> db.system.profile.find().pretty()

Security

Authentication

  • SCRAM (default)
  • X.509

MongoDB Enterprise Only

Authorization

RBAC

  • Each user has one or more Roles
  • Each Role has one or more Privilleges
  • A Previlleges represents a group of Action and the Resources those actions apply to

Localhost Exception

mongodでauthenticationを有効にしても、デフォルトではMongoDBはユーザを作成しないので手詰まりになってしまいます。

そのため、最初にユーザを作成するまではlocalhostからなら認証なしで操作できるというLocalhost Exceptionが存在します。

必ず最初に管理者権限のあるユーザーを作っておき、Localhost Exceptionがclose後に、ユーザを作成できる状況にしておく必要があります。

$ mongo --host 127.0.0.1:27017
> use admin
> db.createUser({
  user: "root",
  pwd: "root123",
  roles : [ "root" ]
})

Role

Role Structure

Role is composed of

  • Set of Privilleges
    • Actions → Resources
  • Network Authentication Restrictions
    • clientSource
    • serverAddress

Resources

  • Database
  • Collection
  • Set of Collections
  • Cluster
    • ReplicaSet
    • Sharded Cluster

Built-In Roles

Each Database or All Database (two scope)

Only one scope

> use admin
> db.createUser(
  { user: "m103-application-user",
    pwd: "m103-application-pass",
    roles: [ { db: "applicationData", role: "readWrite" } ]
  }
)

Server Tools

root@2a9d0458fdcc:/tmp# find /usr/bin/ -name "mongo*"
/usr/bin/mongodump
/usr/bin/mongod
/usr/bin/mongo
/usr/bin/mongos
/usr/bin/mongoexport
/usr/bin/mongotop
/usr/bin/mongosh
/usr/bin/mongoimport
/usr/bin/mongostat
/usr/bin/mongofiles
/usr/bin/mongorestore

mongostat

mongostat — MongoDB Database Tools

CRUD Operationやメモリ、ネットワークの統計を確認することができる

mongodump / mongorestore

mongodumpはBSONで保存されているCollectionをBSONのまま出力する。 データの変換がないために高速。ディレクトリを指定しないと、カレントディレクトリ配下にdump ディレクトリが作成されてその中に吐かれる。metadataはJSON形式で出力される。

mongorestoreコマンドでcollectionをdumpから作成できる。

mongoexport / mongoimport

単一のファイルにJSONファイルとして吐ける(デフォルトでは標準出力に)。 BSONでなくJSONなのでそんなに速くない。また、metadataのファイルを作らないので、database名やcollection名を指定してあげる必要がある。

# mongoimport --port 27000 --file /dataset/products.json \
-d applicationData -c products -u m103-application-user \
-p m103-application-pass --authenticationDatabase=admin

Chapter 2: Replication

What is ReplicaSet?

同じデータセットを持つmongod プロセスのグループ

ReplicaSetには最大1つのPrimaryが存在する。もしPrimaryがunavailableとなった場合には、新しいPrimaryがSecondaryからvoteのプロセスを経て選出されてサービスを継続する(fail over)。 Replica Set Elections に詳細がある。

全てのメンバがRead operatoinを受け入れられるが、Write operatoinはPrimaryのみ。Secondaryは非同期にPrimaryのデータ更新を受け取り同期する。

Replicationのプロトコルには異なるバージョンがあるが、デフォルトはProtocol Vesion1でRAFTをベースにしたもの。

Rplicationメカニズムの中心となるのが、oplog になる。Primaryノードへの書き込みが成功するたびにoplog がidempotentな形式で記録される。

ReplicaSetのメンバには、Primary/Secondaryの他にarbiterという役割も設定できる。arbiterはデータセットを持たない。なのでPrimaryにもなれない。electionのプロセスで頭数を揃えるためにある役割。分散データシステムの一貫性に大きな問題を引き起こすので利用をあまりおすすめしない。

Failoverには過半数のノードが利用可能であることを必要とする。 ReplicaSetは奇数のノードが必要(最低でも3ノード)

ReplicaSetは最大50メンバーまで利用可能だが、voting memberは7まで。7を超えると時間がかかりすぎてしまうため。

Secondary には特定のプロパティが存在する

  • Hidden Node
    • アプリケーションから隠されたデータのコピーを持つこと
    • レプリケーションプロセスの遅延も設定できる(= Delayed Node)
  • Delayed Node
    • アプリケーションレベルの破損に対して、コールドバックアップファイルに頼らずに回復できるようにする目的

Initiate ReplicaSet

Replicationするための設定 replicationを設定すると、clientのauthenticationを行われるようになる

openssl rand -base64 741 > /var/mongodb/pki/m103-keyfile
chmod 400 /var/mongodb/pki/m103-keyfile
# add lines for replications
security:
  keyFile: /var/mongodb/pki/m103-keyfile
replication:
  replSetName: m103-example

Replicationを開始するためのコマンド

rs.initiate() # このコマンドを発行したノードがPrimaryになる
rs.isMaster() # どれが Primary になっているか確認

rs.stepDown() # 意図的にSecondaryをPrimaryに昇格させる

Secondaryを参加させるには、rs.add() を利用する必要がある

mongo --port 27003 -u m103-admin -p m103-pass —-authenticationDatabase admin
rs.add("localhost:27001")
rs.add("localhost:27002")
rs.status() # 確認

Replication Configuration

  • JSON Objectで表現される設定
  • mongo shellから手作業で設定することも可能
    • helper method: rs.initiate(), rs.add(), etc…

Replica Set Configuration — MongoDB Manual

{
  "_id": "replicaSetName",
  "version": X,
  "members": [
    {
      "_id": 1,
      "host": "mongo.example.com:28017",
      "arbiterOnly": false,
      "hidden": false,
      "priority": 1,
      "secondaryDelaySecs": 3600,
    }
  ]
}

Replication Command

  • rs.status()
    • ReplicaSetの総合的な情報を出力してくれる
    • Heartbeatも確認できる
  • rs.hello()
    • ノードのRoleを表示する
    • rs.status() よりシンプル
    • 以前は、rs.isMaster() という名称だったがdeprecatedになっている
  • db.serverStatus()[‘repl’]
    • rs.isMaster() に含まれていないrbidというのが出る
  • rs.printReplicationInfo()
    • oplogに関する情報

Local DB

ReplicaSetの構成を組んでいる場合に local DBの中身は下記のように複数ある standaloneの場合には、startup.logのみ存在する local DBに直接書いたデータはReplicationされない

殆どはサーバ内部で利用している情報 oplog.rsは、レプリケーションカニズムの中心で、レプリケートされているすべてのステートメントを追跡するoplogコレクション

oplog.rsコレクションには、知っておくべきことが幾つか

  • capped collection
    • サイズが制限されているコレクションのこと
  • デフォルトではoplog.rsコレクションは、空きディスクの5%を占める(デカい)、もちろん設定で指定することも出来る
  • oplogは短時間ですぐ増大する(Fear not, young Padawan.)
    • oplogのサイズが埋まったら、古いログから上書きされていく
  • oplogのサイズはどう影響するか?
    • 例えば、Secondaryのノードの接続が途切れてしまった場合、そのノードは Recoveryモードになり同期できてた操作から直近までの操作を一気に書き込んで追いつきます。しかしながら、既に同期できてた操作がoplogに残っていなかった場合に追いつけずエラーになってしまう
> use local
> show collections

me
oplog.rs
replset.election
replset.minvalid
startup.log
system.replset
system.rollback.id

Reconfiguraion Replicaset

例えば、4ノードになっていたのでvote出来るnodeを奇数にしつつ、hiddenにしてしまうのケースの場合には…

これはDBを止めることなく反映することが可能

cfg = rs.conf()
cfg.members[3].votes = 0
cfg.members[3].hidden = true
cfg.members[3].priority = 0
rs.reconfig(cfg)  // updating

rs.conf() // confirm

Reads and Writes on ReplicaSet

Secondaryのノードに対してmongo shellを起動してデータを読み込もうとしてみます。ちなみに ReplicaSetの名称を指定すると自動的にPrimaryにつなぎにいくので注意が必要になる。

Secondaryのノードではこのままだとコマンドを実行できません。 MongoDBはconsistencyを重視しているため、Secondaryから読み込む場合には明示的に伝える必要がある。それが、rs.secondaryOk になる。

consistencyを担保するために書き込みはPrimaryにしか出来ないようになっている。 ReplicaSetが過半数のノードに到達できなくなると、レプリカセットの残りのノードはすべてSecondaryになる。たとえ、残ったのがPrimaryのノードだったとしても。

Failover and Election

Primaryが利用できなくなる理由

一般的にはメンテナンスがそう 例えばローリングアップグレード(e.g. v3.4→ v3.6)

  • SecondaryのMongoDBプロセスを停止し、新しいDBのバージョンで戻ってきます(1台ずつ)
  • 最後に Primary でrs.stepDown() で利用して安全にelectionを開始します
  • electionが完了すると最後の古いバージョンのMongoDBプロセスはSecondaryになる
  • そして同じようにプロセスを新しいバージョンで起動すれば全て完了!かつ可用性も損なわない

Electionの仕組み どのSecondaryがPrimaryに立候補するかという点に関してはロジックが存在する

  • Priorityが全ノード同じ値
    • その場合は最新データを持っているノードが立候補する
    • そのノード自身が自分に投票を行う
    • 2ノードが名乗り出たとしても、投票権を持っているのが奇数ノードであれば問題ないが、偶数ノードだった場合には同点となる可能性があり、もし同点になった場合はelectionをやり直すことになる(=処理がストップする)
  • Priorityがノードごとに違う場合
    • Priorityが高いほど、Primaryになる可能性が高くなる
    • ノードをPrimaryにしたくない場合には、Priorityを0にする
      • Primaryになる資格のないノードのことをpassive nodeと言うらしい

前述したが、ReplicaSetの過半数のノードがダウンしたときには、たとえPrimaryだとしても疎通できなくなる

Write concern

write concern はdeveloperが、write operationに追加できるAckknowledgement(確認)の仕組み ACKのレベルが高いほどDurability(耐久性)が増す

書き込みが成功したことを確認するReplicaSetのメンバが多ければ多いほど、障害が発生したときに永続化が継続する可能性が高い Durabilityを高めようとすると、各ノードから書き込み確認の応答を貰う必要があるので待ちが必要になります。

Write Concern level

  • 0: クライアントは確認応答を待たないので、ノードの接続に成功したかどうかを確認するだけ
  • 1: デフォルトの値。クライアントはPrimaryからの確認応答を待つ
  • => 2 : Primaryと1つ以上のSecondary、例えばlevelが3のときには1つのPrimaryと2つのSecondaryから確認応答を待つ

majority というキーワードを利用することができる。これはReplicaSetのサイズが変わってもいちいち変えなくても良い。levelはメンバの数を2で割って切り上げた値となる。

Write Concernはsharded clusterも対応している。

MongoDBには更に2つのWrite Concernオプションがある。

  • wtimeout: クライアントが操作に失敗したと判断するまでの最大時間。重要なのは書き込みが無かったことになるわけではなく要求した耐久性を満たさなかったということ
  • j: journalの意。各ReplicaSetのメンバが書き込みを受け取ってジャーナルファイルにコミットしないと確認応答を返せない。これをtrue にすることでディスクに書き込まれることまで保証できる。false のときにはメモリに保存する所までの確認。

MongoDB 3.2.6以降は、Write concernが過半数になるとデフォルトでj がtrueになる。

Write concernはクライアントのアプリケーションから指定するっぽい。

Read Concern

起きうる不味いシチュエーション

  • クライアントアプリケーションがdocumentをinsertする
  • そのdocumentがSecondaryにReplicateされる前に、クライアントアプリケーションがReadする
  • 突然Primaryが壊れる
  • ReadしたdocumentはまだSecondaryにReplicateされてない
  • 古いPrimaryがオンラインに戻ったとき、同期プロセスの中でそのデータはRollbackして存在していないことになります
    • 📝ここで古いと行っているのはFailoverが起こるため、復帰したときにはSecondaryになっているからと思います

このシチュエーションを許容できない要件のアプリケーションの場合に困ります。 そんなときに役に立つのがRead Concernである。

Read Concernで指定された数のReplicaSetメンバに書き込まれたと認められたデータのみが返されるようになる。

Read Concern Level

  • Local: Primaryを読み取るときのデフォルトの設定。クラスタ内の最新のデータを返す。Failover時のデータの安全性が保証されない。
  • Available: Secondaryを読み取るときのデフォルトの設定。ReplicaSetのときはLocalと同じで、Sharded Clusterのときに挙動が変わる
  • Majority: 過半数のReplicaSetメンバに書き込まれたことが確認されたデータのみを返す。Durabilityと Fastの中間だが古いデータを返すこともある
  • Linearizeable: read your own writeを提供する。常に新しいデータを返すが、読み取り操作が遅かったり、制限がある。

どのようなRead Concern Levelにするかは、”Fast”, “Safe”, “Latest” の観点で考えると良さそう LocalやAvailableはSecondaryの読み取りに関しては最新が返ってくるとは限らない

Read Preference

読み込みのoperationをルーティングする設定

  • primary(default): Primaryのノードからしか読み取らない
  • primaryPreffered: PrimaryがUnavailableになったときにはSecondaryから読み取る
  • secondry: Secondaryのノードからしか読み取らない
  • secondaryPreffered: Secondaryが全てがUnavailableになったときにはPrimaryから読み取る
  • nearest: 地理的に一番近いところ

Chapter 3: Sharding

What is Sharding?

https://docs.mongodb.com/manual/images/sharded-cluster-production-architecture.bakedsvg.svg

画像引用元

MongoDB sharded clustershard , mongos , config servers で構成されている。

  • shard: 物理的に分散されたcollectionを保存する。replica set としてデプロイされる。
  • mongos: shardへクエリをルーティングする。
  • config servers: shardのmetadataを保存する。

When to sharding?

いつShardingが必要になるのかを考えてみましょう

まず最初にVerticcal scaleが経済的に可能かを確認する。特定されたボトルネックにリソースを増やすことでダウンタイムなしにパフォーマンスが向上する。

しかしながら、経済的な理由や非常に困難なポイントにぶち当たりスケールアップが難しくなります。

もう一つ考慮すべき点は、運用業務への影響です。 データセットの量が10テラバイト級になるとバックアップやリストアに時間がかかる。ディスクのサイズを増やすとインデックスのサイズも増えることになり、多くのRAMが必要になる。

一般的には、個々のサーバーには2~5テラバイトのデータを格納することが望ましいとされている それ以上になると時間がかかりすぎてしまう。

最後に、下記のようなケースでもshardingは有用

  • 並列化可能なSingle Thread Operation
  • 地理的に分散したデータ → zone sharding
    • 特定の地域に保存する必要があるデータ(M121でしっかり理解出来るやつ)
    • ↑のようなデータを取得するクライアント

Sharding Architecuture

https://youtu.be/6cCL4-3gF8o

動画がとても分かりやすかった

mongosconfig server からクエリされたデータがどのshardにあるかを取得し、ルーティングする。各shardに含まれる情報は時間とともに変化する可能性があるのでとても重要。mongos は頻繁にconfig server にアクセスする。

なぜ変化するかというと、各シャードのデータ量が均等になるように分散させるため。

sharded clusterはprimary shardという概念も存在する。各データベースにはprimary shardが割り当てられ、そのデータベースのshard化されていないcollectionは全てそのshardに残る。 primary shardには他にも幾つか役割がある。一つは、aggregation commandのMERGE操作によるもの。

shard key以外を条件にしたクエリの場合、例えば動画の例だとサッカー選手の名前でshardを分けているが、年齢に関するクエリを受け取った場合には全てのshardにクエリを送信する mongos もしくはcluster内でランダムに選択されたshardで、それぞれの結果を収集しソートなどを行います。これをSHARD_MERGE ステージと呼ぶ。

Setting Up a Sharded Cluster

config server の実態はMongoDBのReplicaSetです ただし、.sharding.clusterRole: configsvr を設定ファイルに追加する必要がある

.security.keyFile をレクチャーでは利用しているが、本番環境においてはX509証明書を利用することになる

既存の単一のReplicaSetをsharding構成にRolling Upgradeするには下記の手順を取る

  • CSRS (= Config Server Replica Set) を起動させる
  • mongos を起動させる
    • データを保存する必要が無いため設定ファイルでdbpath プロパティが無い
    • mongosconfig server で作成したユーザを継承する
    • config server の向き先を設定する必要がある
  • 既存のReplicaSetのnodeの設定を変更して再起動する
    • .sharding.clusterRole: shardsvrを設定にいれる
    • Secondaryをそれぞれshutdownする
    • PrimaryでstepDownを行い、他のnodeにPrimaryの役割を引き渡した後にshutdownする
  • mongos からshardを追加する
    • sh.addShard( "rs1/mongodb0.example.net:27018" )
    • ReplicaSetの中の1つのノードを指定するだけでPrimaryを認識可能

参考:

Configuration File Options — MongoDB Manual

ConfigDB

MongoDBが内部的に使うものなので、ユーザ側でデータの書き込みは行わないが有用な情報を読み取ることが出来る

use config

# databaseが幾つにpartitioningされているか
db.databases.find().pretty()

# collectionのshardkeyに関する情報
db.collections.find().pretty()

# shardとして利用しているReplicaSetの情報
db.shards.find().pretty()

# chunkがshardkeyのどの範囲を持っているかとどのshardに属するか
db.chunks.find().pretty()

# mongosの情報
db.mongos.find().pretty()

Shard Keys

sharded collectionのデータを分割して、cluster内のshardに分割するために使用する、indexed field(s) をshard keyと言う

chunkはshard keyを使ってdocumentを分けた論理的なグループのこと shard keyとして選択したフィールドの値によって、各chunkのinclusive lower boundとexclusive upper boundが決まる(e.g. 1 <= x < 6)

新しいdocumentをcollectionに書き込むたびに、mongosルータはどのshardにそのdocumentのkey valueに対応するchunkがあるかを確認し、そのshardのみにdocumentを送る。 つまり、挿入されるdocument全てにshard keyの項目が必要になる。

shardingを行う手順

  • databaseのshardingを有効に: sh.enableSharding("m103")
  • indexを作成: db.products.createIndex( { "sku": 1 } )
  • shard keyを選択: sh.shardCollection( "m103.products", { "sku": 1 } )
    • 📝 shard keyはimmutableとLecture動画で言われているが、shard fieldがimmutableな_id で無い限り、refineCollectionShardKey で更新することができる

Picking a Good Shard Key

What makes a Good Shard Key?

  • High Cardinality
    • Cardinalityが高ければChunkが増えshardの数も増えるのでクラスタの成長を妨げることがない
    • 例えばbool値をkeyにした場合には、上限が2chunkになりshardも2つまでになってしまいます
  • Low Frequency
    • frequencyはデータの中でuniqueな値が発生する頻度を表す
    • 例えばアメリカの州をshard keyにしたとして、90%の確率で「New York」のdocumentが挿入される場合、書き込みの90%以上が1つのシャードに行くことになる = HotSpot!
  • Non-Monotonically Change
    • 単調に変化する値(e.g. タイムスタンプやID)は、chunkが下限と上限を持つ性質上、相性が悪い
    • Monotonicなshard keyを分散させるためには後述のhashed shard key を利用する必要がある

shard keyの選定でもう一つ重要なことはread isolation です。よく実行するクエリに対応しているかどうかを検討する必要がある

shard keyを条件にしたクエリであれば、多くの場合は1つのshardにアクセスするだけで済むが、そうでなければ全てのshardにアクセスするscatter gather な操作になってしまい時間がかかる

shard keyを選ぶときに注意する点

  • 1度shardingしたcollectionはunsharding出来ない
  • 1度shardingしたcollectionはshard keyを更新できない(条件によっては可能)
  • shard keyのvalueは更新できない(これも可能と全セクションで言及があった)

Hashed Shard Keys

hashed shard keyを利用する場合、MongoDBはshard keyに指定しているField Valueのhash値を使ってどのchunkにdocumentを置くか決めることになる。

実際に保管するデータがhash値になるわけではなく、shard keyのベースとなるindex自体がhash値で保存される。データをより均等に分散させることができる。 前述したようにMonotonicな値でもちゃんと分散されるようになる。

hashed shard key の欠点

  • shard key fieldに対する範囲のクエリは単一のshardではなく複数shardに投げられることになる
  • データを地理的なグループに分離できない
  • 利用できるのは単一のField Shard Keyのみで、配列や複合インデックスはサポートしていないし、hash化されているのでsortも速くない

Chunks

Chunkはdocumentsの論理的なグループである config server が保持する最も重要な情報の1つはchunkとshardのマッピングである

chunkが作成されてからの流れは下記のような形

  • collectionをshardingした瞬間に、1つの初期chunkを定義する
    • この初期chunkは$minKeyから$maxKeyまでの範囲にある
  • 時間とともに初期chunkを複数のchunkに分割してshard間でデータが均等になるようにする

shard内のchunk数を決める要素はshard keyのcardinalityの他にchunk sizeがある デフォルトのChunk Sizeは64MB 設定によって1MB <= ChunkSize <= 1024MB の幅で変更することが可能 Chunk Sizeは稼働中に変更することが可能だが、新しいデータを入ってこないとmongosはアクションを起こさないのですぐにはchunk数に反映されない可能性がある

Jumbo chunkという概念がある 新しいdocumentの90%が同じshard keyを持っていたりすると、定義されたchunk sizeよりも大きくなる可能性が高い。そうなった場合は、Jumbo chunkとしてマークされる。Jumbo chunkは大きくて動かせないという判断をされる。 これを避けるためにも、Shard KeyのFrequencyの考慮は大事だ

Balancing

config server のReplicaSetのPrimaryで実行されているbalancer プロセスがshard間でchunkを移動させてデータを均等にしている

balancer は1ラウンドで複数のMigrationを並行に行うことができるが、1つのノードが複数のMigrationプロセスに一度に参加することは出来ない

balancerはデータの移動だけではなく、必要に応じてchunkの分割を行う

balancer は特にユーザの入力や指示を必要としないが、開始停止や設定の変更など行うこともできる

Targeted Queries vs Scatter Gather

Targeted Queryは全てのshardにクエリを飛ばさなくて済むクエリ

combound indexを利用したshard keyを設定することで、scatter gatherなクエリを避けることができる

# Shard Key
{ "sku": 1, "type": 1, "name": 1 }

# Targetable Queries
db.products.find( { "sku": … } )
db.products.find( { "sku": …, "type": … } )
db.products.find( { "sku": …, "type": …, "name": … } )
db.products.find( { "type": …, "name": …, "sku": … } )

# Scatter Gather 
db.products.find( "type": … )
db.products.find( "name": … )

db.products.find({"sku" : 1000000749 }).explain() のようにexplainでクエリがtargetedになっているかを確認できる

  • winningPlan.stage の値がSINGLE_SHARD になっているか
  • inputStage の値がIXSCAN (index scan)になっているか

Targeted Queryにはshard keyが必須になることが大事!

まとめ

ちゃんとモチベーションを維持しつつ進められるような仕組みになっていた。
次はM201のPerformanceをやりたいところだけど、Datadogも触りたいので一旦後回しにしちゃいそう。
この記事なんと2万字になっててビビる。