kopsでKarpenterが起動するノードのKubernetesクラスター参加の仕組み

## はじめに

kopsでKubernetesクラスターを管理している環境でKarpenterを使うと、需要に応じて自動的にノードが起動されます。しかし、これらのノードがどのようにしてクラスターに参加するのか、その詳細な流れはあまり知られていません。本記事では、Karpenterが起動したノードがkopsクラスターに参加するまでの流れをソースコードと共に解説します。

## 1. KarpenterのAWSNodeTemplateとLaunchTemplate

Karpenterがノードを起動する際、最初に使用するのは`AWSNodeTemplate`リソースです。kopsはこのリソースを各インスタンスグループごとに作成します。

```yaml
# https://github.com/kubernetes/kops/blob/5ce66a9d284769e0e4e9e08e00a65c4f723c44aa/upup/models/cloudup/resources/addons/karpenter.sh/k8s-1.19.yaml.template
apiVersion: karpenter.k8s.aws/v1alpha1
kind: AWSNodeTemplate
metadata:
  name: {{ $name }}
spec:
  subnetSelector:
    kops.k8s.io/instance-group/{{ $name }}: "*"
    kubernetes.io/cluster/{{ ClusterName }}: "*"
  launchTemplate: {{ $name }}.{{ ClusterName }}
```

ここで重要なのは`launchTemplate`フィールドです。Karpenterはこの起動テンプレートを使ってEC2インスタンスを起動します。この起動テンプレートにはユーザーデータがセットされており、そこにnodeupの起動コマンドが静的に記載されています。

## 2. EC2インスタンスの起動とユーザーデータスクリプト

EC2インスタンスが起動すると、LaunchTemplateに設定されたユーザーデータスクリプトが実行されます。このスクリプトが実行するのは主にnodeupコマンドです。

```hcl
# https://github.com/kubernetes/kops/blob/5ce66a9d284769e0e4e9e08e00a65c4f723c44aa/tests/integration/update_cluster/karpenter/kubernetes.tf
resource "aws_launch_template" "karpenter-nodes-default-minimal-example-com" {
  # ...
  user_data = base64encode(file("${path.module}/data/aws_launch_template_karpenter-nodes-default.minimal.example.com_user_data"))
  # ...
}
```

ユーザーデータスクリプトの中では、nodeupの実行に必要な環境変数が設定され、S3バケットからnodeupバイナリをダウンロードして実行します。

## 3. nodeupの起動とブートストラップ処理

nodeupが起動すると、インスタンスグループの設定に基づいてノードの設定を行います。ここからkops独自のブートストラッププロセスが始まります。

```go
// https://github.com/kubernetes/kops/blob/5ce66a9d284769e0e4e9e08e00a65c4f723c44aa/upup/pkg/fi/nodeup/command.go
func (c *NodeUpCommand) Run(out io.Writer) error {
    // ...
    task := &nodetasks.BootstrapClientTask{}
    // ...
}

```

nodeupはまず、クラスターへの接続に必要な証明書と設定を取得するため、 `kops-controller` に接続します。

## 4. ブートストラップクライアントの設定

非コントロールプレーンノード（Karpenterノードを含む）では、`BootstrapClientBuilder`が必要な認証情報を生成します。

```go
// https://github.com/kubernetes/kops/blob/5ce66a9d284769e0e4e9e08e00a65c4f723c44aa/nodeup/pkg/model/bootstrap_client.go
func (b BootstrapClientBuilder) Build(c *fi.NodeupModelBuilderContext) error {
    if b.IsMaster {
        return nil
    }

    var authenticator bootstrap.Authenticator

    switch b.CloudProvider() {
    case kops.CloudProviderAWS:
        a, err := awsup.NewAWSAuthenticator(c.Context(), b.Cloud.Region())
        if err != nil {
            return err
        }
        authenticator = a
    // 他のクラウドプロバイダーの場合...
    }

    baseURL := url.URL{
        Scheme: "https",
        Host:   net.JoinHostPort("kops-controller.internal."+b.NodeupConfig.ClusterName,
               strconv.Itoa(wellknownports.KopsControllerPort)),
        Path:   "/",
    }

    bootstrapClient := &kopscontrollerclient.Client{
        Authenticator: authenticator,
        CAs:           []byte(b.NodeupConfig.CAs[fi.CertificateIDCA]),
        BaseURL:       baseURL,
    }

    // ...タスクの作成と追加
    c.AddTask(bootstrapClientTask)
    return nil
}

```

