1クール続けるブログ

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

docker buildxがマルチアーキテクチャでビルドできる仕組みをさらっと確認してみた

記事一覧はこちら

背景/モチベーション

おれはBitbucket PipelineでARMコンテナをビルドしたいんだ! AWSのGravitonインスタンスを使っている会社さん多いのではないでしょうか。スポットインスタンスの価格が落ち着くまで結構かかったので、供給より需要の方が大きかったのではと睨んでいます。

必然的にARMアーキテクチャのコンテナビルドの必要性も高まります。 ただ、調べた限りだとBitbucket Pipelineにはその手立てがなさそうです。

jira.atlassian.com

dockerのexpermentalな機能を使えないという点があり、buildxも使えない状態だったので、諦めていたのですが、dockerのv20.10 からexperimentalが外れるということで、もしかしたらARMアーキテクチャのコンテナのビルドが出来るのかもと思い調べてみた次第です。 (正直、特権コンテナを動かせない時点で詰んでるなあとは思っているんですが…)

buildxとは

下記のようなコマンドでARMアーキテクチャをターゲットにビルドできます。
まず、ここで利用されている buildx コマンドについて明らかにしていきます。

$ docker buildx build --platform linux/amd64,linux/arm64 .

概要

github.com

buildx はBuildkitを用いてビルド機能を拡張するためのDocker CLIプラグインです。 Docker19.03から含まれるようになり、v20.10からexpermentalが外れています。 機能としては主に下記のものがあります。

buildx はドライバのコンセプトによって違う設定で実行できるようになっています。 現在は、Dockerデーモンにバンドルされている docker ドライバとDockerコンテナ内でBuildkitを自動的に起動する docker-container ドライバをサポートしているそう。

DockerデーモンにバンドルされているBuildkitライブラリが異なるストレージコンポーネントを利用しているようなので、docker ドライバではサポートされていない機能があります

ビルダーインスタンスとは

Dockerコンテナのビルドを行う環境のこと。 デフォルトでは、ローカルの共有デーモンを利用する docker ドライバを利用します。

buildxでは、isolatedなビルダーインスタンスを作成することができます。 CIでの利用に適したスコープの限られた環境や異なるブロジェクト用にビルドを分離した環境として使用できる。リモートノードを使用することも可能。

