feat: 失敗セッションの調査性を改善(終了Pod保持 + ログAPI + UI)(v2.1.263-d)
Helm Chart Release / release-chart (push) Successful in 7s
Build Session Image / build-push (push) Successful in 2m3s

セッション Pod がブート失敗で Failed になると reaper が即削除し kubectl logs で
死因を追えなかった。「開く」で 502 になるだけで原因不明だった。

- session.reaper.terminatedRetentionSeconds(既定 900): 終了済み Pod を
  finishedAt からこの秒数は回収しない。manager 回収ループと reaper CronJob 両方が尊重
- GET /api/sessions/<id>/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 検証済み
This commit is contained in:
2026-09-08 21:14:56 +09:00
parent 6ec4378047
commit 1b74e230ad
10 changed files with 184 additions and 16 deletions
+93 -11
View File
@@ -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}
</style></head><body>
<h1>Claude Code — Remote Sessions</h1>
<div id="login" hidden>
@@ -534,7 +584,14 @@ code{background:#8882;padding:.1rem .3rem;border-radius:4px}
</div>
<table><thead><tr><th>ID</th><th>状態</th><th>リポジトリ</th><th></th></tr></thead>
<tbody id="rows"></tbody></table>
<p class="muted">アイドル <span id="idle"></span> 秒で自動破棄されます。</p>
<p class="muted">アイドル <span id="idle"></span> 秒で自動破棄されます。終了したセッションは調査用にしばらく残ります。</p>
</div>
<div class="card" id="logcard" hidden>
<div class="row" style="justify-content:space-between">
<strong>ログ: <code id="logid"></code></strong>
<button class="sec" style="width:auto;margin:0" onclick="$('#logcard').hidden=true">閉じる</button>
</div>
<pre id="logbox"></pre>
</div>
</div>
<script>
@@ -566,16 +623,34 @@ async function doCreate(e){e.preventDefault();
}
async function del(id){if(!confirm('セッション '+id+' を破棄しますか?'))return;
await api('/api/sessions/'+id,{method:'DELETE'});refresh();}
async function showLog(id){
$('#logcard').hidden=false;$('#logid').textContent=id;
$('#logbox').textContent='読み込み中...';
const r=await api('/api/sessions/'+id+'/log');
$('#logbox').textContent=await r.text();
$('#logbox').scrollTop=$('#logbox').scrollHeight;
$('#logcard').scrollIntoView({behavior:'smooth'});
}
function esc(s){return (s||'').replace(/[&<>]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;'}[c]));}
async function refresh(){
const r=await api('/api/sessions');if(!r.ok)return;
const list=(await r.json()).sessions||[];
$('#rows').innerHTML=list.map(s=>`<tr>
<td><code>${s.id}</code></td>
<td>${s.phase}</td>
<td style="word-break:break-all">${s.repo||''}<div class="muted">${s.ref||''}</div></td>
<td><a class="open" href="/s/${s.id}/" target="_blank" rel="noopener">開く</a>
<button class="sec" style="width:auto;margin:.3rem 0 0" onclick="del('${s.id}')">破棄</button></td>
</tr>`).join('')||'<tr><td colspan=4 class="muted">セッションはありません</td></tr>';
$('#rows').innerHTML=list.map(s=>{
let sub=esc(s.ref);
if(s.phase==='Failed'||s.reason){
const d=[s.reason,(s.exitCode!=null?'exit '+s.exitCode:''),esc(s.message)].filter(Boolean).join(' / ');
if(d) sub+=` — <span class="st-Failed">${d}</span>`;
}
const canOpen=s.phase==='Running';
return `<tr>
<td><code>${s.id}</code></td>
<td><span class="st-${s.phase}">${s.phase}</span></td>
<td style="word-break:break-all">${esc(s.repo)}<div class="muted">${sub}</div></td>
<td>${canOpen?`<a class="open" href="/s/${s.id}/" target="_blank" rel="noopener">開く</a> `:''}
<button class="sec" style="width:auto;margin:.3rem .3rem 0 0" onclick="showLog('${s.id}')">ログ</button>
<button class="sec" style="width:auto;margin:.3rem 0 0" onclick="del('${s.id}')">破棄</button></td>
</tr>`;
}).join('')||'<tr><td colspan=4 class="muted">セッションはありません</td></tr>';
}
boot();
</script></body></html>
@@ -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):
+19 -2
View File
@@ -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@<host>/<org>/<repo>.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"