AWSの場合、`AWSAuthenticator`がEC2インスタンスのメタデータを使って認証情報を生成します。これによりノードの正当性をkops-controllerに証明します。

## 5. クラウドプロバイダーベースの認証とトークン生成

AWS環境でのnodeupは、AWS STS (Security Token Service)を使って認証トークンを生成します。

```go
// https://github.com/kubernetes/kops/blob/5ce66a9d284769e0e4e9e08e00a65c4f723c44aa/pkg/kopscontrollerclient/client.go
func (b *Client) Query(ctx context.Context, req any, resp any) error {
    // ...

    reqBytes, err := json.Marshal(req)
    if err != nil {
        return err
    }

    bootstrapURL := b.BaseURL
    bootstrapURL.Path = path.Join(bootstrapURL.Path, "/bootstrap")
    httpReq, err := http.NewRequestWithContext(ctx, "POST", bootstrapURL.String(), bytes.NewReader(reqBytes))
    if err != nil {
        return err
    }
    httpReq.Header.Set("Content-Type", "application/json")

    token, err := b.Authenticator.CreateToken(reqBytes)
    if err != nil {
        return err
    }
    httpReq.Header.Set("Authorization", token)

    // ...リクエスト送信と応答処理
}

```

AWSの`CreateToken`メソッドは、AWS STS GetCallerIdentityリクエストを作成し、それに署名して、結果をBase64エンコードしたものをトークンとして返します。

## 6. kops-controllerへのリクエストとノード検証

kops-controllerはノードからのリクエストを受け取ると、まずトークンの検証を行います。

```go
// https://github.com/kubernetes/kops/blob/5ce66a9d284769e0e4e9e08e00a65c4f723c44aa/cmd/kops-controller/pkg/server/server.go
func (s *Server) bootstrap(w http.ResponseWriter, r *http.Request) {
    // ...

    body, err := io.ReadAll(r.Body)
    if err != nil {
        // エラーハンドリング...
    }

    ctx := r.Context()

    id, err := s.verifier.VerifyToken(ctx, r, r.Header.Get("Authorization"), body)
    if err != nil {
        // エラーハンドリング...
        return
    }

    // ノードが既に登録済みかチェック
    {
        node := &corev1.Node{}
        err := s.uncachedClient.Get(ctx, types.NamespacedName{Name: id.NodeName}, node)
        if err == nil {
            // ...既に登録済みの場合の処理
        }
    }

    // ...
}

```

AWS環境では、`VerifyToken`がトークンをデコードしてSTSリクエストを復元し、実際にAWS STSサービスを呼び出してインスタンスIDを検証します。

## 7. S3からの設定情報取得

認証が成功すると、kops-controllerはS3バケットから該当するインスタンスグループの設定ファイルを取得します。

```go
// https://github.com/kubernetes/kops/blob/5ce66a9d284769e0e4e9e08e00a65c4f723c44aa/cmd/kops-controller/pkg/server/node_config.go
func (s *Server) getNodeConfig(ctx context.Context, req *nodeup.BootstrapRequest, identity *bootstrap.VerifyResult) (*nodeup.NodeConfig, error) {
    // ...

    nodeConfig := &nodeup.NodeConfig{}

    if s.opt.Cloud == "metal" {
        // Metalプロバイダー用の特別処理...
    } else {
        // S3からnodeupconfig.yamlを読み込み
        p := s.configBase.Join("igconfig", "node", instanceGroupName, "nodeupconfig.yaml")

        b, err := p.ReadFile(ctx)
        if err != nil {
            return nil, fmt.Errorf("error loading NodeupConfig %q: %v", p, err)
        }
        nodeConfig.NodeupConfig = string(b)
    }

    // シークレット情報の取得
    {
        // ...
    }

    return nodeConfig, nil
}

```

これはKarpenterノードが正しい設定を取得するための重要なステップです。S3バケット内のパスは`igconfig/node/<インスタンスグループ名>/nodeupconfig.yaml`という形式になっています。

## 8. kubelet用証明書の発行

kops-controllerは、ノードがAPIサーバーに接続するための証明書も発行します。

