1クール続けるブログ

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

conftestで複数ファイルを横断してチェックする

記事一覧はこちら

背景・モチベーション

以前にconftestの概要をざっくり知って試してみたという記事を出しました。

44smkn.hatenadiary.com

実際に実務で利用してみると、1ファイル内だけのテストだけでなく、複数のファイルを横断的に見てチェックを行いたいという需要がありました。
例えば、Serviceで指定しているラベルがDeploymentでのpodのlabelに含まれるかといった検査です。

--combineオプションを利用する

contestのv0.24.0で試しています

--combineオプションとは

www.conftest.dev

上記のドキュメントから確認できるように、--combineというオプションをconftest実行時に付けてあげると複数ファイルを一度にロードしてくれます。しかしながら、単一のファイル向けに宣言していたRuleは正常に動かなくなるため、このオプションを利用する場合には書き直す必要がありそうです。
--combineと毎回宣言するのも手間なので、自分はconftestを実行するディレクトリに、conftest.tomlを配置して設定を行っています。

combine = true

--combineオプションを利用した時のinputの内容が変わる

このcombineオプションを利用すると、inputに入ってくるドキュメントの内容が変わってきます。inputの中身を確かめるだけのpolicyを書いて実行してみます。

# policy/combine.rego
package main

deny[msg] {
    msg = json.marshal(input)
}

入力となるマニフェストは下記です。

# manifests.yaml
kind: Service
metadata:
  name: hello-kubernetes
spec:
  type: LoadBalancer
  ports:
    - port: 80
      targetPort: 8080
  selector:
    app: goodbye-kubernetes
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: hello-kubernetes
spec:
  replicas: 3
  selector:
    matchLabels:
      app: hello-kubernetes

実行すると下記のような結果になります。

$ conftest test  manifests.yaml  --combine
FAIL - Combined - main - [{"contents":{"apiVersion":"v1","kind":"Service","metadata":{"name":"hello-kubernetes"},"spec":{"ports":[{"port":80,"targetPort":8080}],"selector":{"app":"goodbye-kubernetes"},"type":"LoadBalancer"}},"path":"manifests.yaml"},{"contents":{"apiVersion":"apps/v1","kind":"Deployment","metadata":{"name":"hello-kubernetes"},"spec":{"replicas":3,"selector":{"matchLabels":{"app":"hello-kubernetes"}}}},"path":"manifests.yaml"}]

出力結果のjsonを整形してあげると下記のようになります。
それぞれのResourceが配列となっています。ドキュメントにあるようにpathキーとcontentsキーをそれぞれのアイテムが持っています。pathキーはファイル名でcontentsは実際のドキュメントの中身です。

 [{
     "contents": {
         "apiVersion": "v1",
         "kind": "Service",
         "metadata": {
             "name": "hello-kubernetes"
         },
         "spec": {
             "ports": [{
                 "port": 80,
                 "targetPort": 8080
             }],
             "selector": {
                 "app": "goodbye-kubernetes"
             },
             "type": "LoadBalancer"
         }
     },
     "path": "manifests.yaml"
 }, {
     "contents": {
         "apiVersion": "apps/v1",
         "kind": "Deployment",
         "metadata": {
             "name": "hello-kubernetes"
         },
         "spec": {
             "replicas": 3,
             "selector": {
                 "matchLabels": {
                     "app": "hello-kubernetes"
                 }
             }
         }
     },
     "path": "manifests.yaml"
 }]

複数ドキュメントを横断してチェックしてみる

inputのファイルなどはまとめてこちらのリポジトリに置いてあります。

github.com

最初の方でも言及したようにServiceリソースの .spec.selectorが Deploymentの.spec.template.labelsに含まれるかをチェックしてみたいと思います。
※ ここではlabelのkeyをappとします

package main

