3週間でGoを習得しました。昨日、私のコードがk9sにマージされました。

Go未経験から人気のKubernetesターミナルUIへのPRマージまで — HTTP/1.1アップグレードを誤読し、自分のテストスイートを壊し、Kubernetes 1.31がルールを変更した際にcreategetが同じ動詞ではないことを学んだ方法。

セットアップ

自分が書いていない単一の関数から始まりました。

私は1週間k9sに貢献し、58,000スタープロジェクトがどのようにGoコードを構造化しているかを理解しようとしていました。私はinternal/dao/port_forwarder.goに深く入り込み、ユーザーがポッドでポートフォワードを開くことを承認されているかどうかをチェックするコードブロックを見つめていました。チェックはシンプルでした:このサービスアカウントはpods/portforwardcreate権限を持っているか?

Kubernetesのドキュメントを読みました。ポートフォワードにはcreateが必要です。みんなそう言っていました。コードは動作しました。私は先に進みました。

その後、誰かがissueを開きました:「制限されたRBACでk8s 1.31でポートフォワードが失敗する。」

Kubernetes 1.31でPortForwardWebsockets機能が導入されました — SPDYの代わりにWebSocket経由でポートをフォワードする新しいコードパスです。そしてWebSocketは、SPDYとは異なり、pods/portforwardサブリソースにget動詞のみを必要とします。

それがバグでした。コードはcreateをチェックしていました。新しいWebSocketパスにはgetが必要でした。getのみのアクセスを持つユーザーは、kubectlが正常に動作していても、k9sでポートフォワードできませんでした。

これは私が修正しようとし、最初は失敗し、3週間のチュートリアルよりも3日間でGo、HTTPアップグレード、Kubernetes認可について多くを学んだ話です。

これはチュートリアルではありません。これは48時間かかった2行の修正の解剖です。

kubectlを使わない理由は?

私はプロフェッショナルにkubectl port-forwardを使用しています。動作します。WebSocket移行を透過的に処理します。バグレポートはありません。エッジケースはありません。問題はありません。

しかしk9sはkubectlではありません。k9sはclient-goをラップし、ポッド操作のための統一インターフェースを提供するターミナルUIです。k9sがポッドを表示し、Shift-Fを押してポートフォワードできるようにすると、まずそれを実行する権限があるかどうかをチェックします。そしてそのチェックが間違っていました。

問題は学術的ではありませんでした。このRBACロールを持つユーザー:

rules:
- apiGroups: [""]
  resources: ["pods/portforward"]
  verbs: ["get"]

全画面モードに入る 全画面モードを終了する

はKubernetes 1.31+(WebSocketパス)でkubectl port-forwardを正常に実行できましたが、k9sは権限が不足していると判断し、ポートフォワードオプションをサイレントにグレーアウトしていました。

そのユーザーは実在しました。issueには再現手順がありました。実際のクラスター、実際のロール、実際のワークフローが、私がclient-goソースを読まなければ気づくことがなかった動詞の不一致によって壊れていました。

k9sアーキテクチャ

┌─────────────────────────────────────────────────────────────┐
│                         k9s TUI                              │
│                    (tview / terminal UI)                     │
│  User presses Shift-F on a pod                              │
└──────────────────────┬──────────────────────────────────────┘
                       │
                       ▼
┌─────────────────────────────────────────────────────────────┐
│              internal/dao/port_forwarder.go                  │
│                                                              │
│  1. Check pod is Running ( readiness gate )                 │
│  2. Authorize: can user create pods/portforward?            │
│         ▲                                                    │
│         │ THIS WAS THE BUG                                   │
│  3. If yes → open port-forward session                      │
│     If no  → grey out option / show error                   │
└──────────────────────┬──────────────────────────────────────┘
                       │
              ┌────────┴────────┐
              │                 │
              ▼                 ▼
    ┌─────────────┐     ┌─────────────┐
    │ SPDY path   │     │ WebSocket   │
    │ (legacy)    │     │ (k8s 1.31+) │
    │ needs       │     │ needs       │
    │ CREATE verb │     │ GET verb    │
    └─────────────┘     └─────────────┘
              │                 │
              └───────┬─────────┘
                      ▼
            ┌─────────────────┐
            │  K8s API Server │
            │  /api/v1/.../   │
            │  portforward    │
            └─────────────────┘

全画面モードに入る 全画面モードを終了する

見逃していた決定

Path Protocol Required Verb k9s Checked?
Legacy SPDY SPDY/HTTP/1.1 upgrade create on pods/portforward ✅ Yes
WebSocket WebSocket (RFC 6455) get on pods/portforward ❌ No

k9sはcreateのみをチェックしていました。WebSocketパスのget要件については知りませんでした。そのため、getのみのユーザーは、クラスターが実際に使用しているコードパスに対して認可されていてもブロックされました。

失敗(そしてそれぞれから学んだこと)

失敗1:1つの単語を変更して終わりにした

私の最初の修正は恥ずかしいほど単純でした。

port_forwarder.goの関数を見つけました。"create"動詞でSelfSubjectAccessReviewを構築し、APIサーバーに送信していました。これを"get"に変更しました。コンパイルされました。ドラフトPRを開きました。

