1クール続けるブログ

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

s3selectを実行するGo製CLI を作ってみての気づきとか

背景・モチベーション

ALBのアクセスログとかをクエリしたいときがあります。例えばELB5xxエラーが発生していて、そのリクエストの詳細を知りたい時などです。しかも複数ファイルに対して、一括でクエリを投げられるとなお嬉しい。
1日待てばAthenaに放り込まれるが待っていられないぞーという自分がいたりします。

そこで、自分の要件に沿ったs3selectCLIを勉強がてら作ってみました。作る上で参考にしていたのは、GitHub CLIAWS LoadBalancer ControllerCopilot CLIあたりです。

気づきをこの記事でまとめて、後々の自分に役立ててもらおうと思います。

リポジトリはこちらになります。

github.com

CLIについて

cobra を使う前提です

エラーハンドリングについて

cobraSilenceErrorsSilenceUsagetrueにして、cmd.ExecuteC() の呼び出し元でSentinel Error方式を利用しエラーの種類によって出力を分岐させるのが良さそう。エラーによっては Usageを出力したりしなかったりとか諸々パターンが有ると思うので、cobraの設定でハンドリングするのが難しいからかもしれない。
CLIであれば、Error interfaceを実装した独自エラー構造体を作りerrors.Asを使って処理を分岐するといったところまで作り込まなくても良さそう。

func NewCmdRoot(f *cli.Factory, version, buildDate string) *cobra.Command {
    cmd := &cobra.Command{
        SilenceErrors: true,
        SilenceUsage:  true,
    }
}

func main() {
    if _, err := rootCmd.ExecuteC(); err != nil {
        if errors.Is(err, terminal.InterruptErr) {
            fmt.Fprint(stderr, "\n")
        }
    }
}

CLIで共通利用する設定はひとまとめにする

ターミナルの入出力設定やCLIが独自の設定ファイルを用意する場合には、cmd.AddCommandの引数に渡す *cobra.command を生成するコンストラクタに共通の設定を定義した構造体を渡してあげるようにするとスッキリする。

type Factory struct {
    In     io.ReadCloser
    Out    io.Writer
    ErrOut io.Writer

    Config     func() (config.Config, error)
    Logger     *zap.Logger
    Executable string
}

func main() {
    cliFactory := cli.NewFactory(buildVersion, logger)
    rootCmd := root.NewCmdRoot(cliFactory, buildVersion, buildDate)
    // ...
}

テストについて

外部APIを利用するテストにはgomockを使う

外部API、今回の文脈ではaws-sdk-goを利用して叩くAWSAPIをテストする場合についてです。スタブを用意しても良いのかもしれませんが、間接出力も合わせて検証したいのでモックを利用します。モック/スタブの定義は テストダブル - Wikipedia を参考にしています。

Goにおけるモックライブラリでよく使われていそうなのは、golang/mockもしくはstretchr/testifymockパッケージという印象があります。今回、自分は前者を利用しました。
自分で定義したinterfaceに、aws-sdk-goで定義されているインターフェース(下記のコードで言うところのs3iface.S3API)をEmbeddingすることで、mockgenが外部APIのモックコードも自動生成してくれます。

//go:generate mockgen -destination=../../../mocks/aws/services/mock_s3.go -package=mock_services github.com/44smkn/s3select/pkg/aws/services S3
type S3 interface {
    s3iface.S3API

    // wrapper to ListObjectsV2PagesWithContext, which aggregates paged results into list.
    ListObjectsV2AsList(ctx context.Context, input *s3.ListObjectsV2Input) ([]*s3.Object, error)
}

設定ファイル読み込み関数はテストはStubを用意するのも一つの手

テスト対象がメソッドかつ間接出力も検証したいという場合にはモックが必要になるかなと思いますが、テスト対象が関数かつ間接出力の検証も必須でない場合にはStubで十分というのがあります。設定ファイルの読み込み処理なんかは、もちろんメソッドに落とし込むことも出来るかと思いますが関数にしがちかと。
テスト対象から呼び出される関数は変数として宣言しておきます。

var ReadConfigFile = func(filename string) ([]byte, error) { ... }

// テスト関数から defer で呼び出す
func stubConfig(t *testing.T, configContents string) func() {
    t.Helper()
    original := config.ReadConfigFile
    config.ReadConfigFile = func(filename string) ([]byte, error) { ... }
    return func() {
        config.ReadConfigFile = original
    }
}

