はじめに
kopsでKubernetesクラスターを管理している環境でKarpenterを使うと、需要に応じて自動的にノードが起動されます。しかし、これらのノードがどのようにしてクラスターに参加するのか、その詳細な流れはあまり知られていません。本記事では、Karpenterが起動したノードがkopsクラスターに参加するまでの流れをソースコードと共に解説します。
1. KarpenterのAWSNodeTemplateとLaunchTemplate
Karpenterがノードを起動する際、最初に使用するのはAWSNodeTemplate
リソースです。kopsはこのリソースを各インスタンスグループごとに作成します。
# 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コマンドです。
# 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独自のブートストラッププロセスが始まります。
// 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
が必要な認証情報を生成します。
// 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)を使って認証トークンを生成します。
// 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はノードからのリクエストを受け取ると、まずトークンの検証を行います。
// 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バケットから該当するインスタンスグループの設定ファイルを取得します。
// 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サーバーに接続するための証明書も発行します。
// 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ファイルを作成します。
// 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が必要とする設定ファイルも作成します。
// 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
に変わります。
// 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スタックを使います。
# 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
に通信できるようにするため、次の仕組みを取っています。
-
/etc/hostsへの書き込み
nodeup
は起動時に、コントロールプレーンノードのIPアドレスをkops-controller.internal.<ClusterName>
というホスト名に紐づけるエントリを/etc/hosts
へ追記します。// 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番ポートへの通信が許可されます。
// https://github.com/kubernetes/kops/blob/5ce66a9d284769e0e4e9e08e00a65c4f723c44aa/pkg/model/awsmodel/firewall.go
RemoveExtraRules: []string{
// ...
"port=3988", // kops-controller
// ...
},
同じVPC内のノードからのみアクセスを許可する構成がデフォルトとなっており、外部からは直接アクセスできないようになっています。
まとめ
- kops-controllerがノードを検証し、S3からkubeletの設定ファイルを取得、さらに証明書を発行
- nodeupが受け取った証明書と設定を使ってkubeconfigを作成
- kubeletが起動してkubeconfigを使ってAPIサーバーに接続
- kubeletがノードを登録し、
Ready
ステータスになる - Karpenterがノードの状態を監視し、管理を開始
このプロセス全体の鍵となるのは、クラウドプロバイダーのインスタンスアイデンティティを使った強力な認証メカニズムと、kops-controllerによる集中管理です。これによって、Kopsは従来のブートストラップトークンを使わずに、より安全でクラウドネイティブな方法でノードのブートストラッププロセスを実現しています。
技術的詳細の補足
keypairIDsの役割
nodeupプロセスの中で重要な役割を果たすのが、keypairIDsです。これは証明書の発行元を一意に識別するためのIDです。
// 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
このファイルには、ノードの詳細な設定情報が含まれています:
# 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サーバーに接続する際の認証フローは以下のようになります:
- kubeletが起動時に
-kubeconfig=/var/lib/kubelet/kubeconfig
を指定 - kubeconfigファイルから証明書、秘密鍵、CA証明書を読み込み
- TLSクライアント認証を使ってAPIサーバーに接続
- APIサーバーがクライアント証明書を検証
- CommonName:
system:node:<ノード名>
- Organization:
system:nodes
- CommonName:
- APIサーバーがRBACルールに基づいて権限を付与
-
system:nodes
グループにはsystem:node
のClusterRoleがバインドされている -
system:node
のClusterRoleはノードの操作に必要な権限を持つ
-
Karpenter特有の機能
kopsとKarpenterの統合は、以下のファイルで設定されています:
このファイルには、Karpenter用のカスタムリソース定義(CRD)やKarpenterコントローラーのデプロイメント設定が含まれています。
セキュリティの考慮事項
kopsのブートストラップメカニズムは、いくつかの重要なセキュリティ機能を提供しています:
- クラウドプロバイダー認証: EC2インスタンスのIDを証明するために、AWS STSサービスを使用
- TLS相互認証: kubeletとAPIサーバー間の通信はTLS相互認証によって保護
- 最小権限の原則: 各ノードには必要最小限の権限のみが付与される
- 証明書ローテーション: kubelet証明書は自動的にローテーションされる
トラブルシューティング
Karpenterノードがクラスターに参加しない場合は、以下を確認しましょう:
- kops-controller: kops-controllerポッドが正常に動作しているか
- 起動テンプレート: Karpenterが正しい起動テンプレートを使用しているか
- IAM権限: インスタンスプロファイルに必要な権限があるか
- ネットワーク: ノードがAPIサーバーに接続できるか
-
ログ:
/var/log/cloud-init-output.log
や/var/log/kops-controller.log
を確認