nがひとつ多い。

えぬなおの技術的なことを書いていくとこ。

【WSL2】WSL2でgitが遅いのをなんとかする

WSLとgit

WSL2を普段使っているが、大きなプロジェクトになるとgit statusが非常に遅いのがストレスになってきた。

❯❯❯ time git status
git status  0.05s user 2.60s system 44% cpu 6.010 total
❯❯❯ date '+%Y-%m-%dT%H:%M:%S.%3N' && git status && date '+%Y-%m-%dT%H:%M:%S.%3N'
2021-02-12T13:45:34.304
<snip>
2021-02-12T13:45:40.693

え・・・6秒・・・( ^ω^)

理由は単純

2021/1現在、WSL2上のファイルシステムへのLinuxシステムコールの発行/処理は遅い事で知られている。

github.com

中の仕組みを考えてみれば単純で、WSLはWindowsシステムコールLinuxシステムコールをバイパスしているので、まぁパフォーマンスを悪そうだとは思う。 linuxのgitコマンドではLinuxシステムコールから呼んで、Windowsシステムコールに変換して、ディスクにアクセスしてメモリに乗せて・・・みたいな事をするはずという話なんだ。

どうする?

ここで紹介されているようなhackをすればいい。

github.com

zshrcやbash_profileに以下を追記すれば終わり。

