1クール続けるブログ

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

競プロの勉強するときにテストも書く with Go言語

記事一覧はこちら

背景・モチベーション

前半をさっと流し読みだけで止まってしまっている問題解決力を鍛える!アルゴリズムとデータ構造をちゃんと解きながらやろう!と思い、重い腰を上げて取り組み始めました。
始めたはいいものの、入力と出力がそれぞれ標準入力と標準出力だと後から見直したときに見づらいなと思わないでもなかったので、テストを書くようにしました。

本編

要件としてはこちら

  • 普通に実行させたときには、標準入力を受け取って標準出力に出す
  • テストとして実行させるときには、複数のテストケースを並列で試験させるようにする

本来であれば、テスト対象のコードとテストコードはパッケージを分けたほうが良いのですが、 main パッケージの関数にパッケージ外からアクセスできないので諦めました。
※ ちゃんと分離させることもできますが面倒なことが増えるので

問題を解くコード

io.Readerio.Writer を受け取る関数を書きます。よく競プロのサンプルとかで、 bufio.NewScanner(os.Stdin) を見ると思いますが、引数としては io.Reader を実装している型であれば何でもいいので外側から注入できるようにしておきます。
出力も標準出力に出したいのかバッファに吐きたいかは呼び出し側の都合で変わってくるので、 io.Writer を引数に取る fmt.Fprintln を使うようにします。HTTPサーバのサンプル実装とかでよく見る関数ですよね。

// main.go

func solve(r io.Reader, w io.Writer) {
    scanner := bufio.NewScanner(r)
    /* 省略 */
    fmt.Fprintln(w, ans)
}

実行時のエントリポイント

先程の関数に標準入力と標準出力を渡してあげればOKです

// main.go

func main() {
    solve(os.Stdin, os.Stdout)
}

テストコード

テーブル駆動テストを作成します。 solve 関数に渡すのは下記

  • テストケースの値を使った文字列を bytes.Buffer に突っ込んだもの
    • スライスの [] をなくすために strings.Trim(s string) を使っています(他にいい方法があったら教えて下さい)
  • 出力を受け取る空の bytes.Buffer
    • テストケースで保持する期待する値と比較するときには、 buffer.String() を使って文字列に変換する
// main_test.go

func TestSolve(t *testing.T) {
    tests := []struct {
        name string
        v    int
        a    []int
        want string
    }{
        {"case 1", 2, []int{2, 4, 6, 6, 2, 3}, "4"},
        {"case 2", 4, []int{2, 4, 4, 6, 2, 3, 5, 4}, "7"},
    }

    for _, tt := range tests {
        // shadowing するのを忘れない、でないとループ直後のttで動いてしまう
        tt := tt
     
        // サブテストにして名前つけて後で分かるようにしておく
        t.Run(tt.name, func(t *testing.T) {
            t.Parallel() // サブテストを並列で動かすことを許容する
            fmtSlice := strings.Trim(fmt.Sprintf("%v", tt.a), "[]")
            // 入力をio.Readerを実装している bytes.Buffer に突っ込む
            input := bytes.NewBufferString(fmt.Sprintf("%v %v\n%v", len(tt.a), tt.v, fmtSlice))
            buffer := &bytes.Buffer{} // 出力先
            solve(input, buffer)
            got := strings.TrimSpace(buffer.String())
            if tt.want != got {
                t.Errorf("want: %v got: %v", tt.want, got)
            }
        })
    }
}

実際にテストを実行させてみます。

$ go test -v                                                                     
=== RUN   TestSolve
=== PAUSE TestSolve
=== CONT  TestSolve
=== RUN   TestSolve/case_1
=== PAUSE TestSolve/case_1
=== RUN   TestSolve/case_2
=== PAUSE TestSolve/case_2
=== CONT  TestSolve/case_1
=== CONT  TestSolve/case_2
--- PASS: TestSolve (0.00s)
    --- PASS: TestSolve/case_1 (0.00s)
    --- PASS: TestSolve/case_2 (0.00s)
PASS

以上! 本の問題を解いていくぞ!

