From 1b74e230ad854b60525c426e9179677eb901c893 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 8 Sep 2026 21:14:56 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A4=B1=E6=95=97=E3=82=BB=E3=83=83?= =?UTF-8?q?=E3=82=B7=E3=83=A7=E3=83=B3=E3=81=AE=E8=AA=BF=E6=9F=BB=E6=80=A7?= =?UTF-8?q?=E3=82=92=E6=94=B9=E5=96=84=EF=BC=88=E7=B5=82=E4=BA=86Pod?= =?UTF-8?q?=E4=BF=9D=E6=8C=81=20+=20=E3=83=AD=E3=82=B0API=20+=20UI?= =?UTF-8?q?=EF=BC=89(v2.1.263-d)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit セッション Pod がブート失敗で Failed になると reaper が即削除し kubectl logs で 死因を追えなかった。「開く」で 502 になるだけで原因不明だった。 - session.reaper.terminatedRetentionSeconds(既定 900): 終了済み Pod を finishedAt からこの秒数は回収しない。manager 回収ループと reaper CronJob 両方が尊重 - GET /api/sessions//log: セッション Pod のログを text で返す(pods/log get のみ、追加権限なし) - UI: 「ログ」ボタン、状態の色分け、Failed 行に reason/exitCode/message 併記、 Running 以外は「開く」非表示 - session-entrypoint.sh: URL スキームと identity 型の不一致を警告。 git clone を --progress ... 2>&1 にして git/ssh の出力を Pod ログへ - helm lint / template 検証済み --- CHANGELOG-2.1.263-d.md | 38 +++++++++++++ CLAUDE.md | 18 ++++++ Chart.yaml | 2 +- README.md | 8 ++- files/manager.py | 104 ++++++++++++++++++++++++++++++---- files/session-entrypoint.sh | 21 ++++++- templates/cronjob-reaper.yaml | 2 + templates/deployment.yaml | 2 + values.schema.json | 1 + values.yaml | 4 ++ 10 files changed, 184 insertions(+), 16 deletions(-) create mode 100644 CHANGELOG-2.1.263-d.md diff --git a/CHANGELOG-2.1.263-d.md b/CHANGELOG-2.1.263-d.md new file mode 100644 index 0000000..535cdaf --- /dev/null +++ b/CHANGELOG-2.1.263-d.md @@ -0,0 +1,38 @@ +# CHANGELOG 2.1.263-d — 手動リリース + +## 背景 + +セッション Pod がブート失敗(`git clone` エラー等)で `Failed` になると、reaper が +即座に削除するため `kubectl logs` で死因を確認できなかった。「開く」で 502 になるだけで +原因が分からない状態だった。 + +## 追加 / 変更 + +- **`session.reaper.terminatedRetentionSeconds`(既定 900)** + 終了済み(Succeeded / Failed)のセッション Pod を、この秒数が経過するまで回収しない。 + Pod の `containerStatuses[].state.terminated.finishedAt` を基準に判定。`0` で旧挙動(即回収)。 + manager の回収ループと予備 CronJob の両方が尊重する。 +- **`GET /api/sessions//log`** + セッション Pod(`claude` コンテナ)の直近ログを text/plain で返す。manager は既に + `pods/log` の `get` 権限を持つ(追加権限なし)。 +- **Web UI** + - セッション一覧に **「ログ」ボタン**(インラインでログ表示)。 + - 状態を色分け表示。`Failed` の行に `reason` / `exit ` / `message` を併記。 + - `Running` 以外では「開く」を出さない。 +- **セッションブートスクリプト(`files/session-entrypoint.sh`)** + - URL のスキーム(`https://` / `git@` / `ssh://`)と選択された identity の型 + (`ssh` / `https-token`)の不一致を検出して警告。 + - `git clone` を `--progress ... 2>&1` で実行し、git / ssh の出力をそのまま Pod ログへ。 + 失敗時は代表的な原因(認証不足・権限なし・known_hosts 不一致・NetworkPolicy による + egress 遮断)をメッセージで提示。 + +## トラブルシューティング追記(README) + +- 「開く」で 502 / 一覧が `Failed` → UI の「ログ」ボタンで死因確認。 +- clone が 60〜130 秒でタイムアウト → NetworkPolicy(Cilium 等)の egress 遮断を疑い、 + `--set networkPolicy.enabled=false` で切り分け。 +- URL スキームと identity 型を一致させる(HTTPS ↔ `https-token` / SSH ↔ `ssh`)。 + +## バージョン + +`version` = `2.1.263-d` / `appVersion` = `"2.1.263"`。 diff --git a/CLAUDE.md b/CLAUDE.md index 83daeaa..6fa86a7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -57,6 +57,24 @@ Git リポジトリ(GitHub / Gitea など互換 Git 全般)をチェック ## 改修履歴 +### 失敗セッションの調査性改善(終了 Pod の保持 + ログ API + UI)(2026-09-08, v2.1.263-d) + +**症状**: セッション Pod がブート失敗で `Failed` になると reaper が即削除し、`kubectl logs` で +死因を追えない。UI では「開く」で 502 になるだけ。 + +**修正内容**: +- `session.reaper.terminatedRetentionSeconds`(既定 900)を追加。終了済み Pod を + `finishedAt` からこの秒数は残す。`manager.py` の `reap_once` と reaper CronJob の両方が尊重。 +- `GET /api/sessions//log`(`k8s_text` で pods/log を text 取得。追加 RBAC 不要)。 +- UI: 「ログ」ボタン、状態の色分け、`Failed` 行に reason/exitCode/message 併記、 + `Running` 以外は「開く」非表示。 +- `session-entrypoint.sh`: URL スキームと identity 型の不一致を警告。`git clone` を + `--progress ... 2>&1` にして git/ssh 出力を Pod ログへ。 + +**対象ファイル**: `files/manager.py`, `files/session-entrypoint.sh`, `values.yaml`, +`values.schema.json`, `templates/deployment.yaml`, `templates/cronjob-reaper.yaml`, +`README.md`, `Chart.yaml`, `CHANGELOG-2.1.263-d.md` + ### gateway nginx クラッシュループ修正(location 正規表現のクォート)(2026-09-08, v2.1.263-c) **症状**: gateway コンテナが起動直後に diff --git a/Chart.yaml b/Chart.yaml index 619f3f3..42972fd 100644 --- a/Chart.yaml +++ b/Chart.yaml @@ -6,7 +6,7 @@ description: >- 使い捨てコンテナで Git リポジトリ(GitHub / Gitea など)をチェックアウト・編集・ 検証・プッシュする。一定時間アイドルなセッションは自動破棄される。 type: application -version: 2.1.263-c +version: 2.1.263-d appVersion: "2.1.263" keywords: - claude-code diff --git a/README.md b/README.md index 08cef52..9349331 100644 --- a/README.md +++ b/README.md @@ -489,10 +489,13 @@ docker buildx build --platform linux/amd64,linux/arm64 \ | 症状 | 原因と対処 | |---|---| -| UI が 502 / セッションを開くと 502 | セッション Pod がまだ Ready でない(初回は `npm i` に時間)。数十秒待つ。恒久対策は事前ビルドイメージ | +| セッションを「開く」と 502 / 一覧で `Failed` になる | セッション Pod のブートが失敗している。UI の **「ログ」ボタン**(または `kubectl logs -n claude-sess-`)で死因を確認。終了 Pod は `session.reaper.terminatedRetentionSeconds`(既定 900s)残る | +| clone が ~60〜130 秒後にタイムアウトして `Failed` | セッション Pod から Git ホストへの egress が届いていない。多くは **NetworkPolicy(Cilium 等)** が `ipBlock` egress を通していない。`--set networkPolicy.enabled=false` で切り分け。恒久対応は `networkPolicy.sessionEgressExtra` に対象 CIDR を追加、または Cilium 環境なら egress ルールの調整 | +| clone が `could not read Username` / `Authentication failed` | プライベートリポジトリに認証情報が無い。HTTPS URL には `https-token` identity(Gitea/GitHub の PAT)を、SSH URL(`git@host:...` / `ssh://git@host/...`)には `ssh` identity を選ぶ。**URL のスキームと identity の型を一致させる** | +| clone が `repository ... not found` / `does not appear to be a git repository` | パス誤り、またはその identity のアカウントに対象リポジトリの参照権限が無い | +| UI が 502(`/s/...` のログが gateway に出ない) | 前段(LB / Ingress)が実体の無いセッションに返している。まず一覧で該当セッションが `Running` か、上記でブート失敗していないか確認 | | セッション作成が `許可されていない Git ホスト` | `git.allowedHosts` に対象ホストを追加(IP と FQDN は別物) | | clone が `Host key verification failed` | identity の `knownHosts` が対象ホストの鍵と不一致。`ssh-keyscan ` で取り直す | -| clone が `terminated ... could not read Username` | HTTPS の場合 `git.identities.` 未指定 or token 無効。SSH の場合は identity の type/秘密鍵を確認 | | WebSocket がすぐ切れる | Ingress の `proxy-read-timeout` を延長。`auth.mode: token` で Cookie 未設定なら一度 UI からログイン | | セッションが破棄されない | manager の RBAC / ログを確認。予備 CronJob(`session.reaper.cronjob`)が有効か | | `apk` エラーがログに出る | 既定イメージ + `readOnlyRootFilesystem` の制約。事前ビルドイメージを使う | @@ -525,6 +528,7 @@ docker buildx build --platform linux/amd64,linux/arm64 \ | バージョン | Claude Code | 変更内容 | |---|---|---| +| 2.1.263-d | 2.1.263 | 失敗したセッションの調査性を改善。`session.reaper.terminatedRetentionSeconds`(既定 900)で終了 Pod を即削除せず保持。UI に **「ログ」ボタン**(`GET /api/sessions//log`)を追加し、`Failed` の理由(exitCode / reason)を一覧に表示。セッションブートスクリプトが URL スキームと identity 型の不一致を警告し、`git clone` の出力をそのままログに出すよう変更 | | 2.1.263-c | 2.1.263 | 修正: gateway の nginx が起動時 `pcre2_compile() failed: missing closing parenthesis` でクラッシュループ。`location ~` の正規表現に `{10}` が含まれ、nginx がブロック区切りの `{}` と解釈していた。正規表現全体をダブルクォートで囲んで解消 | | 2.1.263-b | 2.1.263 | `anthropic.authMode: credentials`(既定)を追加。`~/.claude/.credentials.json` 全体を注入し、セッション内 `claude` の自己更新に加え、クラスタ内 refresher CronJob(`refreshToken` で Secret を定期更新)でトークン失効に自動追従。→ トークンが変わっても再デプロイ不要。refresher の RBAC は認証情報 Secret 1 個の get/patch に限定 | | 2.1.263-a | 2.1.263 | Service を `LoadBalancer` 対応(kube-vip 等でローカル LB の IP から管理画面へアクセス、`loadBalancerSourceRanges` で接続元制限、既定を `LoadBalancer` に変更)。Ingress は従来どおり併用可。API を使わないサブスクリプション認証(`claude setup-token` → `authMode: oauth`)の手順を README に追記 | diff --git a/files/manager.py b/files/manager.py index e65260f..6d851ed 100644 --- a/files/manager.py +++ b/files/manager.py @@ -64,6 +64,9 @@ GIT_AUTHOR_EMAIL = env("GIT_DEFAULT_AUTHOR_EMAIL", "claude@cafepieters.com") IDLE_TIMEOUT = int(env("IDLE_TIMEOUT_SECONDS", "1800")) MAX_LIFETIME = int(env("MAX_LIFETIME_SECONDS", "43200")) REAP_INTERVAL = int(env("REAP_INTERVAL_SECONDS", "60")) +# 終了済み(Succeeded / Failed)セッション Pod を残す秒数。0 なら即回収。 +# 死因を kubectl logs / UI「ログ」で調べられるよう既定で猶予を持たせる。 +TERMINATED_RETENTION = int(env("TERMINATED_RETENTION_SECONDS", "900")) MAX_SESSIONS = int(env("MAX_SESSIONS", "10")) TTYD_PORT = int(env("TTYD_PORT", "7681")) SESSION_RESOURCES = env_json("SESSION_RESOURCES_JSON", {}) @@ -146,6 +149,23 @@ def k8s(method, path, body=None, ctype="application/json"): def core(path): return "/api/v1/namespaces/" + urllib.parse.quote(SESSIONS_NS) + path + +def k8s_text(path): + """text/plain を返すエンドポイント(Pod ログ)用。(status, text) を返す。""" + req = urllib.request.Request(url=K8S_HOST + path, method="GET") + req.add_header("Authorization", "Bearer " + K8S_TOKEN) + try: + with urllib.request.urlopen(req, context=_ssl_ctx, timeout=30) as resp: + return resp.status, resp.read().decode("utf-8", "replace") + except urllib.error.HTTPError as exc: + return exc.code, exc.read().decode("utf-8", "replace") + + +def session_log(sid, tail=800): + if not SID_RE.match(sid): + return 400, "bad session id" + return k8s_text(core("/pods/claude-sess-" + sid + "/log?container=claude&tailLines=" + str(int(tail)))) + # -------------------------------------------------------------------------- # セッション操作 # -------------------------------------------------------------------------- @@ -166,7 +186,7 @@ def list_sessions(): meta = pod.get("metadata", {}) ann = meta.get("annotations", {}) st = pod.get("status", {}) - out.append({ + info = { "id": meta.get("labels", {}).get(LABEL_SID, ""), "phase": st.get("phase", "Unknown"), "repo": ann.get(ANN_PREFIX + "repo", ""), @@ -175,10 +195,33 @@ def list_sessions(): "owner": ann.get(ANN_PREFIX + "owner", ""), "createdAt": ann.get(ANN_PREFIX + "created-at", ""), "lastActivity": ann.get(ANN_PREFIX + "last-activity", ""), - }) + "reason": st.get("reason", ""), # DeadlineExceeded 等 + "message": st.get("message", ""), + "finishedAt": "", + "exitCode": None, + } + for cs in st.get("containerStatuses", []) or []: + term = (cs.get("state") or {}).get("terminated") + if term: + info["finishedAt"] = term.get("finishedAt", "") + info["exitCode"] = term.get("exitCode") + if not info["reason"]: + info["reason"] = term.get("reason", "") + out.append(info) return out +def _rfc3339_epoch(value): + """RFC3339(...Z)→ epoch 秒。失敗時は 0。""" + if not value: + return 0 + try: + import datetime + return int(datetime.datetime.fromisoformat(value.replace("Z", "+00:00")).timestamp()) + except Exception: # noqa: BLE001 + return 0 + + def active_session_count(): return sum(1 for s in list_sessions() if s["phase"] in ("Pending", "Running")) @@ -434,7 +477,10 @@ def reap_once(): live_ids.add(sid) reason = None if s["phase"] in ("Succeeded", "Failed"): - reason = "phase=" + s["phase"] + # 終了済み Pod は TERMINATED_RETENTION の猶予後に回収(死因調査のため残す) + fin = _rfc3339_epoch(s.get("finishedAt")) or (int(s["createdAt"] or 0) + 120) + if now - fin >= TERMINATED_RETENTION: + reason = "phase=" + s["phase"] else: try: created = int(s["createdAt"] or 0) @@ -508,6 +554,10 @@ td,th{text-align:left;padding:.45rem .3rem;border-bottom:1px solid #8883;vertica a.open{display:inline-block;background:#0891b2;color:#fff;padding:.3rem .7rem;border-radius:6px;text-decoration:none} .muted{opacity:.65;font-size:.85rem} code{background:#8882;padding:.1rem .3rem;border-radius:4px} +.st-Running{color:#059669;font-weight:600} +.st-Failed{color:#dc2626;font-weight:600} +.st-Pending,.st-Succeeded{color:#d97706;font-weight:600} +pre#logbox{white-space:pre-wrap;background:#111;color:#eee;padding:.8rem;border-radius:8px;max-height:50vh;overflow:auto;font-size:.8rem}