docker buildx create コマンドで作成できる。リモートノードを作成するときには、 DOCKER_HOST もしくはリモートコンテキスト名を指定する。 ビルダーの切り替えは、 docker buildx use <name> を使う( kubetcl config use-context みたい )。 docker context のサブコマンドを利用して、リモートのDocker APIエンドポイントを管理できます(参考

厳密には違うかもしれませんが、おおよそ関係性としては下記のような感じになります。

f:id:jrywm121:20210224004538p:plain

マルチプラットフォームビルド

Buildkitは複数のアーキテクチャをターゲットにしたビルドが機能します。 --platform フラグを使用して、ビルド対象のプラットフォームを指定できます。指定された、全てのアーキテクチャのイメージを含むマニフェストリストが作成されます。 QEMUエミュレーションを利用して異なるアーキテクチャにビルドする方法に関しては、次節で見ていきたいと思います。

例えば、 linux/amd64linux/arm64アーキテクチャのネイティブノードを用意してビルドするのであれば、下記のようなコマンドを発行すれば良いはずです(試せてない)。

# それぞれのContextを作成する
$ docker context create node-amd64 \
  --default-stack-orchestrator=swarm \
  --docker host="host=ssh://<username>@amd64server"
$ docker context create node-arm64 \
  --default-stack-orchestrator=swarm \
  --docker host="host=ssh://<username>@arm64server"
# mybuild というビルダーインスタンスを作成
$ docker buildx create --use --name mybuild node-amd64
mybuild
$ docker buildx create --append --name mybuild node-arm64
$ docker buildx build --platform linux/amd64,linux/arm64 .

QEMU で異なるアーキテクチャのコンテナイメージをビルドする

QEMUエミュレーションをbuildxから簡単に利用できますが、どのような形になっているのかは確認しておきたいと思います。

QEMUとは

ArchLinuxのWikiQEMUの公式ページ を参考にしました。

QEMU(きゅーえみゅ)はOSSのマシンエミュレータ、バーチャライザーです。マシンエミュレーターとして使用すると別のアーキテクチャ用にビルドされたOSまたはアプリケーションを動かすことができます。

QEMUのエミュレーションモードは、フルシステムとユーザースペースの2つに分けられます。 フルシステムは周辺機器を含めて1つ以上のプロセッサをエミュレートします。ターゲットのCPUアーキテクチャがホストと一致している場合に、KVMなどのハイパーバイザーを使用することで高速化できるそう。 Dockerから利用されているのはユーザースペースの方です。ホストシステムのリソースを利用して異なるCPUアーキテクチャ用にビルドされたLinux実行ファイルを呼び出せます。

ユーザースペースエミュレーションの特徴としては下記です。

  • System call translation
  • POSIX signal handling
    • ホストから来る全ての信号を動いているプログラムにリダイレクト
  • Threading
    • cloneシステムコールをエミュレートして、Hostのスレッドを作成し、エミュレートされた各スレッドに割り当てます

バイナリの名称は、 qemu-${taget-architecture} という形式になります。例えば、Intel64ビットCPUであれば、 qemu-x86_64 となります。

dockerからQEMUが呼ばれる仕組み

docker公式ブログを参考にしています。

QEMU統合は binfmt_misc handler に依存しています。Linuxが認識できない実行ファイル形式に遭遇したときに、その形式を処理するように構成されたユーザスペースアプリケーション(エミュレータ等)があるかをハンドラーで確認します。もし存在する場合には、その実行ファイルをハンドラーに渡します(参考)。

# 例えばJavaの場合…
# 通常
$ java -jar /path/to/MyProgram.jar
# binfmt_misc を利用する
$ MyProgram.jar

# 例えばQEMUの場合…
# 通常
$ qemu-arm armProgram
# binfmt_misc を利用する
$ armProgram

関心のあるプラットフォームのをカーネルに登録しておく必要があります。 Docker Desktopを利用している場合には、既に主要なプラットフォームは登録済になっています。もしLinuxを利用する場合に、Docker Desktopと同じ方法で登録したい場合には、 linuxkit/binfmt イメージを動かせばよさそう(ドキュメントには docker/binfmt とあったけどGitHub行ったらArchiveされていたため)。

つまりマルチプラットフォームのビルドに必要になる要素は下記

  • 関心のあるプラットフォームのQEMU
  • binfmt_misc で実行ファイルとQEMUを紐付ける

ちなみに、binfmt_misc についてはこちらで詳細を確認しました。 /proc/sys/fs/binfmt_misc/register に対して、特定の形式で書き込むことによって対応づけが出来るようです。また、 grep binfmt /proc/mounts を実行して何も引っかからない場合には、まず、 mount binfmt_misc -t binfmt_misc /proc/sys/fs/binfmt_misc のようにマウントする必要性があるようです。

書き込む形式は下記のようになります。

# :<name>:<type>:<offset>:<magic>:<mask>:<interpreter>:<flags>
:qemu-arm:M:0:\x7f\x45\x4c\x46\x01\x01\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x02\x00\x28\x00:\xff\xff\xff\xff\xff\xff\xff\x00\xff\xff\xff\xff\xff\xff\xff\xff\xfe\xff\xff\xff:/usr/bin/qemu-arm:CF
:Java:M::\xca\xfe\xba\xbe::/usr/local/bin/javawrapper:

それぞれのフィールドについて軽くまとめてみました

  • name
    • 識別子
    • /proc/sys/fs/binfmt_misc の下に新しく追加されるときのファイル名。
  • type
    • MはmagicでEはextentionを意味する
  • offset
    • ファイル内のmagic/maskのオフセット
    • byte単位でカウントされる
    • 省略すると、デフォルトは0で入る
  • magic
    • binfmt_misc が見つけにいくバイト列
  • mask
    • 登録したmagicとファイルを照合する際に、無視したいビット列をマスクすることが出来ます
  • interpreter
    • どのプログラムを呼び出すか
# 登録内容を確認する例
$ cat /proc/sys/fs/binfmt_misc/jar
enabled
interpreter /usr/bin/jexec
flags: 
offset 0
magic 504b0304

果たしてbitbucket pipelineでマルチプラットフォームビルドできる?

冒頭で書きましたとおり、モチベーションはここにあります。 bitbucket pipelineのノードのDockerのバージョンがv20.10になって、buildxがexperimentalから外れて実行できるようになったとしても下記のような課題があるため難しそうです。 冒頭で述べたとおり、特権コンテナが動かせないので…。

  • ノードにQEMUがインストールされているか?
    • もしされていない場合、ホスト側に実行ファイルを置かなくてはいけなさそうなのでキツい
    • ここ見る限りdocker runの --mount オプションが禁止されている
  • binfmt_misc に登録されているか?
    • もしされていなければ登録する必要があるが、ホスト側のファイルに書き込む必要があるので出来るかどうか?

逆にAMIの段階でこの辺が解消していれば、動かせそうな予感…! とりあえず、bitbucket pipelineのDockerバージョンが上がるのを一旦待とうと思います。

以上!

external-dnsの仕組みを少し覗いてみる

記事一覧はこちら

背景・モチベーション

EKS(or GKE)を運用するときにお世話になる external-dns をふんわりとだけ理解て利用してきたのですが、どのようにDNSレコードを操作しているのかをちゃんと理解したいと思ったのがきっかけです。

使い始めた当初はなかった、Route53のルーティングポリシーのサポートなどが加わったりと順調に進化しているように見える、このプロダクトを少し紐解いてみようと思います。

external-dnsとは

github.com

KubernetesのリソースをPublicなDNSサーバを利用して公開してくれます。 kubednsのように、公開されたServiceやIngressKubernetes APIを利用して列挙し、desiredなDNSレコードのリストを作成し、DNSプロバイダと同期します。

DNS プロバイダによって安定レベルが異なります。レベルはStable, Beta, Alphaに分けられますが、Stableになっているのは、 Google Cloud DNSAWS Route 53 のみです(参考)。

EKSでの導入方法

Route53の手順はこちらに記載されている。

下記のようにIAM Policyの作成して、EKSでOIDCプロバイダの機能を利用してk8sのサービスアカウント単位で権限を付与します。 IAM Policy はサンプルのままだと権限が強すぎるので、下記のようにホストゾーンを指定して権限を付与してあげるのが良さそう。

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "route53:ChangeResourceRecordSets"
      ],
      "Resource": [
        "arn:aws:route53:::hostedzone/ABCD1234EFG567",
        "arn:aws:route53:::hostedzone/1234ABCD567EFG",
      ]
    },
    {
      "Effect": "Allow",
      "Action": [
        "route53:ListHostedZones",
        "route53:ListResourceRecordSets"
      ],
      "Resource": [
        "*"
      ]
    }
  ]
}

サービスアカウント external-dns に権限が付与できたら、マニフェストをデプロイする。チュートリアルで明示されている引数は下記です。 コマンド引数の説明を見るときに一番簡単なのは、 docker run k8s.gcr.io/external-dns/external-dns:v0.7.6 --help です。 コマンドオプションはproviderごとにもあるので、数がかなり多いのですが、AWS適用例で指定している引数に関しては記載してみました。

Required

  • --source : エンドポイントを提供するリソース名(service, ingress, node, istio-gateway等から1つ以上選択)
  • --provider : DNSレコードが作成されるDNSプロバイダ(aws, google, azure等から1つ選択)

Optional

  • --policy : sourceとprovider間を同期させる方針、デフォルトは syncsync はレコードの削除も行うので万全を期すなら、 upsert-onlycreate-only を選択する。
  • --domain-filter : ドメインのsuffixで制限をかける
  • --aws-zone-type : ゾーンのタイプで制限をかける(public, privateから選択)
  • --registry : DNSレコードの所有権を追跡する利用する実装、デフォルトではtxtレコードに情報を書き込む(txt, noop, aws-sdから選択)
  • --txt-owner-id : txt regisryを利用しているときにこのexternal-dnsからの更新であることを認識させるためのID、デフォルトでは default という文字列になる
 # マニフェストから一部を抜粋
    containers:
      - name: external-dns
        image: k8s.gcr.io/external-dns/external-dns:v0.7.6
        args:
        - --source=service
        - --source=ingress
        - --domain-filter=external-dns-test.my-org.com # will make ExternalDNS see only the hosted zones matching provided domain, omit to process all available hosted zones
        - --provider=aws
        - --policy=upsert-only # would prevent ExternalDNS from deleting any records, omit to enable full synchronization
        - --aws-zone-type=public # only look at public hosted zones (valid values are public, private or no value for both)
        - --registry=txt
        - --txt-owner-id=my-hostedzone-identifier