deny[msg] {
    input[deploy].contents.kind == "Deployment"
    deployment := input[deploy].contents

    input[svc].contents.kind == "Service"
    service := input[svc].contents

    service.spec.selector.app != deployment.spec.template.metadata.labels.app
    msg := sprintf("Labels are different! Deployment %v has 'app: %v', Service %v has 'app: %v'", [deployment.metadata.name, deployment.spec.template.metadata.labels.app, service.metadata.name, service.spec.selector.app])
}

ちなみにconftestのドキュメントのExampleにあったように実行すると、下記のようなエラーが出てしまいました。同名の変数ではまずいのではと思い変更して実行したところ上手くいきました。
※ Issue上げたところ対応してもらったので現在はドキュメント上直っていると思います。

Error: running test: load: loading policies: get compiler: 1 error occurred: policy/combine.rego:5: rego_compile_error: var deployment referenced above

これで実行すると下記のようになります。
期待したチェックが機能しています。

$ conftest test  manifests.yaml 
FAIL - Combined - main - Labels are different! Deployment hello-kubernetes has 'app: hello-kubernetes', Service hello-kubernetes has 'app: goodbye-kubernetes'

Rego言語は今まで学習してきた言語とだいぶ違うので感覚を掴みづらいですね…!ただめっちゃ便利なので今後も上手くつかって円滑にチェックしていこうと思います。

kubebuilderを利用して簡素なk8sのControllerを作ってみる

記事一覧はこちら

背景・モチベーション

Controllerを書きたい!とはずっと思っていましたが、なかなか一歩を踏み出せませんでした。 しかしながら、external-dnsのコードを読んでふんわり分かってきたので書き始めようと思います。

肝心なControllerの中身ですが、検証環境のコスト削減に役立つものにしたいと思います。Deploymentリソースのアノテーションに、起動する時間と落とす時間を宣言しておくと、そのとおりにpodをスケールアウト/インしてくれるものを書こうと思います。

Controllerを作るときの指針

参考文献

kubebuilder book

つくって学ぶKubebuilder (母国語かつ無料で読める質とは思えないくらいにとても良いドキュメント様です)

Bitnamiさんの記事

Kubernetes公式リポジトリにあるGuideline

Controllerの作り方について

external-dns や Bitnamiさん の記事を読んでいると client-go のライブラリをそのまま利用してControllerが実装されています。しかしながら、Bitnamiさんの記事は少し古く2017年時点でのものでした。 しかし近年は、 kubebuilder というフレームワークを利用することが多いようです。 aws-load-balaner-controllerkubebuilder をベースに作成されていました。

Controllerの仕事

この部分は、k8sのcommunityのリポジトリ作って学ぶKuberbuilderさんdeeeetさんのブログ記事 で触れられています。

Controllerの仕事は、任意のオブジェクトについて、現在の状態が望ましい状態と一致することを保証することです。各Controllerはrootとなる一つのKindにフォーカスしますが、他のkindと相互に作用することもあります。 これらの処理を reconciling と言うそうな。

(…そう思うと今回実装しているのって厳密にControllerと言えるものなのだろうか)

// reconciling loop
for {
  desired := getDesiredState()
  current := getCurrentState()
  makeChanges(desired, current)
}

kubebuilderを利用して雛形を作り不要なものを捨てる

kubebuilderは、CRDやAdmission Webhookを作成することなども想定してコードを生成しています。ただ、今回の自分の用途の場合にはCRDやAdmission Webhookは必要としません。主に検証環境で利用する用途のため、冗長性を確保するためのLeader Electionも今回は利用しません。 そのため不要な生成済コードは取り除くことにします。

生成されたコードの役割はパッと見ではわかりません。公式のドキュメントで一箇所にまとめてある記述が見つからなかったので、作って学ぶKunebuilderさんのこのページを参考にしました。

kubebuilder init --domain ars.44smkn.github.io
kubebuilder create api --group apps --version v1 --kind Deployment --namespaced=false --resource=false --controller=true

雛形を作ったので実装していきます。

Controllerの実装

github.com

実装の大枠