go testするときに -race flagつけるべし

Race Conditionを検出するフラグがgo testにあることを僕は知らなかったです。オライリーから出てるGo言語による並行処理のAppendixに記載ありましたが見逃していました。Go1.1からほぼ全てのgoコマンドに追加されたもので、検出された際には標準エラー出力にログを出力するようになっています。これをCIに組み込むのは有用なので、GitHub Actionで定義したテストにはこの引数を指定しています。

blog.golang.org

ビルドタスクについて

Goで書いても良いんじゃない?

Goではビルドするときに、go build の引数である -ldflagsというgo tool linkコマンドのオプションに渡されるフラグで、-X importpath.name=valueと指定してバージョンはdateを埋め込むことが多いと思います。後はWindowsの場合には、拡張子でexeを付けるとか。
自分はそのようなタスクをシェルスクリプトorMakefileで書くものと思っていましたが、GitHub CLIがgoでビルドタスクを書いてMakefileから呼び出すようにしていました。Windowsでも動きますし有用かもしれません。

.PHONY: bin/gh$(EXE)
bin/gh$(EXE): script/build
   @script/build $@

script/build: script/build.go
  GOOS= GOARCH= GOARM= GOFLAGS= CGO_ENABLED= go build -o $@ $<

goreleaserが便利

goreleaserは有名なので知ってはいましたが、今回始めて使いました。GitHubでのリリースが抽象化されていてあっという間にやりたいことが出来ました。GitHub Actionsとの組み合わせも容易でした。リリースノートもコマンドのオプションで渡せますしね。
自分はやっていないですが、nfpmを利用して、Linuxのパッケージマネージャからも扱えるように出来たりするのも良い点ではと思います。

goreleaser.com

雑多

awssdk利用する上でHandlerは理解しておいたほうが良さそう

今までちょこちょこ aws-sdk-go 使ってきたけど理解が足りなかったところで、aws-sdk-go/aws/request.Handlerにぶら下がるHanlderListをhttpリクエストの対応するフェーズで呼び出しています。

例えば、CloudTrailのレコードにUserAgentが記録されますが、そこにツール名を入れられると後で確認しやすそうです。その場合には、下記のようにHandlerを利用側から追加することが出来ます。
また、各リクエスト毎にもこの設定は可能で、かつHandlerを逆に削除することも可能です。リクエストの可変長引数に request.Optionを渡せるのですが、このOptionの型が func(*Request)なので割と好き勝手に出来てしまいます。

// ハンドラーの追加
func NewCloud(cfg CloudConfig) (Cloud, error) {
    sess := session.Must(session.NewSession(aws.NewConfig().WithRegion(cfg.Region)))
    sess.Handlers.Build.PushFrontNamed(awsrequest.NamedHandler{
        Name: fmt.Sprintf("%s/user-agent", appName),
        Fn:   awsrequest.MakeAddToUserAgentHandler(appName, build.Version),
    })
}

// ハンドラーの削除
func hoge() {
    resp, err := s.cloud.S3().SelectObjectContentWithContext(ctx, input, func(r *awsrequest.Request) {
        r.Handlers.Send.RemoveByName(awsclient.LogHTTPResponseHeaderHandler.Name)
    })
}

renovate入れとく

renovateは依存モジュールのバージョンを最新に保ってくれるためのサービスです。Githubリポジトリにはここから導入することが出来ます。依存しているモジュールがバージョンアップしたら、Pull Requestを発行してくれます。

アップデート後にgo mod tidyを実行するように設定ファイルにpostUpdateOptionsを宣言しておくと良いです。

{
  "postUpdateOptions": [
    "gomodTidy"
  ]
}

docs.renovatebot.com

VSCodeデバッグを行う上でちゃんと launch.json 使う

めっちゃ当たり前!なんだけど出来ていなかった。リクエストを受け付けるアプリケーションやシンプルなものだと、なんとなくでデバッグ出来てしまっていた。
CLIとかだと引数によって挙動が大きく変わるので、launch.jsonargsとかによって複数設定を管理しておくと楽ということに気づけた。

code.visualstudio.com

感想

作ってみたアプリケーション自体は、テストのカバレッジがまだまだ低かったりエラーハンドリングまだまだだったりするので、勉強がてらちまちま書いていこう
以上!

CDK for TerraformがGoをサポートしたので試してみた

記事一覧はこちら

背景・モチベーション

