1クール続けるブログ

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

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"のように表示される

EKSにおけるPodレベルでのIAMロール割当

記事一覧はこちら

NodeレベルでのIAMロール割当しちゃってる…

という私用クラスタを使ってしまっている僕がPodレベルでの権限割当について学んだので、その備忘録です。ALB Controllerの利用をするときに、Pod単位での権限割当を行う導入フローになっているのを見て、そろそろちゃんと勉強せねばと思った次第です。
学ぶときに利用したリソースは下記です。

Kubernetesの中での権限制御

これはRole Based Access Control(RBAC)を利用することになると思います。
Podに割り当てるServiceAccount リソースと「どのリソースに対してどの操作を許すか」という宣言を行ったRoleもしくはClusterRoleを用意し、RoleBindingClusterRoleBindingで紐付けることで制御します。ArgoCDなどのCDツールでは様々なk8sリソースの参照/変更権限が必要になるため、デフォルトのServiceAccountでは権限が足らず、必要な権限を付与したServiceAccountを用意することになりました。

では、podがk8sリソースではなくAWSのリソースを操作するときには、どのような仕組みで制御されているか(例えばS3からデータを取得するなど)。

Kubernetesからawsリソースを操作する時の権限制御

ノード単位での権限制御

EKSローンチ当初はノード単位でのIAMロール割当しか出来ませんでした。
なので、least-privilegedを守るためには、権限の大きいノードグループを別で作成し、podのnodeSelectorでスケジューリングされるノードグループを別々にする必要がありました。
podはスケジューリングされているノードのインスタンスプロファイルを利用してAWSリソースにアクセスします。EC2メタデータサービスを利用して、アクセスキーとシークレットアクセスキーを取得できる例のやつです(instance-metadata-security-credentials)。

pod単位での権限制御

EKSのv1.13から利用できるようになった、podごとにIAMロールを割り当てる方法です。
といっても公式で出たのがそのタイミングというだけで、記事にもありますが、3rd Partyでは kube2iam などがEKSローンチ当初から利用されていたように感じます(kopsで利用されてて実績があったんですかね)。
それらの3rd Partyは「EC2 メタデータ API へのリクエストをインターセプトし、STS API を呼び出して一時クレデンシャルを取得する」という手法を取っていたようです。EC2メタデータのエンドポイントである 169.254.169.254 へのアクセスをノードのiptablesでポート8181で待ち構えているdaemonsetにルーティングさせて、podのiam.amazonaws.com/roleアノテーションを見に行って指定したRoleにAssumeRoleさせるようです。

AWS公式の方法は、IAM Roles for Service Accounts (IRSA) という方法で上記の方法とはアプローチが違うようです。
前述の方法だとiptableをいじることにコストとか、KubernetesのRBACがIAMロールと結びつかないことによって直感的でないことがもしかしていまいちだったのかもしれません(多分もっとちゃんとした理由がある)。

IAM Roles for Service Accounts

OIDC (OpenID Connect)

AWSがpodに権限を付与するために用いた手段では、IAM OIDCプロバイダを利用することが前提になっています。恥ずかしながら、認証周りに疎いのでOIDC知りませんでした。

OpenID Connect 1.0 is a simple identity layer on top of the OAuth 2.0 protocol. - OpenID Connect | OpenID

OAuth2.0 の上にあるシンプルなidentityレイヤなんですね(分からん)。identityというのはset of attributes related to an entity を指すそうで、ここでのentityは人や機械、サービスのことのようです。identityレイヤがもたらすのはIDトークンとユーザ情報のAPIの仕様ということなんだそうな(参考)。OAuth 2.0はUser IDの取得方法やProfile APIの仕様については定めてなかったのか!

そして、AWSではIAMでウェブIDフェデレーションをサポートしています。IDトークンを AWS アカウントのリソースを使用するためのアクセス許可を持つ IAM ロールにマッピングすることが可能です(参考)。APIでいうと、AssumeRoleWithWebIdentityが呼ばれるそうです。
OIDC互換のプロバイダといえば、FacebookGoogleなどがそれにあたりますが、そのプロバイダのIAMエンティティが「IAM OIDC identity provider」です。これにIdP とその設定について埋め込むことで、AWS アカウントと IdP の間の「信頼」が確立されます。

Amazon EKS now hosts a public OIDC discovery endpoint per cluster containing the signing keys for the ProjectedServiceAccountToken JSON web tokens so external systems, like IAM, can validate and accept the OIDC tokens issued by Kubernetes.
- Technical overview - Amazon EKS

このPodに権限を付与するという文脈で用いられるIdPは、EKSクラスタのことになります。
なので、EKSが公開するOIDCディスカバリエンドポイントを設定した「IAM OIDC identity provider」作成する作業がまず必要になります。そうすることで、OIDC プロバイダーを使った認証が有効化され、IAM ロールの引き受けを可能とする JSON Web Token (JWT) の取得ができるようになります。

