1クール続けるブログ

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

kustomizeでCRDのpatchesStrategicMergeを動かしてみる

記事一覧はこちら

背景・モチベーション

以前に記事を書いて、kustomizeでbuildする時にCRDでpatchesStrategicMergeが利用できない悲しみを文字に起こしました。
(紛らわしいので当該記事は非公開にしてあります)

ところが、v4.1.0のリリースで可能になったようです!うれしいですね…!ということで早速試してみた記事です。

参考文献

どのようにCRDでpatchesStrategicMergeを実現するのか

CRDでのpatchesStrategicMergeはkustomizeでどのように実現されるのか確認していきます

Kubernetes本体ではv1.16からCRDのapplyがJMPからSMPに変わっている

kubernetesのv1.15までは、CRDをapplyしたときの挙動がkubernetes/kustomizeで足並み揃っていて、下記のような挙動になっていました。

  • kubernetesのnativeオブジェクトはstrategicMergePatch(SMP)を使う
  • CRDはjsonMergePatch(JMP)を使う

例えば、PodTemplateのコンテナのリストをマージする時などはその違いが顕著です。SMPであれば、各要素のnameをキーとして利用して、同一のキーが存在すれば更新し、無ければ配列に追加するという挙動を取ります。対して、JMPの場合には配列ごと置き換えます。

この状況は、v1.16のServer-Side Applyのリリースによって変わります。
詳しくは、Merge Strategyを参照いただければと思うのですが、API開発者がList/Map/structのマージ方法をOpenAPIスキーマで定義できるmarker(extension)が追加されました。
ちなみにstrategic merge patch で以前から利用されているx-kubernetes-patch-strategy: mergex-kubernetes-patch-merge-keyは、それぞれx-kubernetes-list-type: mapx-kubernetes-list-map-keysとして解釈されます。

そして、Server-Side Applyを利用したCRDで定義されたリソース更新では、markerが存在すれば、その定義に基づいてフィールドの更新を行うようになりました。

しかしながら、kustomizeは従来どおりのJsonMergePatchをCRDのマージで利用する挙動になっていたため、kubernetes v1.16のリリース時からkubernetesとkustomizeの間で挙動の違いが生まれることになってしまいました。

kustomizeでCRDのSMPを行う方法

KubernetesネイティブオブジェクトのSMPに関しては、kustomizeのバイナリにOpenAPIの定義が含まれるので、Merge Strategyはその定義を参照することで確定することができます。対してCRDの定義はバンドルしていません。kustomize自体がどうにかしてCRDの定義を知る必要があります。

そこで、kustomizeはkustomization.yamlファイルにopenapiフィールドを追加して、そのフィールドを読み込んでスキーマを取得するように機能追加を行いました。openapiフィールドが指定されていれば指定されたスキーマを読み込み、そうでなければビルトインの定義を利用するような動きをするようになっています。
この機能追加により、CRDを含む全体のスキーマファイルが手元にあれば、より柔軟なマージ戦略を行うことができるようになりました。

ではスキーマの取得が必要になってきます。これはKuberneteのAPIサーバに対して、/openapi/v2のパスでリクエストすることで可能です。kustomizeはopenapi fetchというサブコマンドの内部でそれを実行することでスキーマの取得をサポートしています。

流れとしては下記のような流れとなります。

  • (CRDのファイルをapplyする)
  • kubectl openapi fetchスキーマのファイルを取得する
  • kustomizationにopenapiのフィールドを追加してファイルを指定する

スキーマのfetchの問題点

kubectl openapi fetch の際の内部処理で/openapi/v2にリクエストしていると先ほど言及させていただきました(参考)。これはOpenAPIのv2での公開です。
OpenAPI v3で定義されたCRDはv2でサポートされていないフィールドを含んだり、表現できないnullableを利用したりします(参考)。それらはkubectl v1.13より前のバージョンでは解釈できないため、互換性を守るために公開時に削除されます。
もしかしたら、この挙動がkustomize buildで影響を及ぼす可能性があります。

実際にCRDでpatchesStrategicMergeできるか確かめてみる

自前のGKEクラスタで確認していきます。バージョンは1.18.16です。

CRDのマニフェストのmergeStrategyを確認する

事前の準備としてCRDのマニフェストのmergeStrategyを確認しておく必要があります。今回はArgoCDのRolloutリソースで確認していきますが、2つのキーの組み合わせで一意に解釈させたい.spec.template.spec.containers.portsなどは既にmarkerが利用されていますが、.spec.template.spec.containersのarrayはそうなっていません。
今回、自分の方でmarkerを追加して試してみたいと思います。.spec.template.spec.containersのarrayのみにmarkerを追加いたしました。ちなみにkustomizeの実装を見ていると、x-kubernetes-list-typeを確認せずにx-kubernetes-patch-strategyのmarkerを確認しに行きます。そのmarkerが見つからないとx-kubernetes-list-map-keysを確認してくれないみたいなので注意してください(該当コード)。
…本家のServer-Side Applyでもこうなっているのかな…?後で確認してみようと思います。

CRDのリファレンスを確認すると、x-kubernetes-patch-strategyが許容するフィールドに存在しないため、schemaをfetchした後に追加してあげる必要があります。辛い…。

確認手順

利用するファイル群はこちらにまとめています。

github.com

自前のGKEクラスタで試してみます。

# CRDの登録
$ cd /tmp
$ git clone https://github.com/44smkn/kustomize-crd-smp-sample.git
$ cd kustomize-crd-smp-sample
$ gcloud container clusters get-credentials my-gke-cluster
$ kubectl apply -f crds/rollout.yaml --validate=false

# kustomizeのインストール
$ curl -s "https://raw.githubusercontent.com/\
kubernetes-sigs/kustomize/master/hack/install_kustomize.sh"  | bash
$ ./kustomize version   # v4.1.0以上であることを確認

# スキーマのfetchとキーの挿入
$ ./kustomize openapi fetch | sed -e '1d' > tmp.json
$ cat tmp.json | jq  '.definitions["io.argoproj.v1alpha1.Rollout"].properties.spec.properties.template.properties.spec.properties.containers |= .+ {"x-kubernetes-patch-strategy": "merge"}' > schema.json


# 確認
$ ./kustomize build > actual.yaml
$ diff -h actual.yaml expected.yaml

上手く動くところまで確認できましたが、仕様の問題なのか自分のドキュメントの読み込みが甘いのかわかりませんが、まだ実用に持っていくのは難しいような要素を感じました。
細かいところを確認して必要そうならIssue挙げてみようと思います。以上!

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も復習しておきたいです!以上!