CDKには元々興味がありました!CloudFormationでのyaml記述はやはり辛いものがあるし、プログラミング言語を利用することでコード補完によるサポートを得られたり、抽象化やvalidateを入れたり出来るのではと期待があります。
CDK for Terraformのv0.0.4がリリースされ、Goのサポート(experimental)を開始したことをこの記事から知り、良い機会なのかなと思い試してみました。

参考文献

CDK For Terraform とは

ここにあるとおり、プログラミング言語でインフラストラクチャを記述したいというDeveloperの要望に応える形で、AWS CDKのチームと協力し、AWS CDKの2つの主要なコンポーネントを活用してTerraform用のCDKをサポートし始めたとのことです。

AWS CDKに関しては、こちらが分かりやすいです。
AWS CDKはCloudFormationテンプレートをjson形式で吐くのに対し、Terraform用CDKはTerraformの構成ファイルをjson形式で吐きます。元々、HCLだけでなくjsonもサポートしているのはあんまり意識したことなかったなあと思ったり。

CDKは多言語をサポートしてます。そこにはjsiiというライブラリとかが絡んできていて、掘るのが面白そうなのですがそれはまた別の記事にしようと思います。

Getting Started

リポジトリはこちら

github.com

※ Terraformはインストール済の前提

実装の初期段階まで

$ brew install cdktf
$ mkdir cdk-for-terraform-sample

# 雛形の作成
$ cdktf init --template="go" --local

# moduleの宣言が自動生成されているので修正する
$ vi go.mod 

プロジェクトルートにある cdktf.json に利用するTerraform ProviderとModuleを宣言します
例えば、AWSのProviderとEKSのModuleを利用するのであれば下記のように宣言する

{
    "language": "go",
    "app": "go run main.go",
    "codeMakerOutput": "generated",
    "terraformProviders": [
        "hashicorp/aws@~> 3.40.0"
    ],
    "terraformModules": [
        {
            "name": "aws-eks-module",
            "source": "terraform-aws-modules/eks/aws",
            "version": "~> 16.0"
        }
    ]
}

下記のコマンドを実行すると、上で設定した内容を元にコードを自動生成します
codeMakerOutput で設定したディレクトリに生成したコードが配置されます

$ cdktf get
⠇ downloading and generating modules and providers...  # めっちゃ時間かかりますし、メモリを6-7GB食います

Goのコードで実装

main.go に宣言された、NewMyStack 関数にてリソースの定義を行っていきます。
基本的な関数の構造としては下記のような形で、第一引数と第二引数は各関数で共通で、第三引数は各リソースごとのinputのパラメータを持った構造体を渡してあげるイメージ。殆どが参照渡しになるので、適宜jsiiパッケージのヘルパー関数を利用する。

vpc := aws.NewCamelCaseResourceName(stack cdktf.TerraformStack, id *string, config *aws.CamelCaseResourceNameConfig)

例えば、簡単なAWSのスタックを作成するとなると下記のようになると思います。

func NewMyStack(scope constructs.Construct, id string) cdktf.TerraformStack {
    stack := cdktf.NewTerraformStack(scope, &id)

    aws.NewAwsProvider(stack, jsii.String("aws"), &aws.AwsProviderConfig{
        Region: jsii.String(region),
    })

    vpc := aws.NewVpc(stack, jsii.String("isucon_vpc"), &aws.VpcConfig{
        CidrBlock: jsii.String("172.16.0.0/16"),
        Tags: &map[string]*string{
            "Name": jsii.String("isucon-training"),
        },
    })

    igw := aws.NewInternetGateway(stack, jsii.String("isucon_vpc_igw"), &aws.InternetGatewayConfig{
        VpcId: vpc.Id(),
    })

    subnet := aws.NewSubnet(stack, jsii.String("isucon9_subnet"), &aws.SubnetConfig{
        VpcId:            vpc.Id(),
        CidrBlock:        jsii.String("172.16.10.0/24"),
        AvailabilityZone: jsii.String("ap-northeast-1d"),
        Tags: &map[string]*string{
            "Name": jsii.String("isucon-training"),
        },
        DependsOn: &[]cdktf.ITerraformDependable{
            igw,
        },
    })

    // 他にもリソースを作成(詳しくはリポジトリをご覧ください)

    return stack
}

デプロイしてみる

下記のコマンド実行時に cdktf synth も実行されて、cdktf.outディレクトリにjson形式でterraformの構成ファイルが生成されます。
注意点としては、cdktfのv0.4.0の時点では cdktf destroy時にエラーが発生します(Issue)。