GoのhttpサーバにおけるMiddlewareとは

記事一覧はこちら

背景・モチベーション

go-chi を好んで使うのですが、ふんわりとした理解でMiddlewareを宣言していました。Middlewareという名称も個人的にはあんまり腑に落ちていなくて、最近少し気になって調べたところ、「ロギングや認証などの横断的な関心事を処理するデザインパターン」を指すようです。NodeJSやRailsでもMiddlewareと呼称するようで、Javaサーブレットではフィルタ、C#ではデリゲートハンドラーと呼ばれているそう(後者2つには馴染みがある)。

このへんでしっかりGoでのMiddlewareの実装を確認しておこうと思いました。最近Goを使っていなかったので、コードリーディングしがてらリハビリをばという気持ちもあります。

参考文献

eli.thegreenplace.net

github.com

GoのHTTPサーバの処理の流れ

まずGoでHTTPサーバを公開するときはどのようなコードになるでしょうか。 下記はGoDocのサンプルコードから拝借したコードです。シンプルな実装だとこのようになると思います。

func main() {
    http.HandleFunc("/bar", func(w http.ResponseWriter, r *http.Request) {
        fmt.Fprintf(w, "Hello, %q", html.EscapeString(r.URL.Path))
    })
    http.ListenAndServe(":8080", nil)
}

ユーザがHTTPサーバを公開するときに実行するhttp.ListenAndServe(addr string, handler Handler) error という関数から見ていきます。
処理の一部を省略したり、改変することで見通しを良くしています。関数の呼び出しに関してもインライン展開している箇所があります。

// https://github.com/golang/go/blob/go1.16/src/net/http/server.go#L3155-L3165
func ListenAndServe(addr string, handler Handler) error {
    srv := &Server{Addr: addr, Handler: handler}
 
    // https://github.com/golang/go/blob/go1.16/src/net/http/server.go#L2890-L2911
    ln, err := net.Listen("tcp", addr)

    // https://github.com/golang/go/blob/go1.16/src/net/http/server.go#L2941-L3015
    for {
        rw, _ := ln.Accept()
        c := srv.newConn(rw)
        go func(ctx context.Context) {
            // https://github.com/golang/go/blob/go1.16/src/net/http/server.go#L1816-L1983
            for {
                w, _ := c.readRequest(ctx)
                serverHandler{c.server}.ServeHTTP(w, w.req)  
            }
        }(connCtx)
    }
}

// https://github.com/golang/go/blob/go1.16/src/net/http/server.go#L2879-L2888
func (sh serverHandler) ServeHTTP(rw ResponseWriter, req *Request) {
    handler := sh.srv.Handler  // このHandlerは、ListenAndServe の第2引数
    if handler == nil {
        handler = DefaultServeMux
    }
    if req.RequestURI == "*" && req.Method == "OPTIONS" {
        handler = globalOptionsHandler{}
    }
    handler.ServeHTTP(rw, req)  // ここでユーザが定義した関数を呼んでいる!
}

追っていくと、ユーザが呼び出したhttp.ListenAndServe(addr string, handler Handler)のときの第2引数にあたるhandlerからServeHTTP(ResponseWriter, *Request)というメソッドを呼んでいます。このメソッドでユーザで定義したコードが実行されるようになっています。

「最初のコードでServeHTTP(ResponseWriter, *Request) を定義していないけど?」と思いますが、今回はhandlerの引数に nil を渡しています。そのため、 DefaultServeMux というhandlerが利用されることになります。これは ServeMux という構造体の型です。この構造体は、 http.Handler のinterfaceを実装しています。

「ではDefaultServeMux はどこで触ったのか?」という疑問が浮かびます。次はサンプル実装で呼んでいた http.HandleFunc に注目してみたいと思います。 下記のコードに出てくる Mux という名称は multiplexer の略で、複数の入力を受け取り、それらを選択したりまとめたりして一つを出力する装置のことだそうです。コードコメントでも、ServeMuxは、httpリクエストのマルチプレクサで、受け取ったリクエストを照合し処理を呼び出すとあります。

