1クール続けるブログ

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

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万字になっててビビる。

Terraformの基礎を学んだ

記事一覧はこちら

Table of Contents

背景・モチベーション

8月から中途入社した会社ではInfrastructureの管理にTerraformを利用しています。業務において自分が利用したことがあるのは、AWSのCloudFormationとGCPのdeployment-managerのみでTerraformはプライベートな利用でとどまっていました。

雰囲気で利用している状況から抜け出すために、1つ1つの概念をちゃんと学習していくことにしました。幸い、HashiCorpのドキュメントはとても充実していたため公式のドキュメントをベースに学習を進めることが出来ました。

参考文献

learn.hashicorp.com

www.terraform.io

Terraform

TerraformはTerraform CoreTerraform Plugins で構成されている。
図は Perform CRUD Operations with Providers | Terraform - HashiCorp Learn から引用させていただきました。

https://learn.hashicorp.com/img/terraform/providers/core-plugins-api.png

  1. Terraform Core 設定を読み込んで、resource dependency graphを構築する
  2. Terraform Plugins (providers and provisioners) は Terraform Core とそれぞれのターゲットとなるAPIの橋渡しを行います

Terraformは設定をHCLというHuman ReadableなHashiCorpの言語で記述する。ただ、Machine ReadableなJSON Syntaxも提供している。
詳細なスペックはthe HCL native syntax specificationに定義してある。

Resources

Define Infrastructure with Terraform Resources | Terraform - HashiCorp Learn

resource blockはTerraform構成において1つ以上のinfrastructure objectを表す。resource blockではresource typeとnameを宣言する。
typeとnameは、resource_type.resource_name という形式のresource identifier(ID)を形成する。下記の例だと、aws_instance.web となる。resource IDは workspace内で一意でなければいけない。

resourceはargumentとattribute、meta-argumentを持つ。

  • Arguments
    • 特定のリソースの設定を行うための引数
    • 多くのArgumentsはリソース固有のもの
    • 必須なものとOptionalなものがあり、必須なArgumentsを指定しない場合にTerraformはエラーを返す
  • Attributes
    • 存在するResourceで公開される値
    • Resourceのattributeへの参照は、resource_type.resource_name.attribute_name という形式になる
    • 設定を指定するArgumentsと異なり、AttributesはクラウドプロバイダやAPIによってassignされる
  • Meta-arguments
    • Resourceの振る舞いを変更するもの
    • 例えば、count というMeta-Argumentsを利用することで複数のResourceを作成することが出来る(他にも、depends_on, for_each , provider , lifecycle がある)
    • Terraform自体の機能なので、ResourceやProviderに固有のものではない
resource "aws_instance" "web" {
  // 以下3つはArguments
  ami                    = "ami-a0cfeed8"
  instance_type          = "t2.micro"
  user_data              = file("init-script.sh")  // file() function
}

Input Variables

Customize Terraform Configuration with Variables | Terraform - HashiCorp Learn Input Variables - Configuration Language - Terraform by HashiCorp

📝 variables.tf っていうファイル作ってそこに集めるのが良くあるパターンぽい

Input variables は、end userが設定をカスタマイズするために値を定義できるようにすることでより、Terraformの設定をより柔軟にする

Declare variables

variablesはImmutableで、Terraformが動くときに変更はない
variable <変数名> { ... } 句で宣言し、3つのOptionalな引数をとる。 全部設定しておくことを推奨。

  • Description: A short description to document the purpose of the variable.
  • Type: The type of data contained in the variable.
  • Default: The default value.

Default値を設定しない場合には、Terraformが設定をapplyする前にassignする必要がある。variableの値はリテラル値でなくてはいけないので、式とかは🙅

Typeがサポートしているkeywordは、 string, number, boolの3種類。
type constructorsを利用することで collectionのような複雑な型も宣言できる: list(<TYPE>), set(<TYPE>), map(<TYPE>), object({<ATTR_NAME> = <TYPE>, …>}), tuple([<TYPE>, …])
any keywordは任意の値がacceptableであることを示す