あとは、ServiceリソースやIngressリソースをexternal-dnsから認識させてあげればOKです。Ingressリソースは、.spec.rule.hosts で宣言しておけば認識されます。また、 external-dns.alpha.kubernetes.io/hostnameアノテーションを利用することで、ServiceとIngressどちらでも紐づけることが可能です。

アノテーションにはいくつか種類があり、例えばRoute53のRouting Policyが使えたり、TTLを指定出来たりします。

apiVersion: v1
kind: Ingress
metadata:
  name: nginx
  annotations:
    external-dns.alpha.kubernetes.io/hostname: nginx.external-dns-test.my-org.com
    external-dns.alpha.kubernetes.io/ttl: 60
    # set-identifierはルーティングポリシーを利用する上で必須の設定項目
    external-dns.alpha.kubernetes.io/set-identifier: default
    external-dns.alpha.kubernetes.io/aws-weight: "30"
spec: {} # 省略

Route53を更新するまでの処理を追う

エントリポイント

external-dnsは起動後に設定を読み込んだ後は、設定したInterval(デフォルト1分)に従い、処理を定期実行しています(該当コード)。 Tickerを使って、処理時間を除いて1秒毎に時間のチェックをしているのは、自前で定期処理を作成として参考になるかも(処理を含めて定期処理であればcronのライブラリで良いと思うけど)。

// Run runs RunOnce in a loop with a delay until context is canceled
func (c *Controller) Run(ctx context.Context) {
    ticker := time.NewTicker(time.Second)
    defer ticker.Stop()
    for {
        if c.ShouldRunOnce(time.Now()) {
            if err := c.RunOnce(ctx); err != nil {
                log.Error(err)
            }
        }
        select {
        case <-ticker.C: // ループが最短でも1秒ごとの処理になるように
        case <-ctx.Done():
            log.Info("Terminating main controller loop")
            return
        }
    }
}

定期的に行っている更新処理の流れ