```go
// https://github.com/kubernetes/kops/blob/5ce66a9d284769e0e4e9e08e00a65c4f723c44aa/cmd/kops-controller/pkg/server/server.go
func (s *Server) issueCert(ctx context.Context, name string, pubKey string, id *bootstrap.VerifyResult, validHours uint32, keypairIDs map[string]string) (string, error) {
    // ...

    issueReq := &pki.IssueCertRequest{
        Signer:    fi.CertificateIDCA,
        Type:      "client",
        PublicKey: key,
        Validity:  time.Hour * time.Duration(validHours),
    }

    // ...

    switch name {
    // ...
    case "kubelet":
        issueReq.Subject = pkix.Name{
            CommonName:   fmt.Sprintf("system:node:%s", id.NodeName),
            Organization: []string{rbac.NodesGroup},
        }
    // ...
    }

    cert, _, _, err := pki.IssueCert(ctx, issueReq, s.keystore)
    if err != nil {
        return "", fmt.Errorf("issuing certificate: %v", err)
    }

    return cert.AsString()
}

```

kubelet用の証明書には、`system:node:<ノード名>`という特別なCommonNameと、`system:nodes`というOrganizationが設定されます。これによりノードはKubernetesのRBACシステムで適切な権限を持つことができます。

## 9. nodeupでのkubeconfig作成

kops-controllerから取得した証明書と設定情報を使って、nodeupはkubeconfigファイルを作成します。

```go
// https://github.com/kubernetes/kops/blob/5ce66a9d284769e0e4e9e08e00a65c4f723c44aa/nodeup/pkg/model/kubelet.go
func (b *KubeletBuilder) Build(c *fi.NodeupModelBuilderContext) error {
    // ...
    {
        var kubeconfig fi.Resource
        if b.HasAPIServer {
            kubeconfig = b.BuildIssuedKubeconfig("kubelet", nodetasks.PKIXName{...}, c)
        } else {
            kubeconfig, err = b.BuildBootstrapKubeconfig("kubelet", c)
            if err != nil {
                return err
            }
        }

        c.AddTask(&nodetasks.File{
            Path:           b.KubeletKubeConfig(), // /var/lib/kubelet/kubeconfig
            Contents:       kubeconfig,
            Type:           nodetasks.FileType_File,
            Mode:           s("0400"),
            BeforeServices: []string{kubeletService},
        })
    }
    // ...
}

```

このkubeconfigファイルは`/var/lib/kubelet/kubeconfig`に保存され、kubeletサービスの起動前に作成されます。

## 10. kubeletの設定と起動

nodeupは、kubeletが必要とする設定ファイルも作成します。

```go
// https://github.com/kubernetes/kops/blob/5ce66a9d284769e0e4e9e08e00a65c4f723c44aa/nodeup/pkg/model/kubelet.go
func (b *KubeletBuilder) buildSystemdEnvironmentFile(ctx context.Context, kubeletConfig *kops.KubeletConfigSpec) (*nodetasks.File, error) {
    // ...
    flags, err := flagbuilder.BuildFlags(kubeletConfig)
    if err != nil {
        return nil, fmt.Errorf("error building kubelet flags: %v", err)
    }

    // ...

    flags += " --kubeconfig=" + b.KubeletKubeConfig()
    flags += " --config=" + kubeletConfigFilePath

    // ...

    sysconfig := "DAEMON_ARGS=\\"" + flags + "\\"\\n"

    // ...
}

```

この設定には、先ほど作成したkubeconfigファイルのパスも含まれています。

## 11. kubeletによるAPIサーバーへの接続

最後に、kubeletサービスが起動し、作成されたkubeconfigを使ってAPIサーバーに接続します。kubeconfigには先ほど発行された証明書が含まれており、APIサーバーはこの証明書を使ってノードを認証します。

kubeletがAPIサーバーに接続すると、自動的に`Node`オブジェクトが作成されます。このNodeオブジェクトは、証明書のCommonName(`system:node:<ノード名>`)から名前が取られます。

## 12. ノードのReadyステータスへの移行

APIサーバーへの接続が成功すると、kubeletは定期的にステータス更新を送信します。ノードの各種チェックが通ると、ノードのステータスが`Ready`に変わります。