variable "public_subnet_count" {
  description = "Number of public subnets."
  type        = number
  default     = 2
}

variable "public_subnet_cidr_blocks" {
  description = "Available cidr blocks for public subnets."
  type        = list(string)
  default     = [
    "10.0.1.0/24",
    "10.0.2.0/24",
    "10.0.3.0/24",
  ]
}

variable "resource_tags" {
  description = "Tags to set for all resources"
  type        = map(string)
  default     = {
    project     = "project-alpha",
    environment = "dev"
  }
}

module "vpc" {
    source  = "terraform-aws-modules/vpc/aws"
    version = "2.44.0"
    public_subnets  = slice(var.public_subnet_cidr_blocks, 0, var.public_subnet_count)
    tags = var.resource_tags
    // …
}

Assign Values

varialeにdefault値を持たせなかったときはterraform apply 時にpromptで聞かれる。ただし、promptはエラーを誘発しやすい。

Assgin Valuesの方法(くわしくはここ

  • 変数を定義したファイル (.tfvars)
    • Terraformはカレントディレクトリのterraform.tfvarsに完全一致するファイルもしくは*.auto.tfvarsに部分一致するファイルを 自動的に全て読み込む
    • 上記に一致しないファイル名でも、-var-file flagで渡すことができるが、ファイルの拡張子は .tfvars or .tfvars.jsonである必要がある
  • Command Line
    • terraformコマンドの-var optionで指定することが可能
    • example: terraform apply -var='image_id_list=["ami-abc123","ami-def456"]' -var="instance_type=t2.micro"
  • 環境変数
    • Terraformは自プロセスに保持する TF_VAR_ から始まる環境変数を読み込む、そのprefixの後には変数名が続く
    • example: export TF_VAR_image_id=ami-abc123
  • Terraform Cloudの場合はworkspace variables

Terraformは以下の順序で変数をロードし、後のソースが前のソースよりも優先される
環境変数の優先度が低いことに注意

  1. 環境変数
  2. terraform.tfvars
  3. terraform.tfvars.json
  4. Any *.auto.tfvars or *.auto.tfvars.json (ファイル名の辞書順)
  5. Any -var and -var-file options on the command line (渡された順番)

Reference Values

variableを参照するときは var.<variable_name>
文字列補間でも参照することができる 例: name = "web-sg-${var.resource_tags["project"]}-${var.resource_tags["environment”]}”

また、Variablesをvalidateすることも可能で、variable ブロックの中で、validation フィールドを持つことができる。 regexall() 関数は正規表現をとり文字列をテスト一致した文字列のリストを返すので、これを使ってcondition に一致すればOKで一致しなければ error_message が出力される

variable "resource_tags" {
  description = "Tags to set for all resources"
  type        = map(string)
  default     = {
    project     = "my-project",
    environment = "dev"
  }

  validation {
    condition     = length(var.resource_tags["project"]) <= 16 && length(regexall("[^a-zA-Z0-9-]", var.resource_tags["project"])) == 0
    error_message = "The project tag must be no more than 16 characters, and only contain letters, numbers, and hyphens."
  }
}

Output Values

Output Data from Terraform | Terraform - HashiCorp Learn Output Values - Configuration Language - Terraform by HashiCorp

📝 outputs.tf っていうファイル作ってそこに集めるのが良くあるパターンぽい

output はTerraform Moduleの戻り値とも言えるようなもので、用途としては主に下記の3つとなる。

  • Child ModuleがParent Moduleにリソース属性のサブセットを公開するため
  • Root Moduleでは、terraform applyを実行した後に特定の値を表示するため
  • remote stateを利用している場合、root moduleのoutputsはterraform_remote_state data source経由(後述)で他の設定からアクセスできる。

Terraformのstateにoutputはロードされ、terraform output commandでクエリすることができる

  • 特定のものをクエリするには、terraform output lb_url と名前を引数に渡してあげるとよい
  • -raw フラグを使うと、stringに対してdouble quoteが付かなくなる