# terrafrom planとほぼ同じような出力が得られる
$ cdktf diff
Stack: cdk-for-terraform-sample
Resources
 + AWS_DEFAULT_ROUTE_TA isucon9_subnet_rout aws_default_route_table.isucon9_subnet_
   BLE                  e_table             route_table
 + AWS_EC2_MANAGED_PREF default_prefix      aws_ec2_managed_prefix_list.default_pre
   IX_LIST                                  fix
 + AWS_EIP              isucon9_qualify_ins aws_eip.isucon9_qualify_instance_eip
                        tance_eip
 + AWS_INSTANCE         isucon9_qualify_ins aws_instance.isucon9_qualify_instance
                        tance
 + AWS_KEY_PAIR         developer_keypair   aws_key_pair.developer_keypair
 + AWS_ROUTE_TABLE_ASSO isucon9_subnet_rout aws_route_table_association.isucon9_sub
   CIATION              e_table_d           net_route_table_d
 ~ AWS_SECURITY_GROUP   isucon9_qualify_ins aws_security_group.isucon9_qualify_inst
                        tance_sg            ance_sg
 + AWS_SUBNET           isucon9_subnet      aws_subnet.isucon9_subnet

Diff: 7 to create, 1 to update, 0 to delete.

# デプロイ
$ cdktf deploy

# お片付け(エラーが発生します)
$ cdktf destroy
ypeError: Cannot read property 'startsWith' of undefined

使ってみての所感

文字列をポインタで渡さなくてはいけない関係上、jsii.String関数を毎回使うのでスッキリ見えないのがあまり良くないかもというのはあります。
また、この段階だからだと思うのですが、Terraformのresourceやmoduleを元にコード生成する過程でかなり時間とリソースを食います。これはネックになりそうです。
規模感が大きくならないと、中々恩恵は受けづらいのかなと思う面もありました。
(でもyaml書くより楽しい…)
以上!

sqlcを利用してGoのDB周りの処理を扱う

記事一覧はこちら

背景・モチベーション

以前にsqlcの作者のブログ記事を読んだことがきっかけです。だいぶ前におそらくredditで話題になっていたものを拾った気がします。
さっと見て開発生産性が上がりそうだなと思ったので試してみました。

sqlcとは

github.com

SQLからtype-safeなコードを生成してくれるツールです。
長い間、ソフトウェアエンジニアはプログラミング言語のアノテートされたオブジェクトからSQLのクエリを生成してきていました。SQLは既に構造化・型付けされた言語のため、SQL自体から正しいtype-safeなコードを生成すべきというのが、作者のモチベーションのようです。
現在のところはPostgreSQLMySQLのみをサポートしており、SQLiteも今後はサポート予定のようです。

sqlcを利用する方法

  1. SQLを書きます
  2. sqlcを実行し、1.で作成したクエリに対するtype-safeなinterfaceを提供するGoのコードを生成します
  3. sqlcが生成したメソッドをユーザのコードから呼びます

sqlcのメリット

コード生成をしている過程で、全てのクエリとDDL文を解析します。テーブルの列の名前や型が一致しない場合には、sqlcはクエリのコンパイルに失敗することで実行時エラーを未然に防ぐことができます。
sqlcが生成するメソッドは厳密なGoの型定義を持つため、クエリの引数やカラムの型を変更した時にコードを更新しなければコンパイルに失敗するようになっています。
安全ですね…!

database/sqlパッケージを直接利用することのデメリット

クエリが増えてくると、マッピングをメンテナンスするのが面倒になり生産性に大きな影響を与えます。
更に良くない点として、下記の3点のような実行時まで気づけないミスを犯してしまう可能性があります。

  • クエリのパラメータの順番を入れ替えた時に、マッピングの更新が漏れる
  • テーブルにカラムが追加された時に、追加したカラムの値を返却するようにクエリを変更する作業に漏れが生じる
  • SQLの方とコードの型に相違がある

gormなどの高レベルライブラリを利用することのデメリット

先程言及したdatabase/sqlパッケージを直接利用することのデメリットは解消されますが、クエリテキストやstructタグを使用して手動でのマッピングをせざるを得ず、間違った場合の実行時エラーが起こる可能性は大きくは減っていない状況と言えます。

sqlcを実際使ってみての感想

この後に動かしてみたことについて記載しますが、感想を先に述べておこうと思います。