1時間以内にメンテナーがコメントしました:「まだSPDYパスを使用しているクラスターの後方互換性を壊します。」

私は1つのハードコードされた動詞を別の動詞に置き換えただけでした。SPDYをチェックしませんでした。両方をチェックしませんでした。Kubernetes <1.31を実行しているクラスターや、WebSocket機能ゲートが無効になっているクラスターについてさえ考えませんでした。「新しい = 正しい」と仮定しました。

根本原因:プロトコル交渉の問題を文字列置換の問題として扱いました。

修正:コードは両方の動詞を独立してチェックする必要がありました。createが認可されている場合、ユーザーはSPDY経由でポートフォワードできます。getが認可されている場合、WebSocket経由でポートフォワードできます。どちらかが認可されている場合、k9sは操作を許可する必要があります。APIサーバーとclient-goは、接続時に実際に使用されるプロトコルを処理します。

// What I wrote first (WRONG — only checks get)
if !utils.CheckPodPortFwd(a.factory.Client(), a.factory.Config(), path) {
    return errors.New("insufficient permission")
}

// What merged (RIGHT — checks create AND get independently)
if !utils.CheckPodPortFwd(a.factory.Client(), a.factory.Config(), path) {
    if !utils.CheckPodPortFwdGet(a.factory.Client(), a.factory.Config(), path) {
        return errors.New("insufficient permission")
    }
}

全画面モードに入る 全画面モードを終了する

実際、最終的にマージされたコードはよりクリーンでした — 新しいCheckPodPortFwdGetヘルパーを追加し、ポートフォワードチェックから両方を呼び出しました。主要な洞察:creategetは互いに置き換える代替手段ではありません。独立したプロトコルパスのための独立した機能です。両方が共存できます。どちらも削除できません。

教訓:プラットフォームが複数のプロトコルをサポートする場合、新しいプロトコルの修正は古いプロトコルのサポートを削除できません。置き換えではなく共存をテストしてください。

失敗2:t.Parallel()を理解していなかったため、テストスイートを破壊した

k9sには実際のテストスイートがあります。おもちゃのテストではなく、複数のシナリオにわたる約50のテストケースを持つテーブル駆動テストです。私は新しいWebSocketパスをカバーするテストを追加しました。ローカルではパスしました。

するとCIが失敗しました。

エラーはモッククライアントのデータ競合でした。セットアップコードを節約するために、テストループの外に共有モックRestClientをインスタンス化していました。一部のテストはt.Parallel()で実行されました。2つの並列テストが同じモックのレスポンス状態を同時に変更しました。

レースディテクタ(go test -race)がそれを検出してビルドを失敗させました。

根本原因:モックセットアップを共有することで効率的だと考えていました。実際には、完全に理解していないテストファイルに並行性の危険性を作っていました。

修正:各テストケースをリファクタリングし、テストクロージャー内で独自のRestClientモックを構築するようにしました。共有状態はありません。追加したテストファイルは223行になりました — ほぼすべてが権限のすべての組み合わせのテストケースでした:

  • ポッドが実行されていない
  • get権限なし、create権限なし → ブロック
  • createのみの権限 → 許可(レガシーSPDY)
  • getのみの権限 → 許可(WebSocketパス、これが修正)
  • 両方の権限 → 許可
// Part of the 223 lines of tests I ended up writing
{
    name: "get-only-portforward-allowed",
    pod:  runningPod,
    authorized: map[string]bool{
        "selfsubjectaccessreviews": true,
        "pods":                   true,
        "portforwardget":         true,
    },
    want: true,
},

全画面モードに入る 全画面モードを終了する

教訓:並列テストは無料ではありません。テストインフラストラクチャを所有していない場合、共有状態は証明されるまで禁止されていると仮定してください。テスト内のdata raceは履歴書の汚点です。

失敗3:HTTP/1.1アップグレードとは何かを知らなかった

実際のバグは動詞文字列よりも深かったです。なぜgetがWebSocketには十分だがSPDYには十分ではないのかを理解する必要がありました。

SPDY(レガシープロトコル)は、ポートフォワードエンドポイントへのHTTP POSTリクエストによって開始されます。HTTP POSTはKubernetes認可でcreate動詞にマッピングされます。そのためSPDYポートフォワードにはcreateが必要です。

WebSocketは異なる方法で開始されます。HTTP GETリクエストから始まり、Upgrade: websocketヘッダーが含まれます。サーバーは101 Switching Protocolsで応答し、接続はWebSocketになります。

初期リクエストがGETであるため、Kubernetes認可はそれをpods/portforwardサブリソースのget動詞にマッピングします。

issueを開いた時点ではこれらのことを知りませんでした。1晩かけて読みました:

  • RFC 6455(WebSocketプロトコル)
  • StreamWithContextのKubernetes client-goソース
  • PortForwardWebsocketsのKEP(KEP-4006)
  • client-goの非推奨をk9sがどのように処理するかのissue履歴

根本原因:ポートフォワードは「1回のAPIコール」だと考えていました。実際にはclient-go、APIサーバー、kubelet間のプロトコル交渉ダンスです。認可モデルはプロトコル依存です。