output blockにはsensitive フィールドを付与できる

  • plan , apply , or destroy のときの表示は <sensitive> となり見れない
  • terraform.tfstate ファイルには平文で入っているし、terraform output ではredactされないことに注意すること

terraform output -json のようにoptionを付けることで、json形式で出力できる。machine-readable format for automationなのでtoolから使うとき便利だよね

output "db_password" {
  value       = aws_db_instance.db.password
  description = "The password for logging in to the database."
  sensitive   = true
}

output "instance_ip_addr" {
  value       = aws_instance.server.private_ip
  description = "The private IP address of the main server instance."

  depends_on = [
    // このIPアドレスが実際に使用される前に、セキュリティグループのルールが作成されていなければ、サービスに到達できない
    aws_security_group_rule.local_access,
  ]
}

Locals

Simplify Terraform Configuration with Locals | Terraform - HashiCorp Learn

Terraformのlocals は設定の中で参照できる名前付きの値です。local valueを利用することで、繰り返しを避けTerraformの設定をsimpleに保つことが出来る。また、値をhard-codingするのではなく、意味のある名前付けを使うことで、より読みやすく出来る。

locals {
  name_suffix = "${var.resource_tags["project"]}-${var.resource_tags["environment"]}"
}

Dependency Lock File

Lock and Upgrade Provider Versions | Terraform - HashiCorp Learn Dependency Lock File (.terraform.lock.hcl) - Configuration Language - Terraform by HashiCorp

📝 versions.tf っていうファイル作ってそこに集めるのが良くあるパターンぽい

Terraformプロバイダは、TerraformとターゲットAPIの間で通信を行い、リソースを管理する。ターゲットAPIが変更されたり、機能が追加されたりすると、プロバイダーのメンテナはプロバイダーを更新してバージョンを上げる。
設定でプロバイダのバージョンを管理するには、以下の2つを行う。

  1. Providerのバージョン指定/制限をterraform blockで行う
  2. dependency lock fileを利用する

terraform init すると、Terraformはcurrent directoryに.terraform.lock.hcl ファイルを作成する。Terraformが、across your team and in ephemeral remote execution environmentsで同じプロバイダバージョンを使用するためにVCSに含める必要がある。

terraform init -upgrade で、すべてのプロバイダーを、設定であらかじめ設定されたバージョン制約内で一貫した最新バージョンにアップグレードします。

lockファイルで確認できるのは、実際に利用しているバージョンと、この選択を行うときの制約(constraint)。また、チェックサム検証を行うので、hashes というフィールドでハッシュを保持する。zh: はzip hashを意味する、これはレガシー。h1: は現在推奨されているhash scheme

ここで、providerのversion constraintsにも触れておく。詳細はここにある。

A module intended to be used as the root of a configuration, 互換性のない新バージョンへの誤ったアップグレードを避けるために、動作することを意図したプロバイダの最大バージョンも指定する必要がある。演算子 ~> は、特定のマイナー・リリース内のパッチ・リリースのみを許可するための便利な略記法です = 📝パッチバージョンしか上がらない?

terraform {
  required_providers {
    mycloud = {
      source  = "hashicorp/aws"
      version = "~> 1.0.4"
    }
  }
}

多くの設定で再利用しようとするモジュールの場合! たとえそのモジュールが特定の新しいバージョンと互換性がないことがわかっていても、~> (または他の最大バージョン制約)を使用しないでください。そうすることでエラーを防ぐことができる場合もありますが、多くの場合、モジュールのユーザーが定期的なアップグレードを行う際に多くのモジュールを同時に更新することを余儀なくされます。最小バージョンを指定し、既知の非互換性を文書化し、最大バージョンをルートモジュールに管理させます

Module

Modules Overview | Terraform - HashiCorp Learn

TerraformでInfrastructureを管理していくと、どんどん複雑な設定になる。 設定ファイルの理解や操作が難しくなり、類似した設定のブロックが増えて重複が生まれる。 プロジェクトやチーム間で構成の一部を共有したいと思っても容易ではなく、切り貼りするだけではメンテナンス困難に。