// https://github.com/golang/go/blob/go1.16/src/net/http/server.go#L2218-L2258
type ServeMux struct {
    m     map[string]struct{
        h       Handler
        pattern string
    }
}

var DefaultServeMux = &defaultServeMux
var defaultServeMux ServeMux

// https://github.com/golang/go/blob/go1.16/src/net/http/server.go#L2509-L2514
func HandleFunc(pattern string, handler func(ResponseWriter, *Request)) {
    e := muxEntry{h: HandlerFunc(handler), pattern: pattern}
    DefaultServeMux.m[pattern] = e  // DefaultServeMux に登録している
}

// https://github.com/golang/go/blob/go1.16/src/net/http/server.go#L2061-L2070
type HandlerFunc func(ResponseWriter, *Request)
func (f HandlerFunc) ServeHTTP(w ResponseWriter, r *Request) {
    f(w, r)
}

// https://github.com/golang/go/blob/go1.16/src/net/http/server.go#L62-L88
type Handler interface {
    ServeHTTP(ResponseWriter, *Request)
}

このように http.HandleFunc では、DefaultServeMux にHandlerを登録する仕組みになっています。引数として渡した関数は、 HandlerFunc というstructに変換されて Handler というinterfaceを実装する型になります。このinterfaceは、 ServeHTTP(w, r) というメソッドを持ちます。

最後にどのようにHandlerが呼び出されているかを確認してみます。 関数と一緒に引数に渡したpatternにmatchするかどうかを見て、ユーザの処理コードを実行しています。

// https://github.com/golang/go/blob/go1.16/src/net/http/server.go#L2437-L2449
// https://github.com/golang/go/blob/go1.16/src/net/http/server.go#L2368-L2435
func (mux *ServeMux) ServeHTTP(w ResponseWriter, r *Request) {
    v, _ := mux.m[r.URL.Path]
    v.h.ServeHTTP(w, r)
}

Middlewareの実装について

GoにおけるMiddlewareは別のHandlerをwrapするHandlerです。ListenAndServe から呼び出されるように登録されていて、呼び出されると前処理を行い、その後にWrapしているHandlerを呼び出します。

上記で見てきた、 ServeMux もMiddlewareです。Handlerのinterfaceを実装していて、Pathパターンを見て正しいユーザコードのHandlerを呼び出す前処理を行っていました。

では、 go-chi でのロギングのMiddlewareはどのように実装されているか見ていこうと思います。
go-chi を利用したシンプルなサーバ実装例は下記です。前セクションとは違って、 http.ListenAndServe(addr, handler) の第2引数に nil ではなく chi.Mux というHandlerを実装した構造体を渡しています。つまり、 DefaultServeMux ではなく、ここで渡したHandlerの ServeHTTP(w, r) が呼ばれることになります。

func main() {
    r := chi.NewRouter()
    r.Use(middleware.Logger)
    r.Get("/", func(w http.ResponseWriter, r *http.Request) {
        w.Write([]byte("welcome"))
    })
    http.ListenAndServe(":3000", r)
}

それでは実際に、Middlewareを登録する処理である (mx *Mux) Use(middlewares ...func(http.Handler) http.Handler) を見ていきたいと思います。また、この後に関係するので、ユーザコードを登録している r.Get(pattern, func(w, r)) に関しても記載します。
前セクションと同じく、見通しを良くするために省略している箇所があります。

// https://github.com/go-chi/chi/blob/v5.0.1/mux.go#L13-L46
type Mux struct {
    // The middleware stack
    middlewares []func(http.Handler) http.Handler
}

// https://github.com/go-chi/chi/blob/v5.0.1/middleware/logger.go#L23-L43
func Logger(next http.Handler) http.Handler {
    fn := func(w http.ResponseWriter, r *http.Request) {
        defer func() { /* ログ出力処理 */ }()
        next.ServeHTTP(ww, WithLogEntry(r, entry))
    }
    return http.HandlerFunc(fn)
}

// https://github.com/go-chi/chi/blob/v5.0.1/mux.go#L92-L103
func (mx *Mux) Use(middlewares ...func(http.Handler) http.Handler) {
    mx.middlewares = append(mx.middlewares, middlewares...)
}