```go
// https://github.com/kubernetes/kops/blob/5ce66a9d284769e0e4e9e08e00a65c4f723c44aa/pkg/validation/validate_cluster.go
switch n.Role {
case "control-plane", "apiserver", "node":
    if !ready {
        v.addError(&ValidationError{
            Kind:          "Node",
            Name:          node.Name,
            Message:       fmt.Sprintf("node %q of role %q is not ready", node.Name, n.Role),
            InstanceGroup: cloudGroup.InstanceGroup,
        })
    }

    v.Nodes = append(v.Nodes, n)
// ...

```

## 13. nodeupとkops-controller間の通信経路がどの様にして実現されているのか？

ここから、**まだクラスターに参加していない段階のノード**がどのようにして`kops-controller`と通信しているのかを見ていきます。

### 13-1. kops-controllerの配置

まず、`kops-controller`の役割と配置をおさらいします。

- **役割**
クラスター内で**ノードの証明書発行**や**ノード管理**を担当するコンポーネントです。
- **配置**
コントロールプレーンノード（masterノード）上で動作する`DaemonSet`として実行されます。
- **ポート**
デフォルトで「3988番ポート」でリッスンします（`wellknownports.KopsControllerPort`）。
- **ネットワーク**`hostNetwork: true` で動作し、コントロールプレーンノードのホストIPと同じIPスタックを使います。

```yaml
# https://github.com/kubernetes/kops/blob/0789af746b4e5beb6eab4b9a4b42b88f3c072d19/upup/pkg/fi/cloudup/tests/bootstrapchannelbuilder/simple/kops-controller.addons.k8s.io-k8s-1.16.yaml
kind: DaemonSet
metadata:
  name: kops-controller
  namespace: kube-system
# ...
spec:
  # ...
  template:
    # ...
    spec:
      affinity:
        nodeAffinity:
          # ... コントロールプレーンノードのみに配置
      dnsPolicy: Default
      hostNetwork: true   # ホストネットワークを使用
```

これにより、`kops-controller`は**各コントロールプレーンノードの** `実IPアドレス:3988` ****で直接アクセスできるようになっています。

### 13-2. nodeupによるホスト名解決 ( /etc/hosts への書き込み )

KubernetesのServiceやIngressが使えない段階でも、`nodeup`が`kops-controller`に通信できるようにするため、次の仕組みを取っています。

1. **/etc/hostsへの書き込み**
    
    `nodeup`は起動時に、コントロールプレーンノードのIPアドレスを`kops-controller.internal.<ClusterName>`というホスト名に紐づけるエントリを`/etc/hosts`へ追記します。
    
    ```go
    // https://github.com/kubernetes/kops/blob/5ce66a9d284769e0e4e9e08e00a65c4f723c44aa/nodeup/pkg/model/etc_hosts.go
    task.Records = append(task.Records, nodetasks.HostRecord{
        Hostname:  "kops-controller.internal." + b.NodeupConfig.ClusterName,
        Addresses: b.BootConfig.APIServerIPs,
    })
    ```
    
    ここで`b.BootConfig.APIServerIPs`はコントロールプレーンノードのIPアドレス群です。これにより、オーバーレイネットワークに参加していない段階でもコントロールプレーンノードのIPアドレスを引くことが可能となります。
    

### 13-3. ファイアウォール（セキュリティグループ）の設定

もちろん、任意のネットワークから3988ポートにアクセスできるわけではありません。AWSの場合、セキュリティグループで**ワーカーノード → コントロールプレーンノードの3988番ポート**への通信が許可されます。

```go
// https://github.com/kubernetes/kops/blob/5ce66a9d284769e0e4e9e08e00a65c4f723c44aa/pkg/model/awsmodel/firewall.go
RemoveExtraRules: []string{
    // ...
    "port=3988", // kops-controller
    // ...
},

```

同じVPC内のノードからのみアクセスを許可する構成がデフォルトとなっており、外部からは直接アクセスできないようになっています。

## まとめ

1. kops-controllerがノードを検証し、S3からkubeletの設定ファイルを取得、さらに証明書を発行
2. nodeupが受け取った証明書と設定を使ってkubeconfigを作成
3. kubeletが起動してkubeconfigを使ってAPIサーバーに接続
4. kubeletがノードを登録し、`Ready`ステータスになる
5. Karpenterがノードの状態を監視し、管理を開始

このプロセス全体の鍵となるのは、クラウドプロバイダーのインスタンスアイデンティティを使った強力な認証メカニズムと、kops-controllerによる集中管理です。これによって、Kopsは従来のブートストラップトークンを使わずに、より安全でクラウドネイティブな方法でノードのブートストラッププロセスを実現しています。