What are modules for?

前述の問題をModuleは解決してくれる

  • Organize configuration
    • 設定の関連する部分をまとめておくことで、設定の理解や更新を容易にする
  • Encapsulate configuration
    • カプセル化することが出来るため、設定の一部の変更をしたときに意図しない変更を防ぐことが出来る
  • Re-use configuration
    • 自分自身、他のメンバー、moduleを公開している Terraform practitionersによって書かれた設定を再利用できる
  • Provide consistency and ensure best practices
    • 設定に一貫性を持たせることで、全ての構成にベストプラクティスが適用されることを保証できる

What is a Terraform module?

Terraform Moduleは、1つのディレクトリにあるTerraform設定ファイルのSetです。 1つのディレクトリに1つ以上の.tf ファイルを配置しただけのシンプルな設定でもModuleになる。このようなディレクトリから直接Terraformコマンドを実行した場合には、それはroot moduleと見なされる。

Calling Modules

設定ではmodule blockを使って他のディレクトリにあるmoduleを呼び出すことができる。Terraformはmodule blockに遭遇すると、そのmoduleの設定ファイルをloadして処理する。

他の設定から呼び出されたmoduleは、その設定のchild moduleと呼ばれることがある。

moduleはローカルのファイルシステム、リモートソースのどちらからも読み込むことができる。 TerraformはTerraform Registry, GitHubなどの様々なリモートソースをサポートしている。

Build and Use a Local Module

GitHub - hashicorp/learn-terraform-modules-create

静的ウェブサイトホスティングのためのAWS S3バケットを管理する例

典型的なchild moduleの構成

.
├── LICENSE
├── README.md
├── main.tf
├── variables.tf
├── outputs.tf
  • LICENSE はTerraformは利用せず公開するときに必要になるもの
  • README.md はTerraformでは利用しないが、Terraform Registryなどで使用される
  • main.tf はModuleの構成のmain setが含まれる
  • variable.tf で定義した変数は、Module利用者側からはmodule blockのArgumentsとして設定される
  • outputs.tf では、Moduleの出力を定義する。moduleで定義されたInfrastructureに関する情報を他の設定に渡すために利用される。

ローカルのModuleをInstallする場合には、terraform get コマンドを発行する

State

State - Terraform by HashiCorp

Purpose

Stateの目的は大きく4つ

  • Mapping to the Real World
  • Metadata
  • Performance
  • (Syncing)

Mapping to the Real World

State is a necessary requirement for Terraform to function. Terraformは設定をreal worldにmapするために、なんらかのデータベースを必要とする。 例えば、"aws_instance" "foo" という宣言がある場合、Terraformはmapを利用してインスタンス i-abcd1234 がそのリソースで表現されていることを知ることができる。

AWSのようなプロバイダの場合、Terraformは理論的にはAWSタグのようなものを使うことができる。Terraformの初期のプロトタイプでは、実際にステートファイルがなく、この方法を使っていた。 しかし、すぐに問題が発生。全てのproviderがタグをサポートしているわけではなかったのだ。

Terraform expects that each remote object is bound to only one resource instance, which is normally guaranteed by Terraform being responsible for creating the objects and recording their identities in the state.

Metadata

Alongside the mappings between resources and remote objects, Terraform must also track metadata such as resource dependencies.

Terraformは通常、依存関係の順序を決定するためにConfigurationを使用します。 しかし、TerraformのConfigurationからリソースを削除する場合、Terraformはそのリソースを削除する方法を知っている必要があります

Terraformは、設定にないリソースにマッピングが存在することを確認し、plan to destoryすることができます しかし、リソースのconfigurationが存在しなくなったため、configurationだけでは依存関係の順序を判断することができません

正しい動作を保証するために、Terraformは最新の依存関係のセットのコピーをstate内に保持します。これでTerraformは、configurationから1つまたは複数のアイテムを削除しても、ステートから正しい破壊順序を決定することができます。