この中で行っている、 RunOnce(ctx context.Context) がメインの処理となります(該当コード)。 流れを箇条書きにすると下記のようになります。

  • Route53に存在するDNSレコードを列挙します
    • まずHostedZoneを列挙します(該当コード
      • すべてのHostedZoneを取得します
        • クエリ結果はキャッシュされます
        • キャッシュの生存期間を過ぎていなければキャッシュから返します
      • 起動引数に設定した、ZoneやDomainのフィルタを使って絞り込みます
    • ZoneごとにDNSレコードの情報を取得します(該当コード
      • Endpoint というドメインオブジェクトに詰め替えます
      • ルーティングポリシーがあれば、 Endpoint.ProviderSpecific というフィールドに突っ込みます
      • (Go SDKのRoute53の実装って引数に関数渡すケースが多いんだ…知らなかった。アウトプットは変数をキャプチャしてそこに詰める感じになってる)
  • k8sのsource(例えば、serviceやingressリソース)からエンドポイントを列挙します
  • 1つ前の処理で作成した望んだ状態に、実際の状態を同期させるために、必要なアクションを計算します
    • keyが文字列の2次元HashMapを作成します、1つ目のKeyがDNS名で2つ目のkeyが SetIdentifier となっています。Valueは現在の状態と望んだ状態を持つ planTableRow構造体です。
    • Mapにそれぞれ currentState と desiredStateを書き込んでいきます。
    • もし、desiredStateとして挙げられたDNS名が既に存在している場合には、そのコンフリクトを解決する
    • 起動引数に設定したPolicy( upsert 等)に従い、アクションをフィルタする
  • 計算したアクションを実行する

まとめ

k8sの変更監視とGoのinterfaceの使い方がかなり勉強になりました。 k8sの周辺ツールは各クラウドプロバイダによる処理の違いを、interfaceとその実装で持たせることが多くて、コード読んでて面白いです。

DNSを扱うというデリケートな処理をどのように実装しているかを俯瞰することができてよかった!以上!

sealed-secrets 学んだ

記事一覧はこちら

背景・モチベーション

ArgoCD導入時に、リポジトリk8sクラスタの認証情報といったセンシティブな情報をk8sのSecretとして保存する必要が出てきました。さらにそれらをGitOpsで管理しなければなりません。 SecretをマニフェストとしてGitリポジトリに平文のまま置いておくのはリスクです。 セキュリティを担保するための方法として、いくつか方法があるなかで sealed-sectets を学ぼうと思いました。

Kubernetes secretsの管理方法

weave worksのこちらの記事を参考にしています

Built-in の secrets

kubectlを使って値を取り出すことは難しくはありません。Base64エンコードされているだけのためデコードすれば内容は見れてしまいます。 Secretsはk8sクラスタのetcdサーバに平文で保存されます。etcdがTLS通信使うように設定されていない場合は、etcdクラスタが同期しているときに通信内容が見えてしまいます。 また、nodeへのrootアクセスができる人は、kubeletになりすますことで全てのsecretsを読み取ることが出来ます。

そのため、セキュリティ要件がゆるゆるな場合を除いて、3rd Partyを利用した方が良いとのことです。

クラウドベンダが提供するマネージドストア

既にクラウドベンダの提供するSecretsのマネージドサービスを利用している場合やすぐに運用を開始したい場合で、かつベンダーロックインが気にならない場合には良い選択肢になります。

  • AWS Secrets Manager
  • Cloud KMS
  • Azure Key Vault

OSSのシークレットマネージャ

物理サーバでの運用を行っている場合または、クラウドベンダによるロックインを避けたい場合にはこの選択肢を取ることになる。

  • Vault
  • Sealed Secrets

Vaultは高機能で多くのk8sユーザが利用しているようです。 Vaultや上記のクラウドのソリューションは、シークレットデータのための second source of truth を導入する形となります。これはGitの外で管理されており、完全に追跡されない別の潜在的クラスタへの変更や障害のソースです(機械翻訳みたいな文章になってしまった)。これはトラブルシューティングを複雑にしレビュープロセスにバックドアを作ります。

Sealed Secretsは、この問題に取り組むために設計されたものだそうです。 Kubernetesオーケストレーションの利点である、「設定が宣言的な json または yaml ファイルのセットに基づいており、バージョン管理に簡単に保存できること = Gitリポジトリsingle source of truth 」に則るように出来ています。

GitOpsで運用する際には、Sealed Secretsは強い選択肢になりそうです。

Sealed Secrets

概要

github.com

Sealed Secretsは下記の2つのコンポーネントから構成されます

  • クラスタで動くController / Operator
  • クライアントで動かすツール kubeseal

kubeseal は 非対称鍵暗号方式 でシークレットを暗号化します。これはControllerのみ復号することができます。暗号化されたシークレットは SealedSecrets の中でエンコードされます。 暗号化された安全な認証情報をGitの設定ファイルに直接格納し共有することができますが、それらのユーザは認証情報にアクセスすることができません。セキュアなGitOpsワークフローに適する方法です。

導入

kustomizeやHelmといった導入方法がありますが、今回は下記の手段で導入していこうと思います。

  • クラスタサイド → kustomize(yaml
  • クライアントサイド → wget(バイナリをダウンロード)

環境としては、Katacodaのk8s Playgroundを利用します

$ VERSION=v0.14.1

# クライアントサイド
$ wget https://github.com/bitnami-labs/sealed-secrets/releases/download/${VERSION}/kubeseal-linux-amd64 -O kubeseal
$ sudo install -m 755 kubeseal /usr/local/bin/kubeseal
$ kubeseal --version
kubeseal version: v0.14.1

# クラスタサイド
$ curl -s "https://raw.githubusercontent.com/\
kubernetes-sigs/kustomize/master/hack/install_kustomize.sh"  | bash
$ mv /root/kustomize /usr/local/bin/kustomize
$ mkdir kustomize && cd kustomize
$ wget https://github.com/bitnami-labs/sealed-secrets/releases/download/${VERSION}/controller.yaml
$ kustomize create --resources controller.yaml # 本来なら更にoverrideするファイルなども作成する
$ kustomize build | kubectl apply --server-side -f -

# 作成
# Secretリソースのファイル作成
$ echo -n hogehoge | kubectl create secret generic repo-password --dry-run=client --from-file=password=/dev/stdin -o json >repo-password-secret.json
# SecretリソースからSealedSecret作成
$ kubeseal <repo-password-secret.json >repo-password-sealedsecret.json
$ kubectl apply -f repo-password-sealedsecret.json
$ kubectl get secret # Secretが作成されていることを確認

GitOpsにのせるときには、SealedSecretをファイルとして作成して、Gitリポジトリで管理することになります。

kubeseal はControllerから公開鍵を取得してデータを暗号化し、Controller側で復号してSecretを作成します。暗号化の処理に関しては、将来、KMSのようなクラウドの暗号化ソリューションにオフロードできるようになることが示唆されています。

既存のSecretをSealedSecretに移行したい場合には、既存のSecretに http://sealedsecrets.bitnami.com/managed: "true" というアノテーションが必要です。

実運用に乗っけるときには、鍵のローテーションにも注意が必要です。 デフォルトは30日ごとに更新されます。ただ、ローテーションされた後も、古い公開鍵で暗号化したものは古い秘密鍵によって復号されますので再暗号化を行なわくてはいけません。 秘密鍵が外部に漏れてしまったケースにおいては、暗号化対象の機密情報自体も更新しないと攻撃されてしまいます。その際、SealedSecretも合わせて鍵の更新を行う必要があります。

ちなみに秘密鍵は下記のようなコマンドで簡単に取得できてしまうそう Secretリソースに関するRBACはガチガチにしておく必要がありますね

$ kubectl get secret -n kube-system -l sealedsecrets.bitnami.com/sealed-secrets-key -o yaml >master.key

GitOpsに適した機密情報の運用について学べました!業務がやりやすくなりそうです。以上!

conftest学んで試してみた

記事一覧はこちら

要約

conftestというOPAのツールを使って、k8sのdeploymentをテストしてみた話です。 OPAとは?というところから、その中で利用するRego言語についても軽くまとめてみました。

Conftestとは

構造化された設定データに対してテストを書くのに役立つツールとのことです。

例えば、KubernetesマニフェストやTerraformのコードをターゲットにテストを書くことが可能なようです。僕は配信で他社さんが使っていることを発表していて知りました。

Conftestはポリシーの宣言にOpen Policy AgentのRego言語を利用します。

Open Policy Agentとは

OPAはスタック全体でのポリシー施行を統一する、OSSのポリシーエンジンとのこと(参考)。発音は"oh-pa"になる。 OPAはポリシーをコードで書ける宣言型言語とポリシーの決定をソフトウェアからオフロードするシンプルなAPIを提供してくれるもの。

例えば、マイクロサービスかつpolyglotで実装していると、色んな言語でそれぞれポリシーを書かなきゃいけなくなるけど、それを避けられるというという認識を僕はしました。

下記の図は公式サイトより引用させていただきました。

https://d33wubrfki0l68.cloudfront.net/b394f524e15a67457b85fdfeed02ff3f2764eb9e/6ac2b/docs/latest/images/opa-service.svg

OPAの実行方法はとしては下記のようなものがある(参考)。

Conftestの go.mod を見る限り、ConftestはOPAのGoのライブラリをよんでポリシーの評価を行っているようです。

OPA Document Modelについて

外部からOPAにデータをロードされる全てのデータを Base Document と呼びます。また、OPAではルールによって生成された値を Virtual Document と呼びます。 これら2つはRegoで、同じ dot/bracket-style で参照することができます。 data というグローバル変数を使って両方にアクセスすることが出来ます。

Base Document はOPAの外部から来るものなので、 data 配下のどこに置かれるかどうかは読み込むソフトウェアによって制御されます。その一方で、 Virtual Documentdata 配下の位置は package 宣言によって制御されます。

ポリシー決定のためにOPAに問い合わせを行う際には、非同期 / 同期の2つの方法があります。 conftestは後者です。同期的にpushされた Base Document は "input" というグローバル変数の下にぶら下がります。ポリシーではこれらにアクセスすることができます。

Rego Languageとは

公式リファレンスはこちらプレイグラウンドも用意されている。

Datalogという言語にインスパイアされた言語らしいです。Datalogを拡張してJSONのような構造化ドキュメントをサポートします。

Scalar値

主に複数の場所から参照される定数を定義するために利用する構文です Strings, numbers, booleans, null値をサポートするそうな。バッククォートで囲めば、escape sequencesが解釈されない生の文字列として扱うことが可能です

greeting     := "Hello"
raw_greeting := `Hello\t`
max_height   := 42
pi           := 3.14159
allowed      := true
location     := null

複合値

こちらも定数定義で利用されることが主な構文。 ObjectはKey-Value形式で、Keyはどの型も許容する。 SetもRegoはサポートする。なお、JSONとして出力された際には、配列として表現される(JSONにSetの概念が無いため)。

# Object
point_p := {"x": 14, "y": 4, "z": 27}
port := {80: "http", 443: "https"}      # port[80] のようにフィールドにアクセス
# Set
s := {point_p.x, point_p.y, point.z}
s == {4, 27, 14}    # true 順序が違ってもOK!

変数

ルールの先頭に現れる変数(例えば deny[msg]msg )は、ルールの入力と出力を兼ねます。Regoでは多くのプログラミング言語と違い、入力と出力を同時に持ちます。 値がバインドされていない変数の場合は出力となるし、そうでなければ出力になります。

参照

参照はネストされたドキュメントで利用されます。 一般的には"dot-access"で表現しますが、canonicalなものとしてはdotを排除したPythonのdictionary lookupに近い形になるそうな どちらの形式も有効ですが、下記4つのケースではcanonical formを利用する必要があります。

  • [a-z], [A-Z], [0-9], _ (underscore) 以外の文字を含む文字列キー
  • numbers, booleans, null などの文字列以外のキー
  • 変数キー
  • 複合キー
# dot-access
sites[0].servers[1].hostname
# canonical form
sites[0]["servers"][1]["hostname"]

# variable key
# indexがi,jとバインディングされつつ、ループしてすべての要素を列挙する
sites[i].servers[j].hostname
sites[_].servers[_].hostname   # i, jを利用する必要がない場合には _ を利用する

# composite key
s[[1, 2]]

Module

Regoにおいて、policyはModuleの内部で宣言されるものです。 Moduleは以下の3つから構成されるものです。

  • 1つのPackage宣言
  • 0個以上のImport文
  • 0個以上のRule定義

Packageは1個以上のModuleで定義されたRuleを特定の名前空間にグループ化します。 同じPackageのモジュールは同じディレクトリに配置しなくてもよい。 Moduleで定義されたRuleは自動的にエクスポートされる。

Import文はModuleがPackage外で定義されたドキュメントへの依存関係を宣言するものです。これでエクスポートされた識別子を参照することができるようになる。 全てのModuleは data input ドキュメントの暗黙のImport文を含みます。

OPA Document Modelで述べましたが、 data に他のModuleはぶら下がることになりますので、 package servers と宣言したModuleにアクセスする時のimport文は下記のようになります。

package opa.examples

import data.servers

http_servers[server] {
    server := servers[_]
    server.protocols[_] == "http"
}

Operators

結構大事なのに、ドキュメントの下の方にあって拾えてなくて苦労した項目 こちらを参照ください ベストプラクティスとしては、なるべくassignmentとcomparisonを利用したほうが良いとのことです

# assignment(:=) はローカル変数の宣言
x := 100 

# comparison(==) は比較
x == 100

# Unification(:=) はassignmentとcomparisonの組み合わせで、比較を真にする値を代入します。
input.kind = "Service"

Rule

ルールを評価するときにOPAは全ての式が真になるかどうかを見ます 下記のように理解することができます

expression-1 AND expression-2 AND ... AND expression-N

Ruleは同名で定義することが可能で、その場合の評価は下記のように理解できます

<rule-1> OR <rule-2> OR ... OR <rule-N>

インストール方法

https://www.conftest.dev/install/#brew

# Macの場合
$ brew tap instrumenta/instrumenta                                                                                                               
$ brew install conftest
$ conftest -v                                                                                                                                    
Version: 0.23.0
Commit: 6190ded
Date: 2021-01-09T10:26:15Z

# DockerHubにもある(Officialマークは無いけど)
# https://hub.docker.com/r/openpolicyagent/conftest
docker pull penpolicyagent/conftest

実行方法

デフォルトではconftestコマンドの実行されたディレクトリ直下ののpolicyディレクトリを見に行きます(該当コード)。さらに.regoファイルの中で、デフォルトではmainパッケージのruleを見に来ます(該当コード)。これは—-policyフラグと--namespaceフラグで上書きすることができます。

$ pwd
.../conftest/examples/kubernetes
$ ll                                                                                             
total 24
-rw-r--r--  1 44smkn  staff   546B  2 12 11:42 deployment+service.yaml
-rw-r--r--  1 44smkn  staff   576B  2 12 11:42 deployment.yaml
drwxr-xr-x  8 44smkn  staff   256B  2 12 11:42 policy
-rw-r--r--  1 44smkn  staff   172B  2 12 11:42 service.yaml
$ conftest test deployment.yaml                                                                  
FAIL - deployment.yaml - main - Containers must not run as root in Deployment hello-kubernetes
FAIL - deployment.yaml - main - Deployment hello-kubernetes must provide app/release labels for pod selectors
FAIL - deployment.yaml - main - hello-kubernetes must include Kubernetes recommended labels: https://kubernetes.io/docs/concepts/overview/working-with-objects/common-labels/#labels
FAIL - deployment.yaml - main - Found deployment hello-kubernetes but deployments are not allowed

5 tests, 1 passed, 0 warnings, 4 failures, 0 exceptions

Conftestが認識するルール

Conftestが探しに行くルールは、deny, violation, warn です(該当コード)。それぞれのルールには、suffixにunderscoreとidentifierをくっつけることができます。例えば、deny_myrule のように記述できる。

violationdenyルールと同じように評価されるが、文字列だけでなく構造体のエラーを返すことができる。

ConftestでKubernetesマニフェストをテストする

試しにk8sマニフェストに自分なりのポリシーを用意してテストしてみようと思います。k8sマニフェストはkustomizeを利用して定義します。

kustomize build | conftest test - のようにパイプで渡してもconftestを流すことができます(該当コード

Deploymentにポリシーを設定してみる

github.com

policyは下記のような感じ

package main

name := input.metadata.name

# deployment かつ Root以外のユーザで起動していなければ 拒否する
# not -> https://www.openpolicyagent.org/docs/latest/policy-language/#negation
deny[msg] {
    input.kind == "Deployment"
    not input.spec.template.spec.securityContext.runAsNonRoot

    msg := sprintf("Containers must not run as root in Deployment %s", [name])
}

# deployment かつ タグ付けされていないコンテナイメージを利用している場合は拒否する
deny[msg] {
    input.kind == "Deployment"
    image := input.spec.template.spec.containers[_].image
    not contains(image, ":")

    msg := sprintf("You must use tagged container images in Deployment %s", [name])
}

コマンドで実行するとこう

$ kustomize build overlays/production | conftest test -                                             
FAIL - - main - Containers must not run as root in Deployment production-prod-sbdemo
FAIL - - main - You must use tagged container images in Deployment production-prod-sbdemo

6 tests, 4 passed, 0 warnings, 2 failures, 0 exceptions

想定通りになりました!Regoは結構深ぼっていくと面白そうですね(ちょっと理解するのに時間がかかりました)。
以上!

Grafanaのダッシュボードをgrafonnetを使って管理する

記事一覧はこちら

要約

Grafanaはすごくパワフルな可視化ツールですよね。
例えば、kuberntesやECSのクラスタ1つごとにダッシュボードを作成することも多いかと思います。そうなると生のjsonでコード管理するのもかなり厳しくなってきます。
jsonnet + grafonnetを利用すれば、コード管理が楽になりそうという話です。

参考にしたのはこちら

grafana.com

jsonnetとは

jsonnetの存在自体はArgoCDがサポートしていたり、Spinnakerのパイプラインを管理するのに用いられているから知ってはいました。
ただ実際には使ったことはなく今回初めて勉強します。

jsonnetjsonのsupersetになっています。実装としてはC++Goの2種類があるようですが、コミュニティは最終的にはGoにすべて移行させたいようなので、Goの実装を利用するのが良いかと思います(参考)。C++での実装の方が少しパフォーマンスが良いらしいのですが、おそらく殆どのjsonnetの利用用途から見ると、その差はそこまで重要ではないはずです。

f:id:jrywm121:20210207104407p:plain

標準的なユースケースについては公式サイトのReferenceにて言及されています。設定ファイルを独立して作成するとたくさんの重複が発生し、保守が難しくなる。それをプログマティックに宣言できるようにしてくれるよとあります。

The standard use case is integrating multiple services which do not know about each other. Writing the configuration for each independently would result in massive duplication and most likely would be difficult to maintain. Jsonnet allows you to specify the configuration on your terms and programmatically set up all individual services.

構文はJsonnet公式のチュートリアルが分かりやすいです。
特徴としては下記かなあとは思います。レベル感の合っていない抜き出し方ですが、自分としてはこの辺がイメージ掴むのに良かったです。

  • Jsonnet is a purely functional language (with OO features).
  • Jsonnetのプログラムはすべて式で構成される → 文は存在しない
  • Jsonnetはオブジェクトの概念を持ち、+キーワードで継承することができる
  • Field Visiblityという概念があり、例えば::を利用したフィールド宣言の場合に最終的なJSONで出力されない

grafonnetとは

grafana.github.io

jsonnet用のライブラリが公式から提供されています。ダッシュボードの作成を用意された便利な関数で行うことができます。これによって大幅にコード行数を少なくすることが出来ます。
grafonnetのようなjsonnetのライブラリであるlibsonnetを導入する際には、単純にgit cloneする方法とjsonnet-bundlerを利用する方法があるそうです。せっかくなので後者の方法でgrafonnetを利用したいと思います。
jsonnet-bundlerのインストール方法は下記のようになります。

# Macの場合
brew install jsonnet-bundler

# amd64の場合
VERSION=v0.4.0
ARCH=amd64
wget -o jb https://github.com/jsonnet-bundler/jsonnet-bundler/releases/download/${VERSION}/jb-linux-${ARCH}
chmod +x jb
sudo mv ./jb /usr/local/bin/jb

ここからgrafonnetを利用できるようにするためには下記のようになる。jb initで依存するパッケージを定義するjsonnetfile.jsonが作成されます。

jb init
jb install https://github.com/grafana/grafonnet-lib   # jsonnetfile.jsonの設定が追記され、jsonnetfile.lock.jsonが作成される

jsonnnetのファイル側からは以下のように定義することで関数などを利用することが可能です。

local grafana = import 'vendor/grafonnet-lib/grafonnet/grafana.libsonnet';

# jsonnet実行時に -J vendor/grafonnet-lib を渡してあげれば下記のような宣言も可能
local grafana = import 'grafonnet/grafana.libsonnet';

jsonnet+grafonnet でダッシュボードを作成してみる

github.com

共通するファイルは下記のように書いてあげる。コメントはgrafonnetのコードを参考にしたが、もうちょっとJSDocよりに書いたほうが型とかが分かりやすかった気がする。

local grafana = import 'vendor/grafonnet-lib/grafonnet/grafana.libsonnet';
local graphPanel = grafana.graphPanel;
local prometheus = grafana.prometheus;

local dashboard = {
    /**
     * create dashboard
     *
     * @name dashboard.new
     *
     * @param title The title of the dashboard
     * @param tags (optional) Array of tags associated to the dashboard, e.g.`['tag1','tag2']`
     * @param description The description of the dashboard
    */
    new(title, tags, description):: grafana.dashboard.new(
        title,
        tags=tags,
        schemaVersion=18,
        editable=true,
        time_from='now-1h',
        refresh='1m',
        description=description,
    )
};

local podCountsPanel = {
    /**
     * create pod counts graph panel
     *
     * @name podCountsPanel.new
     *
     * @param title The title of the podCountsPanel
     * @param description The description of the panel
     * @param datasource Datasource
    */
    new(title, description, datasource):: graphPanel.new(
        title,
        decimals = 0,
        min = 0,
        datasource = datasource,
    )
    .addTarget(
        prometheus.target(
            'sum(kube_pod_status_ready{namespace="default"}))'
        )
    )
};

{
    dashboard: dashboard,
    podCountsPanel: podCountsPanel,
}

そして、呼び出す側からは下記のように宣言する

local tmpl = import "k8s-cluster-summary.libsonnet";
local dashboard = tmpl.dashboard;
local podCountsPanel = tmpl.podCountsPanel;

dashboard.new("booinfo-dev", ['kubernetes', 'dev'], 'summary of bookinfo k8s cluster')
  .addPanels(
    [
      podCountsPanel.new('pod count', 'クラスタpodの総数', 'prometheus') + {
        gridPos: { h: 8, w: 7, x: 0, y: 4 }
      },
    ]
  )

かなり良さげなのですが、既存のダッシュボードを移行しようと思うとかなり労力が必要そうですよね。
新しくダッシュボードを作成するときにはぜひ取り入れてみたい方法だとは思います。以上!

EKSでのAWSユーザorロール認証の流れを追う

記事一覧はこちら

要約

下記の記事で、k8s内のリソースからAWSのリソースを操作する時の権限のマッピングについて書いたけど、今回はIAMロールorユーザがk8sのリソースを操作する時の権限のマッピングについての話です。
k8sのwebhook認証を利用して、EKSはaws-iam-authentificatorとやり取りしてIAMの情報からk8sのsubjectを取得しています。そのフローの中で利用されるのがkube-system/aws-authのConfigMap。k8sのRoleBindingで定義されるユーザやグループにIAMロール/ユーザをマッピングできます。

44smkn.hatenadiary.com

EKSにおけるAuthentification(認証)

参考にさせていただいたのは下記の記事です。

itnext.io

全体の流れは、Kubernetes RBAC and IAM Integration in Amazon EKS using a Java-based Kubernetes Operator | Containers」に記載されている図がとてもわかり易いので、そちらを引用させていただきます。

https://d2908q01vomqb2.cloudfront.net/fe2ef495a1152561572949784c16bf23abb28057/2020/06/07/RBAC-IAM.png

起点となるのはkubectlとなります。もし直接k8s APIを叩くとしても流れとして大きくは変わらないのでkubectlで話を勧めていきます。
kubectlには、client-go credential pluginsという機能があります。k8s.io/client-goがネイティブにサポートしていない認証プロトコルとクライアントを統合してくれるものです。サービスアカウントトークン等とは違い、IAMでの認証はネイティブにサポートされていないので、このプラグインを利用することになります。
EKSの場合に用いるプラグインaws-iam-authenticatorとなります。kubeconfigには下記のように宣言します。

# [...]
users:
- name: ops
  user:
    exec:
      apiVersion: client.authentication.k8s.io/v1alpha1
      command: aws-iam-authenticator
      args:
        - "token"
        - "-i"
        - "<CLUSTER_ID>"
        - "-r"
        - "<ROLE_ARN>"
  # no client certificate/key needed here!

aws-iam-authenticator token -i <CLUSTER_ID> -r <ROLE_ARN>が実行され、標準出力に下記のように出力します。これがBearer Tokenとして、AuthorizationHTTPヘッダとして付与され、k8sAPIサーバにリクエストされます。このトークンの中身は署名付きURLをbase64エンコードされたものになっています(該当コード)。URLを署名するためにはもちろん認証情報が必要になります。ARNを指定した場合には、そのロールにAssumeRoleを行うことになります。
この辺の流れは、Pythonのコードの方が分かりやすいかも。
ここまでが冒頭で引用させていただいた図の①より前の部分になります。

{
  "apiVersion": "client.authentication.k8s.io/v1beta1",
  "kind": "ExecCredential",
  "status": {
    "token": "my-bearer-token",
    "expirationTimestamp": "2018-03-05T17:30:20-08:00"
  }
}

Bearer Tokenの付与されたリクエストはk8sAPIサーバに到達します。APIサーバではそのトークンが正しいかどうかを検証する必要があります。IAMの認証情報であるため、k8sだけでは検証することは不可能です。k8sBearer Tokenを検証するためにWebhook認証という機能があります。別のコンポーネントにPOSTリクエストを投げて、下記のようなレスポンスを返却してもらうことで、k8sでのユーザ/グループを特定します。

{
  "apiVersion": "authentication.k8s.io/v1beta1",
  "kind": "TokenReview",
  "status": {
    "authenticated": true,
    "user": {
      "username": "janedoe@example.com",
      "uid": "42",
      "groups": [
        "developers",
        "qa"
      ],
      "extra": {
        "extrafield1": [
          "extravalue1",
          "extravalue2"
        ]
      }
    }
  }

トークンを検証する別のコンポーネントというのがaws-iam-authenticatorのサーバです。aws-iam-authenticator serverのサブコマンドで起動します。EKSの(おそらく)マスターノードで動いています。k8sAPIサーバはこのエンドポイントをどう知りうるのかというと、aws-iam-authenticatorサーバ起動時に生成される設定ファイルを--authentication-token-webhook-config-fileの引数に渡してあげることで認識できます。

冒頭で引用した①に当たる部分で、k8sAPIサーバからトークンがaws-iam-authenticatorのエンドポイント/authenticateにPOSTリクエストで渡されます。②に当たる部分でトークンの有効性を確認します(該当コード)。その後は、③に当たる部分で、ユーザ名とグループ名をaws-authというConfigMapと照らし合わせてAPIサーバに返す(該当コード)。

k8sAPIサーバは渡されたユーザ名とグループ名に基づいて、Authorization(認可)を行うという流れになります。

aws-authの定義の仕方

ほとんどこのドキュメントに書いてあること。

docs.aws.amazon.com

IAMロール or IAMユーザをKubernetesのユーザにマッピングするための定義

mapRoles:
  - rolearn: arn:aws:iam::xxxxxxxxxxx:role/admin
    username: admin  # k8sが扱うときの名前になる(※1)。
    groups: 
      - system:node  # RoleBindingで指定する、subjectのkindがGroupのものと対応する
mapUsers:
  - userarn: arn:aws:iam::xxxxxxxxxxx:user/ops
    username: ops    # subjectのkindがUserのものと名称が一致すれば、その権限を得る(※2)。opsというユーザが既に存在。
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
  name: ops
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: ClusterRole
  name: edit
subjects:
- apiGroup: rbac.authorization.k8s.io
  kind: User
  name: ops

※1: エラーメッセージとかもError from server (Forbidden): services is forbidden: User "admin" cannot list resource "services" in API group "" in the namespace "default"のように表示される

kustomizeはCRDのpatchesStrategicMergeに対応していなくて辛い話(2021/1現在)

記事一覧はこちら

CRDでkustomize使いたい…

ということがたまーに出てきますよね(圧)!
基本的に、CRDを利用する局面ってHelmを使ってインストールすることが多いと思います。その場合は、value.yamlとの差分を上書きして利用すれば環境差分だけを抽出して別々のファイルで宣言することができます。例えば、MySQL OperatorやPrometheus Operatorを導入するときがそうですよね。
ですが、ArgoCDをデプロイに利用するとなると、RolloutAnalysisTemplateといったCRDをkustomizeで構成したくなります。Helmやksonnetなどもサポートされていますが、kustomizeがシンプルかつ柔軟という気がしていて僕は好きです。

kustomizeはCRDのSMPに対応していない

してないんですよ!!!!!!!
GitHubのIssueをななめ読みしているだけなのでしてるかもしれない。もしそうなら、すみません!
SMPはStrategic Merge Patchesの略で、githubのIssueとはこのように略されていることが多いように思えます。いわゆるkubectl patchでのデフォルトのtypeで利用されるもので、Json Merge Patchと違い、常にリストをまるごと置き換えるのではなく、patchMergeKeyが指定されているリストに関しては、キーがかぶらなければ追加するというマージ方法を取ります。
ただ、CRDの場合には、SMPが利用できないため柔軟なマージを行うことができません。

SMP対応依頼のIssue発見

ぐぬぬ…」と思いつつGitHubのIssueを見ていると、光明を見つけた(ように思われました)。

github.com

Kubernetesのv1.16でSSA(Server Side Apply)がベータリリースされました(v1.16のリリースノード)。このタイミングで下記の対応も入っています。

Server-side apply will now use the openapi provided in the CRD validation field to help figure out how to correctly merge objects and update ownership. (#77354, @jennybuckley)

Server Side Applyマニフェストのフィールドごとに所有者の情報をもたせる機能で、マニフェストをapplyするときに予期せぬ変更を検知することができます(ざっくり)。
Issueを発行した方いわく、このServer Side Applyでの対応により「k8sはv1.16からkubectl apply/patchがJMPではなくSMPになるというのです。このことから、kustomizeとk8sの動作の差分が出てしまうため使用者の誤解を招く可能性がある点について指摘し、kustomizeもCRDのSMPに対応するようにと提案しています。

v1.16以降のkubectl patchの挙動

ですが、katacodaのk8s playgroundでv1.18のクラスタに対して、CRDに対してkubectl patchしようとすると下記のようにエラーが出ます。
また、KubernetesCRDのドキュメントページにも、strategic-merge-patchはサポートされないとあります。

controlplane $ kubectl patch servicemonitor example-app -p "$(cat patch.yaml)"
Error from server (UnsupportedMediaType): the body of the request was in an unknown format - accepted media types include: application/json-patch+json, application/merge-patch+json, application/apply-patch+yaml

kubectl patchはサポートされていないことが分かりました。では、Server-side Applyはどうでしょうか?

Server-side Apply時の挙動

まず、k8sのnative objectであるDeployment.spec.template.spec.containers[]のマージが可能かをやってみます。結論から言うと、当たり前ですが可能でした。

controlplane $ wget https://raw.githubusercontent.com/kubernetes/website/master/content/ja/examples/controllers/nginx-deployment.yaml
controlplane $ kubectl apply --server-side -f nginx-deployment.yaml             # port宣言でprotocolが不足しているので追加
controlplane $ vi nginx-deployment.yaml   # container[].nameを nginx から nginx-opts に変更します
controlplane $ kubectl apply --server-side -f nginx-deployment.yaml --field-manager=external --validate=false
controlplane $ kubectl get deploy nginx-deployment -o yaml
# コンテナの配列が1 -> 2に増えたのでStrategicMergeされていました

次に、ArgoCDのRolloutリソースでマージが可能かやってみましょう。
コンフリクトしてしまいました。リストごと置換するようになっていたからと思います。CRDのマージキーのあたりの設定が入っていれば、native objectと同じような挙動になったのかもしれませんが、公式のCRDの定義をそのまま使う限りには難しいようです。
k8s側でもSMP入るようになれば、kustomize側も実装してくれるのかもですね…!

controlplane $ kubectl create namespace argo-rollouts
controlplane $ kubectl apply -n argo-rollouts -f https://raw.githubusercontent.com/argoproj/argo-rollouts/stable/manifests/install.yaml
controlplane $ kubectl apply --server-side -f https://raw.githubusercontent.com/argoproj/argo-rollouts/master/docs/getting-started/basic/rollout.yaml
controlplane $ vi rollout.yaml  # # container[].nameを rollout-demo から rollout-demo-opts に変更します
controlplane $ kubectl apply --server-side --field-manager=external --validate=false -f rollout.yaml
Please review the fields above--they currently have other managers. Here
are the ways you can resolve this warning:
* If you intend to manage all of these fields, please re-run the apply
  command with the `--force-conflicts` flag.
* If you do not intend to manage all of the fields, please edit your
  manifest to remove references to the fields that should keep their
  current managers.
* You may co-own fields by updating your manifest to match the existing
  value; in this case, you'll become the manager if the other manager(s)
  stop managing the field (remove it from their configuration).
See http://k8s.io/docs/reference/using-api/api-concepts/#conflicts

kustomizeのSMP対応が待たれる

フォークしてCRDのSMPに対応したリポジトリが存在するので、そちらを利用するのも一つの手かもしれません。
マニフェストはできるだけ綺麗かつ体系だった形にしたいですよね!以上!

github.com