## 技術的詳細の補足

### keypairIDsの役割

nodeupプロセスの中で重要な役割を果たすのが、keypairIDsです。これは証明書の発行元を一意に識別するためのIDです。

```go
// https://github.com/kubernetes/kops/blob/5ce66a9d284769e0e4e9e08e00a65c4f723c44aa/nodeup/pkg/model/context.go
func (c *NodeupModelContext) GetBootstrapCert(name string, signer string) (cert, key fi.Resource, err error) {
    // ...
    c.bootstrapKeypairIDs[signer] = c.NodeupConfig.KeypairIDs[signer]
    if c.bootstrapKeypairIDs[signer] == "" {
        return nil, nil, fmt.Errorf("no keypairID for %q", signer)
    }
    // ...
}

```

このkeypairIDを使って、kops-controllerは正しい認証局（CA）を使って証明書を発行します。これにより、クラスター内の全てのノードが同じCAから発行された証明書を持つことが保証されます。

### S3バケットの構造

kopsはS3バケットを使って、クラスターの設定情報を保存しています。Karpenterノード用の設定は以下のようなパスに保存されています：

```
<バケット名>/clusters.example.com/<クラスター名>/igconfig/node/<インスタンスグループ名>/nodeupconfig.yaml

```

このファイルには、ノードの詳細な設定情報が含まれています：

```yaml
# nodeupconfig.yaml（内容例）
Assets:
  amd64:
  - <アセットハッシュ>@<URL>
# ...
KubeletConfig:
  anonymousAuth: false
  cgroupDriver: systemd
  cloudProvider: external
  clusterDNS: 100.64.0.10
  clusterDomain: cluster.local
  nodeLabels:
    karpenter.sh/provisioner-name: karpenter-nodes-default
    node-role.kubernetes.io/node: ""
# ...

```

### 証明書ファイルの保存場所

kubeletが使用する証明書ファイルは、以下の場所に保存されます：

```
/var/lib/kubelet/pki/kubelet-client-current.pem  # クライアント証明書
/var/lib/kubelet/kubeconfig                      # kubeconfigファイル
```

### APIサーバー接続時の認証フロー

kubeletがAPIサーバーに接続する際の認証フローは以下のようになります：

1. kubeletが起動時に`-kubeconfig=/var/lib/kubelet/kubeconfig`を指定
2. kubeconfigファイルから証明書、秘密鍵、CA証明書を読み込み
3. TLSクライアント認証を使ってAPIサーバーに接続
4. APIサーバーがクライアント証明書を検証
    - CommonName: `system:node:<ノード名>`
    - Organization: `system:nodes`
5. APIサーバーがRBACルールに基づいて権限を付与
    - `system:nodes`グループには`system:node`のClusterRoleがバインドされている
    - `system:node`のClusterRoleはノードの操作に必要な権限を持つ

## Karpenter特有の機能

kopsとKarpenterの統合は、以下のファイルで設定されています：

https://github.com/kubernetes/kops/blob/5ce66a9d284769e0e4e9e08e00a65c4f723c44aa/upup/models/cloudup/resources/addons/karpenter.sh/k8s-1.19.yaml.template

このファイルには、Karpenter用のカスタムリソース定義（CRD）やKarpenterコントローラーのデプロイメント設定が含まれています。

## セキュリティの考慮事項

kopsのブートストラップメカニズムは、いくつかの重要なセキュリティ機能を提供しています：

1. **クラウドプロバイダー認証**: EC2インスタンスのIDを証明するために、AWS STSサービスを使用
2. **TLS相互認証**: kubeletとAPIサーバー間の通信はTLS相互認証によって保護
3. **最小権限の原則**: 各ノードには必要最小限の権限のみが付与される
4. **証明書ローテーション**: kubelet証明書は自動的にローテーションされる

## トラブルシューティング

Karpenterノードがクラスターに参加しない場合は、以下を確認しましょう：

1. **kops-controller**: kops-controllerポッドが正常に動作しているか
2. **起動テンプレート**: Karpenterが正しい起動テンプレートを使用しているか
3. **IAM権限**: インスタンスプロファイルに必要な権限があるか
4. **ネットワーク**: ノードがAPIサーバーに接続できるか
5. **ログ**: `/var/log/cloud-init-output.log`や`/var/log/kops-controller.log`を確認