Performance

Terraform stores a cache of the attribute values for all resources in the state. 📝 例えば、EC2におけるPrivateIPとかもそう

When running a terraform plan, Terraformは希望の構成に到達するために必要な変更を効果的に判断するために、リソースの現在の状態を知る必要がある

小規模のインフラの場合、Terraformはプロバイダーに問い合わせて、すべてのリソースから最新の属性を同期することができます。これはTerraformのデフォルトの動作で、planとapplyのたびに、Terraformは状態のすべてのリソースを同期します。

大規模なインフラでは、すべてのリソースへの問い合わせは時間がかかりすぎます。APIのレート制限が掛かる可能性も高い。 Terraformの大規模なユーザーは、この問題を回避するために-refresh=falseフラグと-targetフラグを多用する。これらのシナリオでは、キャッシュされた状態がrecord of truthとして扱われます。

The terraform_remote_state Data Source

The terraform_remote_state Data Source - Terraform by HashiCorp

The terraform_remote_state data source retrieves the root module output values from some other Terraform configuration, using the latest state snapshot from the remote backend.

データを共有する上で、root moduleのoutputは便利!だけど欠点もある。ユーザはstate snapshot全体にアクセスする必要があるが、その中にはSensitiveな情報も含まれることがある。 可能であれば、remote state経由ではなく、別の場所に明示的に公開することを推奨!

構成間で明示的にデータを共有するためには、以下のような様々なプロバイダーのmanaged resource typeとdata sourcesのペアを使用することができる

terraform_remote_stateではなく、別の明示的なconfiguration storeを使用することの主な利点は、コンピュートインスタンス内の設定管理やスケジューラーシステムなど、Terraform以外のシステムでもデータを読み取ることができる可能性があることです

Backend: State Storage and Locking

Backends: State Storage and Locking - Terraform by HashiCorp Backend Overview - Configuration Language - Terraform by HashiCorp

Backends are responsible for storing state and providing an API for state locking. State locking is optional.

State Storage

Backends determine where state is stored. 例えば、 local(default) backendは、ディスク上のローカル JSON ファイルに状態を保存します

When using a non-local backend, Terraform will not persist the state anywhere on disk except in the case of a non-recoverable error where writing the state to the backend failed.

sensitive valueがstateに含まれている場合、remote backendを使用することで、そのstateがディスクに永続化されることなくTerraformを使用することができる。

バックエンドへの状態の永続化にエラーが発生した場合、Terraformはstateをlocalに書き込みます。これはデータ損失を防ぐためです。この場合、エラーが解決したらエンドユーザーが手動でリモートバックエンドに状態をプッシュする必要がある。

Manual State Pull/Push

You can still manually retrieve the state from the remote state using the terraform state pull command. This will load your remote state and output it to stdout.

You can also manually write state with terraform state push. これは非常に危険なので、可能であれば避けてください。これはリモートの状態を上書きします。

Backend Types

Backend Overview - Configuration Language - Terraform by HashiCorp

Terraform's backends are divided into two main types, according to how they handle state and operations:

  • Enhanced backends can both store state and perform operations. There are only two enhanced backends: local and remote.
    • local : The local backend stores state on the local filesystem, locks that state using system APIs, and performs operations locally.
    • remote : The remote backend stores Terraform state and may be used to run operations in Terraform Cloud.
  • Standard backends only store state, and rely on the local backend for performing operations.
    • s3 (with locking via DynamoDB): Stores the state as a given key in a given bucket on Amazon S3. This backend also supports state locking and consistency checking via Dynamo DB.
    • 他にも、gcs とかpg (postgres)とかある

Backends Configuration

Backend Configuration - Configuration Language - Terraform by HashiCorp

Backends are configured with a nested backend block within the top-level terraform block:

terraform {
  backend "remote" {
    organization = "example_corp"

    workspaces {
      name = "my-app-prod"
    }
  }
}

State Locking

State: Locking - Terraform by HashiCorp