Controllerを管理してくれる部分は、前述の kubebuilder コマンドによって既に作成済です。 controller-runtime によって提供された Manager が、全てのControllerの実行、共有キャッシュの設定、APIサーバーへのクライアントの設定を管理してくれます。 生成された main.go 内にてManagerのインスタンス化と Reconciler のセットアップが行われています。

func main() { 
    // ...
    mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{
        Scheme:             scheme,
        MetricsBindAddress: metricsAddr,
        Port:               9443,
        LeaderElection:     false,
        LeaderElectionID:   "08a80630.ars.44smkn.github.io",
    })
    // ...
    if err = (&controllers.DeploymentReconciler{
        Client: mgr.GetClient(),
        Log:    ctrl.Log.WithName("controllers").WithName("Deployment"),
        Scheme: mgr.GetScheme(),
    }).SetupWithManager(mgr); err != nil {
        setupLog.Error(err, "unable to create controller", "controller", "Deployment")
        os.Exit(1)
    }
    // ...
}

では、どこにコードを書いていくことになるかと言うと、 controller ディレクトリ配下の xxxxx_controller.goReconcile メソッドの中です。 下記のようにコメントしてあるので分かりやすいです。

// your logic here

Reconcile が呼び出されるのは、下記のタイミングだそうです。 こちら より引用させていただきました。

  • コントローラが扱うリソースが作成、更新、削除されたとき
  • Reconcileに失敗してリクエストが再度キューに積まれたとき
  • コントローラの起動時
  • 外部イベントが発生したとき
  • キャッシュを再同期するとき(デフォルトでは10時間に1回)

このタイミングで自分の実装ではDeploymentのアノテーションを読み取り、replicas数を操作する処理をcronに登録します。

func (r *DeploymentReconciler) Reconcile(req ctrl.Request) (ctrl.Result, error) {
    // ...
 
    // Reconcilerに生えている Getメソッドを利用してイベントがあったリソースを取得する
    var deployment appsv1.Deployment
    err := r.Get(ctx, req.NamespacedName, &deployment)
    if err != nil {
        return ctrl.Result{}, err
    }
 
    // ...(アノテーションのパースなどをする)
    return ctrl.Result{}, nil
}

動作確認

ローカルでは Kind の環境を利用して動作確認しました。
その後にRBACのマニフェスト等を整備して、Katacodaにデプロイして動くことを確認できました!気持ちいい!

$ kubectl apply -f mf.yaml
serviceaccount/app-regulary-scaler created
clusterrole.rbac.authorization.k8s.io/app-regulary-scaler created
clusterrolebinding.rbac.authorization.k8s.io/app-regulary-scaler created
deployment.apps/controller-manager created
$ kubectl get po
NAME                                  READY   STATUS    RESTARTS   AGE
controller-manager-75ccf997dd-jl426   1/1     Running   0          63s

# 1分後にreplicas数が2になる、replicas数1を宣言しているnginxのマニフェスト
$ kubectl apply -f nginx.yaml
deployment.apps/nginx-deployment created
$ kubectl get po
NAME                                  READY   STATUS    RESTARTS   AGE
controller-manager-75ccf997dd-jl426   1/1     Running   0          4m34s
nginx-deployment-6b474476c4-nrjsm     1/1     Running   0          3m15s
nginx-deployment-6b474476c4-s55xm     1/1     Running   0          2m9

README.mdやリリースをちゃんと整備して仕事の環境とかで使えると便利だなあと思いました!以上!

GitHub Actionsでmarkdownをpdfで出力する

記事一覧はこちら

背景・モチベーション

MarkdownのファイルをPDFに変換するのはVS Codeのエクステンションで出来るけど、Github Actionsでpushするごとに自動で生成してくれたら楽だなあと思いやってみました。 ついでにパブリックベータで提供されている、Githubのdocker registryも試したかったので、そちらも使ってみました!

Pandocのコンテナを作成する

MarkdownをPDFにする手段としてはPandocを選択しました。 md-to-pdf も良いかもと思ったのですが、Linux環境で上手く動かなかったので断念しました。

