GeekFactory

int128.hatenablog.com

kindでクラスタが起動しない原因を調べる

kind create cluster コマンドでKubernetesクラスタが起動しない場合、以下のようなメッセージが表示されます。

 ✗ Starting control-plane 🕹️
ERROR: failed to create cluster: failed to init node with kubeadm: command "docker exec --privileged kind-control-plane kubeadm init --ignore-preflight-errors=all --config=/kind/kubeadm.conf --skip-token-print --v=6" failed with error: exit status 1

クラスタが起動しない原因を調査するには以下の方法があります。

  • kindコマンドのログレベルを上げる
  • kindノードコンテナの内部に入って、Control Planeのログを調査する

kindコマンドのログ

kindコマンドに -v オプションを渡すと詳細なログが表示されるようになります。例えば -v10 を渡すと以下のようなログが表示されます。

Creating cluster "kubelogin-acceptance-test" ...
DEBUG: docker/images.go:70] Pulling image: kindest/node:v1.17.0@sha256:9512edae126da271b66b990b6fff768fbb7cd786c7d39e86bdf55906352fdf62 ...
 ✓ Ensuring node image (kindest/node:v1.17.0) 🖼
 ✓ Preparing nodes 📦
DEBUG: config/config.go:90] Using kubeadm config:
apiVersion: kubeadm.k8s.io/v1beta2
clusterName: kind
controlPlaneEndpoint: 172.17.0.3:6443
controllerManager:
  extraArgs:
    enable-hostpath-provisioner: "true"
kind: ClusterConfiguration
kubernetesVersion: v1.17.0
(以下略)

kindコマンドのログからは、kubeadmに渡す設定ファイルの内容、Control Planeで使うイメージ、Control Planeの各設定ファイルのパスなどが分かります。kindコンテナの内部に入って調査する時の材料になります。

kindノードコンテナの内部にあるログ

kindではノードコンテナの内部でKubernetesクラスタが動いています。ノードコンテナのIDや名前は以下のようにdocker psコマンドで確認できます。

% docker ps
CONTAINER ID        IMAGE                        COMMAND                  CREATED              STATUS              PORTS                       NAMES
38ea3c1f766a        kindest/node:v1.17.0         "/usr/local/bin/entr…"   About a minute ago   Up About a minute   127.0.0.1:32768->6443/tcp   kind-control-plane

kindノードコンテナの内部ではsystemdが動いています。systemdのログは以下のようにdocker logsコマンドで確認できます。

% docker logs kind-control-plane
INFO: ensuring we can execute /bin/mount even with userns-remap
INFO: remounting /sys read-only
(中略)

Welcome to Ubuntu 19.10!

[  OK  ] Started Dispatch Password …ts to Console Directory Watch.
[  OK  ] Listening on Journal Socket.
         Mounting FUSE Control File System...
         Starting Remount Root and Kernel File Systems...

(以下は抜粋)
         Starting containerd container runtime...
[  OK  ] Started kubelet: The Kubernetes Node Agent.
[  OK  ] Started containerd container runtime.

このように、ノードコンテナの起動時にsystemdがcontainerdやkubeletを実行して、Control Planeの各コンポーネントが実行される流れになっています。

コンポーネントのログを確認するには、以下のようにノードコンテナの内部に入る必要があります。

% docker exec -it kind-control-plane /bin/bash

kindノードコンテナの内部では以下のプロセスが動いています。

  • apiserver(コンテナとして動作)
  • kube-controller-manager(コンテナとして動作)
  • kube-scheduler(コンテナとして動作)
  • etcd(コンテナとして動作)
  • kubelet(プロセスとして動作)

これらが立ち上がると以下のコンテナも動き始めます。

  • kube-proxy
  • coredns
  • kindnet
  • local-path-provisioner

各コンテナのマニフェスト/etc/kubernetes/manifests/ にあります。

# ls -la /etc/kubernetes/manifests/
total 24
drwxr-xr-x 1 root root 4096 Jan 24 11:53 .
drwxr-xr-x 1 root root 4096 Jan 24 11:53 ..
-rw------- 1 root root 1805 Jan 24 11:53 etcd.yaml
-rw------- 1 root root 3204 Jan 24 11:53 kube-apiserver.yaml
-rw------- 1 root root 3090 Jan 24 11:53 kube-controller-manager.yaml
-rw------- 1 root root 1120 Jan 24 11:53 kube-scheduler.yaml

また、各コンテナのログは /var/log/containers/ にあります。