f:id:jrywm121:20210129220241p:plain

AssumeRoleWebIdentityではJWTトークンを渡すことになります(参考)。IDトークンに含まれるフィールドissでプロバイダのエンドポイントが分かるので、それがIAM OIDCプロバイダに存在するのかを確認するはず。もし存在すれば、STSからEKSにTokenの検証リクエストを投げます。検証し問題なければ、AssumeRoleの際にリクエストしたRoleを被ることができるユーザかどうかの確認が走るはず(この辺はドキュメントベースでないのでかなり怪しい)。エンドユーザの識別子はIDトークンのsubフィールドに含まれるそうな。
AssumeRole先のIAMロールには下記のようなポリシーをアタッチすることでk8sのサービスアカウントがどのIAMロールをAssumeできるかを制限します。

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Federated": "arn:aws:iam::<AWS_ACCOUNT_ID>:oidc-provider/<OIDC_PROVIDER>"
      },
      "Action": "sts:AssumeRoleWithWebIdentity",
      "Condition": {
        "StringEquals": {
          "<OIDC_PROVIDER>:sub": "system:serviceaccount:<SERVICE_ACCOUNT_NAMESPACE>:<SERVICE_ACCOUNT_NAME>"
        }
      }
    }
  ]
}

最終的には、STSからAssumeしたRoleのACCESS_KEYSECRET_ACCESS_KEYをpodに返却し、podはその認証情報を利用してAWSリソースの操作を行います。

Service Account Token Volume Projection

では、そもそもEKSからpodにOIDC互換のIDトークンをどう渡しているのでしょうか?
Kubernetes v1.12からサポートされたProjectedVolumeをつかって受け渡しているそうです。ServiceAccountのアノテーションに、eks.amazonaws.com/role-arnで引き受けるIAMロールの名称を宣言するわけですが、(おそらく)そのアノテーションが付与されていると、Mutating Admission Controllerが、環境変数projectedVolumepodSpecに付与してくれます。なので、運用者がk8s環境で行うことはサービスアカウントをアノテートするだけですね。

以下、参考ドキュメントから引用

apiVersion: v1
kind: ServiceAccount
metadata:
  annotations:
    eks.amazonaws.com/role-arn: arn:aws:iam::123456789012:role/eksctl-irptest-addon-iamsa-default-my-serviceaccount-Role1-UCGG6NDYZ3UE
  name: my-serviceaccount
secrets:
  - name: my-serviceaccount-token-m5msn
---
apiVersion: apps/v1
kind: Pod
metadata:
  name: myapp
spec:
  serviceAccountName: my-serviceaccount
  containers:
  - name: myapp
    image: myapp:1.2
    env:
    - name: AWS_ROLE_ARN
      value: arn:aws:iam::123456789012:role/eksctl-irptest-addon-iamsa-default-my-serviceaccount-Role1-UCGG6NDYZ3UE
    - name: AWS_WEB_IDENTITY_TOKEN_FILE
      value: /var/run/secrets/eks.amazonaws.com/serviceaccount/token
    volumeMounts:
    - mountPath: /var/run/secrets/eks.amazonaws.com/serviceaccount
      name: aws-iam-token
      readOnly: true
  volumes:
  - name: aws-iam-token
    projected:
      defaultMode: 420
      sources:
      - serviceAccountToken:
          audience: sts.amazonaws.com
          expirationSeconds: 86400
          path: token

ミドルウェアもどんどんpod単位の権限制御に

自分は気づけてなかったのですが、external-dnsaws-load-balancer-controllerなどのリソースは既にpod単位の権限制御前提で導入手順とか記載されていますね。
セキュリティ的にも重要なのでしっかり理解しておきたいです。以上!

mtail事始め

記事一覧はこちら

mtail使っているけどちゃんと理解できていないかも…

という僕がmtailを改めて勉強したので書き留めておこうという記事です。
mtail自体は、apacheのログフォーマットをパースしてPrometheusで収集させるのに利用しているのですが、構築などは自分がプロジェクトにjoinしたときには既にあったため、なんとなくでも使えてはいました。
ですが不具合調査をすることになり、この機会に一から学んでみることにしました。

mtailについて

概要

github.com

mtailはアプリケーションのログからメトリクスを抽出してエクスポートしてくれるツール
メトリクスはPrometheusのようなPull型アーキテクチャを持つツールがスクレイプできるように、JSON形式もしくはPrometheus形式でHTTPサーバで公開できます。また、定期的にcollectdなどのツールに送ることも可能のようです。

入手方法