Pandocとは

PandocHaskellで書かれたコマンドツールで、あるマークアップ形式で書かれた文書を別の形式へ変換してくれるものです。多様なフォーマットをサポートしています。

PDF変換するときには、--pdf-engine のコマンド引数で指定したPDF変換エンジンを利用します。デフォルトの pdflatex では和文をサポートしていないので、代わりに wkhtmltopdf を利用します。pandocでは、コマンド引数に渡されたcssを適用しつつMarkdownをhtmlに変換し、そのhtmlを wkhtmlpdf を使ってPDF化しているようです。

PandocのDockerコンテナを作成する

公式で提供しているイメージもあるのですが、 wkhtmltopdf が入っていないのと和文フォントが入っていないので1から作りました。wkhtmltopdf のダウンロードページにalpine Linux向けが無かったので、ベースイメージはUbuntuにしました。

apt install 中にTimeZoneを入力させるインタラクティブな操作をしないために、最初に設定しています。

FROM ubuntu:focal

ENV TZ=Asia/Tokyo
RUN apt update && apt install -y tzdata
RUN apt install -y pandoc wkhtmltopdf fonts-ipafont fonts-ipaexfont && \
    fc-cache -fv

LABEL org.opencontainers.image.source=https://github.com/44smkn/pandoc-ja-container

ENTRYPOINT [ "pandoc" ]

GitHub Docker Regisryにコンテナイメージをpushする

一度もGitHub Docker Registryを利用したことがなければ、 こちらの手順でコンテナレジストリの機能を有効化する。

f:id:jrywm121:20210323222913p:plain

docker registryを使うための認証には PAT を使うようです。PATのスコープで下記のようにアクセス範囲を設定し作成します。

f:id:jrywm121:20210323222928p:plain

こちらの認証フローを参考にしつつ、下記のような手順でDockerコンテナイメージをpushします。リモートリポジトリは、 ghcr.io/OWNER/IMAGE_NAME:VERSION という名称になるようです。

export CR_PAT=<REPLACE_YOUR_PAT>
echo $CR_PAT | docker login ghcr.io -u USERNAME --password-stdin

docker build -t pandoc/ja:0.1.1 .
docker tag pandoc/ja:0.1.1 ghcr.io/44smkn/pandoc/ja:0.1.1
docker push ghcr.io/44smkn/pandoc/ja:0.1.1

GitHub Actionsに組み込む

github.com

それでは実際にGithub ActionsにPDF生成を組み込んでいこうと思います。 .github/workflowsyamlファイルを作成します。

先程のpushしたイメージを利用するために、Docker public registry action を宣言し、args にpandocコマンドの引数を渡します。 -c で適用するCSSファイルを適用しています。自分はGitHubMarkdownにあたっているCSSを参考にちょこちょこ変えてリポジトリルートに配置しました。

upload-artifact というActionを利用し、生成したPDFを保存します。 出来上がったyamlファイルは下記です。

name: Generate CV PDF

on: push

jobs:
  convert_via_pandoc:
    runs-on: ubuntu-18.04
    steps:
      - uses: actions/checkout@v2
      - run: |
          mkdir output
      - uses: docker://ghcr.io/44smkn/pandoc/ja:0.1.1
        with:
          args: README.md -s -o output/blog.pdf -c style.css --pdf-engine=wkhtmltopdf
      - uses: actions/upload-artifact@master
        with:
          name: curriculum-vitae
          path: output/blog.pdf

成果物はGitHub ActionsのWorkflowのSummaryから取得できます!

f:id:jrywm121:20210323224122p:plain

肝心のPDFですが、下記のような形で生成されました。cssとかもっと凝ればリッチにできそうですが、ひとまず及第点かなと思います。

f:id:jrywm121:20210323224717p:plain

これでMarkdownをバージョン管理しつつ、PDF生成も楽に出来るようになりました! 以上!

競プロの勉強するときにテストも書く 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バージョンが上がるのを一旦待とうと思います。

以上!