// https://github.com/go-chi/chi/blob/v5.0.1/mux.go#L145-L149
// https://github.com/go-chi/chi/blob/v5.0.1/mux.go#L384-L407
func (mx *Mux) Get(pattern string, handlerFn http.HandlerFunc) {
    // ミドルウェアをチェインさせる
    if !mx.inline && mx.handler == nil {
        mx.handler = middlewares[len(middlewares)-1](endpoint)
        for i := len(middlewares) - 2; i >= 0; i-- {
            mx.handler = middlewares[i](mx.handler)
        }
    }
    return mx.tree.InsertRoute(method, pattern, h)
}

Middlewareとして渡している型が func(http.Handler) http.Handler であることに注意してください。 Logger 関数を見ていただければ分かりますが、ロギング処理と引数で渡したhandlerを実行する関数を返しています。
Mux 構造体の middlewares スライスに詰められた後は、 Get(pattern, handleFn) が呼び出されたタイミングで、登録した順にMiddlewareがchainして最後にユーザコードが呼び出されるようなHandlerを生成しています。
それでは、実際にどのように呼ばれているかを (mx *Mux) ServeHTTP(w, r) で確認してみたいと思います。

// https://github.com/go-chi/chi/blob/v5.0.1/mux.go#L58-L90
func (mx *Mux) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    r = r.WithContext(context.WithValue(r.Context(), RouteCtxKey, rctx))
    // Serve the request and once its done, put the request context back in the sync pool
    mx.handler.ServeHTTP(w, r)
    mx.pool.Put(rctx)
}

上記のように、 Mux 構造体のhandlerフィールドの ServeHTTP(w, r) が実行されています。厳密ではないかもしれませんが、イメージとしては下記です。

f:id:jrywm121:20210310231349p:plain

Middlewareに関しては、自前で実装することも多いようで、昔の書籍にはなりますが「Go言語によるWebアプリケーション開発」というオライリーから出ている書籍で自分も写経した記憶があります。

コードリーディングしてまとめるのは疲れますが、自分のためになりますね。 Goに対するモチベが上がってきたので、途中で投げたままのアプリケーションを再開させようかなと思いました!以上!

ArgoCDでArgoCD自身を管理する

記事一覧はこちら

背景/モチベーション

業務で利用し始めたArgoCDリソースを個人でも試しておこうという試みです。ArgoCDをArgoCD自身に管理させるところがちょっと腹落ちしていない箇所なので、やっておこうと思います。

ArgoCDについて

公式docsはこちら

概要

ArgoCDはk8sのための宣言的なGitOpsのCDツールです。2020年4月、CNCFにIncubation-level Projectとして迎え入れられました。 ArgoCDはGitOpsパターンに従います。GitOpsはGitリポジトリを、望んだアプリケーション状態を定義した source of truth として扱います(tracking strategies)。 k8sマニフェストは、 kustomizehelm 等いくつかの指定方法がサポートされています。

アーキテクチャ

docsはこちら

ArgoCDはKubernetesのControllerとして実装されており、このControllerは動いているアプリケーションを監視し、動いている構成とGitリポジトリ内のdesiredな構成を比較します。もし、その2つに差分が見られた場合には、 OutOfSync と見なされます。ArgoCDは差分をレポートすると同時に、ターゲットの状態に自動的または手動で同期させる機能を提供します。

ArgoCDは3つの要素から構成されます。

  • API Server
    • WEB UI, CLI, CI/CDシステムからアクセスされるgRPC/RESTサーバ
    • 主に下記の機能を提供する
      • アプリケーションのオペレーションを呼び出す(sync, rollback等)
      • リポジトリクラスタの認証情報管理
      • アプリケーションの管理とステータスのReport
  • Repository Server
  • Application Controller
    • OutOfSync を検出し、設定によってはターゲットと同期させる
    • ユーザが定義したLifecycle(PreSync, Sync, PostSync)のhookを呼び出す

Core Concepts

言葉の定義とかをちゃんと認識しないとですね docsはこちら