GitHubのリリースページから直接入手できます。2020年10月のリリースからは、armで動くバイナリも配布されています。
コンテナイメージですが、DockerHub上で公式イメージは手に入らないようです。GCRの公開リポジトリとかにありそうだなあと思ったんですがありませんでした。

実行方法

--progsでログをどうパースして、どのようにエクスポートするかを宣言したファイルのパスを指定する。一方で、--logsでは入力となるログファイルのパスを指定する。必須なのはこの2つ(のはず)。
--logtostderrはログをファイルではなく標準エラー出力に履く設定。基本的にmtailはサイドカーコンテナとして動かすケースがほとんどだと思うので、ログ出力は標準出力に履いてCloudWatch Logsなりでキャプチャ/転送してあげるのが易しいはず。
--progsではディレクトリを渡すこともできる。ファイルの名称は、末尾に.mtailがついていないと読み込んでくれない点について注意が必要です。

mtail --progs ./progs.mtail --logs /var/log/apache/access.log --logtostderr

メトリクスの公開先

デフォルトでポートは3903で公開されるので、それに合わせてPrometheusなりでサービスディスカバリしてあげるように設定して上げると良い。--port xxxxのようにコマンドの引数でポートはオーバーライド可能です。

mtailの設定DSL

exportする変数の定義がまず必要です。ここはPrometheusなどの収集ツール側から見れるメトリクス名になります。
counter, gauge, histogram が指定できます。

counter lines_total by path, method  # by を使うことでdimentionを追加できる
gauge queue_length

pattern(正規表現)を満たしたエントリに対してactionを行います
またpatternでキャプチャした変数はactionブロックで利用することが可能です

pattern {
   action
}

# 例: 1行ごとにカウントするシンプルなもの(行末ごとにactionが行われる)
/$/ {
   lines_total++
}

# + でpatternの結合、#より右はコメントなので無視する
/(?P<method>[A-Z]+) / + # %m - The request method.
/(?P<code>\d{3}) / + # %>s - Status code.
/(?P<sent_bytes>\d+) / + # %O - Bytes sent, including headers.
/$/ {
   http_response_size_bytes_total[$method][$code][$protocol] += $sent_bytes
}

mtailのテスト

mtailには構文のテスト方法と実際に得られる値までを計算して確認する方法があります。

  • 構文チェック
mtail --compile_only --progs ./progs
  • 実際にファイルを食わせて出力される値を確認
mtail --one_shot --progs ./progs.mtail --logs testdata/foo.log 
# jsonの出力が吐かれます

他にも色々機能がある…

中間値を保持する機能やHistogramをエクスポートする機能まではまだ追いきれていない状態ですが、なんとなく大枠はつかめたかと思います。
examplesが充実しているのはありがたいですね

mavenキャッシュをDockerコンテナに閉じ込めてビルド高速化を図る

最近、全く更新出来ていなかった。
もう少し一つ一つの記事を軽い気持ちで書いてみてもいいのかもしれない(個人ブログだし)。

記事一覧はこちら

概要

JenkinsやCodeBuildなど、Dockerデーモンを叩ける環境でMavenを使ったJavaアプリケーションのビルドの高速化をする方法です
利用するJava, Mavenのバージョンを統一しつつ、Maven~/.m2/repository/* をキャッシュさせたいという需要がありました
上で名前を上げましたが、CodebuildとかGithub Actionsとかではビルドするときのイメージを指定する形になっているし、S3キャッシュなどのマネージドのキャッシュ機構があるので、前述の需要は簡単に満たせたりします
なので、どちらかというと、VMから直で泥臭くコンテナビルドするときに使える方法になります

方法

この手法でビルドを動かしてみている環境はJenkinsなのですが、Dockerの --cache-from を一緒に利用することで、ビルド時間を半分以下にすることができました。

MY_PRIVATE_REPOSITORY=aws_account_id.dkr.ecr.region.amazonaws.com:sample-app/maven

# 最初の1回と定期的に下記を実施
# ベースイメージのmavenコンテナにセキュリティアップデートとかを入れるため
docker pull maven:3.6-amazoncorretto-8
docker tag maven:3.6-amazoncorretto-8 ${MY_PRIVATE_REPOSITORY}:sample-app 
docker push ${MY_PRIVATE_REPOSITORY}:sample-app

# ビルド
CONTAINER_ID=$(docker run -d --rm --volume $(pwd):/workspace --workdir '/workspace' ${MY_PRIVATE_REPOSITORY}:sample-app sleep 1800s)  # バックエンドで起動
docker exec ${CONTAINER_ID} mvn -U package -Pdev 
docker commit ${CONTAINER_ID} ${MY_PRIVATE_REPOSITORY}:sample-app # ダウンロードしたライブラリをコンテナに閉じ込める
docker stop ${CONTAINER_ID}
docker push ${MY_PRIVATE_REPOSITORY}:sample-app