# ls -la /var/log/containers/
total 28
drwxr-xr-x 2 root root 4096 Jan 24 12:45 .
drwxr-xr-x 4 root root 4096 Jan 24 11:53 ..
lrwxrwxrwx 1 root root  114 Jan 24 11:53 etcd-...
lrwxrwxrwx 1 root root  136 Jan 24 12:45 kube-apiserver-...
lrwxrwxrwx 1 root root  152 Jan 24 11:53 kube-controller-manager-...
lrwxrwxrwx 1 root root  134 Jan 24 11:53 kube-scheduler-...

kubeletのログはどこに出力されるか分かりませんでした。詳しい人教えてください。 kubeletのログは下記のコマンドで確認できます。

# journalctl -u kubelet
Jan 27 01:25:58 kubelogin-acceptance-test-control-plane systemd[1]: Started kubelet: The Kubernetes Node Agent.
(以下略)

これらの設定ファイルやログを参照すると、クラスタが起動しない原因が分かると思います。例えば、apiserverの引数が間違っている場合はapiserverコンテナに以下のログが出ます。

# cat /var/log/containers/kube-apiserver-...
2020-01-24T11:56:18.6797274Z stderr F I0124 11:56:18.679446       1 server.go:596] external host was not specified, using 172.17.0.3
2020-01-24T11:56:18.6807181Z stderr F I0124 11:56:18.680394       1 server.go:150] Version: v1.17.0
2020-01-24T11:56:18.9807349Z stderr F Error: invalid authentication config: 'oidc-issuer-url' ("http://localhost") has invalid scheme ("http"), require 'https'
2020-01-24T11:56:18.9827157Z stderr F Usage:
2020-01-24T11:56:18.9827978Z stderr F   kube-apiserver [flags]
2020-01-24T11:56:18.9828244Z stderr F
2020-01-24T11:56:18.9828494Z stderr F Generic flags:
2020-01-24T11:56:18.9828729Z stderr F
(以下略)

kindにはログを一括エクスポートする機能があります。詳しくは https://kind.sigs.k8s.io/docs/user/quick-start/#exporting-cluster-logs を参照してください。

See Also

GitHub GraphQLで新しいPull Requestを作成する

GitHub GraphQLで新しいPull Requestを作成するにはcreatePullRequest mutationを利用します。RESTの場合と同様に、以下のようにheadとbaseを指定します。

  • head ref: 適用したい変更が含まれるブランチ。cross repositoryの場合はforkされたリポジトリ
  • base ref: 変更を適用するブランチ。cross repositoryの場合はfork元のリポジトリ。一般にはデフォルトブランチ。

本稿の内容はすべてGraphQL API Explorerで再現可能です。

Pull Requestの作成

ここでは以下のPull Requestを作成します。

まず、head repositoryのIDを取得します。

query {
  repository(owner: "octocat", name: "Spoon-Knife") {
    id
  }
}
{
  "data": {
    "repository": {
      "id": "MDEwOlJlcG9zaXRvcnkxMzAwMTky"
    }
  }
}

createPullRequest mutationを実行します。以下の引数が必須です。

repositoryId (ID!) The Node ID of the repository.

baseRefName (String!) The name of the branch you want your changes pulled into. This should be an existing branch on the current repository. You cannot update the base branch on a pull request to point to another repository.

headRefName (String!) The name of the branch where your changes are implemented. For cross-repository pull requests in the same network, namespace head_ref_name with a user like this: username:branch.

title (String!) The title of the pull request.

https://developer.github.com/v4/input_object/createpullrequestinput/

repositoryIdにはhead repositoryのIDを指定します。また、cross repositoryの場合はheadRefNameにユーザ名のプレフィックスを付けます。以下の例を見てください。

mutation {
  createPullRequest(input: {
    repositoryId: "MDEwOlJlcG9zaXRvcnkxMzAwMTky",
    baseRefName: "master",
    headRefName: "int128:patch-1",
    title: "Example",
    body: "Using the GraphQL mutation."
  }) {
    pullRequest {
      id
    }
  }
}
{
  "data": {
    "createPullRequest": {
      "pullRequest": {
        "id": "MDExOlB1bGxSZXF1ZXN0MzY1MDUyNTMy"
      }
    }
  }
}

これで下記のPull Requestが作成されました。

github.com

Pull Requestの検索

前項で作成したPull Requestを検索します。

  1. head refに紐づくPull Requestを検索する。
  2. base repositoryに存在するPull Requestを検索する。

まず、head refに紐づくPull Requestを検索してみましょう。以下のようにassociatedPullRequests connectionを利用します。

{
  repository(owner: "int128", name: "Spoon-Knife") {
    ref(qualifiedName: "refs/heads/patch-1") {
      associatedPullRequests(baseRefName: "master", first: 1) {
        nodes {
          id
          title
        }
      }
    }
  }
}
{
  "data": {
    "repository": {
      "ref": {
        "associatedPullRequests": {
          "nodes": [
            {
              "id": "MDExOlB1bGxSZXF1ZXN0MzY1MDUyNTMy",
              "title": "Example"
            }
          ]
        }
      }
    }
  }
}