If supported by your backend, Terraform will lock your state for all operations that could write state. This prevents others from acquiring the lock and potentially corrupting your state.

Data Sources

Query Data Sources | Terraform - HashiCorp Learn

Terraformはdata sourcesを利用して、disk image IDなどcloud provider APIからの情報や、他のTerraformワークスペースのoutputsを通して残りの環境情報を取得する

下記の例のaws_availability_zones data sourceは、AWS providerの一部。resources blockと同じように、data source blocksも引数を取る。この場合、state Argumentsは現在利用可能なものに限定する。

data sourcesのattributeを参照する場合には、 data.<NAME>.<ATTRIBUTE> という形式をとる。

data "aws_availability_zones" "available" {
  state = "available"
}

resource "aws_subnet" "primary" {
  availability_zone = data.aws_availability_zones.available.names[0]

  // ...
}

他のTerraformワークスペースのoutputを参照するためには、terraform_remote_state を使うことが出来る。

data "terraform_remote_state" "vpc" {
  backend = "remote"

  config = {
    organization = "hashicorp"
    name = "vpc-prod"
  }
}

provider "aws" {
  region = data.terraform_remote_state.vpc.outputs.aws_region
}

Resource Targeting

Target resources | Terraform - HashiCorp Learn

Command: plan - Terraform by HashiCorp

通常のTerraformワークフローでは、plan全体を一度に適用します。ネットワーク障害、upstream cloud platformの問題、Terraformまたはそのproviderのバグが原因で、Terraformの状態がリソースと同期しなくなった場合など、planの一部のみを適用したい場合があります。

You can use the -target option to focus Terraform's attention on only a subset of resources.

depends_on Meta-Arguments

Create Resource Dependencies | Terraform - HashiCorp Learn

ほとんどの場合、Terraformは与えられた設定を元に依存関係を推論し、正しい順序で作成・削除されるようにする。しかし、場合によってTerraformが依存関係を推論できないことがあるので、depends_on 引数で明示的な依存関係を作る必要がある。

aws_eip resourceは、EC2インスタンスにElastic IPを割り当てて関連付けするもの。Elastic IPが作成される前にEC2インスタンスが存在する必要がある。 以下は暗黙的な依存関係の例である。

resource "aws_instance" "example_a" {
  ami           = data.aws_ami.amazon_linux.id
  instance_type = "t2.micro"
}

resource "aws_eip" "ip" {
    vpc = true
    instance = aws_instance.example_a.id // ここの参照から依存グラフをTerraformで作成する
}

明示的に依存関係を宣言しなくてはいけないケースもある。 EC2インスタンス上で、S3バケットの使用を期待するアプリケーションが実行されているとする。この依存関係はアプリケーションがもたらすものなので、Terraformからは見えない。

depends_on Argumentsは任意のresourcesもしくはmodule blockで受け入れられる。これを使って明示的な依存関係を構築できる。

resource "aws_s3_bucket" "example" {
  acl    = "private"
}

resource "aws_instance" "example_c" {
  ami           = data.aws_ami.amazon_linux.id
  instance_type = "t2.micro"

  depends_on = [aws_s3_bucket.example]
}

module "example_sqs_queue" {
  source  = "terraform-aws-modules/sqs/aws"
  version = "2.1.0"

  depends_on = [aws_s3_bucket.example, aws_instance.example_c]
}

Other

  • 似たようなresourceを、countfor_eachで管理することでMaintenabilityを高める
  • Functions を使って動的な設定を作成したり
    • templatefile() 関数を使ってUserdataを動的なものにしたり
    • lookup() を使ってmap[region]ami のhashmapからamiを探し出したり
  • 3項演算子を利用して動的な設定を構成したり(Expression

まとめ

Remote StateやLockファイル、dataのクエリ、Moduleなどプライベートな利用では中々踏み込まないところを公式のドキュメントを通して認識できた。
これで多少は既存のコードとかがちゃんと読めるようになっていると良いな!次はMongoDBについて勉強しよう。