当初からサポートしているPostgresはともかく、MySQLはまだまだ実用には至れない状況だと思います。
確認できた限りで下記のような未サポート/不具合があります(Issueに上がってなさそうなものは自分の方でIssue上げてみました)。

  • [Postgres] bulk impot(COPY句)は未サポート
  • [Postgres/MySQL] 動的where句/order by句は未サポート
    • #364 によると、「CASE句で対応可能では」という提案がされていますが、Postgresは5回以上のクエリで実行計画をキャッシュすることになるがCASE句を利用した場合にはキャッシュが利用できないのでパフォーマンスが悪化する
    • MySQLではCASE文の中にあるパラメータを認識してくれない
  • [MySQL] LIMITとOFFSETで名前付きパラメータを書けない
  • [MySQL] sqlc.arg(foo) IS NULLのパラメータを無視する
  • [MySQL] 同名の名前付きパラメータを別々に認識する

sqlcを実際に動かしてみる

サンプルのリポジトリは下記です。isucon10のisuumoの実装を参考にしながらsqlcを取り入れてみました。
(謎のスイッチが入ってしまってDDDライクに作ってしまった…)

github.com

sqlcのバージョンはv1.8.0です。
まず、sqlcを実行するディレクトリ配下に置く設定ファイルの生成を行います。
queriesschemaで指定したディレクトリパスには、それぞれアプリケーション内で実行するSQLとテーブルを定義するSQLを格納しておきます。
このpathパラメータの配下にファイルが生成されます。

$ cat <<EOF > sqlc.yaml
version: "1"
packages:
  - name: "persistence"
    path: "pkg/persistence"
    queries: "_sql/queries"
    schema: "_sql/schema"
    engine: "mysql"
    emit_prepared_queries: false
EOF

それでは、コードを生成していきます。
先程作成したsqlc.yamlが存在するディレクトリで、sqlc generateを実行することでコードを生成することができます。syntaxにミスがある場合には、このときにエラーという形で確認できます。

  • ${SQL_FILENAME}.sql.go: アプリケーションのコードから呼び出すデータアクセスメソッド郡
  • db.go: sql.DBsql.Txのためのinterfaceを用意
  • models.go: テーブル定義をGoのstructとして表現したもの
$ sqlc generate
$ ls -l pkg/persistence
total 32
-rw-r--r--  1 44smkn  staff   4.5K  5 23 18:06 chair.sql.go
-rw-r--r--  1 44smkn  staff   567B  5 23 18:06 db.go
-rw-r--r--  1 44smkn  staff   415B  5 23 18:06 models.go

定義したSQLと生成されたコードの一例としては下記のようになります。このGetChair()メソッドをアプリケーションから呼び出してデータベースから情報を取得します。
拙いコードですが、上述のリポジトリでレコードのInsertや検索もやってみていますので、良かったら覗いてみてください。

CREATE TABLE chair (
    id          BIGINT  NOT NULL AUTO_INCREMENT,
    name        TEXT    NOT NULL,
    description TEXT    NOT NULL,
    thumbnail   TEXT    NOT NULL,
    price       INT             ,
    height      INT             ,
    width       INT             ,
    depth       INT             ,
    color       TEXT            ,
    features    TEXT            ,
    kind        TEXT            ,
    popularity  INT     NOT NULL,
    stock       INT     NOT NULL,
    PRIMARY KEY (id)
);

-- name: GetChair :one
SELECT * FROM chair WHERE id = ?;
const getChair = `-- name: GetChair :one
SELECT id, name, description, thumbnail, price, height, width, depth, color, features, kind, popularity, stock FROM chair WHERE id = ?
`

func (q *Queries) GetChair(ctx context.Context, id int64) (Chair, error) {
    row := q.db.QueryRowContext(ctx, getChair, id)
    var i Chair
    err := row.Scan(
        &i.ID,
        &i.Name,
        &i.Description,
        &i.Thumbnail,
        &i.Price,
        &i.Height,
        &i.Width,
        &i.Depth,
        &i.Color,
        &i.Features,
        &i.Kind,
        &i.Popularity,
        &i.Stock,
    )
    return i, err
}

sqlcはとてもイケてるなと思うツールの反面、まだまだ枯れておらず実践で利用するのには難しい印象があります。
データベースを扱うのってやっぱり難しいなという平易な感想で締めさせていただきます、以上!

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生成も楽に出来るようになりました! 以上!