Claude Code — Remote Sessions

ID状態リポジトリ
-

アイドル 秒で自動破棄されます。

+

アイドル 秒で自動破棄されます。終了したセッションは調査用にしばらく残ります。

+ + @@ -648,6 +723,13 @@ class Handler(BaseHTTPRequestHandler): if not token_ok(self): return self._json(401, {"error": "unauthorized"}) return self._json(200, {"sessions": list_sessions()}) + m = re.match(r"^/api/sessions/([a-f0-9]{10})/log$", path) + if m: + if not token_ok(self): + return self._json(401, {"error": "unauthorized"}) + status, text = session_log(m.group(1)) + return self._send(status if status == 200 else 200, + text or "(ログはまだありません)", "text/plain; charset=utf-8") return self._send(404, b'{"error":"not found"}') def do_POST(self): diff --git a/files/session-entrypoint.sh b/files/session-entrypoint.sh index 07e5299..96273ff 100644 --- a/files/session-entrypoint.sh +++ b/files/session-entrypoint.sh @@ -86,13 +86,30 @@ esac # -------------------------------------------------------------------------- # 2. clone # -------------------------------------------------------------------------- +# URL のスキームと identity の型が食い違っていないか確認(よくある詰まり) +case "$REPO_URL" in + https://*) + if [ "${GIT_IDENTITY_TYPE:-}" = "ssh" ]; then + err "HTTPS の URL ($REPO_URL) に SSH identity が選ばれています。" + err "→ プライベートリポジトリなら https-token の identity を選ぶか、URL を ssh://git@//.git 形式にしてください。" + fi ;; + git@*|ssh://*) + if [ "${GIT_IDENTITY_TYPE:-}" != "ssh" ]; then + err "SSH の URL ($REPO_URL) に SSH identity が選ばれていません(identity=${GIT_IDENTITY_TYPE:-none})。" + err "→ ssh 型の identity(秘密鍵 + known_hosts)を選んでください。" + fi ;; +esac + REPO_DIR="$WORKSPACE_DIR/$(basename "${REPO_URL%.git}")" if [ -d "$REPO_DIR/.git" ]; then log "既存チェックアウトを再利用: $REPO_DIR" else log "clone 中: $REPO_URL ($REPO_REF)" - if ! git clone --filter=blob:none --no-tags "$REPO_URL" "$REPO_DIR"; then - err "clone に失敗しました"; exit 1 + # 進捗と失敗理由をそのまま Pod ログに出す(git のメッセージが死因の手がかり) + if ! GIT_SSH_COMMAND="${GIT_SSH_COMMAND:-ssh} -v" git clone --progress --filter=blob:none --no-tags "$REPO_URL" "$REPO_DIR" 2>&1; then + err "clone に失敗しました(上の git / ssh の出力を参照)。" + err "よくある原因: 認証情報の不足・権限なし / known_hosts 不一致 / NetworkPolicy による egress 遮断(接続タイムアウト)。" + exit 1 fi fi cd "$REPO_DIR" diff --git a/templates/cronjob-reaper.yaml b/templates/cronjob-reaper.yaml index 185e26c..2eaacae 100644 --- a/templates/cronjob-reaper.yaml +++ b/templates/cronjob-reaper.yaml @@ -46,6 +46,8 @@ spec: value: {{ .Values.session.idleTimeoutSeconds | quote }} - name: MAX_LIFETIME_SECONDS value: {{ .Values.session.maxLifetimeSeconds | quote }} + - name: TERMINATED_RETENTION_SECONDS + value: {{ .Values.session.reaper.terminatedRetentionSeconds | quote }} - name: TTYD_PORT value: {{ .Values.session.ttydPort | quote }} securityContext: diff --git a/templates/deployment.yaml b/templates/deployment.yaml index 4ca2cf6..1eb93c5 100644 --- a/templates/deployment.yaml +++ b/templates/deployment.yaml @@ -151,6 +151,8 @@ spec: value: {{ .Values.session.maxLifetimeSeconds | quote }} - name: REAP_INTERVAL_SECONDS value: {{ .Values.session.reaper.intervalSeconds | quote }} + - name: TERMINATED_RETENTION_SECONDS + value: {{ .Values.session.reaper.terminatedRetentionSeconds | quote }} - name: MAX_SESSIONS value: {{ .Values.session.maxSessions | quote }} - name: TTYD_PORT diff --git a/values.schema.json b/values.schema.json index 741b740..118a84e 100644 --- a/values.schema.json +++ b/values.schema.json @@ -180,6 +180,7 @@ "additionalProperties": false, "properties": { "intervalSeconds": { "type": "integer", "minimum": 10 }, + "terminatedRetentionSeconds": { "type": "integer", "minimum": 0 }, "cronjob": { "type": "object", "additionalProperties": false, diff --git a/values.yaml b/values.yaml index 675273c..0cfdb91 100644 --- a/values.yaml +++ b/values.yaml @@ -268,6 +268,10 @@ session: reaper: # manager 内アイドル回収ループの実行間隔 intervalSeconds: 60 + # 終了済み(Succeeded / Failed)セッション Pod を回収せず残す秒数。 + # 死因を UI の「ログ」ボタンや kubectl logs で確認できるよう猶予を持たせる。 + # 0 にすると即回収(旧挙動)。 + terminatedRetentionSeconds: 900 # 予備の孤児掃除 CronJob(manager 障害時の保険) cronjob: enabled: true