head refから検索する方法はhead refが存在する場合にのみ使えます。Pull Requestをマージ(or クローズ)した後にブランチを削除した場合は、head refが存在しないので検索できなくなります。

次は、base repositoryに存在するPull Requestを検索してみましょう。

{
  repository(owner: "octocat", name: "Spoon-Knife") {
    pullRequests(baseRefName: "master", headRefName: "patch-1", first: 1) {
      totalCount
      nodes {
        id
        title
        url
      }
    }
  }
}
{
  "data": {
    "repository": {
      "pullRequests": {
        "totalCount": 620,
        "nodes": [
          {
            "id": "MDExOlB1bGxSZXF1ZXN0MTU5NzIz",
            "title": "Edited index.html via GitHub",
            "url": "https://github.com/octocat/Spoon-Knife/pull/21"
          }
        ]
      }
    }
  }
}

まったく関係のないPull Requestが出てきてしまいました。この方法では patch-1 というhead refのPull Requestがすべて表示されてしまいます。pullRequests connectionでは検索条件にhead repositoryを入れられないため、フォークされたリポジトリがすべて検索対象になる問題があります。

今のところ、head refから検索する方が確実にPull Requestを絞り込めるのでよさそうです。

アプリケーションの開発フローとGitOps

アプリケーションの開発フローとKubernetesへのデプロイを考えた軌跡を残します。特に結論はないです。

前提

以下を前提とする。

  • Kubernetesにアプリケーションをデプロイする場合を考える。
  • すべての変更はPull Requestを通して適用する。
  • アプリケーションはソースコードを変更することなく、どの実行環境でも動作可能とする。
  • アプリケーションの設定は実行時に環境変数などで注入される。
  • 開発チームはアプリケーションと設定に責任を持ち、自分自身でデプロイを行う。
  • Platformチームは実行環境の健全性に責任を持つ。

以下はスコープ外とする。別に考える必要がある。

  • 実行環境自体のデプロイや変更(例えば、Kubernetesのバージョンアップ)
  • アプリケーションが依存するステートフルなリソース
  • データベーススキーママイグレーション
  • アプリケーションやイメージのセキュリティ検査
  • などなど

選択肢

以下の選択肢が考えられる。ここでは具体的なツールは考えない。

  1. ビルドとデプロイ
    1. アプリケーションのビルドとデプロイを分離する
    2. アプリケーションのビルドとデプロイを連続して行う
  2. ブランチ戦略
    1. tagから実行環境にデプロイする
    2. masterブランチから実行環境にデプロイする
    3. 実行環境ごとにブランチを用意する(例えば、GitLab Flow)
  3. イメージタグの管理
    1. イメージタグをリポジトリで管理する
    2. イメージタグはクラスタ内部でのみ管理する(リポジトリマニフェストには書かない)
  4. アプリケーションとマニフェスト
    1. アプリケーションとマニフェストを別のリポジトリで管理する
    2. アプリケーションとマニフェストを同一リポジトリで管理する

デプロイサイクルの速いチームは、(a-2)のようにブランチの更新を契機にアプリケーションをデプロイした方が無駄な作業が少なくなる。そうしないとdeveloper experienceが大幅に悪化するだろう。一方で、本番環境で問題が発生してすぐに旧バージョンに切り戻したい場合は、(a-1)のようにデプロイを切り離しておく方が早く確実に対応できる。

a, b, c, dには以下の制約がある。

  • アプリケーションのビルドとデプロイを連続して行う場合(a-2)
    • アプリケーションとマニフェストは同じリポジトリに入れる必要がある(d-2)。別のリポジトリにあるとアトミックな更新ができない。
    • 実行環境ごとにブランチを用意する必要がある(b-3)。単一のブランチではデプロイ先の実行環境を判断できない。

以降は具体的なツールを挙げて比較してみよう。

(a-1)(b-1)(c-1)(d-1)

GitOpsを採用する場合に最も簡単なフローから考えてみよう。デプロイの頻度が低いチームはこのフローで十分と思う。

  1. 開発者がアプリケーションリポジトリに新しいタグをpushする。
  2. CIが新しいイメージをビルドする。リポジトリのタグとイメージのタグは名前を合わせておく。
  3. 開発者がマニフェストリポジトリを更新するPRを作成する。
  4. 開発者がマニフェストリポジトリのPRをマージする。
  5. GitOpsツールがマニフェストリポジトリクラスタを同期させる。

メリデメ:

  • 👍 CI/CD外でリソースが変更されても不整合が起こらない。
  • 👍 旧イメージタグへの切り戻しが確実で早い。
  • 😱 デプロイに必要な作業が多すぎる。