Term Meaning
Application k8sリソースのグループ。CRDとして定義される。
Application source type マニフェストをビルドするツールのこと、例えばkustomizeやHelmなど
Target State アプリケーションのあるべき状態。Gitリポジトリのファイルに相当する。
Live State アプリケーションが動いている現在の状態
Reflesh Gitの最新のコードをfetchして差分を認識する
Sync Live StateをTarget Stateに移行させる

ArgoCDでArgoCD自身を管理する

方針

宣言的なセットアップ方法かつArgoCD自身をArgoCDで管理する方法を取りたいと思います(参考)。ArgoCDもk8sマニフェストで表現できるのでこの方法が可能なんですね。 ArgoCDのリリースページをを確認して最新版を取得するように変更します。 kustomize v4.0 以降では、 raw.githubusercontent.com は利用できなくなっていますので、こちらのExampleでの表記では通らないことにご注意ください(参考
※ 自分は kustomizeのv3.9.2 で動かしました

実践

ファイルは下記のリポジトリにまとめています。README.mdに細かい手順の記載もあります。

github.com

基本はkustomizeで管理することにします。 このようにファイル群を作成しました。 applications ディレクトリ配下にArgoCDのApplication(CRD)の設定ファイルが格納される。それ以外のルート直下のディレクトリには各アプリケーションをデプロイするためのマニフェスト群を配置します。

.
├── applications
│   ├── base
│   │   ├── argo-cd.yaml
│   │   ├── external-dns.yaml
│   │   ├── kustomization.yaml
│   │   └── sealed-secrets.yaml
│   └── overlays
│       ├── dev
│       │   ├── helm-values.yaml
│       │   ├── kustomization.yaml
│       │   └── source-path.yaml
│       └── prod
├── argo-cd
│   ├── base
│   │   └── ...
│   └── overlays
│       ├── dev
│       │   └── ...
│       └── prod
└── sealed-secrets
    └── kustomization.yaml

今回はsecretの管理にbitnamiのsealed-secretを利用します! sealed-secret に関する記事は以前書いてますので良かったらどうぞ → こちら

GitOpsで運用していく際には、実際に上記をデプロイする前にリポジトリのCredential情報を暗号化しておく必要があります。sealed-secretsを利用する場合は、暗号化する際にsealed-secretsのControllerに対して公開鍵を取得が行われます。そのため、sealed-secretsのControllerはArgoCDに先んじてデプロイを行います。後ほどsealed-secretsもArgoCDの管理対象に加えます。

# Sealed SecretsのControllerをデプロイ
kustomize build sealed-secrets | kubectl apply -f -

# Secretリソースの作成
kubectl create secret generic repo-secrets -n argocd --dry-run --from-literal=username='<REPLACE_YOUR_USERNAME>' --from-literal=password='<REPLACE_YOUR_PASSWORD>' -o yaml >tmp.yaml
kubeseal -o yaml <tmp.yaml >repo-secrets.yaml 
rm -f tmp.yaml && mv repo-secrets.yaml argo-cd/overlays/dev # 作成したファイルをkustomizeのデプロイ対象に含める

# argocdをデプロイ
kustomize build argo-cd/overlays/dev | kubectl apply -f -

次にArgoCDで管理するApplicationリソースをデプロイします。 これらをデプロイすると、ArgoCDがApplicationの設定を読み取ってリポジトリ<−>クラスタ間の同期をかけます。

kustomize build applications/overlays/dev | kubectl apply -f -

動作確認

ログイン方法はこちらを参照ください。 Ingressのエンドポイントにアクセスすると、下記のような画面を確認できるかと思います。

これでArgoCD自身をArgoCDで管理する方法ができました!

f:id:jrywm121:20210308233352p:plain
argocd

実際に動くと楽しいですね! ここに自作のアプリケーションを載せていくときには、リポジトリごと別にして作成するのが一般的なのかなと思います。

時間見つけて、Argo Rolloutも復習しておきたいです!以上!

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は結構深ぼっていくと面白そうですね(ちょっと理解するのに時間がかかりました)。
以上!