# checks to see if we are in a windows or linux dir
function isWinDir {
  case $PWD/ in
    /mnt/*) return $(true);;
    *) return $(false);;
  esac
}
# wrap the git command to either run windows git or linux
function git {
  if isWinDir
  then
    git.exe "$@"
  else
    /usr/bin/git "$@"
  fi
}

gitコマンドの時だけwindowsのgit.exeでアクセスしちゃうというハックで、筆者も「バカげた方法だが」と書いているがなんというか妥当な対処法だと思われる。 要はWindows側でインストールされたgit.exe経由でファイルの情報を引っ張って来れば無駄なシステムコールのバイパスが減るというわけだ。

デモ

git.exeという外部コマンドになるのでtimeコマンド使えない。という事でdateコマンドでも挟む

❯❯❯ date '+%Y-%m-%dT%H:%M:%S.%3N' && git status && date '+%Y-%m-%dT%H:%M:%S.%3N'
2021-02-12T13:47:18.445
<snip>
2021-02-12T13:47:18.526

処理が80ミリ秒くらいになった。これで快適。

某CS大学院に合格しました

2020年10月から恐らく僕は学生となります。

大学院行く動機

  • 今までの自分の知識基盤が歪で、体系立ってない自分の雑魚さに遂に嫌気が刺した
  • 洋書読むと英語/数学/CSの知識が当然のように要求され、期間を取って1から出直す必要があると感じていた
  • 年齢、家庭、業務状況、総合的に鑑みて 受験準備も含め、通うタイミングが今しかない と思った

https://ko839.up.seesaa.net/image/image-2d94c.jpeg (引用:ワンピース55巻より)

作戦

  • 学部でCSのバックグラウンド無し
  • 学部時代の成績も正直良くない
  • 別に業務で研究とかしても無し
  • 専門的に尖って詳しい分野無し
  • 特にコネ無し

正直、こんな状態だったので面接、提出物で一発逆転ひっくり返さないと入学さえ無理だ(無理かさえも正直分からなかったが正確かな)と当初思いました。
そこでよく大学院の受験時に出す「小論文」にそのまま「研究希望計画書」として書き切り、
しかも1から自分で考えた内容で、かつあまり世に出てない新規性のある(と自分が勝手に思っている)ジャンルであればワンチャン認めてもらえるんじゃないか、という感じでした。
(アカデミックへの情熱があるならそれくらい出来るだろ、と自分に言い聞かせるのも込めて)

それとテーマ的に面接でLinuxカーネルや数学周りの質問/試験も来ると思ったので、CSや数学の勉強も併せて事前にしておきました

研究テーマ

実際、配属先やテーマがまだ確定したわけではないですので、なんとも。 最終的に受験時に出した研究希望計画書は カーネルとかコンテナとかのセキュリティとか です

セキュリティ?

実際、現在他の得意分野としているデータベースや言語やネットワークとか色々考えたですが、
自分が今1から研究計画書を書けない分野なら2年も行っても仕方ないと思っていたので、
(=というふうに自分を追い込まないと勉強も捗らないのかなと勝手に思ったのもあったかも)
そんなこんなで何個か論文を査読しながら書き散らしたのですが 唯一マトモにかけたのがセキュリティ でした。

上記の理由が、大学院選びや分野選びに直撃したのですが、
結果数学やCSの根本を学びとるのにとても良く、
今後のキャリアの方向性としても喜んで極めていきたい分野 なので良かったです。

余談かもですが、IT関連本で僕の一番好きな本はオライリーPGP本で、それに感動し暗号本を買い漁った経緯もあるので、
結果論として伏線回収出来たかもしれません

www.oreilly.co.jp

面接前

1月に大学院説明会に行った時、実は2月に試験があるのでそれをもう受けちゃえと思っていました。 しかし、教授の方と話を進めると「オートマトン」「圏論」「シーケンス暗号」字面しか分からない単語が飛び交い、 そもそも研究したいテーマをその場で話すと、「君がやりたいのはユースケースであって、課題や背景がないし、その評価の引用は?」などと言っていただき、 一種場違いな感触を感じたので2月のその試験は見送り、基礎的な学問を固めてから次点で受ける5月に(結果コロナで受験は7月〜8月にずれ込む形になりました)

面接

受ける前は地獄でした^^あはは・・・
時間が無く、(これは半分は自分のせい)
コロナにも振り回され、(受けようとした大学院の日程は変わりまくった)
研究室訪問は断られまくられ、(これについては自分で考えた研究テーマとアポ取った研究室が違いすぎたのでほぼ自分のせい)
時期改めようとした所、色々腹を決めて受けに行きました。

研究希望計画書のテーマが訪問した大学院の研究室にマッチしてないことが多いのが想定外でした。
結果、受験する大学院がめっちゃ絞られまくったので後がなくなってきたのはキツかったです。
研究室訪問は余裕を持っていきましょう・・・!

しかし現在合格した大学院の先生方には「こんなテーマ、やってる研究室ウチには今までないね。面白いよ」と言っていただいたのは非常に良かったです。

締め

また院の生活が始まったらブログ投稿します ^^

【Vue】【CSS】Vue.jsで"""最初から開いた状態"""でアコーディオンしたい

アコーディオン??

元々jQueryで実現されていたアニメーションで、 イベントに応じて、隠れた要素が伸縮して出てくる、メニューとかでよく使われて「アコーディオンメニュー」なんて言ったりする。 CSSだけだと以下のような見えないチェックボックスをイベントでチェックして表現したりする。

webdesignday.jp

以下は引用させていただいた。

See the Pen accordion menu by Kiyonobu Kasuga (@kiyonobu-kasuga) on CodePen.

一方 transition ディレクティブなんていう便利なコンポーネントが元々ある Vueは、 アニメーションの描写をもう少しスマートに書くことができる。

jp.vuejs.org

引用だと以下のサイトが詳しい。

lab.astamuse.co.jp

See the Pen vue-accordion - fin by 35n139e (@35n139e) on CodePen.

へえ、じゃあそれでいいじゃん。

さて、本題だが、以上のコードで1つ問題がある。 それが表題にあるような、「開いた状態でのアコーディオン」だ。 これはつまり、普通メニューにしろなんにしろ、アコーディオンと言えば閉じた状態からアニメーションを開始するわけだが、 「最初から開いた状態」で作るとただコピペでは作れない。

実際に、シンプルな構成で作ってみる。 「最初から閉じている状態」ならうまくいく。

See the Pen test-accordion-vue01 by nnao45 (@nnao45) on CodePen.

では、この開閉を制御するisShowをtrueで初期状態にしてみよう、

See the Pen test-accordion-vue02 by nnao45 (@nnao45) on CodePen.

うん、うまくいかない。

試行錯誤

思考過程が見たい人はどうぞ

そもそもどうアニメーションしているのか。

初めに紹介したが、Vue.jsには便利な <transition> コンポーネントというやつがありnameで指定したtransitionはCSSで更にアニメーションの動作上にCSSを充てることができる。

例えばtransitionのnameはexpandとすると、上記の例にみたように

.expand-enter-active,
.expand-leave-active {
    transition: height .5s ease-in-out;
    overflow: hidden;
}

.expand-enter,
.expand-leave-to {
    height: 0;
}

のように <name>-enter だのそういうふうに固定名で指定してやるといい。 加えて、transitionコンポーネントをそのアニメーションの動作の各地点でheightを0pxから元ある高さに伸ばす事でアニメーションさせるわけだ。

Vue.component('my-component',{
  template: `
  <div class="app">
        <div class="components">
            <p class="title">アコーディオン1</p>
            <transition
               name="expand"
               @before-enter="beforeEnter"
               @enter="enter"
               @before-leave="beforeLeave"
               @leave="leave"
            >
                <div v-show="isShow" class="body">
                    <p>アコーディオン1の中身</p>
                    <p>アコーディオン1の中身</p>
                    <p>アコーディオン1の中身</p>
                </div>
            </transition>
        </div>
        <button v-on:click="isShow = !isShow">ボタン</button>
    </div>
  `,
  data() {
        return {
            isShow: true
        };
    },
    methods: {
        beforeEnter(el) {
            el.style.height = "0";
        },
        enter(el) {
            el.style.height = el.scrollHeight + "px";
        },
        beforeLeave(el) {
            el.style.height = el.scrollHeight + "px";
        },
        leave(el) {
            el.style.height = "0";
        }
    }
});

上記は多分ネットで一番やられているコピペだと思う。

なにが悪いのか

要はトランジションクラスの指定が最初から空いている状態ではうまくいかないのであろう Vueの公式を見ると、

v-enter: enter の開始状態。要素が挿入される前に適用され、要素が挿入された 1 フレーム後に削除されます。

v-enter-active: enter の活性状態。トランジションに入るフェーズ中に適用されます。要素が挿入される前に追加され、トランジション/アニメーションが終了す ると削除されます。このクラスは、トランジションの開始に対して、期間、遅延、およびイージングカーブを定義するために使用できます。

v-enter-to: バージョン 2.1.8 以降でのみ利用可能です。 enter の終了状態。要素が挿入された 1 フレーム後に追加され (同時に v-enter が削除されます)、トランジション/アニメーションが終了すると削除されます。

v-leave: leave の開始状態。トランジションの終了がトリガされるとき、直ちに追加され、1フレーム後に削除されます。

v-leave-active: leave の活性状態。トランジションが終わるフェーズ中に適用されます。leave トランジションがトリガされるとき、直ちに追加され、トランジション/アニメーションが終了すると削除されます。このクラスは、トランジションの終了に対して、期間、遅延、およびイージングカーブを定義するために使用できます。

v-leave-to: バージョン 2.1.8 以降でのみ利用可能です。 leave の終了状態。leave トランジションがトリガされた 1 フレーム後に追加され (同時に v-leaveが削除されます)、トランジション/アニメーションが終了すると削除されます。

https://jp.vuejs.org/images/transition.png

https://jp.vuejs.org/v2/guide/transitions.html#トランジションクラス

問題点を洗い出す

考察するに開いた状態では一番最初の状態がenterの状態、つまり「DOMが挿入された」という風にとらえられないと考えられる。 こういう場合は requestAnimationFrame がを併用し、要素の再描写を強制的にさせる方法があるそうである。

参考: Transition to Height Auto With Vue.js - Markus Oberlehner

加えてアニメーションの動作の最後には高さを auto にして調整してもらいたいため、enterのafterとleaveのafterも設定しておくこととする。

    methods: {
        enter(el) {
            const height = getComputedStyle(el).height;
            el.style.width = null;
            el.style.position = null;
            el.style.visibility = null;
            el.style.height = 0;
            getComputedStyle(el).height;
            requestAnimationFrame(() => {
                el.style.height = height;
            });
        },
        afterEnter(el) {
            el.style.height = "auto";
        },
        leave(el) {
            const height = getComputedStyle(el).height;

            el.style.height = height;
            getComputedStyle(el).height;

            requestAnimationFrame(() => {
                el.style.height = "0";
            });
        },
        afterLeave(el) {
            el.style.height = "auto";
        }

上記のサイトにも書いてあるが、

The leave() method, which is triggered as soon as the element is hidden or removed from the DOM, retrieves the current height of the element and sets it explicitly in order to make it possible to animate back to 0.

ということで、DOMを消すときはちゃんと requestAnimationFrame0 にアニメーションするように指定すると安定するそうだ。

結論

こうなった

See the Pen test-accordion-vue04 by nnao45 (@nnao45) on CodePen.

gist.github.com

感想

HTML/CSSむずかしすぎ

【EKS】【CDK】eksctlからcdkにEKSのデプロイ機構を移した

はじめに

サービスで使用しているEKSのデプロイをクラスターバージョンを変えるのを機に、 デプロイだけCDKでやることにしたのでその話を備忘録としてまとめておく。

eks

EKSはAWSのマネージドKubernetes。この記事を見ている人に対して説明は不要だろうが、 aws authとかiamとかとにかくawsの既存リソースとの兼ね合いや依存が有り、デプロイと把握が結構大変。

eksctl

github.com

eksctlはweaveworksさんが出している、 そんなデプロイと把握が結構大変なEKSをなんか諸々コマンド1発でぶち上げてくれるツールだ。

eksctl create cluster

cdk

aws.amazon.com

最近色々なコンポーネントがGAになったawsのインフラ構成管理ライブラリ. 注目は、 プログラミング言語でawsリソースをコードで管理出来る という点にある

cdkのeksリソースってどうなの?

実はまだdeveloper preview

https://docs.aws.amazon.com/cdk/api/latest/docs/aws-eks-readme.html

This is a developer preview (public beta) module.

でも必要な設定はそろってたし立てる分には問題なく動いたので、次回以降も使っていくと思う。

なんでeksctlからcdkに変えたの?

  • ぶっちゃけeksctlでもいいんだけど、設定の管理がyamlだと、主に変数の扱いや、乱数生成とかちょっとした小技にシェルのお世話になるのがイヤになってきた。
  • 今回cdkはTypeScriptを使っているが、やっぱり変数に型がついていたりEnumが使えるっていう事で、バグを減らす期待が大き過ぎる。
  • terraformやeksctlみたいなランタイムなしに、みんなのPCには入ってるだろうnodejsで全部動かせるので便利。
  • eksの新しいバージョンリリースは3ヶ月に1回と速く、eksctl使って手でクラスター作った後、IAMとかSG入れてーみたいなのを、cdkで一連のコードの流れで作れるって良いよねって話になった

後僕の今いるチームは、ほぼ全員サーバサイド、MLエンジニア、フロントというコードを書く人たちの集まりなので、CDKの方がマッチしやすいという思惑もある。

今回作ったcdkのプロジェクト概要

今回はEKSクラスターを1からデプロイするところまでを記載する

バージョンとか

  • EKS...1.15
  • CDK...1.4.0 (build 175471f)

構成

ディレクトリ構成

.
├── cdk.json
├── node_modules
├── package-lock.json
├── package.json
├── .eslintrc.js
├── .gitignore
├── src
│   ├── config.ts
│   └── index.ts
└── tsconfig.json

特になんの変哲もないTypeScriptのプロジェクトだと思う。 事始めは以下.

npm init -y
npm i @aws-cdk/{core,aws-eks,aws-ec2,aws-iam}
npm i -D aws-cdk @types/node typescript

# eslintしたいなら
npm install --save-dev eslint @typescript-eslint/eslint-plugin @typescript-eslint/parser prettier eslint-config-prettier eslint-plugin-prettier

srcディレクトリにcdkのコードを入れていくイメージ

package.json

気になるpackage.jsonだが、ほとんどscriptの部分しかいじってない

..
  "scripts": {
    "build": "tsc",
    "watch": "tsc -w",
    "diff": "tsc && cdk diff",
    "deploy": "tsc && cdk deploy",
    "lint": "eslint --ext .js,.ts --ignore-path .gitignore .",
    "lintfix": "eslint --fix --ext .js,.ts --ignore-path .gitignore .",
    "clean": "rm -rf ./dist && rm -rf ./cdk.out"
  },
..

まぁ特段書くこともない。 npm run deploy でデプロイするってイメージ

cdk.json

{
  "app": "node dist/index",
  "context": {
    "env": "test",
    "cluster_version": "1.15",
    "worker_nodes": 6,
    "worker_node_instance_type": "t3.large"
  }
}

お作法通り。 appフィールドにはコンパイル後のdistのindex.jsを見にいくようになっている。 contextフィールドは後述するが、パラメータをここにいれるといい具合にコード側で扱える。 ここのパラメータは特に指定もないので、好きなものを増やすことも出来るし、何も書かなくてもいい。

cdkのコードを見ていきー

お待ちかねのcdkのコードです。

config.ts

export const myVPC = "vpc-XXXX";
export const mySubnets = {
  private: {
    routeTableId: "rtb-XXXX",
    privateA: { id: "subnet-XXXX", az: "ap-northeast-1a" },
    privateC: { id: "subnet-XXXX", az: "ap-northeast-1c" },
    privateD: { id: "subnet-XXXX", az: "ap-northeast-1d" }
  },
  public: {
    routeTableId: "rtb-XXXX",
    publicA: { id: "subnet-XXXX", az: "ap-northeast-1a" },
    publicC: { id: "subnet-XXXX", az: "ap-northeast-1c" },
    publicD: { id: "subnet-XXXX", az: "ap-northeast-1d" }
  }
};
export const mySecurityGroupId = "sg-XXXX";
export const myEksAdmins = ["XXXX"];
export const myEksWorkerNodeIAMPolicies = [
  "service-role/AmazonEC2RoleforSSM",
  "AmazonEC2ContainerRegistryPowerUser",
  ...
];

※実際の設定値とはだいぶ変えてます
マスクが多いのは仕方ないとして、まぁこんな感じでハードコードしそうな設定値はこのconfig.tsに避けておこうかなという感じです ヘッダーファイル的な。 SSMとか使う人はAmazonEC2RoleforSSMロールを忘れずに。

index.ts

さあさあ本体ですね。

概観

一旦細かなコードを抜くとこんな外観となります

#!/usr/bin/env node
import ...
const app = new cdk.App();
const namedVersion = `v${app.node
  .tryGetContext("cluster_version")
  .replace(".", "")}`;
const clusterName = `${app.node.tryGetContext(
  "env"
)}-XXXX-${namedVersion}`;

export class NewEKS extends cdk.Stack {
...
}

new NewEKS(app, clusterName, {
  env: {
    region: "XXXX",
    account: "XXXX"
  }
});
app.synth();

最初、 new cdk.App(); でcdkで作成するAppクラスを作ります。
次にclusterNameを作ります・・・ここは、 <環境名>-<なんとかクラスターみたいな名前>-<EKSバージョン> のような名前にしました。
さて気になるところが app.node.tryGetContext("env") みたいな関数ですが、これが、 cdk.jsoncontext フィールドで書いたパラメータです。
try...みたいな関数の理由は、もし存在しないフィールド指定したら undefined で戻るからです・・・なのでチェックとかしたい場合はしてから使用するといいです。
元々try...ではなかったみたいですが、これはjsonから持ってくるので色々な型がありえるので、文字列でいう空文字みたいな初期値を持たせるのが無理だったんでしょうね。

注意点が、この cdk.Stack という抽象クラスはsynthメソッドを呼ぶ前に envパラメータを持たないと初期化できないところです。 必ずリージョンとAWSアカウント名を入れて初期化しましょう

NewEKSの中身を順番に

export class NewEKS extends cdk.Stack {
  constructor(scope: cdk.App, id: string, props?: cdk.StackProps) {
    super(scope, id, props);
...

お決まりの呪文を書いた後、いよいよAWSリソースを書いていきます。 おおまかな流れは、この cdk.Stack に継承されたクラスの初期化 = リソース作成となります。 そしてこのクラス内で変数を定義 = AWSリソースを作成すると言った流れとなります。ORMっぽいですね。

VPC

まずはEKSを立てるVPCを記述します

 const vpc = Vpc.fromLookup(this, "vpc", {
      vpcId: myVPC
});

結構世にはVPCを新しく作っちゃう系の記事が多いですが、僕は新しくクラスター作るごとにVPCを作らないので、vpcのidから引いてきてオブジェクト作ります

クラスター IAM ロール

Kubernetes コントロールプレーンがリソースを作成するときに使用するIAMを作ってやる必要があります

const eksRole = new Role(this, `eksRole`, {
      assumedBy: new ServicePrincipal("eks.amazonaws.com"),
      managedPolicies: [
        ManagedPolicy.fromAwsManagedPolicyName("AmazonEKSClusterPolicy"),
        ManagedPolicy.fromAwsManagedPolicyName("AmazonEKSServicePolicy")
      ]
});

まぁこれはみんな設定するでしょう。

管理権限 IAM ロール

これはkubernetesの世界での管理者権限に相当するIAMロールです。 必要な場合は追加します

const clusterAdmin = new Role(this, `clusterAdmin`, {
      assumedBy: new AccountRootPrincipal()
});

あくまで例なわけですが、一旦誰にでもkubeリソースを触れる権限を引き継げるようにするにはこの AccountRootPrincipal() をassumeに設定すればいいです。 とはいえクラスターのブートストラップアクションにはあっていいでしょう。

マスターノード

主人公登場ですね

const cluster = new Cluster(this, "cluster", {
      // デフォルトインスタンスは作らない
      defaultCapacity: 0,
      // EKSクラスターをデプロイするVPC
      vpc: vpc,
      // masterノードがコントロールプレーンで使う用のロール
      role: eksRole,
      // クラスターのマスターロール
      mastersRole: clusterAdmin,
      // クラスターの名前
      clusterName: myClusterName,
      // kubectlを使って操作出来るようにする。
      kubectlEnabled: true,
      // kubernetesのヴァージョン
      version: this.node.tryGetContext("cluster_version"),
      // kubernetesにアサインするsubnet
      vpcSubnets: [
        {
          subnets: [
            Subnet.fromSubnetAttributes(this, "privateA", {
              routeTableId: subnets.private.routeTableId,
              subnetId: subnets.private.privateA.id,
              availabilityZone: subnets.private.privateA.az
            }),
            Subnet.fromSubnetAttributes(this, "privateC", {
              routeTableId: subnets.private.routeTableId,
              subnetId: subnets.private.privateC.id,
              availabilityZone: subnets.private.privateC.az
            }),
            Subnet.fromSubnetAttributes(this, "privateD", {
              routeTableId: subnets.private.routeTableId,
              subnetId: subnets.private.privateD.id,
              availabilityZone: subnets.private.privateD.az
            })
          ]
        },
        {
          subnets: [
            Subnet.fromSubnetAttributes(this, "publicA", {
              routeTableId: subnets.public.routeTableId,
              subnetId: subnets.public.publicA.id,
              availabilityZone: subnets.public.publicA.az
            }),
            Subnet.fromSubnetAttributes(this, "publicC", {
              routeTableId: subnets.public.routeTableId,
              subnetId: subnets.public.publicC.id,
              availabilityZone: subnets.public.publicC.az
            }),
            Subnet.fromSubnetAttributes(this, "publicD", {
              routeTableId: subnets.public.routeTableId,
              subnetId: subnets.public.publicD.id,
              availabilityZone: subnets.public.publicD.az
            })
          ]
        }
      ]
});

ポイントは、

  • なぜかデフォルトでワーカーノードインスタンスが2個ついてくるのでdefaultCapacity: 0に設定する

  • しっかりとクラスターをデプロイするsubnetsを全部設定する必要がある。すごい面倒に感じるが、仕方ない。

ワーカーノード

マスターノードに紐付けるauto scalling group。 マネージドノードグループを紐づけるような設定は探したけどまだないらしい。

const defaultCapacity = +this.node.tryGetContext("worker_nodes");
const ng = cluster.addCapacity(`${myClusterName}-worker-nodes`, {
      desiredCapacity: defaultCapacity,
      maxCapacity: defaultCapacity,
      minCapacity: defaultCapacity,
      instanceType: new InstanceType(
        this.node.tryGetContext("worker_node_instance_type")
      )
});

どういうauto scalling groupにしたいのかは正直好みだとは思う。 後一応 +文字列 で defaultCapacityはstringをnumberにキャストしている。

stackoverflow.com

ワーカーノードに細かなオプションをいれる
    // ワーカノードにSecurityGroupを紐付ける
    ng.addSecurityGroup(
      SecurityGroup.fromSecurityGroupId(this, "sg", mySecurityGroupId)
    );
    // ワーカノードにSSMを入れておく
    ng.addUserData(
      "cd /tmp\n" +
        "yum install -y https://s3.amazonaws.com/ec2-downloads-windows/SSMAgent/latest/linux_amd64/amazon-ssm-agent.rpm\n" +
        "systemctl start amazon-ssm-agent"
    );
    // ワーカノードにデフォルトで必要なIAMをいれる。
    for (const managedPolicyName of myEksWorkerNodeIAMPolicies) {
      ng.role.addManagedPolicy(
        ManagedPolicy.fromAwsManagedPolicyName(managedPolicyName)
      );
    }

まぁこれこそ好みだと思うんですが、SecurityGroupとかSSMとかIAMとかを入れておく。

awsAuth

最後にkubeの権限とawsのIAMのマッピングを司るaws authもクラスターを立ち上げるときにいれる。 cdkの作者がよくわかっているところは、このaws authもちゃんと独自クラスとして用意してくれており、型安全なので設定しやすい。

    // kubectlを叩けるIAMを設定しておく。
    const awsAuth = new AwsAuth(this, "AwsAuth", {
      cluster: cluster
    });
    // クラスター自身のIAMをマップ
    awsAuth.addRoleMapping(ng.role, {
      groups: ["system:bootstrappers", "system:nodes"],
      username: "system:node:{{EC2PrivateDNSName}}"
    });
    // clusterの管理権限をマップ
    awsAuth.addMastersRole(
      Role.fromRoleArn(this, 'clusterAdminAtAwsAuth', clusterAdmin.roleArn),
      clusterAdmin.roleName
    );
    // 追加の管理ユーザをマップ
    for (let i = 0; i < myEksAdmins.length; i++) {
      const userName = myEksAdmins[i];
      awsAuth.addUserMapping(User.fromUserName(this, userName, userName), {
        username: userName,
        groups: ["system:masters"]
      });
    }

IAM型やUser型に縛ってawsAuthのconfigMapに投入出来るのは本当に便利だなあ

完成だー

色々マスクしたり、見にくいかもですが、全体像はこちら・・・!

#!/usr/bin/env node
import ...
const app = new cdk.App();
const namedVersion = `v${app.node
  .tryGetContext("cluster_version")
  .replace(".", "")}`;
const clusterName = `${app.node.tryGetContext(
  "env"
)}-XXXX-${namedVersion}`;

export class NewEKS extends cdk.Stack {
  constructor(scope: cdk.App, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    const vpc = Vpc.fromLookup(this, "vpc", {
      vpcId: myVPC
    });

    const eksRole = new Role(this, `eksRole`, {
      assumedBy: new ServicePrincipal("eks.amazonaws.com"),
      managedPolicies: [
        ManagedPolicy.fromAwsManagedPolicyName("AmazonEKSClusterPolicy"),
        ManagedPolicy.fromAwsManagedPolicyName("AmazonEKSServicePolicy")
      ]
    });

    const clusterAdmin = new Role(this, `clusterAdmin`, {
      assumedBy: new AccountRootPrincipal()
    });

    const cluster = new Cluster(this, "cluster", {
      // デフォルトインスタンスは作らない
      defaultCapacity: 0,
      // EKSクラスターをデプロイするVPC
      vpc: vpc,
      // masterノードがコントロールプレーンで使う用のロール
      role: eksRole,
      // クラスターのマスターロール
      mastersRole: clusterAdmin,
      // クラスターの名前
      clusterName: myClusterName,
      // kubectlを使って操作出来るようにする。
      kubectlEnabled: true,
      // kubernetesのヴァージョン
      version: this.node.tryGetContext("cluster_version"),
      // kubernetesにアサインするsubnet
      vpcSubnets: [
        {
          subnets: [
            Subnet.fromSubnetAttributes(this, "privateA", {
              routeTableId: subnets.private.routeTableId,
              subnetId: subnets.private.privateA.id,
              availabilityZone: subnets.private.privateA.az
            }),
            Subnet.fromSubnetAttributes(this, "privateC", {
              routeTableId: subnets.private.routeTableId,
              subnetId: subnets.private.privateC.id,
              availabilityZone: subnets.private.privateC.az
            }),
            Subnet.fromSubnetAttributes(this, "privateD", {
              routeTableId: subnets.private.routeTableId,
              subnetId: subnets.private.privateD.id,
              availabilityZone: subnets.private.privateD.az
            })
          ]
        },
        {
          subnets: [
            Subnet.fromSubnetAttributes(this, "publicA", {
              routeTableId: subnets.public.routeTableId,
              subnetId: subnets.public.publicA.id,
              availabilityZone: subnets.public.publicA.az
            }),
            Subnet.fromSubnetAttributes(this, "publicC", {
              routeTableId: subnets.public.routeTableId,
              subnetId: subnets.public.publicC.id,
              availabilityZone: subnets.public.publicC.az
            }),
            Subnet.fromSubnetAttributes(this, "publicD", {
              routeTableId: subnets.public.routeTableId,
              subnetId: subnets.public.publicD.id,
              availabilityZone: subnets.public.publicD.az
            })
          ]
        }
      ]
    });

    const defaultCapacity = +this.node.tryGetContext("worker_nodes");
    const ng = cluster.addCapacity(`${myClusterName}-worker-nodes`, {
      desiredCapacity: defaultCapacity,
      maxCapacity: defaultCapacity,
      minCapacity: defaultCapacity,
      instanceType: new InstanceType(
        this.node.tryGetContext("worker_node_instance_type")
      )
    });

    // ワーカノードにSecurityGroupを紐付ける
   ng.addSecurityGroup(
     SecurityGroup.fromSecurityGroupId(this, "sg", mySecurityGroupId)
   );
   // ワーカノードにSSMを入れておく
   ng.addUserData(
     "cd /tmp\n" +
       "yum install -y https://s3.amazonaws.com/ec2-downloads-windows/SSMAgent/latest/linux_amd64/amazon-ssm-agent.rpm\n" +
       "systemctl start amazon-ssm-agent"
   );
   // ワーカノードにデフォルトで必要なIAMをいれる。
   for (const managedPolicyName of myEksWorkerNodeIAMPolicies) {
     ng.role.addManagedPolicy(
       ManagedPolicy.fromAwsManagedPolicyName(managedPolicyName)
     );
   }

   // kubectlを叩けるIAMを設定しておく。
    const awsAuth = new AwsAuth(this, "AwsAuth", {
      cluster: cluster
    });
    // クラスター自身のIAMをマップ
    awsAuth.addRoleMapping(ng.role, {
      groups: ["system:bootstrappers", "system:nodes"],
      username: "system:node:{{EC2PrivateDNSName}}"
    });
    // clusterの管理権限をマップ
    awsAuth.addMastersRole(
      Role.fromRoleArn(this, 'clusterAdminAtAwsAuth', clusterAdmin.roleArn),
      clusterAdmin.roleName
    );
    // 追加の管理ユーザをマップ
    for (let i = 0; i < myEksAdmins.length; i++) {
      const userName = myEksAdmins[i];
      awsAuth.addUserMapping(User.fromUserName(this, userName, userName), {
        username: userName,
        groups: ["system:masters"]
      });
    }
}

new NewEKS(app, clusterName, {
  env: {
    region: "XXXX",
    account: "XXXX"
  }
});
app.synth();

デプロイはこちら

npm run deploy

コードとしては、 app.synth() のコールにより、Cloudformationとしてawsにデプロイされ、もし既に存在すれば差分をデプロイする形となります。 しばらく待つと、クラスターができます。

これだけでも、コンパイル時点でかなりのバグを減らすことができますが、チームでいくつもEKSクラスターを作成する場合より一層使うメリットを享受することができます

おわりに

今回はデプロイするだけでしたが、調べていくとCDK、マニフェストの適用やHelmチャートも扱えたりできます。 僕のチームでも、将来的にArgoを導入したりしたときなどはArgoだけCDK経由でデプロイとかも考えていいのかもしれません。

CDK、まだLambdaとEKSくらいでしか使ってないですが、すごいいいですねーという感想でした。

参考リンク

OSXでfzf(peco) + ghq + Intellijでコマンドラインからintellij IDEAを呼ぶ

TL;DR

peco + ghq + VS Codeとかでやってるのと変わらんけど。

VS Codeでもghq - Qiita

ghq, peco, hubで快適Gitライフを手に入れよう! - Qiita

ghqでインストール済みのレポジトリをIntellij IDEAで開く

gi() {
  local REPO=$(ghq root)/$(ghq list | fzf)
  if [ ! "${REPO}" = "$(ghq root)/" ]; then
    /Applications/IntelliJ\ IDEA.app/Contents/MacOS/idea ${REPO}
  fi
}

ghqで未インストールのレポジトリをIntellij IDEAで開く

ggi() {
  if [ -z ${1} ]; then
    echo "Usage: ${0} <github repo URL>"
    return 1
  fi
  local RESULT=""
  ghq get ${1}
  if echo ${1} | grep 'https://' >/dev/null 2>&1 ; then
    RESULT=$(echo ${1} | cut -c 9-)
  elif echo ${1} | grep 'git@' >/dev/null 2>&1 ; then
    RESULT=$(echo ${1} | sed  "s&git@github.com:&github.com/&") 
  fi

  if echo ${RESULT} | grep '.git' >/dev/null 2>&1 ; then
    RESULT=$(echo ${RESULT} | rev | cut -c 5- | rev)
  fi

  /Applications/IntelliJ\ IDEA.app/Contents/MacOS/idea $(ghq root)/${RESULT}
}

【Scala】catsのValidatedの使い所、及びValidatedNecとValidatedNelについて

初めに

本記事は Scala Advent Calendar 2019の12日目の記事の代行です。 仕事でなんとなく使っていた、 Scalaの関数型ライブラリのcatsのバリデーションモナドのValidatedNecとValidatedNelの違いについて勉強していきます。 typelevel.org

catsのValidateについて

今日のプログラミングでは「バリデーション」という機能は重要です。 パスワード、メールアドレス、ユーザ名など・・・サービスの品質を高めるために重要なアクションです。

catsを利用したScalaのような型クラスプログラミングがしていく時に、 「検査したかどうか」を型で表現できると複雑な検査項目も安全にエラーハンドルするようにできるのがValidatedNecとValidatedNelです。

Validated, ValidatedNec, ValidatedNelについて

Validated

sealed abstract class Validated[+E, +A] extends Product with Serializable {
  // Implementation elided
}

ジェネリクスな抽象クラスがValidatedで、左に検査エラーが、右に検査が通った時の値が入ります。

final case class Valid[+A](a: A) extends Validated[Nothing, A]
final case class Invalid[+E](e: E) extends Validated[E, Nothing]

この Validated は検査がOKだったことを表すValid[+A](a: A) 、ダメだったことを表すInvalid[+E](e: E) があり、 それぞれ Validated を継承しているので、パターンマッチによって安全に検査内容を取り出すことができます。

import cats.data.Validated
import cats.data.Validated.{Invalid, Valid}

case class Config(map: Map[String, String]) {
  def parse[A : Read](key: String): Validated[ConfigError, A] =
    map.get(key) match {
      case None        => Invalid(MissingConfig(key))
      case Some(value) =>
        Read[A].read(value) match {
          case None    => Invalid(ParseError(key))
          case Some(a) => Valid(a)
        }
    }
}

(すごい・・・ Either みたいです・・・w)

このように検査エラーは検査内容を破棄せずとも、検査結果を一つの Validated クラスとして表現することができました。 さて、Validatedには ValidatedNecValidatedNel というエイリアスがあり、微妙な違いがあるのでそれにもに触れていきます

ValidatedNel、 ValidatedNec

公式ドキュメントに読むと、

ValidatedNec[DomainValidation, A] is an alias for Validated[NonEmptyChain[DomainValidation], A]. Typically, you’ll see that Validated is accompanied by a NonEmptyChain when it comes to accumulation. The thing here is that you can define your own accumulative data structure and you’re not limited to the aforementioned construction.

ValidatedNec [DomainValidation、A]はValidated [NonEmptyChain [DomainValidation]、A]のエイリアスです。通常、Validatedには蓄積に関してNonEmptyChainが付随していることがわかります。ここで重要なことは、独自の累積データ構造を定義でき、前述の構造に限定されないということです。

NonEmptyChainとは、catsのChainに関わってくることで、空じゃないChainを表します。

typelevel.org

多分そんな深入りしない場合は、高速なコレクションみたいなニュアンスで知っておけばいいと思います。

scala> NonEmptyChain(1, 2, 3, 4).toChain.map(println)
1
2
3
4

つまり、leftに入れるような検査エラー系を蓄積できるのがValidatedNecというわけです。

ちなみにValidatedNelはドキュメントでは触れられてないですが、ValidatedNecのChainがListになったバージョンです。 なのでこのブログでもValidatedNelは省略していきます。

type ValidatedNel[+E, +A] = cats.data.Validated[cats.data.NonEmptyList[E], A]
type ValidatedNec[+E, +A] = cats.data.Validated[cats.data.NonEmptyChain[E], A]

さて例をみていきます。

import cats.SemigroupK
import cats.data.NonEmptyChain
import cats.implicits._

trait Read[A] {
  def read(s: String): Option[A]
}

object Read {
  def apply[A](implicit A: Read[A]): Read[A] = A

  implicit val stringRead: Read[String] =
    new Read[String] { def read(s: String): Option[String] = Some(s) }

  implicit val intRead: Read[Int] =
    new Read[Int] {
      def read(s: String): Option[Int] =
        if (s.matches("-?[0-9]+")) Some(s.toInt)
        else None
    }
}

sealed abstract class ConfigError
final case class MissingConfig(field: String) extends ConfigError
final case class ParseError(field: String) extends ConfigError

case class ConnectionParams(url: String, port: Int)

val config = Config(Map(("endpoint", "127.0.0.1"), ("port", "not an int")))

implicit val necSemigroup: Semigroup[NonEmptyChain[ConfigError]] =
  SemigroupK[NonEmptyChain].algebra[ConfigError]

implicit val readString: Read[String] = Read.stringRead
implicit val readInt: Read[Int] = Read.intRead

上記の例では、何かの設定でendpointのアドレス、ポート番号の検査をする時で、

def parallelValidate[E, A, B, C](v1: Validated[E, A], v2: Validated[E, B])(f: (A, B) => C): Validated[E, C] =
  (v1, v2) match {
    case (Valid(a), Valid(b))       => Valid(f(a, b))
    case (Valid(_), i@Invalid(_))   => i
    case (i@Invalid(_), Valid(_))   => i
    case (Invalid(e1), Invalid(e2)) => ???
  }

val v1 = parallelValidate(config.parse[String]("url").toValidatedNec,
                          config.parse[Int]("port").toValidatedNec)(ConnectionParams.apply)
// v1: cats.data.Validated[cats.data.NonEmptyChain[ConfigError],ConnectionParams] = Invalid(Chain(MissingConfig(url), ParseError(port)))

val v2 = parallelValidate(config.parse[String]("endpoint").toValidatedNec,
                          config.parse[Int]("port").toValidatedNec)(ConnectionParams.apply)
// v2: cats.data.Validated[cats.data.NonEmptyChain[ConfigError],ConnectionParams] = Invalid(Chain(ParseError(port)))

val config = Config(Map(("endpoint", "127.0.0.1"), ("port", "1234")))
// config: Config = Config(Map(endpoint -> 127.0.0.1, port -> 1234))

val v3 = parallelValidate(config.parse[String]("endpoint").toValidatedNec,
                          config.parse[Int]("port").toValidatedNec)(ConnectionParams.apply)
// v3: cats.data.Validated[cats.data.NonEmptyChain[ConfigError],ConnectionParams] = Valid(ConnectionParams(127.0.0.1,1234))

例では v1は "url" のような存在しないフィールドはMissingConfigクラス、 "not an int" のような文字列をintにパースしたときはParseErrorクラスを内包したChainにスタックさせてInvalidで返します。

v2は"port" だけおかしいのでParseErrorクラスのみ、v3は検査を成功した例です。 このようにValidatedNecは検査エラーをスタックして返せるので、多段な検査が必要な場合のデバッグに重宝するはずです。

Eitherとの違いについて。

もちろん Validated という名前もあり、使用目的がわかりやすいというのもありますが、ドキュメントによると、

We’ve established that an error-accumulating data type such as Validated can’t have a valid Monad instance. Sometimes the task at hand requires error-accumulation. However, sometimes we want a monadic structure that we can use for sequential validation (such as in a for-comprehension). This leaves us in a bit of a conundrum.

Validatedなどのエラーを蓄積するデータ型には、有効なMonadインスタンスを含めることはできません。時々、手元のタスクはエラーの蓄積を必要とします。ただし、シーケンシャルバリデーション(for-comprehensionなど)に使用できるモナド構造が必要な場合があります。これは、私たちにちょっとした難問を残します。

Cats has decided to solve this problem by using separate data structures for error-accumulation (Validated) and short-circuiting monadic behavior (Either). Catsは、エラー累積(検証済み)と短絡モナド挙動(Either)に別々のデータ構造を使用することで、この問題を解決することにしました。

If you are trying to decide whether you want to use Validated or Either, a simple heuristic is to use Validated if you want error-accumulation and to otherwise use Either. ValidatedまたはBothのどちらを使用するかを決定しようとする場合、エラーを累積する場合はValidatedを使用し、それ以外の場合はBothを使用するのが簡単なヒューリスティックです。

要はモナド則を守りながらエラー蓄積をしたいときはValidated 、単純に2個どちらかの型を返すだけなら Either を使いましょう、とのことでした。

おわりに

なんとなーく雰囲気で使っていたcatsのValidatedの概要を知ることができました。 catsによる型やモナド則による強力な制約でバグを減らしていきましょう。

【Kubernetes】KubernetesのCronJobのcronスケジュールはどう管理されているのか?

はじめに

本記事は Kuberenetes Advent Calendar 2019の17日目の記事です。 Kubernetesのリソースの中に、定期的に揮発性のあるジョブを生み出すCronJobについての記事です。

本題

cronjobは以下のようなマニフェストで定義されます。

apiVersion: batch/v1beta1
kind: CronJob
metadata:
  name: hello
spec:
  schedule: "*/1 * * * *"
  jobTemplate:
    spec:
      template:
        spec:
          containers:
          - name: hello
            image: busybox
            args:
            - /bin/sh
            - -c
            - date; echo Hello from the Kubernetes cluster
          restartPolicy: OnFailure

Running Automated Tasks with a CronJob - Kubernetes

さて、このcronjobの schedule: "*/1 * * * *" が今回のブログの主役です。 このスケジュールは一体どう管理されているんでしょうか?パースして・・・どうしてるんだろう?気になりませんか? 今回はこの文字列がどうやって管理されて、ジョブの生成に繋がっているかに焦点をおきます。

仮説は以下です。

Kubernetesからglibcのlibcron的な何かを叩いている
Kubernetes上で文字列をパースして独自実装されている
③第三者のなんかのライブラリで間接的に使っている

多分cronjobというやつもdeploymentと同じでコントローラがあると思っていて、 そいつがなんやかんやして定期的にjobをリソースを生み出している機構があるのでしょう。 そこまでいけたらゴールです。 さて、見ていきますか。

ソースを読む。

もう最初からソース見た方が早いよねってことで。

func startCronJobController(ctx ControllerContext) (http.Handler, bool, error) {
    if !ctx.AvailableResources[schema.GroupVersionResource{Group: "batch", Version: "v1beta1", Resource: "cronjobs"}] {
        return nil, false, nil
    }
    cjc, err := cronjob.NewController(
        ctx.ClientBuilder.ClientOrDie("cronjob-controller"),
    )
    if err != nil {
        return nil, true, fmt.Errorf("error creating CronJob controller: %v", err)
    }
    go cjc.Run(ctx.Stop)
    return nil, true, nil
}

https://github.com/kubernetes/kubernetes/blob/1514bb2141d3c82830f64aa0e1f8c3650116b803/cmd/kube-controller-manager/app/batch.go#L45

うん、、、どうやら予想通り cronjob-controller という奴がいそうで、 "k8s.io/kubernetes/pkg/controller/cronjob" を見ていけば何かがわかりそう。

ここにあるようだ→ https://github.com/kubernetes/kubernetes/blob/1514bb2141/pkg/controller/cronjob/controller.go

よく見てみると、わずか数個しか関数がないのがわかります。

func NewController(kubeClient clientset.Interface) (*Controller, error) 
func (jm *Controller) Run(stopCh <-chan struct{}) 
func (jm *Controller) syncAll()
func cleanupFinishedJobs(sj *batchv1beta1.CronJob, js []batchv1.Job, jc jobControlInterface,
    sjc sjControlInterface, recorder record.EventRecorder)
func removeOldestJobs(sj *batchv1beta1.CronJob, js []batchv1.Job, jc jobControlInterface, maxJobs int32, recorder record.EventRecorder) 
func syncOne(sj *batchv1beta1.CronJob, js []batchv1.Job, now time.Time, jc jobControlInterface, sjc sjControlInterface, recorder record.EventRecorder)
func deleteJob(sj *batchv1beta1.CronJob, job *batchv1.Job, jc jobControlInterface, recorder record.EventRecorder)
func getRef(object runtime.Object) (*v1.ObjectReference, error)

cronjobを使ったことあるひとは関数名だけで動作が手に取ったようにわかるかもしれません。 知らんけど多分、 syncAll syncOne がjobを生み出している関数だってことくらいはわかりました。

syncAll

こいつは単純で、 クラスター内のcronjobをリソースを持ってきて、それをfor文でぶん回して syncOne を叩いてるだけのようです。

func (jm *Controller) syncAll() {
    // snip
    for _, sj := range sjs {
        syncOne(&sj, jobsBySj[sj.UID], time.Now(), jm.jobControl, jm.sjControl, jm.recorder)
        cleanupFinishedJobs(&sj, jobsBySj[sj.UID], jm.jobControl, jm.sjControl, jm.recorder)
    }
    // snip
}

syncOne

コメントアウトに注目

// syncOne reconciles a CronJob with a list of any Jobs that it created.
// All known jobs created by "sj" should be included in "js".
// The current time is passed in to facilitate testing.
// It has no receiver, to facilitate testing.
func syncOne(sj *batchv1beta1.CronJob, js []batchv1.Job, now time.Time, jc jobControlInterface, sjc sjControlInterface, recorder record.EventRecorder) {

// syncOne reconciles a CronJob with a list of any Jobs that it created. // All known jobs created by "sj" should be included in "js". // The current time is passed in to facilitate testing. // It has no receiver, to facilitate testing.

syncOneは、CronJobと、作成したジョブのリストを照合します。 「sj」によって作成されたすべての既知のジョブは、「js」に含める必要があります。テストを容易にするために、現在の時刻が渡されます。テストを容易にするための受信機はありません。

結構重要なのはなんと、この now time.Time という引数はテストのために渡されているということが書かれていますが、 多分関数の中で time.Now()するとテストの時に照合しにくくなってしまうから引数で渡そう的なアイディアで、実処理にも使われてはいると推測します。

するすると見ていくと、実にあやしいコードを見つけます。

   times, err := getRecentUnmetScheduleTimes(*sj, now)
    if err != nil {
        recorder.Eventf(sj, v1.EventTypeWarning, "FailedNeedsStart", "Cannot determine if job needs to be started: %v", err)
        klog.Errorf("Cannot determine if %s needs to be started: %v", nameForLog, err)
        return
    }
    // TODO: handle multiple unmet start times, from oldest to newest, updating status as needed.
    if len(times) == 0 {
        klog.V(4).Infof("No unmet start times for %s", nameForLog)
        return
    }
    if len(times) > 1 {
        klog.V(4).Infof("Multiple unmet start times for %s so only starting last one", nameForLog)
    }

https://github.com/kubernetes/kubernetes/blob/1514bb2141/pkg/controller/cronjob/controller.go#L268-L281

うんーどうやらここっぽいねえ。その後の処理を見ても、どうやらこの times というリストが結構参照されているので重要な奴だそうだ。 どう生み出されているのか見ていこう。

(ちなみにsjは*batchv1beta1.CronJobなので、cronjobリソースそのものだ、なので尚更あやしい)

getRecentUnmetScheduleTimes

この子はさっき見ていた controller.go にはなかった。 同パッケージ内の utils.go に入っている。

// getRecentUnmetScheduleTimes gets a slice of times (from oldest to latest) that have passed when a Job should have started but did not.
//
// If there are too many (>100) unstarted times, just give up and return an empty slice.
// If there were missed times prior to the last known start time, then those are not returned.
func getRecentUnmetScheduleTimes(sj batchv1beta1.CronJob, now time.Time) ([]time.Time, error) {
    starts := []time.Time{}
    sched, err := cron.ParseStandard(sj.Spec.Schedule)
    if err != nil {
        return starts, fmt.Errorf("Unparseable schedule: %s : %s", sj.Spec.Schedule, err)
    }

    var earliestTime time.Time
    if sj.Status.LastScheduleTime != nil {
        earliestTime = sj.Status.LastScheduleTime.Time
    } else {
        // If none found, then this is either a recently created scheduledJob,
        // or the active/completed info was somehow lost (contract for status
        // in kubernetes says it may need to be recreated), or that we have
        // started a job, but have not noticed it yet (distributed systems can
        // have arbitrary delays).  In any case, use the creation time of the
        // CronJob as last known start time.
        earliestTime = sj.ObjectMeta.CreationTimestamp.Time
    }
    if sj.Spec.StartingDeadlineSeconds != nil {
        // Controller is not going to schedule anything below this point
        schedulingDeadline := now.Add(-time.Second * time.Duration(*sj.Spec.StartingDeadlineSeconds))

        if schedulingDeadline.After(earliestTime) {
            earliestTime = schedulingDeadline
        }
    }
    if earliestTime.After(now) {
        return []time.Time{}, nil
    }

    for t := sched.Next(earliestTime); !t.After(now); t = sched.Next(t) {
        starts = append(starts, t)
        // An object might miss several starts. For example, if
        // controller gets wedged on friday at 5:01pm when everyone has
        // gone home, and someone comes in on tuesday AM and discovers
        // the problem and restarts the controller, then all the hourly
        // jobs, more than 80 of them for one hourly scheduledJob, should
        // all start running with no further intervention (if the scheduledJob
        // allows concurrency and late starts).
        //
        // However, if there is a bug somewhere, or incorrect clock
        // on controller's server or apiservers (for setting creationTimestamp)
        // then there could be so many missed start times (it could be off
        // by decades or more), that it would eat up all the CPU and memory
        // of this controller. In that case, we want to not try to list
        // all the missed start times.
        //
        // I've somewhat arbitrarily picked 100, as more than 80,
        // but less than "lots".
        if len(starts) > 100 {
            // We can't get the most recent times so just return an empty slice
            return []time.Time{}, fmt.Errorf("too many missed start time (> 100). Set or decrease .spec.startingDeadlineSeconds or check clock skew")
        }
    }
    return starts, nil
}

https://github.com/kubernetes/kubernetes/blob/1514bb2141d3c82830f64aa0e1f8c3650116b803/pkg/controller/cronjob/utils.go#L89-L149

コメントアウトから

getRecentUnmetScheduleTimes gets a slice of times (from oldest to latest) that have passed when a Job should have started but did not.

getRecentUnmetScheduleTimesは、ジョブが開始されるべきであるが、開始されなかったときに経過した時間のスライス(最も古いものから最新のものまで)を取得します。

ほほう。先ほどの読み出し元の関数と見比べてみても、この getRecentUnmetScheduleTimes が返す []time.Time を元に、 スケジュール時間がすぎて開始されるべきジョブ を見極めるようです。

っと言っていると・・・おっっ!!!

   "github.com/robfig/cron"

https://github.com/kubernetes/kubernetes/blob/1514bb2141d3c82830f64aa0e1f8c3650116b803/pkg/controller/cronjob/utils.go#L23

   sched, err := cron.ParseStandard(sj.Spec.Schedule)

https://github.com/kubernetes/kubernetes/blob/1514bb2141d3c82830f64aa0e1f8c3650116b803/pkg/controller/cronjob/utils.go#L95

おおー!これが答えみたいなもんです・・・!なんと、予想の ③第三者のなんかのライブラリで間接的に使っている でした・・・結構意外でしょう?取れ高ですね。 さて、最後にこの関数をみていきましょう

github.com

(めっちゃスターついてますやん)

cron.ParseStandard

// ParseStandard returns a new crontab schedule representing the given
// standardSpec (https://en.wikipedia.org/wiki/Cron). It requires 5 entries
// representing: minute, hour, day of month, month and day of week, in that
// order. It returns a descriptive error if the spec is not valid.
//
// It accepts
//   - Standard crontab specs, e.g. "* * * * ?"
//   - Descriptors, e.g. "@midnight", "@every 1h30m"
func ParseStandard(standardSpec string) (Schedule, error) {
    return standardParser.Parse(standardSpec)
}

// Parse returns a new crontab schedule representing the given spec.
// It returns a descriptive error if the spec is not valid.
// It accepts crontab specs and features configured by NewParser.
func (p Parser) Parse(spec string) (Schedule, error) {
    if len(spec) == 0 {
        return nil, fmt.Errorf("empty spec string")
    }

    // Extract timezone if present
    var loc = time.Local
    if strings.HasPrefix(spec, "TZ=") || strings.HasPrefix(spec, "CRON_TZ=") {
        var err error
        i := strings.Index(spec, " ")
        eq := strings.Index(spec, "=")
        if loc, err = time.LoadLocation(spec[eq+1 : i]); err != nil {
            return nil, fmt.Errorf("provided bad location %s: %v", spec[eq+1:i], err)
        }
        spec = strings.TrimSpace(spec[i:])
    }

    // Handle named schedules (descriptors), if configured
    if strings.HasPrefix(spec, "@") {
        if p.options&Descriptor == 0 {
            return nil, fmt.Errorf("parser does not accept descriptors: %v", spec)
        }
        return parseDescriptor(spec, loc)
    }

    // Split on whitespace.
    fields := strings.Fields(spec)

    // Validate & fill in any omitted or optional fields
    var err error
    fields, err = normalizeFields(fields, p.options)
    if err != nil {
        return nil, err
    }

    field := func(field string, r bounds) uint64 {
        if err != nil {
            return 0
        }
        var bits uint64
        bits, err = getField(field, r)
        return bits
    }

    var (
        second     = field(fields[0], seconds)
        minute     = field(fields[1], minutes)
        hour       = field(fields[2], hours)
        dayofmonth = field(fields[3], dom)
        month      = field(fields[4], months)
        dayofweek  = field(fields[5], dow)
    )
    if err != nil {
        return nil, err
    }

    return &SpecSchedule{
        Second:   second,
        Minute:   minute,
        Hour:     hour,
        Dom:      dayofmonth,
        Month:    month,
        Dow:      dayofweek,
        Location: loc,
    }, nil
}

https://github.com/robfig/cron/blob/e843a09e5b2db454d77aad25b1660173445fb2fc/parser.go

 ParseStandard(standardSpec string) 

引数が文字列なので間違って無さそうですが、何よりその処理中が興味深いですね・・・つまり構文解析は自前で実装してるわけです。 使い方も間違って無さそう

func TestStandardSpecSchedule(t *testing.T) {
    entries := []struct {
        expr     string
        expected Schedule
        err      string
    }{
        {
            expr:     "5 * * * *",
            expected: &SpecSchedule{1 << seconds.min, 1 << 5, all(hours), all(dom), all(months), all(dow), time.Local},
        },
        {
            expr:     "@every 5m",
            expected: ConstantDelaySchedule{time.Duration(5) * time.Minute},
        },
        {
            expr: "5 j * * *",
            err:  "failed to parse int from",
        },
        {
            expr: "* * * *",
            err:  "expected exactly 5 fields",
        },
    }

    for _, c := range entries {
        actual, err := ParseStandard(c.expr)
        if len(c.err) != 0 && (err == nil || !strings.Contains(err.Error(), c.err)) {
            t.Errorf("%s => expected %v, got %v", c.expr, c.err, err)
        }
        if len(c.err) == 0 && err != nil {
            t.Errorf("%s => unexpected error %v", c.expr, err)
        }
        if !reflect.DeepEqual(actual, c.expected) {
            t.Errorf("%s => expected %b, got %b", c.expr, c.expected, actual)
        }
    }
}

https://github.com/robfig/cron/blob/master/parser_test.go#L315-L351

結論

ということで、KubernetesのCronJobのcronスケジュールは https://github.com/robfig/cron によってパースしたのちに、現在時刻から比較してジョブを生成する工程に入る、でした。

ちょっぴり考察すると、libcronみたいなCのライブラリとかを使っちゃうとKuberentesのコントローラ自体の依存が増えてしまうので、 今回使われた robfig/cronのようなgo言語のみでcron文字列をパースできるライブラリを使ったんじゃないかなーと思ったりラジバンダリ😪

おまけ

このcronjobのスケジューラのライブラリのなかで、使われた ParseStandard ですが、これは、

  • The "standard" cron format, described on the Cron wikipedia page and used by the cron Linux system utility.
  • The cron format used by the Quartz Scheduler, commonly used for scheduled jobs in Java software

以下の二つのページを参照してくれよとREADME書いてあるので、何のフォーマットが対応しているかは以下を参照してください。

https://en.wikipedia.org/wiki/Cron

[http://www.quartz-scheduler.org/documentation/quartz-2.3.0/tutorials/tutorial-lesson-06.html