(a-1)(b-2)(c-1)(d-1)

前項からdeveloper experienceを改善するためにイメージタグの更新を自動化する。イメージタグはcommit hashから生成する。

  1. 開発者がアプリケーションリポジトリのmasterブランチを更新する。
  2. CIが新しいイメージをビルドする。
  3. CIがマニフェストリポジトリのイメージタグを更新するPRを作成する。
  4. 開発者がマニフェストリポジトリのPRをマージする。もしマニフェストを変更したい場合はPRと合わせて更新する。
  5. GitOpsツールがマニフェストリポジトリクラスタを同期させる。

メリデメ:

  • 👍 開発者はPRをマージするだけで新イメージタグをデプロイできる。
  • 👍 CI/CD外でリソースが変更されても不整合が起こらない。
  • 👍 旧イメージタグへの切り戻しが確実で早い。
  • 😱 マニフェストリポジトリの更新やPRの作成を作り込むのが大変そう。

(a-2)(b-3)(c-1)(d-2)

前項ではPRを挟んでイメージタグを更新していた。ビルドとデプロイを連続して行いたい場合(a-2)、アプリケーションとマニフェストをアトミックに更新できないことが課題となる。そこで、アプリケーションとマニフェストを同じリポジトリで管理する(d-2)。

アプリケーションとマニフェストのライフサイクルが合わなくなるので、実行環境ごとにブランチを用意する(b-3)。

  1. 開発者がリポジトリの (master | staging | production) ブランチに対するPRをマージする。
  2. CIが新しいイメージをビルドする。アプリケーションリポジトリに含まれるマニフェストレンダリングする。
  3. CIがマニフェストリポジトリを更新する。
  4. GitOpsツールがマニフェストリポジトリクラスタを同期させる。

メリデメ:

  • 👍 開発者は実行環境に対応するブランチを更新するだけで新イメージタグをデプロイできる。
  • 👍 CI/CD外でリソースが変更されても不整合が起こらない。
  • 😱 切り戻す場合はアプリケーションのrevert commitが必要になる。
  • 😱 マニフェストの変更だけでもイメージが新しくなってしまう。
  • 😱 マニフェストリポジトリの更新やPRの作成を作り込むのが大変そう。

(a-2)(b-3)(c-2)(d-2)

GitOpsを使わない方法も考えてみよう。まずは、アプリケーションとマニフェストを同じリポジトリで管理する。

  1. 開発者がリポジトリの (master | staging | production) ブランチに対するPRをマージする。
  2. CIが新しいイメージをビルドする。
  3. CIがhelm upgradeを実行する。

もしビルドとデプロイを切り離したい場合(a-1)は、CIの手動ジョブを使えばよい。

メリデメ:

  • 👍 開発者は実行環境に対応するブランチを更新するだけで新イメージタグをデプロイできる。
  • 👍 CIで完結する。複数ツールのピタゴラスイッチがない。
  • 👍 緊急の切り戻しが必要な場合はリソースを直接変更できる。(直接変更できてしまうデメリットでもある)
  • 😱 新しいマニフェストにないリソースを確実に削除するため、Helmが必須になる。他のツールは使えない。
  • 😱 CI/CD外でリソースが変更されると不整合が発生する。
  • 😱 マニフェストの変更だけでもイメージが新しくなってしまう。
  • 😱 リポジトリクラスタの差分を確認することは可能(helm-diffを利用)だが、Argo CDのような分かりやすさはない。

(a-1)(b-2)(c-2)(d-1)

アプリケーションとマニフェストを別のリポジトリで管理する方法もあるが、GitOpsを利用する場合とほぼ同じフローで、わざわざHelmを利用するメリットは小さい。

まとめ

思いつくままに挙げていったところこんな感じです。誰かカルノー図で組み合わせ網羅をお願いします😓

ちなみに、EC2 + Auto Scaling Group + Immutable Infrastructureなサービスの開発チームにいた時は以下のフローでやっていました。

  1. 開発者がアプリケーションリポジトリに新しいタグをpushする。
  2. CIが新しいTar ballをビルドしてS3に配置する。
  3. 開発者がタグ名を指定してデプロイツールを実行する。(社内の内製ツール)
  4. デプロイツールがAuto Scaling GroupのLaunch Configurationを更新する。
  5. デプロイツールがEC2 Instanceをterminateする。

一方で、少人数で速いサイクルの開発を行っているチームでは以下のようなフローでした。

  1. 開発者がアプリケーションリポジトリのmasterブランチにpushする。
  2. CIが開発環境にデプロイする。
  3. 開発者が後続の手動ジョブを実行する。
  4. CIが本番環境にデプロイする。

というわけでオチは特にないです。