修正:修正を変更しませんでした。両方の動詞をチェックすればコードは正しかったです。しかし、ブロックされたユーザーが必要な権限を正確に知ることができるように、エラーメッセージを更新して両方の許容可能な動詞を示すようにしました:

return fmt.Errorf("user is not authorized to create or get portforward %q", path)

全画面モードに入る 全画面モードを終了する

教訓:分散システムのバグを修正する前に、それが構築されているプロトコルを理解してください。症状は欠けている動詞でした。原因はプロトコルアップグレードでした。プロトコルを理解していない修正はレガシーパスを壊していたでしょう。

PR

Stat Value
Changed files 2
Lines added 240
Lines deleted 3
Files touched internal/dao/port_forwarder.go, internal/dao/port_forwarder_test.go
Tests added 6 test cases covering every permission combination
Review rounds 2
Time from first draft to merge 48 hours

diffは小さいです。ロジックはシンプルです。努力は、なぜロジックが存在する必要があったのかを理解し、私の初稿のミスを他の誰かが犯すのを防ぐテストで証明することでした。

PRタイトル: fix(dao): allow port-forward with 'get' verb on pods/portforward for K8s 1.31+ WebSocket path

クリーンにマージされました。フォローアップ修正は必要ありませんでした。

大規模プロジェクトへの貢献について学んだこと

1. コードではなくissueから始める

ファイルに触れる前に、元のissue(#4144)を3回読みました。報告者はKubernetesバージョン、RBACロール、正確なエラーメッセージを含めていました。この再現がなければ、WebSocket移行のコンテキストを理解することはなかったでしょう。

2. ソースファイルの前にテストファイルを読む

port_forwarder_test.goは、port_forwarder.goよりもk9sが認可をどのように扱っているかについて多くを教えてくれました。テストは嘘をつけないドキュメントです。

3. go test -raceはオプションではない

レースディテクタは、メンテナーより先に私のバグを検出しました。恥ずかしいレビューのコメントをプライベートなCI失敗に変えました。ローカルで実行してください。常に。

4. 2行の修正には200行のテストが必要

本番変更は約20行でした。テストは223行でした。それが本番Goでの比率です。修正が修正前には失敗していたテストを持っていない場合、修正は完了していません。

5. 後方互換性は制約であり、提案ではない

私の初稿はKubernetes 1.31で動作しました。まだ1.30またはそれ以前を使用しているユーザーベースの半分でk9sを壊していたでしょう。実際の修正は両方のパスを同時にサポートします。これが「私のマシンでは動作する」と「k9sにマージされた」の違いです。

なぜこれが重要か(そして重要でないか)

重要である理由:

  • k9sには約58,000のGitHubスターと数千人の日常ユーザーがいます。私の修正は実際の人々と実際のクラスターに影響します。
  • 私はTodoMVCを構築するのではなく、client-goソースを読んでテーブル駆動テストを書くことでGoを学びました。
  • 私は今、Kubernetes認可、HTTPアップグレード、プロトコル交渉を以前よりも高いレベルで理解しています。

重要でない理由:

  • 20行の修正です。Googleのシニアエンジニアはコーヒーの前にこのようなdiffを書きます。
  • 新しいサブシステムを設計したわけではありません。コードベースをリファクタリングしたわけではありません。動詞チェックを修正しただけです。
  • 価値はコードにはありません。価値は、issueを読み、コンテキストを理解し、正しい修正を書き、レビューを生き延び、出荷できることの証明にあります。

それが私が構築しているシグナルです。

次は何?

k9sへの貢献を続けています。コードベースは十分に複雑で、tviewがターミナルUIをどのようにレンダリングするか、k9sウォッチャーがAPIサーバーのポーリングをどのように回避するか、daoレイヤーがclient-go操作をどのように抽象化するかを教え続けています。

今日、Checkovにも貢献しました — Cloud SQLとGKEクラスターの欠けているGCPタグ付け可能リソースを追加しました。小さなPR。2行。20分でマージされました。それが筋肉を構築したときに起こることです:2回目の貢献は1回目よりも速いです。

TL;DR

3週間前にGoの学習を開始しました。ポートフォワード認可がcreate動詞のみをチェックし、Kubernetes 1.31の新しいWebSocketパスに必要なget動詞を見逃しているk9sのバグを見つけました。

私の最初の修正は間違っていました — creategetに置き換え、レガシーSPDYクラスターの後方互換性をほぼ壊しました。最終的な修正は両方の動詞を独立してチェックします。

すべての権限の組み合わせをカバーする223行のテーブル駆動テストを書きました。PRは48時間でマージされました。テスト内のdata raceは、並列テストがモック状態を共有しないことを教えてくれました。

言語を学習している場合、チュートリアルプロジェクトを構築しないでください。実際のプロジェクトで実際のバグを見つけて修正してください。テストスイートはどのコースよりも多くのことを教えてくれます。

あなたの最初のオープンソース貢献の話は何ですか?コメントで共有してください — 特にテストスイートを壊して生き延びた場合。