refactor: 문서 개선 중
This commit is contained in:
+365
@@ -0,0 +1,365 @@
|
||||
{
|
||||
"schemaVersion": 5,
|
||||
"runId": "2026-09-21-0218-remediation-01-case-an-empty-token-installed-the-agent-anyway",
|
||||
"project": "virtualization",
|
||||
"record": "docs/virtualization/tech-log-studio/build-completion-judgment/case/case-an-empty-token-installed-the-agent-anyway.md",
|
||||
"startedAt": "2026-09-21T02:24:28+00:00",
|
||||
"finishedAt": "2026-09-21T02:24:30+00:00",
|
||||
"stages": [
|
||||
{
|
||||
"id": "S1",
|
||||
"name": "코드베이스 → SSOT",
|
||||
"skill": "analyzing-codebase-for-tech-log",
|
||||
"runBy": "ssot-analyst",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "이번 remediation은 새 evidence와 현재 SSOT/Record의 정합성·provenance·command/S4를 재검토한다. 코드베이스→SSOT 전면 분석은 다시 하지 않는다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": "edd45dfec66d155a621cd41186b83b5582f2244c",
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"gates": [],
|
||||
"notes": "",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-21T02:24:28+00:00",
|
||||
"elapsedSeconds": 0,
|
||||
"finishedBy": "chatgpt-virtualization-20260921-remediation-v5"
|
||||
},
|
||||
{
|
||||
"id": "S2",
|
||||
"name": "SSOT → 분해 계약",
|
||||
"skill": "deriving-tech-log-root-tree",
|
||||
"runBy": "tree-deriver",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "기존 91 Record 분해 계약은 유지한다. certbot Question의 RESOLVED 상태와 sourcePolicy는 project tree rebuild에서 별도로 동기화했다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": "ab59130196d79e947b32e3b5e6b75335a9e5c1eb",
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"gates": [],
|
||||
"notes": "",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-21T02:24:28+00:00",
|
||||
"elapsedSeconds": 0,
|
||||
"finishedBy": "chatgpt-virtualization-20260921-remediation-v5"
|
||||
},
|
||||
{
|
||||
"id": "S3",
|
||||
"name": "글감 → 기록",
|
||||
"skill": "writing-tech-log-records",
|
||||
"runBy": "record-writer",
|
||||
"status": "DONE",
|
||||
"skipReason": "",
|
||||
"skillEcho": "**인용한 줄은 SSOT 에서 찾아 대조한다.**",
|
||||
"skillRevision": null,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
"docs/virtualization/tech-log-studio/build-completion-judgment/case/case-an-empty-token-installed-the-agent-anyway.md"
|
||||
],
|
||||
"gates": [
|
||||
{
|
||||
"cmd": "python3 scripts/studio-body.py docs/virtualization/tech-log-studio/build-completion-judgment/case/case-an-empty-token-installed-the-agent-anyway.md -o runs/virtualization/2026-09-21-0218-remediation-01-case-an-empty-token-installed-the-agent-anyway/stage/S3/studio-body.md",
|
||||
"exit": 0,
|
||||
"status": "PASS",
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"generation": 1,
|
||||
"at": "2026-09-21T02:24:28+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node --experimental-transform-types .agents/skills/writing-tech-log-records/scripts/check_body.mjs runs/virtualization/2026-09-21-0218-remediation-01-case-an-empty-token-installed-the-agent-anyway/stage/S3/studio-body.md --frontend /shared/codebase/tech-log-frontend",
|
||||
"exit": 0,
|
||||
"status": "PASS",
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"generation": 1,
|
||||
"at": "2026-09-21T02:24:28+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node .agents/skills/rewriting-technical-prose-naturally/scripts/check_prose.mjs --warn docs/virtualization/tech-log-studio/build-completion-judgment/case/case-an-empty-token-installed-the-agent-anyway.md",
|
||||
"exit": 0,
|
||||
"status": "PASS",
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"generation": 1,
|
||||
"at": "2026-09-21T02:24:29+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node .agents/skills/writing-tech-log-records/scripts/check_evidence.mjs virtualization --repo",
|
||||
"exit": 3,
|
||||
"status": "UNVERIFIABLE",
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"generation": 1,
|
||||
"at": "2026-09-21T02:24:29+00:00",
|
||||
"semanticId": "evidence-repo",
|
||||
"reason": "source repositories unavailable on current machine",
|
||||
"acceptedByProjectReview": true
|
||||
}
|
||||
],
|
||||
"notes": "record-writer/fact 계약으로 새 evidence와 SSOT의 current truth를 대조했다. --repo 대조는 실제 실행했고 source checkout 부재만 UNVERIFIABLE로 남겼다.",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-21T02:24:29+00:00",
|
||||
"elapsedSeconds": 1,
|
||||
"generation": 1,
|
||||
"owner": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"finishedBy": "chatgpt-virtualization-20260921-remediation-v5"
|
||||
},
|
||||
{
|
||||
"id": "S4",
|
||||
"name": "기록 → 그림",
|
||||
"skill": "technical-visualizer",
|
||||
"runBy": "diagram-maker",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "이 Record 변경은 current truth/evidence/version/provenance 정리이며 새 관계 그림을 요구하지 않는다. 기존 그림은 project-wide 14장 검증에서 별도로 확인했다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": "edd45dfec66d155a621cd41186b83b5582f2244c",
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"gates": [],
|
||||
"notes": "",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-21T02:24:29+00:00",
|
||||
"elapsedSeconds": 0,
|
||||
"finishedBy": "chatgpt-virtualization-20260921-remediation-v5"
|
||||
},
|
||||
{
|
||||
"id": "S5",
|
||||
"name": "AI 티 제거",
|
||||
"skill": "rewriting-technical-prose-naturally",
|
||||
"runBy": "prose-rewriter",
|
||||
"status": "DONE",
|
||||
"skipReason": "",
|
||||
"skillEcho": "This is an **editorial** pass. The source's facts, evidence, causal chain, uncertainty, decision status, and technical depth are the contract.",
|
||||
"skillRevision": null,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
"docs/virtualization/tech-log-studio/build-completion-judgment/case/case-an-empty-token-installed-the-agent-anyway.md"
|
||||
],
|
||||
"gates": [
|
||||
{
|
||||
"cmd": "node .agents/skills/rewriting-technical-prose-naturally/scripts/check_prose.mjs --warn docs/virtualization/tech-log-studio/build-completion-judgment/case/case-an-empty-token-installed-the-agent-anyway.md",
|
||||
"exit": 0,
|
||||
"status": "PASS",
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"generation": 1,
|
||||
"at": "2026-09-21T02:24:29+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node .agents/skills/rewriting-technical-prose-naturally/scripts/style_profile.mjs docs/virtualization/tech-log-studio/build-completion-judgment/case/case-an-empty-token-installed-the-agent-anyway.md",
|
||||
"exit": 0,
|
||||
"status": "PASS",
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"generation": 1,
|
||||
"at": "2026-09-21T02:24:29+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node --experimental-transform-types .agents/skills/writing-tech-log-records/scripts/check_body.mjs runs/virtualization/2026-09-21-0218-remediation-01-case-an-empty-token-installed-the-agent-anyway/stage/S5/studio-body.md --frontend /shared/codebase/tech-log-frontend",
|
||||
"exit": 0,
|
||||
"status": "PASS",
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"generation": 1,
|
||||
"at": "2026-09-21T02:24:29+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node .agents/skills/writing-tech-log-records/scripts/check_evidence.mjs virtualization --repo",
|
||||
"exit": 3,
|
||||
"status": "UNVERIFIABLE",
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"generation": 1,
|
||||
"at": "2026-09-21T02:24:29+00:00",
|
||||
"semanticId": "evidence-repo",
|
||||
"reason": "source repositories unavailable on current machine",
|
||||
"acceptedByProjectReview": true
|
||||
}
|
||||
],
|
||||
"notes": "대규모 문체 재작성 없이 stale fact/relation만 현재 evidence에 맞췄다. style_profile은 측정값으로만 기록했다.",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-21T02:24:29+00:00",
|
||||
"elapsedSeconds": 0,
|
||||
"generation": 1,
|
||||
"owner": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"finishedBy": "chatgpt-virtualization-20260921-remediation-v5"
|
||||
},
|
||||
{
|
||||
"id": "S6",
|
||||
"name": "일한 사람의 목소리",
|
||||
"skill": "writing-as-the-person-who-did-it",
|
||||
"runBy": "voice-writer",
|
||||
"status": "DONE",
|
||||
"skipReason": "",
|
||||
"skillEcho": "찾은 것이 없으면 **이 스킬은 여기서 끝난다.** 없는 목소리를 채우지 않는다.",
|
||||
"skillRevision": "edd45dfec66d155a621cd41186b83b5582f2244c",
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
"docs/virtualization/tech-log-studio/build-completion-judgment/case/case-an-empty-token-installed-the-agent-anyway.md"
|
||||
],
|
||||
"gates": [
|
||||
{
|
||||
"cmd": "node .agents/skills/writing-as-the-person-who-did-it/scripts/check_voice.mjs docs/virtualization/tech-log-studio/build-completion-judgment/case/case-an-empty-token-installed-the-agent-anyway.md",
|
||||
"exit": 0,
|
||||
"status": "PASS",
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"generation": 1,
|
||||
"at": "2026-09-21T02:24:29+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node .agents/skills/rewriting-technical-prose-naturally/scripts/check_prose.mjs --warn docs/virtualization/tech-log-studio/build-completion-judgment/case/case-an-empty-token-installed-the-agent-anyway.md",
|
||||
"exit": 0,
|
||||
"status": "PASS",
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"generation": 1,
|
||||
"at": "2026-09-21T02:24:30+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node --experimental-transform-types .agents/skills/writing-tech-log-records/scripts/check_body.mjs runs/virtualization/2026-09-21-0218-remediation-01-case-an-empty-token-installed-the-agent-anyway/stage/S6/studio-body.md --frontend /shared/codebase/tech-log-frontend",
|
||||
"exit": 0,
|
||||
"status": "PASS",
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"generation": 1,
|
||||
"at": "2026-09-21T02:24:30+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node .agents/skills/writing-tech-log-records/scripts/check_evidence.mjs virtualization --repo",
|
||||
"exit": 3,
|
||||
"status": "UNVERIFIABLE",
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"generation": 1,
|
||||
"at": "2026-09-21T02:24:30+00:00",
|
||||
"semanticId": "evidence-repo",
|
||||
"reason": "source repositories unavailable on current machine",
|
||||
"acceptedByProjectReview": true
|
||||
}
|
||||
],
|
||||
"notes": "상류 SSOT/evidence를 다시 보며 실제 작업 흔적이 없는 서사는 추가하지 않았다.",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-21T02:24:30+00:00",
|
||||
"elapsedSeconds": 1,
|
||||
"generation": 1,
|
||||
"owner": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"finishedBy": "chatgpt-virtualization-20260921-remediation-v5"
|
||||
},
|
||||
{
|
||||
"id": "S7",
|
||||
"name": "Studio 저장",
|
||||
"skill": "publishing-tech-log-to-studio",
|
||||
"runBy": "studio-validator",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "이번 remediation에서 Studio import/save는 요청되지 않았다. 저장소 산출물과 검증 영수증만 갱신한다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": "862e502af3e956b49ccd2ae0a8de3fd32f90df9c",
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"gates": [],
|
||||
"notes": "",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-21T02:24:30+00:00",
|
||||
"elapsedSeconds": 0,
|
||||
"finishedBy": "chatgpt-virtualization-20260921-remediation-v5"
|
||||
}
|
||||
],
|
||||
"qualityReviews": {
|
||||
"commandPedagogy": {
|
||||
"initialAnalysis": {
|
||||
"cmd": "python3 scripts/check-command-pedagogy.py --mode reference docs/virtualization/tech-log-studio/build-completion-judgment/case/case-an-empty-token-installed-the-agent-anyway.md -o runs/virtualization/2026-09-21-0218-remediation-01-case-an-empty-token-installed-the-agent-anyway/stage/S3/command-initial.json",
|
||||
"exit": 0,
|
||||
"shellBlocks": 6,
|
||||
"findings": 0,
|
||||
"majorFindings": 0,
|
||||
"artifact": {
|
||||
"path": "runs/virtualization/2026-09-21-0218-remediation-01-case-an-empty-token-installed-the-agent-anyway/stage/S3/command-initial.json",
|
||||
"sha256": "398c87c315d3579cecf3706b418337c5f73a280e1b4fc50765b51d06a108514c"
|
||||
}
|
||||
},
|
||||
"finalAnalysis": {
|
||||
"cmd": "python3 scripts/check-command-pedagogy.py --mode reference docs/virtualization/tech-log-studio/build-completion-judgment/case/case-an-empty-token-installed-the-agent-anyway.md -o runs/virtualization/2026-09-21-0218-remediation-01-case-an-empty-token-installed-the-agent-anyway/stage/S6/command-final.json",
|
||||
"exit": 0,
|
||||
"shellBlocks": 6,
|
||||
"findings": 0,
|
||||
"majorFindings": 0,
|
||||
"artifact": {
|
||||
"path": "runs/virtualization/2026-09-21-0218-remediation-01-case-an-empty-token-installed-the-agent-anyway/stage/S6/command-final.json",
|
||||
"sha256": "398c87c315d3579cecf3706b418337c5f73a280e1b4fc50765b51d06a108514c"
|
||||
}
|
||||
},
|
||||
"planner": {
|
||||
"runBy": "command-pedagogy-planner",
|
||||
"skill": "writing-practitioner-guides",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "initial deterministic analysis finding이 0이다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": null,
|
||||
"notes": "현재 reference-mode 분석을 확인했다.",
|
||||
"artifact": null
|
||||
},
|
||||
"editor": {
|
||||
"runBy": "command-pedagogy-editor",
|
||||
"skill": "writing-practitioner-guides",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "initial deterministic analysis finding이 0이다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": null,
|
||||
"notes": "현재 reference-mode 분석을 확인했다.",
|
||||
"artifact": null
|
||||
},
|
||||
"reviewer": {
|
||||
"runBy": "command-pedagogy-reviewer",
|
||||
"skill": "writing-practitioner-guides",
|
||||
"status": "DONE",
|
||||
"skipReason": "",
|
||||
"skillEcho": "**Optimize for human operation, not command compactness.**",
|
||||
"skillRevision": null,
|
||||
"verdict": "PASS",
|
||||
"notes": "project-level command audit와 현재 final analysis를 대조했다. final major finding은 0이다.",
|
||||
"artifact": {
|
||||
"path": "runs/virtualization/2026-09-21-0218-remediation-01-case-an-empty-token-installed-the-agent-anyway/stage/quality/command-review.json",
|
||||
"sha256": "0b2a43628af6f54fce9b70de3277ed5af957e6a96c0a686b11054cd813898a59"
|
||||
},
|
||||
"sourceSha256": "a02be06af0e6283fcc2846b21e294dab76cbda3c154eeffe79d086f756386e45"
|
||||
}
|
||||
},
|
||||
"technicalEvidence": {
|
||||
"runBy": "fact-reviewer",
|
||||
"status": "DONE",
|
||||
"skipReason": "",
|
||||
"verdict": "PASS",
|
||||
"notes": "current truth/evidence/provenance를 SSOT와 역대조했다. live source는 실제 --repo 결과 exit 3이며 UNVERIFIABLE로 보존했다.",
|
||||
"sourceSha256": "a02be06af0e6283fcc2846b21e294dab76cbda3c154eeffe79d086f756386e45",
|
||||
"liveSourceReconciliation": "UNVERIFIABLE",
|
||||
"liveSourceReason": "source repositories unavailable on current machine",
|
||||
"acceptedByProjectReview": true,
|
||||
"artifact": {
|
||||
"path": "runs/virtualization/2026-09-21-0218-remediation-01-case-an-empty-token-installed-the-agent-anyway/stage/quality/technical-evidence-review.json",
|
||||
"sha256": "afeb1e5a41d46774848d2d7bd37c54591950f5887f3ace01b80246e8c363ceb7"
|
||||
}
|
||||
}
|
||||
},
|
||||
"riders": [],
|
||||
"sessions": [
|
||||
{
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"openedAt": "2026-09-21T02:24:28+00:00"
|
||||
},
|
||||
{
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"stage": "S3",
|
||||
"generation": 1,
|
||||
"beganAt": "2026-09-21T02:24:28+00:00"
|
||||
},
|
||||
{
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"stage": "S5",
|
||||
"generation": 1,
|
||||
"beganAt": "2026-09-21T02:24:29+00:00"
|
||||
},
|
||||
{
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"stage": "S6",
|
||||
"generation": 1,
|
||||
"beganAt": "2026-09-21T02:24:29+00:00"
|
||||
}
|
||||
],
|
||||
"revision": 23,
|
||||
"updatedAt": "2026-09-21T02:24:30+00:00",
|
||||
"executionEnvironment": {
|
||||
"mode": "current-remediation-contract-replay",
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"agentToolAvailable": false,
|
||||
"note": "별도 subagent 실행 도구가 현재 환경에 없어 현재 세션이 각 .claude/agents 역할 정의와 연결된 SKILL/contract를 직접 읽고 역할을 분리해 수행했다. runBy는 계약 역할명이며 별도 프로세스 실행을 주장하지 않는다."
|
||||
}
|
||||
}
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"authority": "deterministic",
|
||||
"gate": "command-pedagogy-signals",
|
||||
"section_id": "docs/virtualization/tech-log-studio/build-completion-judgment/case/case-an-empty-token-installed-the-agent-anyway.md",
|
||||
"mode": "reference",
|
||||
"source_sha256": "a02be06af0e6283fcc2846b21e294dab76cbda3c154eeffe79d086f756386e45",
|
||||
"result": "PASS",
|
||||
"requires_editor": false,
|
||||
"blocks": [
|
||||
{
|
||||
"id": "command-001",
|
||||
"ordinal": 1,
|
||||
"language": "bash",
|
||||
"info_string": "",
|
||||
"label": null,
|
||||
"mode": "reference",
|
||||
"mode_explicit": false,
|
||||
"start": 3254,
|
||||
"end": 3381,
|
||||
"source_sha256": "0cd243cfe1e714ecc8750ac7e59763763dfc28813502c8018385681a22261e50",
|
||||
"body_sha256": "2a7f3e1f45fdd6c5b0e8fe964617d70102eca068dafaa03f46ab9df876a49316",
|
||||
"source": "```bash\nTOKEN=$(ssh kc-lab-1 'sudo cat /var/lib/rancher/k3s/server/node-token')\necho \"${#TOKEN} 자\" # 값이 아니라 길이만 확인한다\n```",
|
||||
"body": "TOKEN=$(ssh kc-lab-1 'sudo cat /var/lib/rancher/k3s/server/node-token')\necho \"${#TOKEN} 자\" # 값이 아니라 길이만 확인한다\n"
|
||||
},
|
||||
{
|
||||
"id": "command-002",
|
||||
"ordinal": 2,
|
||||
"language": "bash",
|
||||
"info_string": "",
|
||||
"label": null,
|
||||
"mode": "reference",
|
||||
"mode_explicit": false,
|
||||
"start": 3472,
|
||||
"end": 3639,
|
||||
"source_sha256": "8669893711bc209d5c1ac0f0c48a76e9a71f6f673f990125fbd206153aee607b",
|
||||
"body_sha256": "9306236bb20e9b7f9ebc51cc9c6760121a4f98c230fe03c82bf57de30fb48608",
|
||||
"source": "```bash\nssh kc-lab-2 \"curl -sfL https://get.k3s.io | sudo sh -s - agent \\\n --server https://192.168.122.11:6443 \\\n --token '$TOKEN' \\\n --node-ip 192.168.122.12\"\n```",
|
||||
"body": "ssh kc-lab-2 \"curl -sfL https://get.k3s.io | sudo sh -s - agent \\\n --server https://192.168.122.11:6443 \\\n --token '$TOKEN' \\\n --node-ip 192.168.122.12\"\n"
|
||||
},
|
||||
{
|
||||
"id": "command-003",
|
||||
"ordinal": 3,
|
||||
"language": "bash",
|
||||
"info_string": "",
|
||||
"label": null,
|
||||
"mode": "reference",
|
||||
"mode_explicit": false,
|
||||
"start": 3754,
|
||||
"end": 3871,
|
||||
"source_sha256": "becc3b230571a0ca6dd847f96f79e71035a24512f6386b98a6f7ece4640b391d",
|
||||
"body_sha256": "6a80a900884a7815dc4319a55f84a9f0ec972b3a9053e59e1fd55314a1a43059",
|
||||
"source": "```bash\n[kc-lab-1] $ ssh kc-lab-1 'sudo cat /var/lib/rancher/k3s/server/node-token'\nHost key verification failed.\n```",
|
||||
"body": "[kc-lab-1] $ ssh kc-lab-1 'sudo cat /var/lib/rancher/k3s/server/node-token'\nHost key verification failed.\n"
|
||||
},
|
||||
{
|
||||
"id": "command-004",
|
||||
"ordinal": 4,
|
||||
"language": "bash",
|
||||
"info_string": "",
|
||||
"label": null,
|
||||
"mode": "reference",
|
||||
"mode_explicit": false,
|
||||
"start": 4440,
|
||||
"end": 4477,
|
||||
"source_sha256": "ba8a7e2b4715d0991bb17557d004d85eebd3622ab64bb7f96da1857aae7ddf35",
|
||||
"body_sha256": "4161727a277945c14e7e36543b8e89dd1c9678f4ff865ddc63caecfa66e0b6a2",
|
||||
"source": "```bash\nkubectl get nodes -o wide\n```",
|
||||
"body": "kubectl get nodes -o wide\n"
|
||||
},
|
||||
{
|
||||
"id": "command-005",
|
||||
"ordinal": 5,
|
||||
"language": "bash",
|
||||
"info_string": "",
|
||||
"label": null,
|
||||
"mode": "reference",
|
||||
"mode_explicit": false,
|
||||
"start": 4822,
|
||||
"end": 4888,
|
||||
"source_sha256": "710c3f4b246a5eddb9955a955f0ddb3c7ff0baecd5600dcdced31278d9214154",
|
||||
"body_sha256": "146fe14b61766555ee1fbb260c5a026315bc0cc76ea82baff5edc057cb3c0b80",
|
||||
"source": "```bash\n[ ${#TOKEN} -ge 50 ] || echo \"TOKEN 이 비었다 — 3번으로 돌아간다\"\n```",
|
||||
"body": "[ ${#TOKEN} -ge 50 ] || echo \"TOKEN 이 비었다 — 3번으로 돌아간다\"\n"
|
||||
},
|
||||
{
|
||||
"id": "command-006",
|
||||
"ordinal": 6,
|
||||
"language": "bash",
|
||||
"info_string": "",
|
||||
"label": null,
|
||||
"mode": "reference",
|
||||
"mode_explicit": false,
|
||||
"start": 5334,
|
||||
"end": 5637,
|
||||
"source_sha256": "62ee8509be195f25e7efa1e5013800e746be547448bd5e3bf88073d45bbf0520",
|
||||
"body_sha256": "c3c974825adec113eb75cb4424d6bf574126f14f34eb03be584985d545373252",
|
||||
"source": "```bash\nssh kc-lab-1 'sudo cat /var/lib/rancher/k3s/server/node-token' \\\n | ssh kc-lab-2 'sudo tee /tmp/token >/dev/null'\nssh kc-lab-2 \"curl -sfL https://get.k3s.io | sudo sh -s - agent \\\n --server https://192.168.122.11:6443 --token-file /tmp/token \\\n --node-ip 192.168.122.12; rm -f /tmp/token\"\n```",
|
||||
"body": "ssh kc-lab-1 'sudo cat /var/lib/rancher/k3s/server/node-token' \\\n | ssh kc-lab-2 'sudo tee /tmp/token >/dev/null'\nssh kc-lab-2 \"curl -sfL https://get.k3s.io | sudo sh -s - agent \\\n --server https://192.168.122.11:6443 --token-file /tmp/token \\\n --node-ip 192.168.122.12; rm -f /tmp/token\"\n"
|
||||
}
|
||||
],
|
||||
"findings": [],
|
||||
"extensions": {
|
||||
"command_like_text_blocks": []
|
||||
}
|
||||
}
|
||||
+169
@@ -0,0 +1,169 @@
|
||||
---
|
||||
kind: CASE
|
||||
slug: an-empty-token-installed-the-agent-anyway
|
||||
title: 빈 토큰이 조용히 흘러갔다 — 설치 출력은 성공이었고 agent 만 5초마다 다시 죽었다
|
||||
topic: build-completion-judgment
|
||||
topicName: 끝났다는 판정
|
||||
project: virtualization
|
||||
status: 게시 전
|
||||
sourceRevision: import-head 9465582b5d1630eb4ae7c4e078021486919bf6b6 · uncommitted-working-tree snapshot
|
||||
evidence:
|
||||
- ../../../final/evidence/raw/lab-state-before-rebuild/15-k3s-precheck.txt
|
||||
- ../../../final/evidence/raw/lab-state-before-rebuild/21-k3s-token.txt
|
||||
- ../../../final/evidence/raw/lab-state-before-rebuild/22-k3s-agent-install.txt
|
||||
source:
|
||||
- final/document.md#185-가이드-묶음이-스스로-정한-규약
|
||||
- final/document.md#188-단계-02-k3s-server-와-agent
|
||||
- final/document.md#184-이-부의-출처와-범위
|
||||
---
|
||||
|
||||
# 빈 토큰이 조용히 흘러갔다 — 설치 출력은 성공이었고 agent 만 5초마다 다시 죽었다
|
||||
|
||||
k3s agent 설치는 오류 한 줄 없이 끝났는데 kubectl get nodes 에는 노드가 하나뿐이었다. 게스트 안에서 친 ssh 가 실패해 토큰이 빈 문자열로 넘어갔고, 설치 스크립트는 그 전까지를 성공으로 찍었다. 값을 찍지 않고 길이만 재는 한 줄을 설치 앞에 두면 잡힌다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **도구가 낸 출력은 대상의 상태가 아니다 — 그 명령이 무엇을 세는지부터 가른다**
|
||||
설치 스크립트의 출력을 노드가 붙었다는 뜻으로 읽은 사건이라 그 규칙의 사례가 된다.
|
||||
- **가까운 층부터 한 층씩 건너뛰며 확인한다 — 층마다 성공 신호가 다르다**
|
||||
설치가 끝났다는 출력 대신 각 층에서 무엇이 성공 신호인지로 판정을 옮기는 절차다.
|
||||
- **cloud-init 이 안 도는 원인은 넷인데 증상은 「SSH 가 안 붙는다」 하나였다**
|
||||
같은 구축의 한 단계 앞에서 벌어진 일이고, 거기서도 실패가 아무 오류 없이 지나갔다.
|
||||
- **가이드대로 다시 쳐서 이 실험대가 같은 상태로 서는가**
|
||||
여기 적은 설치 명령을 다시 쳐서 같은 상태가 되는지는 확인된 적이 없다.
|
||||
- **단계별 구축 문서는 작성 시점이 아니라 실행 순서로 검증한다**
|
||||
명령을 어느 셸에서 치는지를 단계마다 확인하라는 규칙이고, 그 확인이 빠졌을 때 나온 실패가 이것이다.
|
||||
|
||||
## 문제
|
||||
|
||||
k3s agent 설치가 끝까지 돌았고 출력에 오류가 없었다. 그런데 두 노드가 Ready 로 나와야 할 kubectl get nodes 에 두 번째 노드가 나타나지 않았다.
|
||||
|
||||
설치 출력의 오류 : x
|
||||
kubectl get nodes 의 노드 수 : 1
|
||||
k3s-agent 유닛 : 5초마다 재시작
|
||||
journalctl -u k3s-agent : level=fatal msg="Error: --token is required"
|
||||
|
||||
실패가 화면에 나오지 않아 설치는 끝난 것으로 읽힌다.
|
||||
|
||||
## 결론
|
||||
|
||||
토큰을 꺼내는 ssh 를 게스트 안에서 쳤고 그 명령이 Host key verification failed. 로 끝났다. 명령 치환으로 감싸면 오류는 stderr 로 흘러가고 변수에는 빈 문자열이 담긴다. 셸은 아무 불평도 하지 않는다.
|
||||
|
||||
agent 는 --token '' 을 받아 level=fatal msg="Error: --token is required" 로 죽지만, 설치 스크립트는 그 전까지를 다 성공으로 찍고 끝난다. 그래서 설치 출력만 보면 성공이고, 유닛이 Restart=always 라 5초마다 조용히 재시도한다.
|
||||
|
||||
해결 : 게스트에 들어가지 않고 lab host 한 셸에서 ssh kc-lab-1 '...' 형태로 친다
|
||||
가드 : 값을 쓰기 전에 길이로 가른다. 이 실험대의 토큰은 108자였다
|
||||
히스토리에 남기지 않으려면 : --token-file 로 넘긴다. 토큰을 꺼낸 셸과 같은 셸에서 쳐야 한다는 제약도 없어진다
|
||||
|
||||
## 검증 환경
|
||||
|
||||
호스트 : test-server, Arch Linux
|
||||
CPU : i5-1135G7, 논리 코어 8
|
||||
RAM : 11,648MiB
|
||||
QEMU : 11.1.1
|
||||
libvirt : 12.7.0
|
||||
게스트 : Debian 12 genericcloud
|
||||
k3s : v1.36.4+k3s1
|
||||
server 노드 : kc-lab-1, 192.168.122.11
|
||||
agent 노드 : kc-lab-2, 192.168.122.12
|
||||
토큰 길이 : 108자
|
||||
토큰 형식 : K10<해시>::server:<비밀번호>
|
||||
|
||||
## 재현 조건
|
||||
|
||||
1. lab host 에서 게스트 세 대를 세우고 kc-lab-1 에 k3s server 를 깐다.
|
||||
2. lab host 에서 ssh kc-lab-1 로 게스트에 로그인한다.
|
||||
3. 게스트 프롬프트에서 TOKEN=$(ssh kc-lab-1 'sudo cat /var/lib/rancher/k3s/server/node-token') 을 친다.
|
||||
4. echo "${#TOKEN} 자" 가 0 을 낸다.
|
||||
5. 같은 셸에서 agent 설치 명령에 --token "$TOKEN" 을 넘긴다. 설치 출력은 오류 없이 끝난다.
|
||||
6. lab host 로 돌아와 kubectl get nodes 를 친다. 노드가 하나뿐이다.
|
||||
7. ssh kc-lab-2 'journalctl -u k3s-agent' 에서 --token is required 를 찾는다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
## 토큰이 지나는 길과 명령을 치는 셸
|
||||
|
||||
k3s server 는 `kc-lab-1` 에, agent 는 `kc-lab-2` 에 깔고 둘 다 lab host 에서 `ssh` 로 원격 실행한다. 가이드가 코드 블록마다 어느 기계에서 치는지를 붙여 둔 까닭이 여기에 있다 — 게스트는 libvirt NAT(`192.168.122.0/24`) 안에 있어 워크스테이션에서 직접 닿지 않고, `ssh kc-lab-1` 이라는 별칭도 lab host 의 `~/.ssh/config` 에만 있다.
|
||||
|
||||
agent 가 server 에 붙으려면 server 가 만든 node-token 이 필요하고, 그 값을 lab host 에서 꺼내 같은 셸에서 설치 명령에 넘긴다.
|
||||
|
||||
```bash
|
||||
TOKEN=$(ssh kc-lab-1 'sudo cat /var/lib/rancher/k3s/server/node-token')
|
||||
echo "${#TOKEN} 자" # 값이 아니라 길이만 확인한다
|
||||
```
|
||||
|
||||
비밀은 길이나 존재 여부만 확인하고 값을 찍지 않는다. 가이드가 먼저 정한 표기 규약이고, 값을 찍으면 터미널 스크롤백과 화면 공유에 그대로 남기 때문이다.
|
||||
|
||||
```bash
|
||||
ssh kc-lab-2 "curl -sfL https://get.k3s.io | sudo sh -s - agent \
|
||||
--server https://192.168.122.11:6443 \
|
||||
--token '$TOKEN' \
|
||||
--node-ip 192.168.122.12"
|
||||
```
|
||||
|
||||
## 게스트 안에서 친 ssh 는 빈 문자열이 된다
|
||||
|
||||
토큰을 꺼내려고 게스트에 먼저 들어가면 같은 명령이 다르게 끝난다. 게스트에는 lab host 의 개인키도 `~/.ssh/config` 도 없다.
|
||||
|
||||
```bash
|
||||
[kc-lab-1] $ ssh kc-lab-1 'sudo cat /var/lib/rancher/k3s/server/node-token'
|
||||
Host key verification failed.
|
||||
```
|
||||
|
||||
여기까지는 오류 문구가 찍힌다. 문제는 이 명령을 `TOKEN=$(...)` 로 감쌌을 때다. 명령 치환은 표준 출력만 변수에 담으므로 오류는 stderr 로 흘러가고 `TOKEN` 에는 빈 문자열이 담기며, 셸은 아무 불평도 하지 않는다. 뒤이어 치는 설치 명령은 `--token ''` 을 넘긴 것과 같아진다.
|
||||
|
||||
## 설치 출력이 성공으로 끝나는 경로
|
||||
|
||||
빈 문자열을 받은 설치도 끝까지 돈다. agent 가 `--token ''` 을 받아 `level=fatal msg="Error: --token is required"` 로 죽는데, 설치 스크립트는 그 전까지를 다 성공으로 찍고 끝나므로 설치 출력만 보면 성공이다. 성공으로 찍힌 것은 내려받기와 유닛 생성과 `enable` 까지다. 유닛은 `Restart=always` 라 5초마다 조용히 재시도하고, 그래서 실패가 화면이 아니라 `journalctl -u k3s-agent` 안에서만 되풀이된다.
|
||||
|
||||
k3s server 와 agent 를 세우는 단계가 끝났다는 판정은 lab host 에서 노드 목록을 쳐서 두 노드가 `Ready` 로 나오는 것이다.
|
||||
|
||||
```bash
|
||||
kubectl get nodes -o wide
|
||||
```
|
||||
|
||||
```
|
||||
NAME STATUS ROLES AGE VERSION INTERNAL-IP
|
||||
kc-lab-1 Ready control-plane 47m v1.36.4+k3s1 192.168.122.11
|
||||
kc-lab-2 Ready <none> 21m v1.36.4+k3s1 192.168.122.12
|
||||
```
|
||||
|
||||
빈 토큰으로 깔린 agent 는 이 목록에 올라오지 않는다.
|
||||
|
||||
## 값을 찍지 않고 길이만 잰다
|
||||
|
||||
토큰을 화면에 찍어 눈으로 대조하는 방법은 표기 규약이 막아 두었다. 그래서 설치 앞에 길이를 재는 한 줄을 둔다.
|
||||
|
||||
```bash
|
||||
[ ${#TOKEN} -ge 50 ] || echo "TOKEN 이 비었다 — 3번으로 돌아간다"
|
||||
```
|
||||
|
||||
이 실험대의 토큰은 108자였고 형식이 `K10<해시>::server:<비밀번호>` 라 k3s 판올림에 따라 자릿수가 달라진다. 그래서 이 가드는 값을 맞춰 보지 않고 길이가 `0` 이 아닌지만 본다.
|
||||
|
||||
길이가 `0` 으로 나오는 길은 셋이다. 가이드는 게스트 안에서 친 경우를 그중 가장 흔하다고 적어 두었고, 나머지 둘은 server 가 아직 안 떠서 토큰 파일이 없거나 새 셸을 열어 변수가 사라진 경우를 가리킨다. 가드 한 줄은 그 몇 분을 막는다 — 설치가 성공으로 끝나고, 노드 목록에서 한 줄이 빠진 것을 알아채고, `journalctl` 까지 가는 동안이다.
|
||||
|
||||
## 토큰을 파일로 넘기면 같은 셸 제약이 없어진다
|
||||
|
||||
위 가드는 `TOKEN` 이 셸 변수라 토큰을 꺼낸 셸과 같은 셸에서 쳐야 하고, 다른 창에서는 비어 있다. 토큰이 명령줄에 들어가는 것도 걸리면 파일로 넘긴다.
|
||||
|
||||
```bash
|
||||
ssh kc-lab-1 'sudo cat /var/lib/rancher/k3s/server/node-token' \
|
||||
| ssh kc-lab-2 'sudo tee /tmp/token >/dev/null'
|
||||
ssh kc-lab-2 "curl -sfL https://get.k3s.io | sudo sh -s - agent \
|
||||
--server https://192.168.122.11:6443 --token-file /tmp/token \
|
||||
--node-ip 192.168.122.12; rm -f /tmp/token"
|
||||
```
|
||||
|
||||
토큰이 셸 히스토리에 남지 않고, 변수를 쓰지 않으니 셸이 달라도 된다.
|
||||
|
||||
## 확인하지 못한 것
|
||||
|
||||
원래 빈 토큰 사건의 `level=fatal msg="Error: --token is required"` 와 당시 설치 출력은 여전히 직접 raw가 없다. 다만 2026-09-17 후속 원문은 현재 정상 경로를 보강한다. `15-k3s-precheck.txt` 에는 별도 precheck에서 `Host key verification failed.`가 남아 있고, `21-k3s-token.txt` 는 재구축 시점의 **비어 있지 않은 토큰**을 길이로 확인했으며, `22-k3s-agent-install.txt` 는 그 토큰으로 agent 설치가 성공한 출력을 담는다. 이 셋은 원래 빈 토큰 실패의 재현이 아니라 **follow-up corroboration**이다. 과거 사건의 토큰 108자와 후속 실행의 길이가 다르더라도 같은 측정으로 덮어쓰지 않는다.
|
||||
|
||||
이 실패를 일부러 다시 만들어 본 기록도 없어서, 빈 토큰으로 설치하면 설치 출력이 성공으로 끝난다는 것은 한 번의 관측이다. 재현이 쉽지 않은 까닭은 이 부의 검증 방식에 있다 — 만드는 명령은 다시 치면 돌고 있는 실험대가 없어지므로 구축할 때 쓴 것을 옮기고 결과 상태를 확인하는 것으로 대신했다. 재현 없이 남길 수 있는 것은 `journalctl` 쪽이고, 그 출력을 `final/evidence/raw/` 에 남기면 진단 절에 증거가 붙는다.
|
||||
|
||||
가드 한 줄이 실제로 빈 토큰을 잡아 본 기록도 없다.
|
||||
|
||||
<!-- body:end -->
|
||||
+169
@@ -0,0 +1,169 @@
|
||||
---
|
||||
kind: CASE
|
||||
slug: an-empty-token-installed-the-agent-anyway
|
||||
title: 빈 토큰이 조용히 흘러갔다 — 설치 출력은 성공이었고 agent 만 5초마다 다시 죽었다
|
||||
topic: build-completion-judgment
|
||||
topicName: 끝났다는 판정
|
||||
project: virtualization
|
||||
status: 게시 전
|
||||
sourceRevision: import-head 9465582b5d1630eb4ae7c4e078021486919bf6b6 · uncommitted-working-tree snapshot
|
||||
evidence:
|
||||
- ../../../final/evidence/raw/lab-state-before-rebuild/15-k3s-precheck.txt
|
||||
- ../../../final/evidence/raw/lab-state-before-rebuild/21-k3s-token.txt
|
||||
- ../../../final/evidence/raw/lab-state-before-rebuild/22-k3s-agent-install.txt
|
||||
source:
|
||||
- final/document.md#185-가이드-묶음이-스스로-정한-규약
|
||||
- final/document.md#188-단계-02-k3s-server-와-agent
|
||||
- final/document.md#184-이-부의-출처와-범위
|
||||
---
|
||||
|
||||
# 빈 토큰이 조용히 흘러갔다 — 설치 출력은 성공이었고 agent 만 5초마다 다시 죽었다
|
||||
|
||||
k3s agent 설치는 오류 한 줄 없이 끝났는데 kubectl get nodes 에는 노드가 하나뿐이었다. 게스트 안에서 친 ssh 가 실패해 토큰이 빈 문자열로 넘어갔고, 설치 스크립트는 그 전까지를 성공으로 찍었다. 값을 찍지 않고 길이만 재는 한 줄을 설치 앞에 두면 잡힌다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **도구가 낸 출력은 대상의 상태가 아니다 — 그 명령이 무엇을 세는지부터 가른다**
|
||||
설치 스크립트의 출력을 노드가 붙었다는 뜻으로 읽은 사건이라 그 규칙의 사례가 된다.
|
||||
- **가까운 층부터 한 층씩 건너뛰며 확인한다 — 층마다 성공 신호가 다르다**
|
||||
설치가 끝났다는 출력 대신 각 층에서 무엇이 성공 신호인지로 판정을 옮기는 절차다.
|
||||
- **cloud-init 이 안 도는 원인은 넷인데 증상은 「SSH 가 안 붙는다」 하나였다**
|
||||
같은 구축의 한 단계 앞에서 벌어진 일이고, 거기서도 실패가 아무 오류 없이 지나갔다.
|
||||
- **가이드대로 다시 쳐서 이 실험대가 같은 상태로 서는가**
|
||||
여기 적은 설치 명령을 다시 쳐서 같은 상태가 되는지는 확인된 적이 없다.
|
||||
- **단계별 구축 문서는 작성 시점이 아니라 실행 순서로 검증한다**
|
||||
명령을 어느 셸에서 치는지를 단계마다 확인하라는 규칙이고, 그 확인이 빠졌을 때 나온 실패가 이것이다.
|
||||
|
||||
## 문제
|
||||
|
||||
k3s agent 설치가 끝까지 돌았고 출력에 오류가 없었다. 그런데 두 노드가 Ready 로 나와야 할 kubectl get nodes 에 두 번째 노드가 나타나지 않았다.
|
||||
|
||||
설치 출력의 오류 : x
|
||||
kubectl get nodes 의 노드 수 : 1
|
||||
k3s-agent 유닛 : 5초마다 재시작
|
||||
journalctl -u k3s-agent : level=fatal msg="Error: --token is required"
|
||||
|
||||
실패가 화면에 나오지 않아 설치는 끝난 것으로 읽힌다.
|
||||
|
||||
## 결론
|
||||
|
||||
토큰을 꺼내는 ssh 를 게스트 안에서 쳤고 그 명령이 Host key verification failed. 로 끝났다. 명령 치환으로 감싸면 오류는 stderr 로 흘러가고 변수에는 빈 문자열이 담긴다. 셸은 아무 불평도 하지 않는다.
|
||||
|
||||
agent 는 --token '' 을 받아 level=fatal msg="Error: --token is required" 로 죽지만, 설치 스크립트는 그 전까지를 다 성공으로 찍고 끝난다. 그래서 설치 출력만 보면 성공이고, 유닛이 Restart=always 라 5초마다 조용히 재시도한다.
|
||||
|
||||
해결 : 게스트에 들어가지 않고 lab host 한 셸에서 ssh kc-lab-1 '...' 형태로 친다
|
||||
가드 : 값을 쓰기 전에 길이로 가른다. 이 실험대의 토큰은 108자였다
|
||||
히스토리에 남기지 않으려면 : --token-file 로 넘긴다. 토큰을 꺼낸 셸과 같은 셸에서 쳐야 한다는 제약도 없어진다
|
||||
|
||||
## 검증 환경
|
||||
|
||||
호스트 : test-server, Arch Linux
|
||||
CPU : i5-1135G7, 논리 코어 8
|
||||
RAM : 11,648MiB
|
||||
QEMU : 11.1.1
|
||||
libvirt : 12.7.0
|
||||
게스트 : Debian 12 genericcloud
|
||||
k3s : v1.36.4+k3s1
|
||||
server 노드 : kc-lab-1, 192.168.122.11
|
||||
agent 노드 : kc-lab-2, 192.168.122.12
|
||||
토큰 길이 : 108자
|
||||
토큰 형식 : K10<해시>::server:<비밀번호>
|
||||
|
||||
## 재현 조건
|
||||
|
||||
1. lab host 에서 게스트 세 대를 세우고 kc-lab-1 에 k3s server 를 깐다.
|
||||
2. lab host 에서 ssh kc-lab-1 로 게스트에 로그인한다.
|
||||
3. 게스트 프롬프트에서 TOKEN=$(ssh kc-lab-1 'sudo cat /var/lib/rancher/k3s/server/node-token') 을 친다.
|
||||
4. echo "${#TOKEN} 자" 가 0 을 낸다.
|
||||
5. 같은 셸에서 agent 설치 명령에 --token "$TOKEN" 을 넘긴다. 설치 출력은 오류 없이 끝난다.
|
||||
6. lab host 로 돌아와 kubectl get nodes 를 친다. 노드가 하나뿐이다.
|
||||
7. ssh kc-lab-2 'journalctl -u k3s-agent' 에서 --token is required 를 찾는다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
## 토큰이 지나는 길과 명령을 치는 셸
|
||||
|
||||
k3s server 는 `kc-lab-1` 에, agent 는 `kc-lab-2` 에 깔고 둘 다 lab host 에서 `ssh` 로 원격 실행한다. 가이드가 코드 블록마다 어느 기계에서 치는지를 붙여 둔 까닭이 여기에 있다 — 게스트는 libvirt NAT(`192.168.122.0/24`) 안에 있어 워크스테이션에서 직접 닿지 않고, `ssh kc-lab-1` 이라는 별칭도 lab host 의 `~/.ssh/config` 에만 있다.
|
||||
|
||||
agent 가 server 에 붙으려면 server 가 만든 node-token 이 필요하고, 그 값을 lab host 에서 꺼내 같은 셸에서 설치 명령에 넘긴다.
|
||||
|
||||
```bash
|
||||
TOKEN=$(ssh kc-lab-1 'sudo cat /var/lib/rancher/k3s/server/node-token')
|
||||
echo "${#TOKEN} 자" # 값이 아니라 길이만 확인한다
|
||||
```
|
||||
|
||||
비밀은 길이나 존재 여부만 확인하고 값을 찍지 않는다. 가이드가 먼저 정한 표기 규약이고, 값을 찍으면 터미널 스크롤백과 화면 공유에 그대로 남기 때문이다.
|
||||
|
||||
```bash
|
||||
ssh kc-lab-2 "curl -sfL https://get.k3s.io | sudo sh -s - agent \
|
||||
--server https://192.168.122.11:6443 \
|
||||
--token '$TOKEN' \
|
||||
--node-ip 192.168.122.12"
|
||||
```
|
||||
|
||||
## 게스트 안에서 친 ssh 는 빈 문자열이 된다
|
||||
|
||||
토큰을 꺼내려고 게스트에 먼저 들어가면 같은 명령이 다르게 끝난다. 게스트에는 lab host 의 개인키도 `~/.ssh/config` 도 없다.
|
||||
|
||||
```bash
|
||||
[kc-lab-1] $ ssh kc-lab-1 'sudo cat /var/lib/rancher/k3s/server/node-token'
|
||||
Host key verification failed.
|
||||
```
|
||||
|
||||
여기까지는 오류 문구가 찍힌다. 문제는 이 명령을 `TOKEN=$(...)` 로 감쌌을 때다. 명령 치환은 표준 출력만 변수에 담으므로 오류는 stderr 로 흘러가고 `TOKEN` 에는 빈 문자열이 담기며, 셸은 아무 불평도 하지 않는다. 뒤이어 치는 설치 명령은 `--token ''` 을 넘긴 것과 같아진다.
|
||||
|
||||
## 설치 출력이 성공으로 끝나는 경로
|
||||
|
||||
빈 문자열을 받은 설치도 끝까지 돈다. agent 가 `--token ''` 을 받아 `level=fatal msg="Error: --token is required"` 로 죽는데, 설치 스크립트는 그 전까지를 다 성공으로 찍고 끝나므로 설치 출력만 보면 성공이다. 성공으로 찍힌 것은 내려받기와 유닛 생성과 `enable` 까지다. 유닛은 `Restart=always` 라 5초마다 조용히 재시도하고, 그래서 실패가 화면이 아니라 `journalctl -u k3s-agent` 안에서만 되풀이된다.
|
||||
|
||||
k3s server 와 agent 를 세우는 단계가 끝났다는 판정은 lab host 에서 노드 목록을 쳐서 두 노드가 `Ready` 로 나오는 것이다.
|
||||
|
||||
```bash
|
||||
kubectl get nodes -o wide
|
||||
```
|
||||
|
||||
```
|
||||
NAME STATUS ROLES AGE VERSION INTERNAL-IP
|
||||
kc-lab-1 Ready control-plane 47m v1.36.4+k3s1 192.168.122.11
|
||||
kc-lab-2 Ready <none> 21m v1.36.4+k3s1 192.168.122.12
|
||||
```
|
||||
|
||||
빈 토큰으로 깔린 agent 는 이 목록에 올라오지 않는다.
|
||||
|
||||
## 값을 찍지 않고 길이만 잰다
|
||||
|
||||
토큰을 화면에 찍어 눈으로 대조하는 방법은 표기 규약이 막아 두었다. 그래서 설치 앞에 길이를 재는 한 줄을 둔다.
|
||||
|
||||
```bash
|
||||
[ ${#TOKEN} -ge 50 ] || echo "TOKEN 이 비었다 — 3번으로 돌아간다"
|
||||
```
|
||||
|
||||
이 실험대의 토큰은 108자였고 형식이 `K10<해시>::server:<비밀번호>` 라 k3s 판올림에 따라 자릿수가 달라진다. 그래서 이 가드는 값을 맞춰 보지 않고 길이가 `0` 이 아닌지만 본다.
|
||||
|
||||
길이가 `0` 으로 나오는 길은 셋이다. 가이드는 게스트 안에서 친 경우를 그중 가장 흔하다고 적어 두었고, 나머지 둘은 server 가 아직 안 떠서 토큰 파일이 없거나 새 셸을 열어 변수가 사라진 경우를 가리킨다. 가드 한 줄은 그 몇 분을 막는다 — 설치가 성공으로 끝나고, 노드 목록에서 한 줄이 빠진 것을 알아채고, `journalctl` 까지 가는 동안이다.
|
||||
|
||||
## 토큰을 파일로 넘기면 같은 셸 제약이 없어진다
|
||||
|
||||
위 가드는 `TOKEN` 이 셸 변수라 토큰을 꺼낸 셸과 같은 셸에서 쳐야 하고, 다른 창에서는 비어 있다. 토큰이 명령줄에 들어가는 것도 걸리면 파일로 넘긴다.
|
||||
|
||||
```bash
|
||||
ssh kc-lab-1 'sudo cat /var/lib/rancher/k3s/server/node-token' \
|
||||
| ssh kc-lab-2 'sudo tee /tmp/token >/dev/null'
|
||||
ssh kc-lab-2 "curl -sfL https://get.k3s.io | sudo sh -s - agent \
|
||||
--server https://192.168.122.11:6443 --token-file /tmp/token \
|
||||
--node-ip 192.168.122.12; rm -f /tmp/token"
|
||||
```
|
||||
|
||||
토큰이 셸 히스토리에 남지 않고, 변수를 쓰지 않으니 셸이 달라도 된다.
|
||||
|
||||
## 확인하지 못한 것
|
||||
|
||||
원래 빈 토큰 사건의 `level=fatal msg="Error: --token is required"` 와 당시 설치 출력은 여전히 직접 raw가 없다. 다만 2026-09-17 후속 원문은 현재 정상 경로를 보강한다. `15-k3s-precheck.txt` 에는 별도 precheck에서 `Host key verification failed.`가 남아 있고, `21-k3s-token.txt` 는 재구축 시점의 **비어 있지 않은 토큰**을 길이로 확인했으며, `22-k3s-agent-install.txt` 는 그 토큰으로 agent 설치가 성공한 출력을 담는다. 이 셋은 원래 빈 토큰 실패의 재현이 아니라 **follow-up corroboration**이다. 과거 사건의 토큰 108자와 후속 실행의 길이가 다르더라도 같은 측정으로 덮어쓰지 않는다.
|
||||
|
||||
이 실패를 일부러 다시 만들어 본 기록도 없어서, 빈 토큰으로 설치하면 설치 출력이 성공으로 끝난다는 것은 한 번의 관측이다. 재현이 쉽지 않은 까닭은 이 부의 검증 방식에 있다 — 만드는 명령은 다시 치면 돌고 있는 실험대가 없어지므로 구축할 때 쓴 것을 옮기고 결과 상태를 확인하는 것으로 대신했다. 재현 없이 남길 수 있는 것은 `journalctl` 쪽이고, 그 출력을 `final/evidence/raw/` 에 남기면 진단 절에 증거가 붙는다.
|
||||
|
||||
가드 한 줄이 실제로 빈 토큰을 잡아 본 기록도 없다.
|
||||
|
||||
<!-- body:end -->
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"authority": "deterministic",
|
||||
"gate": "command-pedagogy-signals",
|
||||
"section_id": "docs/virtualization/tech-log-studio/build-completion-judgment/case/case-an-empty-token-installed-the-agent-anyway.md",
|
||||
"mode": "reference",
|
||||
"source_sha256": "a02be06af0e6283fcc2846b21e294dab76cbda3c154eeffe79d086f756386e45",
|
||||
"result": "PASS",
|
||||
"requires_editor": false,
|
||||
"blocks": [
|
||||
{
|
||||
"id": "command-001",
|
||||
"ordinal": 1,
|
||||
"language": "bash",
|
||||
"info_string": "",
|
||||
"label": null,
|
||||
"mode": "reference",
|
||||
"mode_explicit": false,
|
||||
"start": 3254,
|
||||
"end": 3381,
|
||||
"source_sha256": "0cd243cfe1e714ecc8750ac7e59763763dfc28813502c8018385681a22261e50",
|
||||
"body_sha256": "2a7f3e1f45fdd6c5b0e8fe964617d70102eca068dafaa03f46ab9df876a49316",
|
||||
"source": "```bash\nTOKEN=$(ssh kc-lab-1 'sudo cat /var/lib/rancher/k3s/server/node-token')\necho \"${#TOKEN} 자\" # 값이 아니라 길이만 확인한다\n```",
|
||||
"body": "TOKEN=$(ssh kc-lab-1 'sudo cat /var/lib/rancher/k3s/server/node-token')\necho \"${#TOKEN} 자\" # 값이 아니라 길이만 확인한다\n"
|
||||
},
|
||||
{
|
||||
"id": "command-002",
|
||||
"ordinal": 2,
|
||||
"language": "bash",
|
||||
"info_string": "",
|
||||
"label": null,
|
||||
"mode": "reference",
|
||||
"mode_explicit": false,
|
||||
"start": 3472,
|
||||
"end": 3639,
|
||||
"source_sha256": "8669893711bc209d5c1ac0f0c48a76e9a71f6f673f990125fbd206153aee607b",
|
||||
"body_sha256": "9306236bb20e9b7f9ebc51cc9c6760121a4f98c230fe03c82bf57de30fb48608",
|
||||
"source": "```bash\nssh kc-lab-2 \"curl -sfL https://get.k3s.io | sudo sh -s - agent \\\n --server https://192.168.122.11:6443 \\\n --token '$TOKEN' \\\n --node-ip 192.168.122.12\"\n```",
|
||||
"body": "ssh kc-lab-2 \"curl -sfL https://get.k3s.io | sudo sh -s - agent \\\n --server https://192.168.122.11:6443 \\\n --token '$TOKEN' \\\n --node-ip 192.168.122.12\"\n"
|
||||
},
|
||||
{
|
||||
"id": "command-003",
|
||||
"ordinal": 3,
|
||||
"language": "bash",
|
||||
"info_string": "",
|
||||
"label": null,
|
||||
"mode": "reference",
|
||||
"mode_explicit": false,
|
||||
"start": 3754,
|
||||
"end": 3871,
|
||||
"source_sha256": "becc3b230571a0ca6dd847f96f79e71035a24512f6386b98a6f7ece4640b391d",
|
||||
"body_sha256": "6a80a900884a7815dc4319a55f84a9f0ec972b3a9053e59e1fd55314a1a43059",
|
||||
"source": "```bash\n[kc-lab-1] $ ssh kc-lab-1 'sudo cat /var/lib/rancher/k3s/server/node-token'\nHost key verification failed.\n```",
|
||||
"body": "[kc-lab-1] $ ssh kc-lab-1 'sudo cat /var/lib/rancher/k3s/server/node-token'\nHost key verification failed.\n"
|
||||
},
|
||||
{
|
||||
"id": "command-004",
|
||||
"ordinal": 4,
|
||||
"language": "bash",
|
||||
"info_string": "",
|
||||
"label": null,
|
||||
"mode": "reference",
|
||||
"mode_explicit": false,
|
||||
"start": 4440,
|
||||
"end": 4477,
|
||||
"source_sha256": "ba8a7e2b4715d0991bb17557d004d85eebd3622ab64bb7f96da1857aae7ddf35",
|
||||
"body_sha256": "4161727a277945c14e7e36543b8e89dd1c9678f4ff865ddc63caecfa66e0b6a2",
|
||||
"source": "```bash\nkubectl get nodes -o wide\n```",
|
||||
"body": "kubectl get nodes -o wide\n"
|
||||
},
|
||||
{
|
||||
"id": "command-005",
|
||||
"ordinal": 5,
|
||||
"language": "bash",
|
||||
"info_string": "",
|
||||
"label": null,
|
||||
"mode": "reference",
|
||||
"mode_explicit": false,
|
||||
"start": 4822,
|
||||
"end": 4888,
|
||||
"source_sha256": "710c3f4b246a5eddb9955a955f0ddb3c7ff0baecd5600dcdced31278d9214154",
|
||||
"body_sha256": "146fe14b61766555ee1fbb260c5a026315bc0cc76ea82baff5edc057cb3c0b80",
|
||||
"source": "```bash\n[ ${#TOKEN} -ge 50 ] || echo \"TOKEN 이 비었다 — 3번으로 돌아간다\"\n```",
|
||||
"body": "[ ${#TOKEN} -ge 50 ] || echo \"TOKEN 이 비었다 — 3번으로 돌아간다\"\n"
|
||||
},
|
||||
{
|
||||
"id": "command-006",
|
||||
"ordinal": 6,
|
||||
"language": "bash",
|
||||
"info_string": "",
|
||||
"label": null,
|
||||
"mode": "reference",
|
||||
"mode_explicit": false,
|
||||
"start": 5334,
|
||||
"end": 5637,
|
||||
"source_sha256": "62ee8509be195f25e7efa1e5013800e746be547448bd5e3bf88073d45bbf0520",
|
||||
"body_sha256": "c3c974825adec113eb75cb4424d6bf574126f14f34eb03be584985d545373252",
|
||||
"source": "```bash\nssh kc-lab-1 'sudo cat /var/lib/rancher/k3s/server/node-token' \\\n | ssh kc-lab-2 'sudo tee /tmp/token >/dev/null'\nssh kc-lab-2 \"curl -sfL https://get.k3s.io | sudo sh -s - agent \\\n --server https://192.168.122.11:6443 --token-file /tmp/token \\\n --node-ip 192.168.122.12; rm -f /tmp/token\"\n```",
|
||||
"body": "ssh kc-lab-1 'sudo cat /var/lib/rancher/k3s/server/node-token' \\\n | ssh kc-lab-2 'sudo tee /tmp/token >/dev/null'\nssh kc-lab-2 \"curl -sfL https://get.k3s.io | sudo sh -s - agent \\\n --server https://192.168.122.11:6443 --token-file /tmp/token \\\n --node-ip 192.168.122.12; rm -f /tmp/token\"\n"
|
||||
}
|
||||
],
|
||||
"findings": [],
|
||||
"extensions": {
|
||||
"command_like_text_blocks": []
|
||||
}
|
||||
}
|
||||
+169
@@ -0,0 +1,169 @@
|
||||
---
|
||||
kind: CASE
|
||||
slug: an-empty-token-installed-the-agent-anyway
|
||||
title: 빈 토큰이 조용히 흘러갔다 — 설치 출력은 성공이었고 agent 만 5초마다 다시 죽었다
|
||||
topic: build-completion-judgment
|
||||
topicName: 끝났다는 판정
|
||||
project: virtualization
|
||||
status: 게시 전
|
||||
sourceRevision: import-head 9465582b5d1630eb4ae7c4e078021486919bf6b6 · uncommitted-working-tree snapshot
|
||||
evidence:
|
||||
- ../../../final/evidence/raw/lab-state-before-rebuild/15-k3s-precheck.txt
|
||||
- ../../../final/evidence/raw/lab-state-before-rebuild/21-k3s-token.txt
|
||||
- ../../../final/evidence/raw/lab-state-before-rebuild/22-k3s-agent-install.txt
|
||||
source:
|
||||
- final/document.md#185-가이드-묶음이-스스로-정한-규약
|
||||
- final/document.md#188-단계-02-k3s-server-와-agent
|
||||
- final/document.md#184-이-부의-출처와-범위
|
||||
---
|
||||
|
||||
# 빈 토큰이 조용히 흘러갔다 — 설치 출력은 성공이었고 agent 만 5초마다 다시 죽었다
|
||||
|
||||
k3s agent 설치는 오류 한 줄 없이 끝났는데 kubectl get nodes 에는 노드가 하나뿐이었다. 게스트 안에서 친 ssh 가 실패해 토큰이 빈 문자열로 넘어갔고, 설치 스크립트는 그 전까지를 성공으로 찍었다. 값을 찍지 않고 길이만 재는 한 줄을 설치 앞에 두면 잡힌다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **도구가 낸 출력은 대상의 상태가 아니다 — 그 명령이 무엇을 세는지부터 가른다**
|
||||
설치 스크립트의 출력을 노드가 붙었다는 뜻으로 읽은 사건이라 그 규칙의 사례가 된다.
|
||||
- **가까운 층부터 한 층씩 건너뛰며 확인한다 — 층마다 성공 신호가 다르다**
|
||||
설치가 끝났다는 출력 대신 각 층에서 무엇이 성공 신호인지로 판정을 옮기는 절차다.
|
||||
- **cloud-init 이 안 도는 원인은 넷인데 증상은 「SSH 가 안 붙는다」 하나였다**
|
||||
같은 구축의 한 단계 앞에서 벌어진 일이고, 거기서도 실패가 아무 오류 없이 지나갔다.
|
||||
- **가이드대로 다시 쳐서 이 실험대가 같은 상태로 서는가**
|
||||
여기 적은 설치 명령을 다시 쳐서 같은 상태가 되는지는 확인된 적이 없다.
|
||||
- **단계별 구축 문서는 작성 시점이 아니라 실행 순서로 검증한다**
|
||||
명령을 어느 셸에서 치는지를 단계마다 확인하라는 규칙이고, 그 확인이 빠졌을 때 나온 실패가 이것이다.
|
||||
|
||||
## 문제
|
||||
|
||||
k3s agent 설치가 끝까지 돌았고 출력에 오류가 없었다. 그런데 두 노드가 Ready 로 나와야 할 kubectl get nodes 에 두 번째 노드가 나타나지 않았다.
|
||||
|
||||
설치 출력의 오류 : x
|
||||
kubectl get nodes 의 노드 수 : 1
|
||||
k3s-agent 유닛 : 5초마다 재시작
|
||||
journalctl -u k3s-agent : level=fatal msg="Error: --token is required"
|
||||
|
||||
실패가 화면에 나오지 않아 설치는 끝난 것으로 읽힌다.
|
||||
|
||||
## 결론
|
||||
|
||||
토큰을 꺼내는 ssh 를 게스트 안에서 쳤고 그 명령이 Host key verification failed. 로 끝났다. 명령 치환으로 감싸면 오류는 stderr 로 흘러가고 변수에는 빈 문자열이 담긴다. 셸은 아무 불평도 하지 않는다.
|
||||
|
||||
agent 는 --token '' 을 받아 level=fatal msg="Error: --token is required" 로 죽지만, 설치 스크립트는 그 전까지를 다 성공으로 찍고 끝난다. 그래서 설치 출력만 보면 성공이고, 유닛이 Restart=always 라 5초마다 조용히 재시도한다.
|
||||
|
||||
해결 : 게스트에 들어가지 않고 lab host 한 셸에서 ssh kc-lab-1 '...' 형태로 친다
|
||||
가드 : 값을 쓰기 전에 길이로 가른다. 이 실험대의 토큰은 108자였다
|
||||
히스토리에 남기지 않으려면 : --token-file 로 넘긴다. 토큰을 꺼낸 셸과 같은 셸에서 쳐야 한다는 제약도 없어진다
|
||||
|
||||
## 검증 환경
|
||||
|
||||
호스트 : test-server, Arch Linux
|
||||
CPU : i5-1135G7, 논리 코어 8
|
||||
RAM : 11,648MiB
|
||||
QEMU : 11.1.1
|
||||
libvirt : 12.7.0
|
||||
게스트 : Debian 12 genericcloud
|
||||
k3s : v1.36.4+k3s1
|
||||
server 노드 : kc-lab-1, 192.168.122.11
|
||||
agent 노드 : kc-lab-2, 192.168.122.12
|
||||
토큰 길이 : 108자
|
||||
토큰 형식 : K10<해시>::server:<비밀번호>
|
||||
|
||||
## 재현 조건
|
||||
|
||||
1. lab host 에서 게스트 세 대를 세우고 kc-lab-1 에 k3s server 를 깐다.
|
||||
2. lab host 에서 ssh kc-lab-1 로 게스트에 로그인한다.
|
||||
3. 게스트 프롬프트에서 TOKEN=$(ssh kc-lab-1 'sudo cat /var/lib/rancher/k3s/server/node-token') 을 친다.
|
||||
4. echo "${#TOKEN} 자" 가 0 을 낸다.
|
||||
5. 같은 셸에서 agent 설치 명령에 --token "$TOKEN" 을 넘긴다. 설치 출력은 오류 없이 끝난다.
|
||||
6. lab host 로 돌아와 kubectl get nodes 를 친다. 노드가 하나뿐이다.
|
||||
7. ssh kc-lab-2 'journalctl -u k3s-agent' 에서 --token is required 를 찾는다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
## 토큰이 지나는 길과 명령을 치는 셸
|
||||
|
||||
k3s server 는 `kc-lab-1` 에, agent 는 `kc-lab-2` 에 깔고 둘 다 lab host 에서 `ssh` 로 원격 실행한다. 가이드가 코드 블록마다 어느 기계에서 치는지를 붙여 둔 까닭이 여기에 있다 — 게스트는 libvirt NAT(`192.168.122.0/24`) 안에 있어 워크스테이션에서 직접 닿지 않고, `ssh kc-lab-1` 이라는 별칭도 lab host 의 `~/.ssh/config` 에만 있다.
|
||||
|
||||
agent 가 server 에 붙으려면 server 가 만든 node-token 이 필요하고, 그 값을 lab host 에서 꺼내 같은 셸에서 설치 명령에 넘긴다.
|
||||
|
||||
```bash
|
||||
TOKEN=$(ssh kc-lab-1 'sudo cat /var/lib/rancher/k3s/server/node-token')
|
||||
echo "${#TOKEN} 자" # 값이 아니라 길이만 확인한다
|
||||
```
|
||||
|
||||
비밀은 길이나 존재 여부만 확인하고 값을 찍지 않는다. 가이드가 먼저 정한 표기 규약이고, 값을 찍으면 터미널 스크롤백과 화면 공유에 그대로 남기 때문이다.
|
||||
|
||||
```bash
|
||||
ssh kc-lab-2 "curl -sfL https://get.k3s.io | sudo sh -s - agent \
|
||||
--server https://192.168.122.11:6443 \
|
||||
--token '$TOKEN' \
|
||||
--node-ip 192.168.122.12"
|
||||
```
|
||||
|
||||
## 게스트 안에서 친 ssh 는 빈 문자열이 된다
|
||||
|
||||
토큰을 꺼내려고 게스트에 먼저 들어가면 같은 명령이 다르게 끝난다. 게스트에는 lab host 의 개인키도 `~/.ssh/config` 도 없다.
|
||||
|
||||
```bash
|
||||
[kc-lab-1] $ ssh kc-lab-1 'sudo cat /var/lib/rancher/k3s/server/node-token'
|
||||
Host key verification failed.
|
||||
```
|
||||
|
||||
여기까지는 오류 문구가 찍힌다. 문제는 이 명령을 `TOKEN=$(...)` 로 감쌌을 때다. 명령 치환은 표준 출력만 변수에 담으므로 오류는 stderr 로 흘러가고 `TOKEN` 에는 빈 문자열이 담기며, 셸은 아무 불평도 하지 않는다. 뒤이어 치는 설치 명령은 `--token ''` 을 넘긴 것과 같아진다.
|
||||
|
||||
## 설치 출력이 성공으로 끝나는 경로
|
||||
|
||||
빈 문자열을 받은 설치도 끝까지 돈다. agent 가 `--token ''` 을 받아 `level=fatal msg="Error: --token is required"` 로 죽는데, 설치 스크립트는 그 전까지를 다 성공으로 찍고 끝나므로 설치 출력만 보면 성공이다. 성공으로 찍힌 것은 내려받기와 유닛 생성과 `enable` 까지다. 유닛은 `Restart=always` 라 5초마다 조용히 재시도하고, 그래서 실패가 화면이 아니라 `journalctl -u k3s-agent` 안에서만 되풀이된다.
|
||||
|
||||
k3s server 와 agent 를 세우는 단계가 끝났다는 판정은 lab host 에서 노드 목록을 쳐서 두 노드가 `Ready` 로 나오는 것이다.
|
||||
|
||||
```bash
|
||||
kubectl get nodes -o wide
|
||||
```
|
||||
|
||||
```
|
||||
NAME STATUS ROLES AGE VERSION INTERNAL-IP
|
||||
kc-lab-1 Ready control-plane 47m v1.36.4+k3s1 192.168.122.11
|
||||
kc-lab-2 Ready <none> 21m v1.36.4+k3s1 192.168.122.12
|
||||
```
|
||||
|
||||
빈 토큰으로 깔린 agent 는 이 목록에 올라오지 않는다.
|
||||
|
||||
## 값을 찍지 않고 길이만 잰다
|
||||
|
||||
토큰을 화면에 찍어 눈으로 대조하는 방법은 표기 규약이 막아 두었다. 그래서 설치 앞에 길이를 재는 한 줄을 둔다.
|
||||
|
||||
```bash
|
||||
[ ${#TOKEN} -ge 50 ] || echo "TOKEN 이 비었다 — 3번으로 돌아간다"
|
||||
```
|
||||
|
||||
이 실험대의 토큰은 108자였고 형식이 `K10<해시>::server:<비밀번호>` 라 k3s 판올림에 따라 자릿수가 달라진다. 그래서 이 가드는 값을 맞춰 보지 않고 길이가 `0` 이 아닌지만 본다.
|
||||
|
||||
길이가 `0` 으로 나오는 길은 셋이다. 가이드는 게스트 안에서 친 경우를 그중 가장 흔하다고 적어 두었고, 나머지 둘은 server 가 아직 안 떠서 토큰 파일이 없거나 새 셸을 열어 변수가 사라진 경우를 가리킨다. 가드 한 줄은 그 몇 분을 막는다 — 설치가 성공으로 끝나고, 노드 목록에서 한 줄이 빠진 것을 알아채고, `journalctl` 까지 가는 동안이다.
|
||||
|
||||
## 토큰을 파일로 넘기면 같은 셸 제약이 없어진다
|
||||
|
||||
위 가드는 `TOKEN` 이 셸 변수라 토큰을 꺼낸 셸과 같은 셸에서 쳐야 하고, 다른 창에서는 비어 있다. 토큰이 명령줄에 들어가는 것도 걸리면 파일로 넘긴다.
|
||||
|
||||
```bash
|
||||
ssh kc-lab-1 'sudo cat /var/lib/rancher/k3s/server/node-token' \
|
||||
| ssh kc-lab-2 'sudo tee /tmp/token >/dev/null'
|
||||
ssh kc-lab-2 "curl -sfL https://get.k3s.io | sudo sh -s - agent \
|
||||
--server https://192.168.122.11:6443 --token-file /tmp/token \
|
||||
--node-ip 192.168.122.12; rm -f /tmp/token"
|
||||
```
|
||||
|
||||
토큰이 셸 히스토리에 남지 않고, 변수를 쓰지 않으니 셸이 달라도 된다.
|
||||
|
||||
## 확인하지 못한 것
|
||||
|
||||
원래 빈 토큰 사건의 `level=fatal msg="Error: --token is required"` 와 당시 설치 출력은 여전히 직접 raw가 없다. 다만 2026-09-17 후속 원문은 현재 정상 경로를 보강한다. `15-k3s-precheck.txt` 에는 별도 precheck에서 `Host key verification failed.`가 남아 있고, `21-k3s-token.txt` 는 재구축 시점의 **비어 있지 않은 토큰**을 길이로 확인했으며, `22-k3s-agent-install.txt` 는 그 토큰으로 agent 설치가 성공한 출력을 담는다. 이 셋은 원래 빈 토큰 실패의 재현이 아니라 **follow-up corroboration**이다. 과거 사건의 토큰 108자와 후속 실행의 길이가 다르더라도 같은 측정으로 덮어쓰지 않는다.
|
||||
|
||||
이 실패를 일부러 다시 만들어 본 기록도 없어서, 빈 토큰으로 설치하면 설치 출력이 성공으로 끝난다는 것은 한 번의 관측이다. 재현이 쉽지 않은 까닭은 이 부의 검증 방식에 있다 — 만드는 명령은 다시 치면 돌고 있는 실험대가 없어지므로 구축할 때 쓴 것을 옮기고 결과 상태를 확인하는 것으로 대신했다. 재현 없이 남길 수 있는 것은 `journalctl` 쪽이고, 그 출력을 `final/evidence/raw/` 에 남기면 진단 절에 증거가 붙는다.
|
||||
|
||||
가드 한 줄이 실제로 빈 토큰을 잡아 본 기록도 없다.
|
||||
|
||||
<!-- body:end -->
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"scope": "command-pedagogy",
|
||||
"reviewer": "command-pedagogy-reviewer",
|
||||
"verdict": "PASS",
|
||||
"source_sha256": "a02be06af0e6283fcc2846b21e294dab76cbda3c154eeffe79d086f756386e45",
|
||||
"findings": [],
|
||||
"notes": "reference mode final major finding은 0이다. historical compact command는 reference disposition으로 보존한다."
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"record": "docs/virtualization/tech-log-studio/build-completion-judgment/case/case-an-empty-token-installed-the-agent-anyway.md",
|
||||
"sourceSha256": "a02be06af0e6283fcc2846b21e294dab76cbda3c154eeffe79d086f756386e45",
|
||||
"verdict": "PASS",
|
||||
"liveSourceReconciliation": "UNVERIFIABLE",
|
||||
"liveSourceReason": "source repositories unavailable on current machine",
|
||||
"acceptedByProjectReview": true,
|
||||
"checks": [
|
||||
"local SSOT/Record evidence mismatch 0",
|
||||
"S3/S5/S6 check_evidence --repo invoked",
|
||||
"source checkout absence preserved as exit 3"
|
||||
],
|
||||
"note": "현재 project evidence gate는 불일치 0건·대조 불가 2건이다. source checkout 부재를 PASS로 위장하지 않고 UNVERIFIABLE로 보존했다."
|
||||
}
|
||||
+371
@@ -0,0 +1,371 @@
|
||||
{
|
||||
"schemaVersion": 5,
|
||||
"runId": "2026-09-21-0218-remediation-02-case-cloud-init-failures-all-look-like-ssh-refused",
|
||||
"project": "virtualization",
|
||||
"record": "docs/virtualization/tech-log-studio/build-completion-judgment/case/case-cloud-init-failures-all-look-like-ssh-refused.md",
|
||||
"startedAt": "2026-09-21T02:24:30+00:00",
|
||||
"finishedAt": "2026-09-21T02:24:32+00:00",
|
||||
"stages": [
|
||||
{
|
||||
"id": "S1",
|
||||
"name": "코드베이스 → SSOT",
|
||||
"skill": "analyzing-codebase-for-tech-log",
|
||||
"runBy": "ssot-analyst",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "이번 remediation은 새 evidence와 현재 SSOT/Record의 정합성·provenance·command/S4를 재검토한다. 코드베이스→SSOT 전면 분석은 다시 하지 않는다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": "edd45dfec66d155a621cd41186b83b5582f2244c",
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"gates": [],
|
||||
"notes": "",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-21T02:24:30+00:00",
|
||||
"elapsedSeconds": 0,
|
||||
"finishedBy": "chatgpt-virtualization-20260921-remediation-v5"
|
||||
},
|
||||
{
|
||||
"id": "S2",
|
||||
"name": "SSOT → 분해 계약",
|
||||
"skill": "deriving-tech-log-root-tree",
|
||||
"runBy": "tree-deriver",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "기존 91 Record 분해 계약은 유지한다. certbot Question의 RESOLVED 상태와 sourcePolicy는 project tree rebuild에서 별도로 동기화했다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": "ab59130196d79e947b32e3b5e6b75335a9e5c1eb",
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"gates": [],
|
||||
"notes": "",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-21T02:24:30+00:00",
|
||||
"elapsedSeconds": 0,
|
||||
"finishedBy": "chatgpt-virtualization-20260921-remediation-v5"
|
||||
},
|
||||
{
|
||||
"id": "S3",
|
||||
"name": "글감 → 기록",
|
||||
"skill": "writing-tech-log-records",
|
||||
"runBy": "record-writer",
|
||||
"status": "DONE",
|
||||
"skipReason": "",
|
||||
"skillEcho": "**인용한 줄은 SSOT 에서 찾아 대조한다.**",
|
||||
"skillRevision": null,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
"docs/virtualization/tech-log-studio/build-completion-judgment/case/case-cloud-init-failures-all-look-like-ssh-refused.md"
|
||||
],
|
||||
"gates": [
|
||||
{
|
||||
"cmd": "python3 scripts/studio-body.py docs/virtualization/tech-log-studio/build-completion-judgment/case/case-cloud-init-failures-all-look-like-ssh-refused.md -o runs/virtualization/2026-09-21-0218-remediation-02-case-cloud-init-failures-all-look-like-ssh-refused/stage/S3/studio-body.md",
|
||||
"exit": 0,
|
||||
"status": "PASS",
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"generation": 1,
|
||||
"at": "2026-09-21T02:24:30+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node --experimental-transform-types .agents/skills/writing-tech-log-records/scripts/check_body.mjs runs/virtualization/2026-09-21-0218-remediation-02-case-cloud-init-failures-all-look-like-ssh-refused/stage/S3/studio-body.md --frontend /shared/codebase/tech-log-frontend",
|
||||
"exit": 0,
|
||||
"status": "PASS",
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"generation": 1,
|
||||
"at": "2026-09-21T02:24:31+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node .agents/skills/rewriting-technical-prose-naturally/scripts/check_prose.mjs --warn docs/virtualization/tech-log-studio/build-completion-judgment/case/case-cloud-init-failures-all-look-like-ssh-refused.md",
|
||||
"exit": 0,
|
||||
"status": "PASS",
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"generation": 1,
|
||||
"at": "2026-09-21T02:24:31+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node .agents/skills/writing-tech-log-records/scripts/check_evidence.mjs virtualization --repo",
|
||||
"exit": 3,
|
||||
"status": "UNVERIFIABLE",
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"generation": 1,
|
||||
"at": "2026-09-21T02:24:31+00:00",
|
||||
"semanticId": "evidence-repo",
|
||||
"reason": "source repositories unavailable on current machine",
|
||||
"acceptedByProjectReview": true
|
||||
}
|
||||
],
|
||||
"notes": "record-writer/fact 계약으로 새 evidence와 SSOT의 current truth를 대조했다. --repo 대조는 실제 실행했고 source checkout 부재만 UNVERIFIABLE로 남겼다.",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-21T02:24:31+00:00",
|
||||
"elapsedSeconds": 1,
|
||||
"generation": 1,
|
||||
"owner": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"finishedBy": "chatgpt-virtualization-20260921-remediation-v5"
|
||||
},
|
||||
{
|
||||
"id": "S4",
|
||||
"name": "기록 → 그림",
|
||||
"skill": "technical-visualizer",
|
||||
"runBy": "diagram-maker",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "이 Record 변경은 current truth/evidence/version/provenance 정리이며 새 관계 그림을 요구하지 않는다. 기존 그림은 project-wide 14장 검증에서 별도로 확인했다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": "edd45dfec66d155a621cd41186b83b5582f2244c",
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"gates": [],
|
||||
"notes": "",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-21T02:24:31+00:00",
|
||||
"elapsedSeconds": 0,
|
||||
"finishedBy": "chatgpt-virtualization-20260921-remediation-v5"
|
||||
},
|
||||
{
|
||||
"id": "S5",
|
||||
"name": "AI 티 제거",
|
||||
"skill": "rewriting-technical-prose-naturally",
|
||||
"runBy": "prose-rewriter",
|
||||
"status": "DONE",
|
||||
"skipReason": "",
|
||||
"skillEcho": "This is an **editorial** pass. The source's facts, evidence, causal chain, uncertainty, decision status, and technical depth are the contract.",
|
||||
"skillRevision": null,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
"docs/virtualization/tech-log-studio/build-completion-judgment/case/case-cloud-init-failures-all-look-like-ssh-refused.md"
|
||||
],
|
||||
"gates": [
|
||||
{
|
||||
"cmd": "node .agents/skills/rewriting-technical-prose-naturally/scripts/check_prose.mjs --warn docs/virtualization/tech-log-studio/build-completion-judgment/case/case-cloud-init-failures-all-look-like-ssh-refused.md",
|
||||
"exit": 0,
|
||||
"status": "PASS",
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"generation": 1,
|
||||
"at": "2026-09-21T02:24:31+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node .agents/skills/rewriting-technical-prose-naturally/scripts/style_profile.mjs docs/virtualization/tech-log-studio/build-completion-judgment/case/case-cloud-init-failures-all-look-like-ssh-refused.md",
|
||||
"exit": 0,
|
||||
"status": "PASS",
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"generation": 1,
|
||||
"at": "2026-09-21T02:24:31+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node --experimental-transform-types .agents/skills/writing-tech-log-records/scripts/check_body.mjs runs/virtualization/2026-09-21-0218-remediation-02-case-cloud-init-failures-all-look-like-ssh-refused/stage/S5/studio-body.md --frontend /shared/codebase/tech-log-frontend",
|
||||
"exit": 0,
|
||||
"status": "PASS",
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"generation": 1,
|
||||
"at": "2026-09-21T02:24:31+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node .agents/skills/writing-tech-log-records/scripts/check_evidence.mjs virtualization --repo",
|
||||
"exit": 3,
|
||||
"status": "UNVERIFIABLE",
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"generation": 1,
|
||||
"at": "2026-09-21T02:24:32+00:00",
|
||||
"semanticId": "evidence-repo",
|
||||
"reason": "source repositories unavailable on current machine",
|
||||
"acceptedByProjectReview": true
|
||||
}
|
||||
],
|
||||
"notes": "대규모 문체 재작성 없이 stale fact/relation만 현재 evidence에 맞췄다. style_profile은 측정값으로만 기록했다.",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-21T02:24:32+00:00",
|
||||
"elapsedSeconds": 1,
|
||||
"generation": 1,
|
||||
"owner": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"finishedBy": "chatgpt-virtualization-20260921-remediation-v5"
|
||||
},
|
||||
{
|
||||
"id": "S6",
|
||||
"name": "일한 사람의 목소리",
|
||||
"skill": "writing-as-the-person-who-did-it",
|
||||
"runBy": "voice-writer",
|
||||
"status": "DONE",
|
||||
"skipReason": "",
|
||||
"skillEcho": "찾은 것이 없으면 **이 스킬은 여기서 끝난다.** 없는 목소리를 채우지 않는다.",
|
||||
"skillRevision": "edd45dfec66d155a621cd41186b83b5582f2244c",
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
"docs/virtualization/tech-log-studio/build-completion-judgment/case/case-cloud-init-failures-all-look-like-ssh-refused.md"
|
||||
],
|
||||
"gates": [
|
||||
{
|
||||
"cmd": "node .agents/skills/writing-as-the-person-who-did-it/scripts/check_voice.mjs docs/virtualization/tech-log-studio/build-completion-judgment/case/case-cloud-init-failures-all-look-like-ssh-refused.md",
|
||||
"exit": 0,
|
||||
"status": "PASS",
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"generation": 1,
|
||||
"at": "2026-09-21T02:24:32+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node .agents/skills/rewriting-technical-prose-naturally/scripts/check_prose.mjs --warn docs/virtualization/tech-log-studio/build-completion-judgment/case/case-cloud-init-failures-all-look-like-ssh-refused.md",
|
||||
"exit": 0,
|
||||
"status": "PASS",
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"generation": 1,
|
||||
"at": "2026-09-21T02:24:32+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node --experimental-transform-types .agents/skills/writing-tech-log-records/scripts/check_body.mjs runs/virtualization/2026-09-21-0218-remediation-02-case-cloud-init-failures-all-look-like-ssh-refused/stage/S6/studio-body.md --frontend /shared/codebase/tech-log-frontend",
|
||||
"exit": 0,
|
||||
"status": "PASS",
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"generation": 1,
|
||||
"at": "2026-09-21T02:24:32+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node .agents/skills/writing-tech-log-records/scripts/check_evidence.mjs virtualization --repo",
|
||||
"exit": 3,
|
||||
"status": "UNVERIFIABLE",
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"generation": 1,
|
||||
"at": "2026-09-21T02:24:32+00:00",
|
||||
"semanticId": "evidence-repo",
|
||||
"reason": "source repositories unavailable on current machine",
|
||||
"acceptedByProjectReview": true
|
||||
}
|
||||
],
|
||||
"notes": "상류 SSOT/evidence를 다시 보며 실제 작업 흔적이 없는 서사는 추가하지 않았다.",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-21T02:24:32+00:00",
|
||||
"elapsedSeconds": 0,
|
||||
"generation": 1,
|
||||
"owner": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"finishedBy": "chatgpt-virtualization-20260921-remediation-v5"
|
||||
},
|
||||
{
|
||||
"id": "S7",
|
||||
"name": "Studio 저장",
|
||||
"skill": "publishing-tech-log-to-studio",
|
||||
"runBy": "studio-validator",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "이번 remediation에서 Studio import/save는 요청되지 않았다. 저장소 산출물과 검증 영수증만 갱신한다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": "862e502af3e956b49ccd2ae0a8de3fd32f90df9c",
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"gates": [],
|
||||
"notes": "",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-21T02:24:32+00:00",
|
||||
"elapsedSeconds": 0,
|
||||
"finishedBy": "chatgpt-virtualization-20260921-remediation-v5"
|
||||
}
|
||||
],
|
||||
"qualityReviews": {
|
||||
"commandPedagogy": {
|
||||
"initialAnalysis": {
|
||||
"cmd": "python3 scripts/check-command-pedagogy.py --mode reference docs/virtualization/tech-log-studio/build-completion-judgment/case/case-cloud-init-failures-all-look-like-ssh-refused.md -o runs/virtualization/2026-09-21-0218-remediation-02-case-cloud-init-failures-all-look-like-ssh-refused/stage/S3/command-initial.json",
|
||||
"exit": 0,
|
||||
"shellBlocks": 6,
|
||||
"findings": 5,
|
||||
"majorFindings": 0,
|
||||
"artifact": {
|
||||
"path": "runs/virtualization/2026-09-21-0218-remediation-02-case-cloud-init-failures-all-look-like-ssh-refused/stage/S3/command-initial.json",
|
||||
"sha256": "ae393228dbb54df1727efe617f139d3fdf19ddd4d2f81421b4ca7e31c4380455"
|
||||
}
|
||||
},
|
||||
"finalAnalysis": {
|
||||
"cmd": "python3 scripts/check-command-pedagogy.py --mode reference docs/virtualization/tech-log-studio/build-completion-judgment/case/case-cloud-init-failures-all-look-like-ssh-refused.md -o runs/virtualization/2026-09-21-0218-remediation-02-case-cloud-init-failures-all-look-like-ssh-refused/stage/S6/command-final.json",
|
||||
"exit": 0,
|
||||
"shellBlocks": 6,
|
||||
"findings": 5,
|
||||
"majorFindings": 0,
|
||||
"artifact": {
|
||||
"path": "runs/virtualization/2026-09-21-0218-remediation-02-case-cloud-init-failures-all-look-like-ssh-refused/stage/S6/command-final.json",
|
||||
"sha256": "ae393228dbb54df1727efe617f139d3fdf19ddd4d2f81421b4ca7e31c4380455"
|
||||
}
|
||||
},
|
||||
"planner": {
|
||||
"runBy": "command-pedagogy-planner",
|
||||
"skill": "writing-practitioner-guides",
|
||||
"status": "DONE",
|
||||
"skipReason": "",
|
||||
"skillEcho": "**Optimize for human operation, not command compactness.**",
|
||||
"skillRevision": null,
|
||||
"notes": "현재 세션이 canonical command contract를 직접 읽고 역할을 분리해 수행했다. current audit에서 새 major defect가 없어 editor patch는 비어 있다.",
|
||||
"artifact": {
|
||||
"path": "runs/virtualization/2026-09-21-0218-remediation-02-case-cloud-init-failures-all-look-like-ssh-refused/stage/quality/command-plan.json",
|
||||
"sha256": "1cb3cf0c1672b459ce13d602511fdb01813b0cef290536b49b5f312b7cdc2079"
|
||||
}
|
||||
},
|
||||
"editor": {
|
||||
"runBy": "command-pedagogy-editor",
|
||||
"skill": "writing-practitioner-guides",
|
||||
"status": "DONE",
|
||||
"skipReason": "",
|
||||
"skillEcho": "**Optimize for human operation, not command compactness.**",
|
||||
"skillRevision": null,
|
||||
"notes": "현재 세션이 canonical command contract를 직접 읽고 역할을 분리해 수행했다. current audit에서 새 major defect가 없어 editor patch는 비어 있다.",
|
||||
"artifact": {
|
||||
"path": "runs/virtualization/2026-09-21-0218-remediation-02-case-cloud-init-failures-all-look-like-ssh-refused/stage/quality/command-patch-set.json",
|
||||
"sha256": "2847caadc8ecd451d92c057407f1d9dfd77794bfdb46909d49538f3720528142"
|
||||
}
|
||||
},
|
||||
"reviewer": {
|
||||
"runBy": "command-pedagogy-reviewer",
|
||||
"skill": "writing-practitioner-guides",
|
||||
"status": "DONE",
|
||||
"skipReason": "",
|
||||
"skillEcho": "**Optimize for human operation, not command compactness.**",
|
||||
"skillRevision": null,
|
||||
"verdict": "PASS",
|
||||
"notes": "project-level command audit와 현재 final analysis를 대조했다. final major finding은 0이다.",
|
||||
"artifact": {
|
||||
"path": "runs/virtualization/2026-09-21-0218-remediation-02-case-cloud-init-failures-all-look-like-ssh-refused/stage/quality/command-review.json",
|
||||
"sha256": "52ab48ee7e448d026af5fc791f6bebd3b3a6bd442b97f0a2126c5101eaf16eb4"
|
||||
},
|
||||
"sourceSha256": "1385070d8fd0ac929ff29d0ca47f20f21f34afe6b84d624ecc2c67bb891b4e7f"
|
||||
}
|
||||
},
|
||||
"technicalEvidence": {
|
||||
"runBy": "fact-reviewer",
|
||||
"status": "DONE",
|
||||
"skipReason": "",
|
||||
"verdict": "PASS",
|
||||
"notes": "current truth/evidence/provenance를 SSOT와 역대조했다. live source는 실제 --repo 결과 exit 3이며 UNVERIFIABLE로 보존했다.",
|
||||
"sourceSha256": "1385070d8fd0ac929ff29d0ca47f20f21f34afe6b84d624ecc2c67bb891b4e7f",
|
||||
"liveSourceReconciliation": "UNVERIFIABLE",
|
||||
"liveSourceReason": "source repositories unavailable on current machine",
|
||||
"acceptedByProjectReview": true,
|
||||
"artifact": {
|
||||
"path": "runs/virtualization/2026-09-21-0218-remediation-02-case-cloud-init-failures-all-look-like-ssh-refused/stage/quality/technical-evidence-review.json",
|
||||
"sha256": "9c7a699df6fa08979c8c0907a9bf7a157aceda87d23a41da273b6eb5ee2cb5c2"
|
||||
}
|
||||
}
|
||||
},
|
||||
"riders": [],
|
||||
"sessions": [
|
||||
{
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"openedAt": "2026-09-21T02:24:30+00:00"
|
||||
},
|
||||
{
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"stage": "S3",
|
||||
"generation": 1,
|
||||
"beganAt": "2026-09-21T02:24:30+00:00"
|
||||
},
|
||||
{
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"stage": "S5",
|
||||
"generation": 1,
|
||||
"beganAt": "2026-09-21T02:24:31+00:00"
|
||||
},
|
||||
{
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"stage": "S6",
|
||||
"generation": 1,
|
||||
"beganAt": "2026-09-21T02:24:32+00:00"
|
||||
}
|
||||
],
|
||||
"revision": 23,
|
||||
"updatedAt": "2026-09-21T02:24:32+00:00",
|
||||
"executionEnvironment": {
|
||||
"mode": "current-remediation-contract-replay",
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"agentToolAvailable": false,
|
||||
"note": "별도 subagent 실행 도구가 현재 환경에 없어 현재 세션이 각 .claude/agents 역할 정의와 연결된 SKILL/contract를 직접 읽고 역할을 분리해 수행했다. runBy는 계약 역할명이며 별도 프로세스 실행을 주장하지 않는다."
|
||||
}
|
||||
}
|
||||
+142
@@ -0,0 +1,142 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"authority": "deterministic",
|
||||
"gate": "command-pedagogy-signals",
|
||||
"section_id": "docs/virtualization/tech-log-studio/build-completion-judgment/case/case-cloud-init-failures-all-look-like-ssh-refused.md",
|
||||
"mode": "reference",
|
||||
"source_sha256": "1385070d8fd0ac929ff29d0ca47f20f21f34afe6b84d624ecc2c67bb891b4e7f",
|
||||
"result": "WARN",
|
||||
"requires_editor": true,
|
||||
"blocks": [
|
||||
{
|
||||
"id": "command-001",
|
||||
"ordinal": 1,
|
||||
"language": "bash",
|
||||
"info_string": "",
|
||||
"label": null,
|
||||
"mode": "reference",
|
||||
"mode_explicit": false,
|
||||
"start": 3596,
|
||||
"end": 4000,
|
||||
"source_sha256": "bdc6de989bb66a402efd5a02d452493367df00d8ae6a670b5ef282bd041f96af",
|
||||
"body_sha256": "1eacd95e68672b219f9da681d3f60e1633d929e4c91fb9de427e9f6980b3bcf0",
|
||||
"source": "```bash\nprintf 'instance-id: kc-lab-1-%s\\nlocal-hostname: kc-lab-1\\n' \"$(date +%s)\" > meta-kc-lab-1\n\nxorrisofs -quiet -output seed-kc-lab-1.iso -volid CIDATA -joliet -rock \\\n -graft-points /user-data=kc-lab-1.yaml /meta-data=meta-kc-lab-1\n\nvirsh vol-create-as default seed-kc-lab-1.iso \"$(stat -c%s seed-kc-lab-1.iso)\" --format raw\nvirsh vol-upload --pool default seed-kc-lab-1.iso seed-kc-lab-1.iso\n```",
|
||||
"body": "printf 'instance-id: kc-lab-1-%s\\nlocal-hostname: kc-lab-1\\n' \"$(date +%s)\" > meta-kc-lab-1\n\nxorrisofs -quiet -output seed-kc-lab-1.iso -volid CIDATA -joliet -rock \\\n -graft-points /user-data=kc-lab-1.yaml /meta-data=meta-kc-lab-1\n\nvirsh vol-create-as default seed-kc-lab-1.iso \"$(stat -c%s seed-kc-lab-1.iso)\" --format raw\nvirsh vol-upload --pool default seed-kc-lab-1.iso seed-kc-lab-1.iso\n"
|
||||
},
|
||||
{
|
||||
"id": "command-002",
|
||||
"ordinal": 2,
|
||||
"language": "bash",
|
||||
"info_string": "",
|
||||
"label": null,
|
||||
"mode": "reference",
|
||||
"mode_explicit": false,
|
||||
"start": 4757,
|
||||
"end": 5068,
|
||||
"source_sha256": "803c1ab0252462b528ca2c3cbd8b75a8092c341fdd5164105c62044de6769214",
|
||||
"body_sha256": "730949296b862deaccf42f56ff4a7a57606ac80caa1a3a58844b71c97cc0098b",
|
||||
"source": "```bash\nvirt-install --name kc-lab-1 --memory 5120 --vcpus 2 \\\n --disk size=20,backing_store=/var/lib/libvirt/images/base.qcow2 \\\n --disk vol=default/seed-kc-lab-1.iso,device=disk,bus=virtio,readonly=on \\\n --network network=default,mac=52:54:00:aa:bb:11 \\\n --import --os-variant debian12 --noautoconsole\n```",
|
||||
"body": "virt-install --name kc-lab-1 --memory 5120 --vcpus 2 \\\n --disk size=20,backing_store=/var/lib/libvirt/images/base.qcow2 \\\n --disk vol=default/seed-kc-lab-1.iso,device=disk,bus=virtio,readonly=on \\\n --network network=default,mac=52:54:00:aa:bb:11 \\\n --import --os-variant debian12 --noautoconsole\n"
|
||||
},
|
||||
{
|
||||
"id": "command-003",
|
||||
"ordinal": 3,
|
||||
"language": "bash",
|
||||
"info_string": "",
|
||||
"label": null,
|
||||
"mode": "reference",
|
||||
"mode_explicit": false,
|
||||
"start": 5278,
|
||||
"end": 5363,
|
||||
"source_sha256": "4161c7dc1caf84c64102828a83fdafc2c2eb7a1c25d04e704108b633e22e515a",
|
||||
"body_sha256": "cb02df91a3396e5f570131fecf0d87c895e694a0e865a0cfa10632230224b2aa",
|
||||
"source": "```bash\nssh kc-lab-edge 'hostname; ip -4 -br addr show enp1s0; cloud-init status'\n```",
|
||||
"body": "ssh kc-lab-edge 'hostname; ip -4 -br addr show enp1s0; cloud-init status'\n"
|
||||
},
|
||||
{
|
||||
"id": "command-004",
|
||||
"ordinal": 4,
|
||||
"language": "bash",
|
||||
"info_string": "",
|
||||
"label": null,
|
||||
"mode": "reference",
|
||||
"mode_explicit": false,
|
||||
"start": 5671,
|
||||
"end": 5747,
|
||||
"source_sha256": "06a21ca14f841ba9fdc4d546e21742fc530f98d0851b70f78a9c664dfcf0f4f1",
|
||||
"body_sha256": "9864270918a37847211f71b749650942978f66a85d028a9eaa5f664957c12d7f",
|
||||
"source": "```bash\nvirsh screenshot kc-lab-1 /tmp/kc1.ppm # 확장자와 무관하게 PNG 로 저장된다\n```",
|
||||
"body": "virsh screenshot kc-lab-1 /tmp/kc1.ppm # 확장자와 무관하게 PNG 로 저장된다\n"
|
||||
},
|
||||
{
|
||||
"id": "command-005",
|
||||
"ordinal": 5,
|
||||
"language": "bash",
|
||||
"info_string": "",
|
||||
"label": null,
|
||||
"mode": "reference",
|
||||
"mode_explicit": false,
|
||||
"start": 6822,
|
||||
"end": 7021,
|
||||
"source_sha256": "ee462cc3b45493cc41a14acebe05597086cb2239a9c591a16571eeffb207c753",
|
||||
"body_sha256": "f5b201c2a5bcead8d7dccf57ca1b606e46adfdb49a3960a97b0351aa59ea7ea2",
|
||||
"source": "```bash\ngrep -c '__' kc-lab-1.yaml # 0 이어야 한다\ngrep -c 'ssh-ed25519\\|ssh-rsa' kc-lab-1.yaml # 2 여야 한다\npython3 -c 'import yaml,sys; yaml.safe_load(open(\"kc-lab-1.yaml\")); print(\"YAML OK\")'\n```",
|
||||
"body": "grep -c '__' kc-lab-1.yaml # 0 이어야 한다\ngrep -c 'ssh-ed25519\\|ssh-rsa' kc-lab-1.yaml # 2 여야 한다\npython3 -c 'import yaml,sys; yaml.safe_load(open(\"kc-lab-1.yaml\")); print(\"YAML OK\")'\n"
|
||||
},
|
||||
{
|
||||
"id": "command-006",
|
||||
"ordinal": 6,
|
||||
"language": "bash",
|
||||
"info_string": "label=\"[lab host] [reference] historical command\"",
|
||||
"label": "[lab host] [reference] historical command",
|
||||
"mode": "reference",
|
||||
"mode_explicit": true,
|
||||
"start": 7192,
|
||||
"end": 7425,
|
||||
"source_sha256": "cc6e1a5eb0abfa7ce26a71be38401c228c39e83b179422a7b0089fcc5b68bbe5",
|
||||
"body_sha256": "750670d72b498faf8522953d1d0e6c14bf7845c0dca7366dd963f9f5b3e88c01",
|
||||
"source": "```bash label=\"[lab host] [reference] historical command\"\nssh donghyeon@192.168.122.11 'umask 077; cat > ~/kc-lab-2.yaml' < kc-lab-2.yaml\nssh donghyeon@192.168.122.11 'cloud-init schema -c ~/kc-lab-2.yaml; rm -f ~/kc-lab-2.yaml'\n```",
|
||||
"body": "ssh donghyeon@192.168.122.11 'umask 077; cat > ~/kc-lab-2.yaml' < kc-lab-2.yaml\nssh donghyeon@192.168.122.11 'cloud-init schema -c ~/kc-lab-2.yaml; rm -f ~/kc-lab-2.yaml'\n"
|
||||
}
|
||||
],
|
||||
"findings": [
|
||||
{
|
||||
"block_id": "command-003",
|
||||
"code": "compound-remote-shell",
|
||||
"severity": "minor",
|
||||
"evidence": "ssh kc-lab-edge 'hostname; ip -4 -br addr show enp1s0; cloud-init status'",
|
||||
"instruction": "복합 원격 셸은 검토 신호다. 읽기 전용 확인이나 검증 성공 뒤 변경하는 안전 조건이면 유지할 수 있고, 서로 다른 효과를 숨기면 관찰 가능한 단계로 분리한다."
|
||||
},
|
||||
{
|
||||
"block_id": "command-006",
|
||||
"code": "raw-ssh-ip",
|
||||
"severity": "minor",
|
||||
"evidence": "ssh donghyeon@192.168.122.11",
|
||||
"instruction": "같은 관리 호스트를 반복해서 쓴다면 SSH alias를 먼저 정의하고 주 흐름에서는 alias를 사용한다."
|
||||
},
|
||||
{
|
||||
"block_id": "command-006",
|
||||
"code": "compound-remote-shell",
|
||||
"severity": "minor",
|
||||
"evidence": "ssh donghyeon@192.168.122.11 'umask 077; cat > ~/kc-lab-2.yaml'",
|
||||
"instruction": "복합 원격 셸은 검토 신호다. 읽기 전용 확인이나 검증 성공 뒤 변경하는 안전 조건이면 유지할 수 있고, 서로 다른 효과를 숨기면 관찰 가능한 단계로 분리한다."
|
||||
},
|
||||
{
|
||||
"block_id": "command-006",
|
||||
"code": "cleanup-chained-with-verification",
|
||||
"severity": "minor",
|
||||
"evidence": "ssh donghyeon@192.168.122.11 'cloud-init schema -c ~/kc-lab-2.yaml; rm",
|
||||
"instruction": "reference block이면 위험한 historical form을 그대로 보존하되 따라 하는 절차와 분리해 설명한다. 실행 절차라면 검증과 파괴적 정리를 별도 명령으로 분리해 삭제 전에 실패를 관찰할 수 있게 한다."
|
||||
},
|
||||
{
|
||||
"block_id": "command-006",
|
||||
"code": "repeated-raw-ssh-host",
|
||||
"severity": "minor",
|
||||
"evidence": "192.168.122.11 repeated 2 times",
|
||||
"instruction": "같은 관리 호스트를 반복하면 SSH alias를 사전 설정으로 둘 수 있는지 검토한다."
|
||||
}
|
||||
],
|
||||
"extensions": {
|
||||
"command_like_text_blocks": []
|
||||
}
|
||||
}
|
||||
+201
@@ -0,0 +1,201 @@
|
||||
---
|
||||
kind: CASE
|
||||
slug: cloud-init-failures-all-look-like-ssh-refused
|
||||
title: cloud-init 이 안 도는 원인은 넷인데 증상은 「SSH 가 안 붙는다」 하나였다
|
||||
topic: build-completion-judgment
|
||||
topicName: 끝났다는 판정
|
||||
project: virtualization
|
||||
status: 게시 전
|
||||
sourceRevision: import-head 9465582b5d1630eb4ae7c4e078021486919bf6b6 · uncommitted-working-tree snapshot
|
||||
evidence:
|
||||
- ../../../final/evidence/raw/lab-state-before-rebuild/14-cloudinit-schema-check.txt
|
||||
- ../../../final/evidence/raw/lab-state-before-rebuild/15-k3s-precheck.txt
|
||||
source:
|
||||
- final/document.md#187-단계-01-게스트-세-대
|
||||
- final/document.md#186-단계-00-lab-host-가상화-준비
|
||||
---
|
||||
|
||||
# cloud-init 이 안 도는 원인은 넷인데 증상은 「SSH 가 안 붙는다」 하나였다
|
||||
|
||||
SSH 가 안 붙는 원인은 넷이고 넷 다 게스트 밖에서 정해진다. 그래서 SSH 설정을 고치기 전에 호스트명 한 낱말을 본다. 시드를 붙인 방식, YAML 파싱, vol-upload 누락, 가상 네트워크 autostart 가 그 넷이고, cloud-init 은 어느 쪽이든 오류를 남기지 않는다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **빈 토큰이 조용히 흘러갔다 — 설치 출력은 성공이었고 agent 만 5초마다 다시 죽었다**
|
||||
바로 다음 단계에서 벌어진 같은 모양의 실패이고, 거기서도 성공한 출력이 실패를 덮었다.
|
||||
- **도구가 낸 출력은 대상의 상태가 아니다 — 그 명령이 무엇을 세는지부터 가른다**
|
||||
스키마 검사기의 통과와 거부를 게스트의 상태로 읽으면 양쪽 방향으로 다 틀린다.
|
||||
- **가이드대로 다시 쳐서 이 실험대가 같은 상태로 서는가**
|
||||
게스트를 만드는 명령은 다시 쳐 본 적이 없어 여기 적은 원인 넷도 그 물음에 걸린다.
|
||||
- **qcow2 파일 한 장이 담는 것 — 매핑표와 데이터 클러스터가 같은 파일 안에 있고, 파일 밖을 가리키는 것은 백킹 파일 경로 하나다**
|
||||
게스트 디스크가 base 이미지 위의 오버레이라 `virt-install` 이 10GB 를 즉시 할당한 것으로 찍힌다.
|
||||
- **단계별 구축 문서는 작성 시점이 아니라 실행 순서로 검증한다**
|
||||
시드를 만드는 순서와 DHCP 예약을 넣는 순서가 결과를 가르는 단계라 그 규칙이 받는다.
|
||||
|
||||
## 문제
|
||||
|
||||
게스트 세 대가 running 인데 lab host 에서 SSH 가 키로 붙지 않는다.
|
||||
|
||||
virsh list --all : kc-lab-1 · kc-lab-2 · kc-lab-edge 모두 running
|
||||
ssh donghyeon@192.168.122.11 : Permission denied (publickey)
|
||||
게스트의 hostname : localhost
|
||||
cloud-init 이 남긴 오류 : x
|
||||
|
||||
cloud-init 이 돌지 않으면 사용자도 SSH 키도 들어가지 않는다. 그런데 cloud-init 은 데이터소스를 못 찾아도 YAML 파싱에 실패해도 오류를 남기지 않아서, 원인 넷이 전부 SSH 하나로만 나타난다.
|
||||
|
||||
## 결론
|
||||
|
||||
증상 하나 뒤에 원인이 넷이고 넷 다 게스트 밖에서 정해진다.
|
||||
|
||||
시드를 --cloud-init 으로 붙였다 : SATA CD-ROM 으로 붙는데 Debian genericcloud 이미지에는 그 드라이버가 없다. 디스크로, bus=virtio 로 붙인다
|
||||
YAML 파싱에 실패했다 : cloud-init 이 아무 오류를 남기지 않는다
|
||||
vol-upload 를 빠뜨렸다 : 목록에는 이름이 보이는데 안이 0 으로 채워져 있어 cidata 라벨을 못 찾는다
|
||||
default 네트워크의 autostart 가 no 다 : 지금은 되고 호스트를 재부팅한 다음에야 세 게스트의 SSH 가 한꺼번에 실패한다
|
||||
|
||||
판정 : SSH 설정을 고치기 전에 호스트명 한 낱말을 본다. ssh kc-lab-edge 'hostname' 이 kc-lab-edge 를 내면 시드가 읽혔고, 시드가 읽혔으면 같은 파일에 있던 SSH 키도 들어갔다
|
||||
못 들어가면 : virsh screenshot 으로 화면을 떠서 로그인 프롬프트 앞의 호스트명을 읽는다
|
||||
키가 안 들어갔을 때의 탈출구 : cloud-init 의 plain_text_passwd 로 콘솔 로그인
|
||||
|
||||
반대 방향도 하나 있다. 게스트의 cloud-init 22.4.2 스키마 검사기는 sudo 를 리스트로 쓴 것을 거부한다. 그런데 그 표기로도 부팅은 되고, kc-lab-1 과 kc-lab-2 에서 그 상태로 NOPASSWD sudo 가 돌고 있다. 검사가 통과해도 안 도는 쪽이 넷이고 검사에 걸려도 도는 쪽이 하나라, 어느 방향이든 검사 결과를 게스트의 상태로 읽으면 틀린다.
|
||||
|
||||
## 검증 환경
|
||||
|
||||
호스트 : test-server, Arch Linux
|
||||
RAM : 11,648MiB
|
||||
QEMU : 11.1.1
|
||||
libvirt : 12.7.0
|
||||
base 이미지 : Debian 12 genericcloud amd64
|
||||
게스트 OS : Debian GNU/Linux 12 (bookworm)
|
||||
게스트 : kc-lab-edge 192.168.122.10 · kc-lab-1 192.168.122.11 · kc-lab-2 192.168.122.12
|
||||
시드 : seed-<이름>.iso, CIDATA 라벨, bus=virtio 로 붙임
|
||||
게스트의 cloud-init : 22.4.2
|
||||
cloud-init 이 깐 패키지 : curl · nftables
|
||||
running 에서 done 까지 : 약 50초
|
||||
|
||||
## 재현 조건
|
||||
|
||||
1. base 이미지를 받고 게스트마다 cloud-init user-data 를 쓴다.
|
||||
2. 시드 iso 를 만들고 virsh vol-create-as 로 볼륨을 만든 뒤 virsh vol-upload 로 채운다.
|
||||
3. DHCP 예약을 먼저 넣고 virt-install 로 게스트를 만든다. 시드는 bus=virtio 로 붙인다.
|
||||
4. virsh list --all 로 세 게스트가 running 인지 본다.
|
||||
5. lab host 에서 ssh kc-lab-edge 'hostname; ip -4 -br addr show enp1s0; cloud-init status' 를 친다.
|
||||
6. 호스트명이 게스트 이름이고 cloud-init status 가 done 이면 통과다. localhost 가 나오면 2번과 3번을 다시 본다.
|
||||
7. SSH 가 안 붙으면 virsh screenshot kc-lab-1 /tmp/kc1.ppm 으로 화면을 떠서 로그인 프롬프트 앞의 호스트명을 읽는다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
## 시드가 게스트에 들어가는 길
|
||||
|
||||
cloud-init 이 게스트 안에서 사용자를 만들고 SSH 키를 넣으려면 먼저 데이터소스를 찾아야 한다. 이 실험대는 그것을 `cidata` 라벨이 붙은 볼륨으로 준다. 그 안에는 `user-data` 와 `meta-data` 라는 정확한 이름의 파일 둘이 들어 있다.
|
||||
|
||||
```bash
|
||||
printf 'instance-id: kc-lab-1-%s\nlocal-hostname: kc-lab-1\n' "$(date +%s)" > meta-kc-lab-1
|
||||
|
||||
xorrisofs -quiet -output seed-kc-lab-1.iso -volid CIDATA -joliet -rock \
|
||||
-graft-points /user-data=kc-lab-1.yaml /meta-data=meta-kc-lab-1
|
||||
|
||||
virsh vol-create-as default seed-kc-lab-1.iso "$(stat -c%s seed-kc-lab-1.iso)" --format raw
|
||||
virsh vol-upload --pool default seed-kc-lab-1.iso seed-kc-lab-1.iso
|
||||
```
|
||||
|
||||
볼륨을 만드는 것과 채우는 것이 다른 명령이다. `vol-create-as` 는 빈 볼륨을 만들 뿐이고 내용은 `vol-upload` 가 넣는다. 뒤엣것을 빠뜨리면 목록에는 이름이 보이는데 안이 0 으로 채워져 있고, cloud-init 은 `cidata` 라벨을 못 찾은 채 끝난다.
|
||||
|
||||
`instance-id` 에 타임스탬프를 넣는 것은 cloud-init 이 인스턴스마다 한 번만 초기화 모듈을 돌리기 때문이다. id 가 같으면 user-data 를 고쳐도 반영되지 않는다.
|
||||
|
||||
## 증상 하나에 원인 넷
|
||||
|
||||
| 무엇이 어긋났나 | 게스트에서 무엇으로 나타나나 |
|
||||
|---|---|
|
||||
| 시드를 `--cloud-init` 으로 붙였다 | SSH 가 `Permission denied (publickey)` · 호스트명이 `localhost` |
|
||||
| YAML 파싱에 실패했다 | 같다 |
|
||||
| `vol-upload` 를 빠뜨렸다 | 같다 |
|
||||
| `default` 네트워크의 autostart 가 `no` 다 | 호스트를 재부팅한 뒤 세 게스트가 한꺼번에 |
|
||||
|
||||
넷 가운데 이 실험대에서 관측으로 적힌 것은 시드를 붙인 방식이다. `--cloud-init` 옵션은 시드를 SATA CD-ROM 으로 붙이는데, Debian `genericcloud` 이미지는 크기를 줄이려고 물리 하드웨어 드라이버를 뺐다. AHCI 장치가 보이지 않아 cloud-init 이 데이터소스를 못 찾고 조용히 끝난다. 그래서 시드를 디스크로, `bus=virtio` 로 붙인다.
|
||||
|
||||
```bash
|
||||
virt-install --name kc-lab-1 --memory 5120 --vcpus 2 \
|
||||
--disk size=20,backing_store=/var/lib/libvirt/images/base.qcow2 \
|
||||
--disk vol=default/seed-kc-lab-1.iso,device=disk,bus=virtio,readonly=on \
|
||||
--network network=default,mac=52:54:00:aa:bb:11 \
|
||||
--import --os-variant debian12 --noautoconsole
|
||||
```
|
||||
|
||||
마지막 원인은 한 단계 앞에서 만들어진다. lab host 를 준비할 때 `default` 네트워크의 autostart 를 켜지 않으면 지금은 세 게스트가 다 붙고, 호스트를 재부팅한 다음에야 SSH 가 한꺼번에 실패한다. 그때 원인을 게스트 안에서 찾게 된다.
|
||||
|
||||
## 호스트명 한 낱말이 시드와 SSH 를 가른다
|
||||
|
||||
게스트에 SSH 가 붙는다면 확인은 한 줄로 끝난다.
|
||||
|
||||
```bash
|
||||
ssh kc-lab-edge 'hostname; ip -4 -br addr show enp1s0; cloud-init status'
|
||||
```
|
||||
|
||||
```
|
||||
kc-lab-edge
|
||||
enp1s0 UP 192.168.122.10/24 metric 100
|
||||
status: done
|
||||
```
|
||||
|
||||
호스트명이 바뀌었다는 것은 시드가 읽혔다는 뜻이고, 시드가 읽혔으면 같은 파일에 있던 SSH 키도 들어갔다. `localhost` 가 나오면 SSH 설정을 고치지 말고 시드부터 의심한다. `cloud-init status` 가 `running` 이면 아직 패키지를 받는 중이고, 이 실험대에서는 `done` 까지 약 50초 걸렸다.
|
||||
|
||||
게스트에 못 들어가면 화면을 직접 뜬다.
|
||||
|
||||
```bash
|
||||
virsh screenshot kc-lab-1 /tmp/kc1.ppm # 확장자와 무관하게 PNG 로 저장된다
|
||||
```
|
||||
|
||||
로그인 프롬프트 앞의 호스트명 한 낱말만 읽는다. `localhost login:` 이면 cloud-init 이 아예 안 돌았으므로 SSH 쪽은 볼 것이 없다. `kc-lab-1 login:` 이면 cloud-init 은 돌았고 그 안의 사용자·키 단계에서 틀린 것이라 콘솔로 들어가 로그를 본다. 콘솔 로그인에 쓰는 비밀번호가 cloud-init 의 `plain_text_passwd` 이고, 키가 안 들어갔을 때 게스트로 들어가는 길이 이것 하나다.
|
||||
|
||||
순서를 이렇게 정한 까닭이 여기에 있다. 화면 한 장과 콘솔에서 보는 로그 두 줄이 「SSH 가 안 되는 이유」를 절반으로 줄인다. 호스트명이 그 절반을 가르므로, 어느 쪽 절반을 볼지가 정해지기 전에는 SSH 설정을 열 이유가 없다.
|
||||
|
||||
## 검사가 거부해도 부팅은 된다
|
||||
|
||||
게스트의 cloud-init `22.4.2` 스키마 검사기는 `sudo` 를 리스트로 쓴 것을 거부한다.
|
||||
|
||||
```yaml
|
||||
sudo: ['ALL=(ALL) NOPASSWD:ALL'] # 거부된다
|
||||
sudo: "ALL=(ALL) NOPASSWD:ALL" # 통과한다
|
||||
```
|
||||
|
||||
```
|
||||
Error: Cloud config schema errors: users.0: {'name': 'donghyeon', 'groups':
|
||||
['sudo'], 'shell': '/bin/bash', ...} is not valid under any of the given schemas
|
||||
```
|
||||
|
||||
어느 키가 문제인지는 알려 주지 않는다. `users.0` 전체를 통째로 찍고 어느 스키마에도 안 맞는다고만 한다. 그리고 리스트 표기로도 부팅은 된다 — `kc-lab-1` 과 `kc-lab-2` 가 그 표기로 만들어졌고 NOPASSWD sudo 가 멀쩡히 돌고 있다.
|
||||
|
||||
`NOPASSWD:ALL` 을 넣은 것은 k3s 설치와 장애 주입이 비대화식으로 돌아야 해서다. 비밀번호를 물으면 원격 실행이 거기서 멈춘다. 검사기가 거부한 줄이 바로 그 줄이다.
|
||||
|
||||
검사가 통과해도 cloud-init 이 안 도는 경로가 넷이고, 검사가 거부해도 도는 경로가 하나다.
|
||||
|
||||
## 시드를 만들기 전에 세 줄로 거른다
|
||||
|
||||
```bash
|
||||
grep -c '__' kc-lab-1.yaml # 0 이어야 한다
|
||||
grep -c 'ssh-ed25519\|ssh-rsa' kc-lab-1.yaml # 2 여야 한다
|
||||
python3 -c 'import yaml,sys; yaml.safe_load(open("kc-lab-1.yaml")); print("YAML OK")'
|
||||
```
|
||||
|
||||
`0` 과 `2` 와 `YAML OK` 셋이 맞아야 시드를 만든다. 셋이 맞아도 cloud-config 로 유효하지는 않다 — 키 이름을 `users` 대신 `user` 로 친 오타는 이 세 줄을 그냥 통과한다. 그래서 게스트가 한 대라도 떠 있으면 cloud-init 자신의 스키마 검사기를 쓴다.
|
||||
|
||||
```bash label="[lab host] [reference] historical command"
|
||||
ssh donghyeon@192.168.122.11 'umask 077; cat > ~/kc-lab-2.yaml' < kc-lab-2.yaml
|
||||
ssh donghyeon@192.168.122.11 'cloud-init schema -c ~/kc-lab-2.yaml; rm -f ~/kc-lab-2.yaml'
|
||||
```
|
||||
|
||||
이 두 줄은 **당시 문서에 남은 historical command**다. 둘째 줄은 스키마 검사가 실패해도 `; rm` 때문에 파일을 지운다. 따라 하는 절차에서는 검사와 삭제를 분리하고, 검사 성공 여부를 본 뒤 삭제한다.
|
||||
|
||||
이 파일에는 콘솔 비밀번호가 평문으로 들어 있어 `/tmp` 가 아니라 자기 홈에 `600` 으로 두고, 검사가 끝나면 바로 지운다.
|
||||
|
||||
## 확인하지 못한 것
|
||||
|
||||
넷 가운데 SSOT 가 관측으로 표시한 것은 시드를 SATA 로 붙였을 때 AHCI 장치가 보이지 않는 것과 스키마 검사기의 거부 문구뿐이다. YAML 파싱 실패와 `vol-upload` 누락과 네트워크 autostart 셋은 가이드가 막히면 표에 적어 둔 항목이라, 이 실험대에서 실제로 그 증상을 본 것인지 예상해 적은 것인지 SSOT 가 가르지 않았다. 이 글에서 그 셋은 그렇게 되는 구조까지이고 그렇게 됐다가 아니다.
|
||||
|
||||
후속 원문이 생겼다. `14-cloudinit-schema-check.txt` 는 cloud-init 22.4.2가 `sudo` 리스트 표기를 실제로 거부한 출력을 다시 담아 **스키마 거부 claim을 직접 보강한다**. `15-k3s-precheck.txt` 의 `Host key verification failed.` 는 2026-09-17 재구축 중 나온 후속 SSH 관측으로, 원래 사건의 `Permission denied (publickey)`를 재현한 것은 아니다. 원래 `Permission denied (publickey)`, `cloud-init status: done`, 약 50초, `virsh screenshot` 화면은 여전히 SSOT 본문만 근거이고 직접 raw는 없다.
|
||||
|
||||
만드는 명령은 재실행으로 검증되지 않았다. 게스트를 다시 만들면 돌고 있는 실험대가 없어지므로 시드와 관련된 셋은 다시 재현하기 어렵다. 네트워크 autostart 만은 호스트를 재부팅해 확인할 수 있는데 그 기록도 없다.
|
||||
|
||||
<!-- body:end -->
|
||||
+201
@@ -0,0 +1,201 @@
|
||||
---
|
||||
kind: CASE
|
||||
slug: cloud-init-failures-all-look-like-ssh-refused
|
||||
title: cloud-init 이 안 도는 원인은 넷인데 증상은 「SSH 가 안 붙는다」 하나였다
|
||||
topic: build-completion-judgment
|
||||
topicName: 끝났다는 판정
|
||||
project: virtualization
|
||||
status: 게시 전
|
||||
sourceRevision: import-head 9465582b5d1630eb4ae7c4e078021486919bf6b6 · uncommitted-working-tree snapshot
|
||||
evidence:
|
||||
- ../../../final/evidence/raw/lab-state-before-rebuild/14-cloudinit-schema-check.txt
|
||||
- ../../../final/evidence/raw/lab-state-before-rebuild/15-k3s-precheck.txt
|
||||
source:
|
||||
- final/document.md#187-단계-01-게스트-세-대
|
||||
- final/document.md#186-단계-00-lab-host-가상화-준비
|
||||
---
|
||||
|
||||
# cloud-init 이 안 도는 원인은 넷인데 증상은 「SSH 가 안 붙는다」 하나였다
|
||||
|
||||
SSH 가 안 붙는 원인은 넷이고 넷 다 게스트 밖에서 정해진다. 그래서 SSH 설정을 고치기 전에 호스트명 한 낱말을 본다. 시드를 붙인 방식, YAML 파싱, vol-upload 누락, 가상 네트워크 autostart 가 그 넷이고, cloud-init 은 어느 쪽이든 오류를 남기지 않는다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **빈 토큰이 조용히 흘러갔다 — 설치 출력은 성공이었고 agent 만 5초마다 다시 죽었다**
|
||||
바로 다음 단계에서 벌어진 같은 모양의 실패이고, 거기서도 성공한 출력이 실패를 덮었다.
|
||||
- **도구가 낸 출력은 대상의 상태가 아니다 — 그 명령이 무엇을 세는지부터 가른다**
|
||||
스키마 검사기의 통과와 거부를 게스트의 상태로 읽으면 양쪽 방향으로 다 틀린다.
|
||||
- **가이드대로 다시 쳐서 이 실험대가 같은 상태로 서는가**
|
||||
게스트를 만드는 명령은 다시 쳐 본 적이 없어 여기 적은 원인 넷도 그 물음에 걸린다.
|
||||
- **qcow2 파일 한 장이 담는 것 — 매핑표와 데이터 클러스터가 같은 파일 안에 있고, 파일 밖을 가리키는 것은 백킹 파일 경로 하나다**
|
||||
게스트 디스크가 base 이미지 위의 오버레이라 `virt-install` 이 10GB 를 즉시 할당한 것으로 찍힌다.
|
||||
- **단계별 구축 문서는 작성 시점이 아니라 실행 순서로 검증한다**
|
||||
시드를 만드는 순서와 DHCP 예약을 넣는 순서가 결과를 가르는 단계라 그 규칙이 받는다.
|
||||
|
||||
## 문제
|
||||
|
||||
게스트 세 대가 running 인데 lab host 에서 SSH 가 키로 붙지 않는다.
|
||||
|
||||
virsh list --all : kc-lab-1 · kc-lab-2 · kc-lab-edge 모두 running
|
||||
ssh donghyeon@192.168.122.11 : Permission denied (publickey)
|
||||
게스트의 hostname : localhost
|
||||
cloud-init 이 남긴 오류 : x
|
||||
|
||||
cloud-init 이 돌지 않으면 사용자도 SSH 키도 들어가지 않는다. 그런데 cloud-init 은 데이터소스를 못 찾아도 YAML 파싱에 실패해도 오류를 남기지 않아서, 원인 넷이 전부 SSH 하나로만 나타난다.
|
||||
|
||||
## 결론
|
||||
|
||||
증상 하나 뒤에 원인이 넷이고 넷 다 게스트 밖에서 정해진다.
|
||||
|
||||
시드를 --cloud-init 으로 붙였다 : SATA CD-ROM 으로 붙는데 Debian genericcloud 이미지에는 그 드라이버가 없다. 디스크로, bus=virtio 로 붙인다
|
||||
YAML 파싱에 실패했다 : cloud-init 이 아무 오류를 남기지 않는다
|
||||
vol-upload 를 빠뜨렸다 : 목록에는 이름이 보이는데 안이 0 으로 채워져 있어 cidata 라벨을 못 찾는다
|
||||
default 네트워크의 autostart 가 no 다 : 지금은 되고 호스트를 재부팅한 다음에야 세 게스트의 SSH 가 한꺼번에 실패한다
|
||||
|
||||
판정 : SSH 설정을 고치기 전에 호스트명 한 낱말을 본다. ssh kc-lab-edge 'hostname' 이 kc-lab-edge 를 내면 시드가 읽혔고, 시드가 읽혔으면 같은 파일에 있던 SSH 키도 들어갔다
|
||||
못 들어가면 : virsh screenshot 으로 화면을 떠서 로그인 프롬프트 앞의 호스트명을 읽는다
|
||||
키가 안 들어갔을 때의 탈출구 : cloud-init 의 plain_text_passwd 로 콘솔 로그인
|
||||
|
||||
반대 방향도 하나 있다. 게스트의 cloud-init 22.4.2 스키마 검사기는 sudo 를 리스트로 쓴 것을 거부한다. 그런데 그 표기로도 부팅은 되고, kc-lab-1 과 kc-lab-2 에서 그 상태로 NOPASSWD sudo 가 돌고 있다. 검사가 통과해도 안 도는 쪽이 넷이고 검사에 걸려도 도는 쪽이 하나라, 어느 방향이든 검사 결과를 게스트의 상태로 읽으면 틀린다.
|
||||
|
||||
## 검증 환경
|
||||
|
||||
호스트 : test-server, Arch Linux
|
||||
RAM : 11,648MiB
|
||||
QEMU : 11.1.1
|
||||
libvirt : 12.7.0
|
||||
base 이미지 : Debian 12 genericcloud amd64
|
||||
게스트 OS : Debian GNU/Linux 12 (bookworm)
|
||||
게스트 : kc-lab-edge 192.168.122.10 · kc-lab-1 192.168.122.11 · kc-lab-2 192.168.122.12
|
||||
시드 : seed-<이름>.iso, CIDATA 라벨, bus=virtio 로 붙임
|
||||
게스트의 cloud-init : 22.4.2
|
||||
cloud-init 이 깐 패키지 : curl · nftables
|
||||
running 에서 done 까지 : 약 50초
|
||||
|
||||
## 재현 조건
|
||||
|
||||
1. base 이미지를 받고 게스트마다 cloud-init user-data 를 쓴다.
|
||||
2. 시드 iso 를 만들고 virsh vol-create-as 로 볼륨을 만든 뒤 virsh vol-upload 로 채운다.
|
||||
3. DHCP 예약을 먼저 넣고 virt-install 로 게스트를 만든다. 시드는 bus=virtio 로 붙인다.
|
||||
4. virsh list --all 로 세 게스트가 running 인지 본다.
|
||||
5. lab host 에서 ssh kc-lab-edge 'hostname; ip -4 -br addr show enp1s0; cloud-init status' 를 친다.
|
||||
6. 호스트명이 게스트 이름이고 cloud-init status 가 done 이면 통과다. localhost 가 나오면 2번과 3번을 다시 본다.
|
||||
7. SSH 가 안 붙으면 virsh screenshot kc-lab-1 /tmp/kc1.ppm 으로 화면을 떠서 로그인 프롬프트 앞의 호스트명을 읽는다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
## 시드가 게스트에 들어가는 길
|
||||
|
||||
cloud-init 이 게스트 안에서 사용자를 만들고 SSH 키를 넣으려면 먼저 데이터소스를 찾아야 한다. 이 실험대는 그것을 `cidata` 라벨이 붙은 볼륨으로 준다. 그 안에는 `user-data` 와 `meta-data` 라는 정확한 이름의 파일 둘이 들어 있다.
|
||||
|
||||
```bash
|
||||
printf 'instance-id: kc-lab-1-%s\nlocal-hostname: kc-lab-1\n' "$(date +%s)" > meta-kc-lab-1
|
||||
|
||||
xorrisofs -quiet -output seed-kc-lab-1.iso -volid CIDATA -joliet -rock \
|
||||
-graft-points /user-data=kc-lab-1.yaml /meta-data=meta-kc-lab-1
|
||||
|
||||
virsh vol-create-as default seed-kc-lab-1.iso "$(stat -c%s seed-kc-lab-1.iso)" --format raw
|
||||
virsh vol-upload --pool default seed-kc-lab-1.iso seed-kc-lab-1.iso
|
||||
```
|
||||
|
||||
볼륨을 만드는 것과 채우는 것이 다른 명령이다. `vol-create-as` 는 빈 볼륨을 만들 뿐이고 내용은 `vol-upload` 가 넣는다. 뒤엣것을 빠뜨리면 목록에는 이름이 보이는데 안이 0 으로 채워져 있고, cloud-init 은 `cidata` 라벨을 못 찾은 채 끝난다.
|
||||
|
||||
`instance-id` 에 타임스탬프를 넣는 것은 cloud-init 이 인스턴스마다 한 번만 초기화 모듈을 돌리기 때문이다. id 가 같으면 user-data 를 고쳐도 반영되지 않는다.
|
||||
|
||||
## 증상 하나에 원인 넷
|
||||
|
||||
| 무엇이 어긋났나 | 게스트에서 무엇으로 나타나나 |
|
||||
|---|---|
|
||||
| 시드를 `--cloud-init` 으로 붙였다 | SSH 가 `Permission denied (publickey)` · 호스트명이 `localhost` |
|
||||
| YAML 파싱에 실패했다 | 같다 |
|
||||
| `vol-upload` 를 빠뜨렸다 | 같다 |
|
||||
| `default` 네트워크의 autostart 가 `no` 다 | 호스트를 재부팅한 뒤 세 게스트가 한꺼번에 |
|
||||
|
||||
넷 가운데 이 실험대에서 관측으로 적힌 것은 시드를 붙인 방식이다. `--cloud-init` 옵션은 시드를 SATA CD-ROM 으로 붙이는데, Debian `genericcloud` 이미지는 크기를 줄이려고 물리 하드웨어 드라이버를 뺐다. AHCI 장치가 보이지 않아 cloud-init 이 데이터소스를 못 찾고 조용히 끝난다. 그래서 시드를 디스크로, `bus=virtio` 로 붙인다.
|
||||
|
||||
```bash
|
||||
virt-install --name kc-lab-1 --memory 5120 --vcpus 2 \
|
||||
--disk size=20,backing_store=/var/lib/libvirt/images/base.qcow2 \
|
||||
--disk vol=default/seed-kc-lab-1.iso,device=disk,bus=virtio,readonly=on \
|
||||
--network network=default,mac=52:54:00:aa:bb:11 \
|
||||
--import --os-variant debian12 --noautoconsole
|
||||
```
|
||||
|
||||
마지막 원인은 한 단계 앞에서 만들어진다. lab host 를 준비할 때 `default` 네트워크의 autostart 를 켜지 않으면 지금은 세 게스트가 다 붙고, 호스트를 재부팅한 다음에야 SSH 가 한꺼번에 실패한다. 그때 원인을 게스트 안에서 찾게 된다.
|
||||
|
||||
## 호스트명 한 낱말이 시드와 SSH 를 가른다
|
||||
|
||||
게스트에 SSH 가 붙는다면 확인은 한 줄로 끝난다.
|
||||
|
||||
```bash
|
||||
ssh kc-lab-edge 'hostname; ip -4 -br addr show enp1s0; cloud-init status'
|
||||
```
|
||||
|
||||
```
|
||||
kc-lab-edge
|
||||
enp1s0 UP 192.168.122.10/24 metric 100
|
||||
status: done
|
||||
```
|
||||
|
||||
호스트명이 바뀌었다는 것은 시드가 읽혔다는 뜻이고, 시드가 읽혔으면 같은 파일에 있던 SSH 키도 들어갔다. `localhost` 가 나오면 SSH 설정을 고치지 말고 시드부터 의심한다. `cloud-init status` 가 `running` 이면 아직 패키지를 받는 중이고, 이 실험대에서는 `done` 까지 약 50초 걸렸다.
|
||||
|
||||
게스트에 못 들어가면 화면을 직접 뜬다.
|
||||
|
||||
```bash
|
||||
virsh screenshot kc-lab-1 /tmp/kc1.ppm # 확장자와 무관하게 PNG 로 저장된다
|
||||
```
|
||||
|
||||
로그인 프롬프트 앞의 호스트명 한 낱말만 읽는다. `localhost login:` 이면 cloud-init 이 아예 안 돌았으므로 SSH 쪽은 볼 것이 없다. `kc-lab-1 login:` 이면 cloud-init 은 돌았고 그 안의 사용자·키 단계에서 틀린 것이라 콘솔로 들어가 로그를 본다. 콘솔 로그인에 쓰는 비밀번호가 cloud-init 의 `plain_text_passwd` 이고, 키가 안 들어갔을 때 게스트로 들어가는 길이 이것 하나다.
|
||||
|
||||
순서를 이렇게 정한 까닭이 여기에 있다. 화면 한 장과 콘솔에서 보는 로그 두 줄이 「SSH 가 안 되는 이유」를 절반으로 줄인다. 호스트명이 그 절반을 가르므로, 어느 쪽 절반을 볼지가 정해지기 전에는 SSH 설정을 열 이유가 없다.
|
||||
|
||||
## 검사가 거부해도 부팅은 된다
|
||||
|
||||
게스트의 cloud-init `22.4.2` 스키마 검사기는 `sudo` 를 리스트로 쓴 것을 거부한다.
|
||||
|
||||
```yaml
|
||||
sudo: ['ALL=(ALL) NOPASSWD:ALL'] # 거부된다
|
||||
sudo: "ALL=(ALL) NOPASSWD:ALL" # 통과한다
|
||||
```
|
||||
|
||||
```
|
||||
Error: Cloud config schema errors: users.0: {'name': 'donghyeon', 'groups':
|
||||
['sudo'], 'shell': '/bin/bash', ...} is not valid under any of the given schemas
|
||||
```
|
||||
|
||||
어느 키가 문제인지는 알려 주지 않는다. `users.0` 전체를 통째로 찍고 어느 스키마에도 안 맞는다고만 한다. 그리고 리스트 표기로도 부팅은 된다 — `kc-lab-1` 과 `kc-lab-2` 가 그 표기로 만들어졌고 NOPASSWD sudo 가 멀쩡히 돌고 있다.
|
||||
|
||||
`NOPASSWD:ALL` 을 넣은 것은 k3s 설치와 장애 주입이 비대화식으로 돌아야 해서다. 비밀번호를 물으면 원격 실행이 거기서 멈춘다. 검사기가 거부한 줄이 바로 그 줄이다.
|
||||
|
||||
검사가 통과해도 cloud-init 이 안 도는 경로가 넷이고, 검사가 거부해도 도는 경로가 하나다.
|
||||
|
||||
## 시드를 만들기 전에 세 줄로 거른다
|
||||
|
||||
```bash
|
||||
grep -c '__' kc-lab-1.yaml # 0 이어야 한다
|
||||
grep -c 'ssh-ed25519\|ssh-rsa' kc-lab-1.yaml # 2 여야 한다
|
||||
python3 -c 'import yaml,sys; yaml.safe_load(open("kc-lab-1.yaml")); print("YAML OK")'
|
||||
```
|
||||
|
||||
`0` 과 `2` 와 `YAML OK` 셋이 맞아야 시드를 만든다. 셋이 맞아도 cloud-config 로 유효하지는 않다 — 키 이름을 `users` 대신 `user` 로 친 오타는 이 세 줄을 그냥 통과한다. 그래서 게스트가 한 대라도 떠 있으면 cloud-init 자신의 스키마 검사기를 쓴다.
|
||||
|
||||
```bash label="[lab host] [reference] historical command"
|
||||
ssh donghyeon@192.168.122.11 'umask 077; cat > ~/kc-lab-2.yaml' < kc-lab-2.yaml
|
||||
ssh donghyeon@192.168.122.11 'cloud-init schema -c ~/kc-lab-2.yaml; rm -f ~/kc-lab-2.yaml'
|
||||
```
|
||||
|
||||
이 두 줄은 **당시 문서에 남은 historical command**다. 둘째 줄은 스키마 검사가 실패해도 `; rm` 때문에 파일을 지운다. 따라 하는 절차에서는 검사와 삭제를 분리하고, 검사 성공 여부를 본 뒤 삭제한다.
|
||||
|
||||
이 파일에는 콘솔 비밀번호가 평문으로 들어 있어 `/tmp` 가 아니라 자기 홈에 `600` 으로 두고, 검사가 끝나면 바로 지운다.
|
||||
|
||||
## 확인하지 못한 것
|
||||
|
||||
넷 가운데 SSOT 가 관측으로 표시한 것은 시드를 SATA 로 붙였을 때 AHCI 장치가 보이지 않는 것과 스키마 검사기의 거부 문구뿐이다. YAML 파싱 실패와 `vol-upload` 누락과 네트워크 autostart 셋은 가이드가 막히면 표에 적어 둔 항목이라, 이 실험대에서 실제로 그 증상을 본 것인지 예상해 적은 것인지 SSOT 가 가르지 않았다. 이 글에서 그 셋은 그렇게 되는 구조까지이고 그렇게 됐다가 아니다.
|
||||
|
||||
후속 원문이 생겼다. `14-cloudinit-schema-check.txt` 는 cloud-init 22.4.2가 `sudo` 리스트 표기를 실제로 거부한 출력을 다시 담아 **스키마 거부 claim을 직접 보강한다**. `15-k3s-precheck.txt` 의 `Host key verification failed.` 는 2026-09-17 재구축 중 나온 후속 SSH 관측으로, 원래 사건의 `Permission denied (publickey)`를 재현한 것은 아니다. 원래 `Permission denied (publickey)`, `cloud-init status: done`, 약 50초, `virsh screenshot` 화면은 여전히 SSOT 본문만 근거이고 직접 raw는 없다.
|
||||
|
||||
만드는 명령은 재실행으로 검증되지 않았다. 게스트를 다시 만들면 돌고 있는 실험대가 없어지므로 시드와 관련된 셋은 다시 재현하기 어렵다. 네트워크 autostart 만은 호스트를 재부팅해 확인할 수 있는데 그 기록도 없다.
|
||||
|
||||
<!-- body:end -->
|
||||
+142
@@ -0,0 +1,142 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"authority": "deterministic",
|
||||
"gate": "command-pedagogy-signals",
|
||||
"section_id": "docs/virtualization/tech-log-studio/build-completion-judgment/case/case-cloud-init-failures-all-look-like-ssh-refused.md",
|
||||
"mode": "reference",
|
||||
"source_sha256": "1385070d8fd0ac929ff29d0ca47f20f21f34afe6b84d624ecc2c67bb891b4e7f",
|
||||
"result": "WARN",
|
||||
"requires_editor": true,
|
||||
"blocks": [
|
||||
{
|
||||
"id": "command-001",
|
||||
"ordinal": 1,
|
||||
"language": "bash",
|
||||
"info_string": "",
|
||||
"label": null,
|
||||
"mode": "reference",
|
||||
"mode_explicit": false,
|
||||
"start": 3596,
|
||||
"end": 4000,
|
||||
"source_sha256": "bdc6de989bb66a402efd5a02d452493367df00d8ae6a670b5ef282bd041f96af",
|
||||
"body_sha256": "1eacd95e68672b219f9da681d3f60e1633d929e4c91fb9de427e9f6980b3bcf0",
|
||||
"source": "```bash\nprintf 'instance-id: kc-lab-1-%s\\nlocal-hostname: kc-lab-1\\n' \"$(date +%s)\" > meta-kc-lab-1\n\nxorrisofs -quiet -output seed-kc-lab-1.iso -volid CIDATA -joliet -rock \\\n -graft-points /user-data=kc-lab-1.yaml /meta-data=meta-kc-lab-1\n\nvirsh vol-create-as default seed-kc-lab-1.iso \"$(stat -c%s seed-kc-lab-1.iso)\" --format raw\nvirsh vol-upload --pool default seed-kc-lab-1.iso seed-kc-lab-1.iso\n```",
|
||||
"body": "printf 'instance-id: kc-lab-1-%s\\nlocal-hostname: kc-lab-1\\n' \"$(date +%s)\" > meta-kc-lab-1\n\nxorrisofs -quiet -output seed-kc-lab-1.iso -volid CIDATA -joliet -rock \\\n -graft-points /user-data=kc-lab-1.yaml /meta-data=meta-kc-lab-1\n\nvirsh vol-create-as default seed-kc-lab-1.iso \"$(stat -c%s seed-kc-lab-1.iso)\" --format raw\nvirsh vol-upload --pool default seed-kc-lab-1.iso seed-kc-lab-1.iso\n"
|
||||
},
|
||||
{
|
||||
"id": "command-002",
|
||||
"ordinal": 2,
|
||||
"language": "bash",
|
||||
"info_string": "",
|
||||
"label": null,
|
||||
"mode": "reference",
|
||||
"mode_explicit": false,
|
||||
"start": 4757,
|
||||
"end": 5068,
|
||||
"source_sha256": "803c1ab0252462b528ca2c3cbd8b75a8092c341fdd5164105c62044de6769214",
|
||||
"body_sha256": "730949296b862deaccf42f56ff4a7a57606ac80caa1a3a58844b71c97cc0098b",
|
||||
"source": "```bash\nvirt-install --name kc-lab-1 --memory 5120 --vcpus 2 \\\n --disk size=20,backing_store=/var/lib/libvirt/images/base.qcow2 \\\n --disk vol=default/seed-kc-lab-1.iso,device=disk,bus=virtio,readonly=on \\\n --network network=default,mac=52:54:00:aa:bb:11 \\\n --import --os-variant debian12 --noautoconsole\n```",
|
||||
"body": "virt-install --name kc-lab-1 --memory 5120 --vcpus 2 \\\n --disk size=20,backing_store=/var/lib/libvirt/images/base.qcow2 \\\n --disk vol=default/seed-kc-lab-1.iso,device=disk,bus=virtio,readonly=on \\\n --network network=default,mac=52:54:00:aa:bb:11 \\\n --import --os-variant debian12 --noautoconsole\n"
|
||||
},
|
||||
{
|
||||
"id": "command-003",
|
||||
"ordinal": 3,
|
||||
"language": "bash",
|
||||
"info_string": "",
|
||||
"label": null,
|
||||
"mode": "reference",
|
||||
"mode_explicit": false,
|
||||
"start": 5278,
|
||||
"end": 5363,
|
||||
"source_sha256": "4161c7dc1caf84c64102828a83fdafc2c2eb7a1c25d04e704108b633e22e515a",
|
||||
"body_sha256": "cb02df91a3396e5f570131fecf0d87c895e694a0e865a0cfa10632230224b2aa",
|
||||
"source": "```bash\nssh kc-lab-edge 'hostname; ip -4 -br addr show enp1s0; cloud-init status'\n```",
|
||||
"body": "ssh kc-lab-edge 'hostname; ip -4 -br addr show enp1s0; cloud-init status'\n"
|
||||
},
|
||||
{
|
||||
"id": "command-004",
|
||||
"ordinal": 4,
|
||||
"language": "bash",
|
||||
"info_string": "",
|
||||
"label": null,
|
||||
"mode": "reference",
|
||||
"mode_explicit": false,
|
||||
"start": 5671,
|
||||
"end": 5747,
|
||||
"source_sha256": "06a21ca14f841ba9fdc4d546e21742fc530f98d0851b70f78a9c664dfcf0f4f1",
|
||||
"body_sha256": "9864270918a37847211f71b749650942978f66a85d028a9eaa5f664957c12d7f",
|
||||
"source": "```bash\nvirsh screenshot kc-lab-1 /tmp/kc1.ppm # 확장자와 무관하게 PNG 로 저장된다\n```",
|
||||
"body": "virsh screenshot kc-lab-1 /tmp/kc1.ppm # 확장자와 무관하게 PNG 로 저장된다\n"
|
||||
},
|
||||
{
|
||||
"id": "command-005",
|
||||
"ordinal": 5,
|
||||
"language": "bash",
|
||||
"info_string": "",
|
||||
"label": null,
|
||||
"mode": "reference",
|
||||
"mode_explicit": false,
|
||||
"start": 6822,
|
||||
"end": 7021,
|
||||
"source_sha256": "ee462cc3b45493cc41a14acebe05597086cb2239a9c591a16571eeffb207c753",
|
||||
"body_sha256": "f5b201c2a5bcead8d7dccf57ca1b606e46adfdb49a3960a97b0351aa59ea7ea2",
|
||||
"source": "```bash\ngrep -c '__' kc-lab-1.yaml # 0 이어야 한다\ngrep -c 'ssh-ed25519\\|ssh-rsa' kc-lab-1.yaml # 2 여야 한다\npython3 -c 'import yaml,sys; yaml.safe_load(open(\"kc-lab-1.yaml\")); print(\"YAML OK\")'\n```",
|
||||
"body": "grep -c '__' kc-lab-1.yaml # 0 이어야 한다\ngrep -c 'ssh-ed25519\\|ssh-rsa' kc-lab-1.yaml # 2 여야 한다\npython3 -c 'import yaml,sys; yaml.safe_load(open(\"kc-lab-1.yaml\")); print(\"YAML OK\")'\n"
|
||||
},
|
||||
{
|
||||
"id": "command-006",
|
||||
"ordinal": 6,
|
||||
"language": "bash",
|
||||
"info_string": "label=\"[lab host] [reference] historical command\"",
|
||||
"label": "[lab host] [reference] historical command",
|
||||
"mode": "reference",
|
||||
"mode_explicit": true,
|
||||
"start": 7192,
|
||||
"end": 7425,
|
||||
"source_sha256": "cc6e1a5eb0abfa7ce26a71be38401c228c39e83b179422a7b0089fcc5b68bbe5",
|
||||
"body_sha256": "750670d72b498faf8522953d1d0e6c14bf7845c0dca7366dd963f9f5b3e88c01",
|
||||
"source": "```bash label=\"[lab host] [reference] historical command\"\nssh donghyeon@192.168.122.11 'umask 077; cat > ~/kc-lab-2.yaml' < kc-lab-2.yaml\nssh donghyeon@192.168.122.11 'cloud-init schema -c ~/kc-lab-2.yaml; rm -f ~/kc-lab-2.yaml'\n```",
|
||||
"body": "ssh donghyeon@192.168.122.11 'umask 077; cat > ~/kc-lab-2.yaml' < kc-lab-2.yaml\nssh donghyeon@192.168.122.11 'cloud-init schema -c ~/kc-lab-2.yaml; rm -f ~/kc-lab-2.yaml'\n"
|
||||
}
|
||||
],
|
||||
"findings": [
|
||||
{
|
||||
"block_id": "command-003",
|
||||
"code": "compound-remote-shell",
|
||||
"severity": "minor",
|
||||
"evidence": "ssh kc-lab-edge 'hostname; ip -4 -br addr show enp1s0; cloud-init status'",
|
||||
"instruction": "복합 원격 셸은 검토 신호다. 읽기 전용 확인이나 검증 성공 뒤 변경하는 안전 조건이면 유지할 수 있고, 서로 다른 효과를 숨기면 관찰 가능한 단계로 분리한다."
|
||||
},
|
||||
{
|
||||
"block_id": "command-006",
|
||||
"code": "raw-ssh-ip",
|
||||
"severity": "minor",
|
||||
"evidence": "ssh donghyeon@192.168.122.11",
|
||||
"instruction": "같은 관리 호스트를 반복해서 쓴다면 SSH alias를 먼저 정의하고 주 흐름에서는 alias를 사용한다."
|
||||
},
|
||||
{
|
||||
"block_id": "command-006",
|
||||
"code": "compound-remote-shell",
|
||||
"severity": "minor",
|
||||
"evidence": "ssh donghyeon@192.168.122.11 'umask 077; cat > ~/kc-lab-2.yaml'",
|
||||
"instruction": "복합 원격 셸은 검토 신호다. 읽기 전용 확인이나 검증 성공 뒤 변경하는 안전 조건이면 유지할 수 있고, 서로 다른 효과를 숨기면 관찰 가능한 단계로 분리한다."
|
||||
},
|
||||
{
|
||||
"block_id": "command-006",
|
||||
"code": "cleanup-chained-with-verification",
|
||||
"severity": "minor",
|
||||
"evidence": "ssh donghyeon@192.168.122.11 'cloud-init schema -c ~/kc-lab-2.yaml; rm",
|
||||
"instruction": "reference block이면 위험한 historical form을 그대로 보존하되 따라 하는 절차와 분리해 설명한다. 실행 절차라면 검증과 파괴적 정리를 별도 명령으로 분리해 삭제 전에 실패를 관찰할 수 있게 한다."
|
||||
},
|
||||
{
|
||||
"block_id": "command-006",
|
||||
"code": "repeated-raw-ssh-host",
|
||||
"severity": "minor",
|
||||
"evidence": "192.168.122.11 repeated 2 times",
|
||||
"instruction": "같은 관리 호스트를 반복하면 SSH alias를 사전 설정으로 둘 수 있는지 검토한다."
|
||||
}
|
||||
],
|
||||
"extensions": {
|
||||
"command_like_text_blocks": []
|
||||
}
|
||||
}
|
||||
+201
@@ -0,0 +1,201 @@
|
||||
---
|
||||
kind: CASE
|
||||
slug: cloud-init-failures-all-look-like-ssh-refused
|
||||
title: cloud-init 이 안 도는 원인은 넷인데 증상은 「SSH 가 안 붙는다」 하나였다
|
||||
topic: build-completion-judgment
|
||||
topicName: 끝났다는 판정
|
||||
project: virtualization
|
||||
status: 게시 전
|
||||
sourceRevision: import-head 9465582b5d1630eb4ae7c4e078021486919bf6b6 · uncommitted-working-tree snapshot
|
||||
evidence:
|
||||
- ../../../final/evidence/raw/lab-state-before-rebuild/14-cloudinit-schema-check.txt
|
||||
- ../../../final/evidence/raw/lab-state-before-rebuild/15-k3s-precheck.txt
|
||||
source:
|
||||
- final/document.md#187-단계-01-게스트-세-대
|
||||
- final/document.md#186-단계-00-lab-host-가상화-준비
|
||||
---
|
||||
|
||||
# cloud-init 이 안 도는 원인은 넷인데 증상은 「SSH 가 안 붙는다」 하나였다
|
||||
|
||||
SSH 가 안 붙는 원인은 넷이고 넷 다 게스트 밖에서 정해진다. 그래서 SSH 설정을 고치기 전에 호스트명 한 낱말을 본다. 시드를 붙인 방식, YAML 파싱, vol-upload 누락, 가상 네트워크 autostart 가 그 넷이고, cloud-init 은 어느 쪽이든 오류를 남기지 않는다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **빈 토큰이 조용히 흘러갔다 — 설치 출력은 성공이었고 agent 만 5초마다 다시 죽었다**
|
||||
바로 다음 단계에서 벌어진 같은 모양의 실패이고, 거기서도 성공한 출력이 실패를 덮었다.
|
||||
- **도구가 낸 출력은 대상의 상태가 아니다 — 그 명령이 무엇을 세는지부터 가른다**
|
||||
스키마 검사기의 통과와 거부를 게스트의 상태로 읽으면 양쪽 방향으로 다 틀린다.
|
||||
- **가이드대로 다시 쳐서 이 실험대가 같은 상태로 서는가**
|
||||
게스트를 만드는 명령은 다시 쳐 본 적이 없어 여기 적은 원인 넷도 그 물음에 걸린다.
|
||||
- **qcow2 파일 한 장이 담는 것 — 매핑표와 데이터 클러스터가 같은 파일 안에 있고, 파일 밖을 가리키는 것은 백킹 파일 경로 하나다**
|
||||
게스트 디스크가 base 이미지 위의 오버레이라 `virt-install` 이 10GB 를 즉시 할당한 것으로 찍힌다.
|
||||
- **단계별 구축 문서는 작성 시점이 아니라 실행 순서로 검증한다**
|
||||
시드를 만드는 순서와 DHCP 예약을 넣는 순서가 결과를 가르는 단계라 그 규칙이 받는다.
|
||||
|
||||
## 문제
|
||||
|
||||
게스트 세 대가 running 인데 lab host 에서 SSH 가 키로 붙지 않는다.
|
||||
|
||||
virsh list --all : kc-lab-1 · kc-lab-2 · kc-lab-edge 모두 running
|
||||
ssh donghyeon@192.168.122.11 : Permission denied (publickey)
|
||||
게스트의 hostname : localhost
|
||||
cloud-init 이 남긴 오류 : x
|
||||
|
||||
cloud-init 이 돌지 않으면 사용자도 SSH 키도 들어가지 않는다. 그런데 cloud-init 은 데이터소스를 못 찾아도 YAML 파싱에 실패해도 오류를 남기지 않아서, 원인 넷이 전부 SSH 하나로만 나타난다.
|
||||
|
||||
## 결론
|
||||
|
||||
증상 하나 뒤에 원인이 넷이고 넷 다 게스트 밖에서 정해진다.
|
||||
|
||||
시드를 --cloud-init 으로 붙였다 : SATA CD-ROM 으로 붙는데 Debian genericcloud 이미지에는 그 드라이버가 없다. 디스크로, bus=virtio 로 붙인다
|
||||
YAML 파싱에 실패했다 : cloud-init 이 아무 오류를 남기지 않는다
|
||||
vol-upload 를 빠뜨렸다 : 목록에는 이름이 보이는데 안이 0 으로 채워져 있어 cidata 라벨을 못 찾는다
|
||||
default 네트워크의 autostart 가 no 다 : 지금은 되고 호스트를 재부팅한 다음에야 세 게스트의 SSH 가 한꺼번에 실패한다
|
||||
|
||||
판정 : SSH 설정을 고치기 전에 호스트명 한 낱말을 본다. ssh kc-lab-edge 'hostname' 이 kc-lab-edge 를 내면 시드가 읽혔고, 시드가 읽혔으면 같은 파일에 있던 SSH 키도 들어갔다
|
||||
못 들어가면 : virsh screenshot 으로 화면을 떠서 로그인 프롬프트 앞의 호스트명을 읽는다
|
||||
키가 안 들어갔을 때의 탈출구 : cloud-init 의 plain_text_passwd 로 콘솔 로그인
|
||||
|
||||
반대 방향도 하나 있다. 게스트의 cloud-init 22.4.2 스키마 검사기는 sudo 를 리스트로 쓴 것을 거부한다. 그런데 그 표기로도 부팅은 되고, kc-lab-1 과 kc-lab-2 에서 그 상태로 NOPASSWD sudo 가 돌고 있다. 검사가 통과해도 안 도는 쪽이 넷이고 검사에 걸려도 도는 쪽이 하나라, 어느 방향이든 검사 결과를 게스트의 상태로 읽으면 틀린다.
|
||||
|
||||
## 검증 환경
|
||||
|
||||
호스트 : test-server, Arch Linux
|
||||
RAM : 11,648MiB
|
||||
QEMU : 11.1.1
|
||||
libvirt : 12.7.0
|
||||
base 이미지 : Debian 12 genericcloud amd64
|
||||
게스트 OS : Debian GNU/Linux 12 (bookworm)
|
||||
게스트 : kc-lab-edge 192.168.122.10 · kc-lab-1 192.168.122.11 · kc-lab-2 192.168.122.12
|
||||
시드 : seed-<이름>.iso, CIDATA 라벨, bus=virtio 로 붙임
|
||||
게스트의 cloud-init : 22.4.2
|
||||
cloud-init 이 깐 패키지 : curl · nftables
|
||||
running 에서 done 까지 : 약 50초
|
||||
|
||||
## 재현 조건
|
||||
|
||||
1. base 이미지를 받고 게스트마다 cloud-init user-data 를 쓴다.
|
||||
2. 시드 iso 를 만들고 virsh vol-create-as 로 볼륨을 만든 뒤 virsh vol-upload 로 채운다.
|
||||
3. DHCP 예약을 먼저 넣고 virt-install 로 게스트를 만든다. 시드는 bus=virtio 로 붙인다.
|
||||
4. virsh list --all 로 세 게스트가 running 인지 본다.
|
||||
5. lab host 에서 ssh kc-lab-edge 'hostname; ip -4 -br addr show enp1s0; cloud-init status' 를 친다.
|
||||
6. 호스트명이 게스트 이름이고 cloud-init status 가 done 이면 통과다. localhost 가 나오면 2번과 3번을 다시 본다.
|
||||
7. SSH 가 안 붙으면 virsh screenshot kc-lab-1 /tmp/kc1.ppm 으로 화면을 떠서 로그인 프롬프트 앞의 호스트명을 읽는다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
## 시드가 게스트에 들어가는 길
|
||||
|
||||
cloud-init 이 게스트 안에서 사용자를 만들고 SSH 키를 넣으려면 먼저 데이터소스를 찾아야 한다. 이 실험대는 그것을 `cidata` 라벨이 붙은 볼륨으로 준다. 그 안에는 `user-data` 와 `meta-data` 라는 정확한 이름의 파일 둘이 들어 있다.
|
||||
|
||||
```bash
|
||||
printf 'instance-id: kc-lab-1-%s\nlocal-hostname: kc-lab-1\n' "$(date +%s)" > meta-kc-lab-1
|
||||
|
||||
xorrisofs -quiet -output seed-kc-lab-1.iso -volid CIDATA -joliet -rock \
|
||||
-graft-points /user-data=kc-lab-1.yaml /meta-data=meta-kc-lab-1
|
||||
|
||||
virsh vol-create-as default seed-kc-lab-1.iso "$(stat -c%s seed-kc-lab-1.iso)" --format raw
|
||||
virsh vol-upload --pool default seed-kc-lab-1.iso seed-kc-lab-1.iso
|
||||
```
|
||||
|
||||
볼륨을 만드는 것과 채우는 것이 다른 명령이다. `vol-create-as` 는 빈 볼륨을 만들 뿐이고 내용은 `vol-upload` 가 넣는다. 뒤엣것을 빠뜨리면 목록에는 이름이 보이는데 안이 0 으로 채워져 있고, cloud-init 은 `cidata` 라벨을 못 찾은 채 끝난다.
|
||||
|
||||
`instance-id` 에 타임스탬프를 넣는 것은 cloud-init 이 인스턴스마다 한 번만 초기화 모듈을 돌리기 때문이다. id 가 같으면 user-data 를 고쳐도 반영되지 않는다.
|
||||
|
||||
## 증상 하나에 원인 넷
|
||||
|
||||
| 무엇이 어긋났나 | 게스트에서 무엇으로 나타나나 |
|
||||
|---|---|
|
||||
| 시드를 `--cloud-init` 으로 붙였다 | SSH 가 `Permission denied (publickey)` · 호스트명이 `localhost` |
|
||||
| YAML 파싱에 실패했다 | 같다 |
|
||||
| `vol-upload` 를 빠뜨렸다 | 같다 |
|
||||
| `default` 네트워크의 autostart 가 `no` 다 | 호스트를 재부팅한 뒤 세 게스트가 한꺼번에 |
|
||||
|
||||
넷 가운데 이 실험대에서 관측으로 적힌 것은 시드를 붙인 방식이다. `--cloud-init` 옵션은 시드를 SATA CD-ROM 으로 붙이는데, Debian `genericcloud` 이미지는 크기를 줄이려고 물리 하드웨어 드라이버를 뺐다. AHCI 장치가 보이지 않아 cloud-init 이 데이터소스를 못 찾고 조용히 끝난다. 그래서 시드를 디스크로, `bus=virtio` 로 붙인다.
|
||||
|
||||
```bash
|
||||
virt-install --name kc-lab-1 --memory 5120 --vcpus 2 \
|
||||
--disk size=20,backing_store=/var/lib/libvirt/images/base.qcow2 \
|
||||
--disk vol=default/seed-kc-lab-1.iso,device=disk,bus=virtio,readonly=on \
|
||||
--network network=default,mac=52:54:00:aa:bb:11 \
|
||||
--import --os-variant debian12 --noautoconsole
|
||||
```
|
||||
|
||||
마지막 원인은 한 단계 앞에서 만들어진다. lab host 를 준비할 때 `default` 네트워크의 autostart 를 켜지 않으면 지금은 세 게스트가 다 붙고, 호스트를 재부팅한 다음에야 SSH 가 한꺼번에 실패한다. 그때 원인을 게스트 안에서 찾게 된다.
|
||||
|
||||
## 호스트명 한 낱말이 시드와 SSH 를 가른다
|
||||
|
||||
게스트에 SSH 가 붙는다면 확인은 한 줄로 끝난다.
|
||||
|
||||
```bash
|
||||
ssh kc-lab-edge 'hostname; ip -4 -br addr show enp1s0; cloud-init status'
|
||||
```
|
||||
|
||||
```
|
||||
kc-lab-edge
|
||||
enp1s0 UP 192.168.122.10/24 metric 100
|
||||
status: done
|
||||
```
|
||||
|
||||
호스트명이 바뀌었다는 것은 시드가 읽혔다는 뜻이고, 시드가 읽혔으면 같은 파일에 있던 SSH 키도 들어갔다. `localhost` 가 나오면 SSH 설정을 고치지 말고 시드부터 의심한다. `cloud-init status` 가 `running` 이면 아직 패키지를 받는 중이고, 이 실험대에서는 `done` 까지 약 50초 걸렸다.
|
||||
|
||||
게스트에 못 들어가면 화면을 직접 뜬다.
|
||||
|
||||
```bash
|
||||
virsh screenshot kc-lab-1 /tmp/kc1.ppm # 확장자와 무관하게 PNG 로 저장된다
|
||||
```
|
||||
|
||||
로그인 프롬프트 앞의 호스트명 한 낱말만 읽는다. `localhost login:` 이면 cloud-init 이 아예 안 돌았으므로 SSH 쪽은 볼 것이 없다. `kc-lab-1 login:` 이면 cloud-init 은 돌았고 그 안의 사용자·키 단계에서 틀린 것이라 콘솔로 들어가 로그를 본다. 콘솔 로그인에 쓰는 비밀번호가 cloud-init 의 `plain_text_passwd` 이고, 키가 안 들어갔을 때 게스트로 들어가는 길이 이것 하나다.
|
||||
|
||||
순서를 이렇게 정한 까닭이 여기에 있다. 화면 한 장과 콘솔에서 보는 로그 두 줄이 「SSH 가 안 되는 이유」를 절반으로 줄인다. 호스트명이 그 절반을 가르므로, 어느 쪽 절반을 볼지가 정해지기 전에는 SSH 설정을 열 이유가 없다.
|
||||
|
||||
## 검사가 거부해도 부팅은 된다
|
||||
|
||||
게스트의 cloud-init `22.4.2` 스키마 검사기는 `sudo` 를 리스트로 쓴 것을 거부한다.
|
||||
|
||||
```yaml
|
||||
sudo: ['ALL=(ALL) NOPASSWD:ALL'] # 거부된다
|
||||
sudo: "ALL=(ALL) NOPASSWD:ALL" # 통과한다
|
||||
```
|
||||
|
||||
```
|
||||
Error: Cloud config schema errors: users.0: {'name': 'donghyeon', 'groups':
|
||||
['sudo'], 'shell': '/bin/bash', ...} is not valid under any of the given schemas
|
||||
```
|
||||
|
||||
어느 키가 문제인지는 알려 주지 않는다. `users.0` 전체를 통째로 찍고 어느 스키마에도 안 맞는다고만 한다. 그리고 리스트 표기로도 부팅은 된다 — `kc-lab-1` 과 `kc-lab-2` 가 그 표기로 만들어졌고 NOPASSWD sudo 가 멀쩡히 돌고 있다.
|
||||
|
||||
`NOPASSWD:ALL` 을 넣은 것은 k3s 설치와 장애 주입이 비대화식으로 돌아야 해서다. 비밀번호를 물으면 원격 실행이 거기서 멈춘다. 검사기가 거부한 줄이 바로 그 줄이다.
|
||||
|
||||
검사가 통과해도 cloud-init 이 안 도는 경로가 넷이고, 검사가 거부해도 도는 경로가 하나다.
|
||||
|
||||
## 시드를 만들기 전에 세 줄로 거른다
|
||||
|
||||
```bash
|
||||
grep -c '__' kc-lab-1.yaml # 0 이어야 한다
|
||||
grep -c 'ssh-ed25519\|ssh-rsa' kc-lab-1.yaml # 2 여야 한다
|
||||
python3 -c 'import yaml,sys; yaml.safe_load(open("kc-lab-1.yaml")); print("YAML OK")'
|
||||
```
|
||||
|
||||
`0` 과 `2` 와 `YAML OK` 셋이 맞아야 시드를 만든다. 셋이 맞아도 cloud-config 로 유효하지는 않다 — 키 이름을 `users` 대신 `user` 로 친 오타는 이 세 줄을 그냥 통과한다. 그래서 게스트가 한 대라도 떠 있으면 cloud-init 자신의 스키마 검사기를 쓴다.
|
||||
|
||||
```bash label="[lab host] [reference] historical command"
|
||||
ssh donghyeon@192.168.122.11 'umask 077; cat > ~/kc-lab-2.yaml' < kc-lab-2.yaml
|
||||
ssh donghyeon@192.168.122.11 'cloud-init schema -c ~/kc-lab-2.yaml; rm -f ~/kc-lab-2.yaml'
|
||||
```
|
||||
|
||||
이 두 줄은 **당시 문서에 남은 historical command**다. 둘째 줄은 스키마 검사가 실패해도 `; rm` 때문에 파일을 지운다. 따라 하는 절차에서는 검사와 삭제를 분리하고, 검사 성공 여부를 본 뒤 삭제한다.
|
||||
|
||||
이 파일에는 콘솔 비밀번호가 평문으로 들어 있어 `/tmp` 가 아니라 자기 홈에 `600` 으로 두고, 검사가 끝나면 바로 지운다.
|
||||
|
||||
## 확인하지 못한 것
|
||||
|
||||
넷 가운데 SSOT 가 관측으로 표시한 것은 시드를 SATA 로 붙였을 때 AHCI 장치가 보이지 않는 것과 스키마 검사기의 거부 문구뿐이다. YAML 파싱 실패와 `vol-upload` 누락과 네트워크 autostart 셋은 가이드가 막히면 표에 적어 둔 항목이라, 이 실험대에서 실제로 그 증상을 본 것인지 예상해 적은 것인지 SSOT 가 가르지 않았다. 이 글에서 그 셋은 그렇게 되는 구조까지이고 그렇게 됐다가 아니다.
|
||||
|
||||
후속 원문이 생겼다. `14-cloudinit-schema-check.txt` 는 cloud-init 22.4.2가 `sudo` 리스트 표기를 실제로 거부한 출력을 다시 담아 **스키마 거부 claim을 직접 보강한다**. `15-k3s-precheck.txt` 의 `Host key verification failed.` 는 2026-09-17 재구축 중 나온 후속 SSH 관측으로, 원래 사건의 `Permission denied (publickey)`를 재현한 것은 아니다. 원래 `Permission denied (publickey)`, `cloud-init status: done`, 약 50초, `virsh screenshot` 화면은 여전히 SSOT 본문만 근거이고 직접 raw는 없다.
|
||||
|
||||
만드는 명령은 재실행으로 검증되지 않았다. 게스트를 다시 만들면 돌고 있는 실험대가 없어지므로 시드와 관련된 셋은 다시 재현하기 어렵다. 네트워크 autostart 만은 호스트를 재부팅해 확인할 수 있는데 그 기록도 없다.
|
||||
|
||||
<!-- body:end -->
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"source_sha256": "1385070d8fd0ac929ff29d0ca47f20f21f34afe6b84d624ecc2c67bb891b4e7f",
|
||||
"patches": []
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"section_id": "docs/virtualization/tech-log-studio/build-completion-judgment/case/case-cloud-init-failures-all-look-like-ssh-refused.md",
|
||||
"source_sha256": "1385070d8fd0ac929ff29d0ca47f20f21f34afe6b84d624ecc2c67bb891b4e7f",
|
||||
"mode": "reference",
|
||||
"command_groups": [
|
||||
{
|
||||
"id": "review-001",
|
||||
"block_id": "command-003",
|
||||
"source_sha256": "4161c7dc1caf84c64102828a83fdafc2c2eb7a1c25d04e704108b633e22e515a",
|
||||
"goal": "deterministic signal이 가리킨 기존 command block을 현재 문맥에서 재검토한다.",
|
||||
"execution_context": {
|
||||
"host": "본문 직전 문맥에 적힌 현재 실행 환경",
|
||||
"cwd": "본문에 별도 cwd가 없으면 현재 셸 작업 디렉터리를 유지한다."
|
||||
},
|
||||
"prerequisites": [],
|
||||
"steps": [
|
||||
{
|
||||
"command": "ssh kc-lab-edge 'hostname; ip -4 -br addr show enp1s0; cloud-init status'",
|
||||
"reason": "project-level current audit에서 high-confidence defect가 아닌 review signal로 판정했다.",
|
||||
"expected_result": "본문에 적힌 관찰·검증 의미를 유지한다."
|
||||
}
|
||||
],
|
||||
"cleanup": []
|
||||
},
|
||||
{
|
||||
"id": "review-002",
|
||||
"block_id": "command-006",
|
||||
"source_sha256": "cc6e1a5eb0abfa7ce26a71be38401c228c39e83b179422a7b0089fcc5b68bbe5",
|
||||
"goal": "deterministic signal이 가리킨 기존 command block을 현재 문맥에서 재검토한다.",
|
||||
"execution_context": {
|
||||
"host": "lab host",
|
||||
"cwd": "본문에 별도 cwd가 없으면 현재 셸 작업 디렉터리를 유지한다."
|
||||
},
|
||||
"prerequisites": [],
|
||||
"steps": [
|
||||
{
|
||||
"command": "ssh donghyeon@192.168.122.11 'umask 077; cat > ~/kc-lab-2.yaml' < kc-lab-2.yaml\nssh donghyeon@192.168.122.11 'cloud-init schema -c ~/kc-lab-2.yaml; rm -f ~/kc-lab-2.yaml'",
|
||||
"reason": "project-level current audit에서 high-confidence defect가 아닌 review signal로 판정했다.",
|
||||
"expected_result": "본문에 적힌 관찰·검증 의미를 유지한다."
|
||||
}
|
||||
],
|
||||
"cleanup": []
|
||||
}
|
||||
]
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"scope": "command-pedagogy",
|
||||
"reviewer": "command-pedagogy-reviewer",
|
||||
"verdict": "PASS",
|
||||
"source_sha256": "1385070d8fd0ac929ff29d0ca47f20f21f34afe6b84d624ecc2c67bb891b4e7f",
|
||||
"findings": [
|
||||
{
|
||||
"block_id": "command-003",
|
||||
"code": "compound-remote-shell",
|
||||
"severity": "minor",
|
||||
"decision": "KEEP",
|
||||
"reason": "2026-09-20 virtualization project-level audit에서 high-confidence defect로 판정하지 않은 current review signal이다."
|
||||
},
|
||||
{
|
||||
"block_id": "command-006",
|
||||
"code": "raw-ssh-ip",
|
||||
"severity": "minor",
|
||||
"decision": "KEEP",
|
||||
"reason": "2026-09-20 virtualization project-level audit에서 high-confidence defect로 판정하지 않은 current review signal이다."
|
||||
},
|
||||
{
|
||||
"block_id": "command-006",
|
||||
"code": "compound-remote-shell",
|
||||
"severity": "minor",
|
||||
"decision": "KEEP",
|
||||
"reason": "2026-09-20 virtualization project-level audit에서 high-confidence defect로 판정하지 않은 current review signal이다."
|
||||
},
|
||||
{
|
||||
"block_id": "command-006",
|
||||
"code": "cleanup-chained-with-verification",
|
||||
"severity": "minor",
|
||||
"decision": "KEEP",
|
||||
"reason": "2026-09-20 virtualization project-level audit에서 high-confidence defect로 판정하지 않은 current review signal이다."
|
||||
},
|
||||
{
|
||||
"block_id": "command-006",
|
||||
"code": "repeated-raw-ssh-host",
|
||||
"severity": "minor",
|
||||
"decision": "KEEP",
|
||||
"reason": "2026-09-20 virtualization project-level audit에서 high-confidence defect로 판정하지 않은 current review signal이다."
|
||||
}
|
||||
],
|
||||
"notes": "reference mode final major finding은 0이다. historical compact command는 reference disposition으로 보존한다."
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"record": "docs/virtualization/tech-log-studio/build-completion-judgment/case/case-cloud-init-failures-all-look-like-ssh-refused.md",
|
||||
"sourceSha256": "1385070d8fd0ac929ff29d0ca47f20f21f34afe6b84d624ecc2c67bb891b4e7f",
|
||||
"verdict": "PASS",
|
||||
"liveSourceReconciliation": "UNVERIFIABLE",
|
||||
"liveSourceReason": "source repositories unavailable on current machine",
|
||||
"acceptedByProjectReview": true,
|
||||
"checks": [
|
||||
"local SSOT/Record evidence mismatch 0",
|
||||
"S3/S5/S6 check_evidence --repo invoked",
|
||||
"source checkout absence preserved as exit 3"
|
||||
],
|
||||
"note": "현재 project evidence gate는 불일치 0건·대조 불가 2건이다. source checkout 부재를 PASS로 위장하지 않고 UNVERIFIABLE로 보존했다."
|
||||
}
|
||||
+365
@@ -0,0 +1,365 @@
|
||||
{
|
||||
"schemaVersion": 5,
|
||||
"runId": "2026-09-21-0218-remediation-03-case-renewal-succeeded-while-the-old-certificate-kept-serving",
|
||||
"project": "virtualization",
|
||||
"record": "docs/virtualization/tech-log-studio/build-completion-judgment/case/case-renewal-succeeded-while-the-old-certificate-kept-serving.md",
|
||||
"startedAt": "2026-09-21T02:24:32+00:00",
|
||||
"finishedAt": "2026-09-21T02:24:35+00:00",
|
||||
"stages": [
|
||||
{
|
||||
"id": "S1",
|
||||
"name": "코드베이스 → SSOT",
|
||||
"skill": "analyzing-codebase-for-tech-log",
|
||||
"runBy": "ssot-analyst",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "이번 remediation은 새 evidence와 현재 SSOT/Record의 정합성·provenance·command/S4를 재검토한다. 코드베이스→SSOT 전면 분석은 다시 하지 않는다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": "edd45dfec66d155a621cd41186b83b5582f2244c",
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"gates": [],
|
||||
"notes": "",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-21T02:24:33+00:00",
|
||||
"elapsedSeconds": 0,
|
||||
"finishedBy": "chatgpt-virtualization-20260921-remediation-v5"
|
||||
},
|
||||
{
|
||||
"id": "S2",
|
||||
"name": "SSOT → 분해 계약",
|
||||
"skill": "deriving-tech-log-root-tree",
|
||||
"runBy": "tree-deriver",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "기존 91 Record 분해 계약은 유지한다. certbot Question의 RESOLVED 상태와 sourcePolicy는 project tree rebuild에서 별도로 동기화했다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": "ab59130196d79e947b32e3b5e6b75335a9e5c1eb",
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"gates": [],
|
||||
"notes": "",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-21T02:24:33+00:00",
|
||||
"elapsedSeconds": 0,
|
||||
"finishedBy": "chatgpt-virtualization-20260921-remediation-v5"
|
||||
},
|
||||
{
|
||||
"id": "S3",
|
||||
"name": "글감 → 기록",
|
||||
"skill": "writing-tech-log-records",
|
||||
"runBy": "record-writer",
|
||||
"status": "DONE",
|
||||
"skipReason": "",
|
||||
"skillEcho": "**인용한 줄은 SSOT 에서 찾아 대조한다.**",
|
||||
"skillRevision": null,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
"docs/virtualization/tech-log-studio/build-completion-judgment/case/case-renewal-succeeded-while-the-old-certificate-kept-serving.md"
|
||||
],
|
||||
"gates": [
|
||||
{
|
||||
"cmd": "python3 scripts/studio-body.py docs/virtualization/tech-log-studio/build-completion-judgment/case/case-renewal-succeeded-while-the-old-certificate-kept-serving.md -o runs/virtualization/2026-09-21-0218-remediation-03-case-renewal-succeeded-while-the-old-certificate-kept-serving/stage/S3/studio-body.md",
|
||||
"exit": 0,
|
||||
"status": "PASS",
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"generation": 1,
|
||||
"at": "2026-09-21T02:24:33+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node --experimental-transform-types .agents/skills/writing-tech-log-records/scripts/check_body.mjs runs/virtualization/2026-09-21-0218-remediation-03-case-renewal-succeeded-while-the-old-certificate-kept-serving/stage/S3/studio-body.md --frontend /shared/codebase/tech-log-frontend",
|
||||
"exit": 0,
|
||||
"status": "PASS",
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"generation": 1,
|
||||
"at": "2026-09-21T02:24:33+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node .agents/skills/rewriting-technical-prose-naturally/scripts/check_prose.mjs --warn docs/virtualization/tech-log-studio/build-completion-judgment/case/case-renewal-succeeded-while-the-old-certificate-kept-serving.md",
|
||||
"exit": 0,
|
||||
"status": "PASS",
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"generation": 1,
|
||||
"at": "2026-09-21T02:24:33+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node .agents/skills/writing-tech-log-records/scripts/check_evidence.mjs virtualization --repo",
|
||||
"exit": 3,
|
||||
"status": "UNVERIFIABLE",
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"generation": 1,
|
||||
"at": "2026-09-21T02:24:33+00:00",
|
||||
"semanticId": "evidence-repo",
|
||||
"reason": "source repositories unavailable on current machine",
|
||||
"acceptedByProjectReview": true
|
||||
}
|
||||
],
|
||||
"notes": "record-writer/fact 계약으로 새 evidence와 SSOT의 current truth를 대조했다. --repo 대조는 실제 실행했고 source checkout 부재만 UNVERIFIABLE로 남겼다.",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-21T02:24:33+00:00",
|
||||
"elapsedSeconds": 0,
|
||||
"generation": 1,
|
||||
"owner": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"finishedBy": "chatgpt-virtualization-20260921-remediation-v5"
|
||||
},
|
||||
{
|
||||
"id": "S4",
|
||||
"name": "기록 → 그림",
|
||||
"skill": "technical-visualizer",
|
||||
"runBy": "diagram-maker",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "이 Record 변경은 current truth/evidence/version/provenance 정리이며 새 관계 그림을 요구하지 않는다. 기존 그림은 project-wide 14장 검증에서 별도로 확인했다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": "edd45dfec66d155a621cd41186b83b5582f2244c",
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"gates": [],
|
||||
"notes": "",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-21T02:24:33+00:00",
|
||||
"elapsedSeconds": 0,
|
||||
"finishedBy": "chatgpt-virtualization-20260921-remediation-v5"
|
||||
},
|
||||
{
|
||||
"id": "S5",
|
||||
"name": "AI 티 제거",
|
||||
"skill": "rewriting-technical-prose-naturally",
|
||||
"runBy": "prose-rewriter",
|
||||
"status": "DONE",
|
||||
"skipReason": "",
|
||||
"skillEcho": "This is an **editorial** pass. The source's facts, evidence, causal chain, uncertainty, decision status, and technical depth are the contract.",
|
||||
"skillRevision": null,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
"docs/virtualization/tech-log-studio/build-completion-judgment/case/case-renewal-succeeded-while-the-old-certificate-kept-serving.md"
|
||||
],
|
||||
"gates": [
|
||||
{
|
||||
"cmd": "node .agents/skills/rewriting-technical-prose-naturally/scripts/check_prose.mjs --warn docs/virtualization/tech-log-studio/build-completion-judgment/case/case-renewal-succeeded-while-the-old-certificate-kept-serving.md",
|
||||
"exit": 0,
|
||||
"status": "PASS",
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"generation": 1,
|
||||
"at": "2026-09-21T02:24:33+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node .agents/skills/rewriting-technical-prose-naturally/scripts/style_profile.mjs docs/virtualization/tech-log-studio/build-completion-judgment/case/case-renewal-succeeded-while-the-old-certificate-kept-serving.md",
|
||||
"exit": 0,
|
||||
"status": "PASS",
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"generation": 1,
|
||||
"at": "2026-09-21T02:24:34+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node --experimental-transform-types .agents/skills/writing-tech-log-records/scripts/check_body.mjs runs/virtualization/2026-09-21-0218-remediation-03-case-renewal-succeeded-while-the-old-certificate-kept-serving/stage/S5/studio-body.md --frontend /shared/codebase/tech-log-frontend",
|
||||
"exit": 0,
|
||||
"status": "PASS",
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"generation": 1,
|
||||
"at": "2026-09-21T02:24:34+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node .agents/skills/writing-tech-log-records/scripts/check_evidence.mjs virtualization --repo",
|
||||
"exit": 3,
|
||||
"status": "UNVERIFIABLE",
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"generation": 1,
|
||||
"at": "2026-09-21T02:24:34+00:00",
|
||||
"semanticId": "evidence-repo",
|
||||
"reason": "source repositories unavailable on current machine",
|
||||
"acceptedByProjectReview": true
|
||||
}
|
||||
],
|
||||
"notes": "대규모 문체 재작성 없이 stale fact/relation만 현재 evidence에 맞췄다. style_profile은 측정값으로만 기록했다.",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-21T02:24:34+00:00",
|
||||
"elapsedSeconds": 1,
|
||||
"generation": 1,
|
||||
"owner": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"finishedBy": "chatgpt-virtualization-20260921-remediation-v5"
|
||||
},
|
||||
{
|
||||
"id": "S6",
|
||||
"name": "일한 사람의 목소리",
|
||||
"skill": "writing-as-the-person-who-did-it",
|
||||
"runBy": "voice-writer",
|
||||
"status": "DONE",
|
||||
"skipReason": "",
|
||||
"skillEcho": "찾은 것이 없으면 **이 스킬은 여기서 끝난다.** 없는 목소리를 채우지 않는다.",
|
||||
"skillRevision": "edd45dfec66d155a621cd41186b83b5582f2244c",
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
"docs/virtualization/tech-log-studio/build-completion-judgment/case/case-renewal-succeeded-while-the-old-certificate-kept-serving.md"
|
||||
],
|
||||
"gates": [
|
||||
{
|
||||
"cmd": "node .agents/skills/writing-as-the-person-who-did-it/scripts/check_voice.mjs docs/virtualization/tech-log-studio/build-completion-judgment/case/case-renewal-succeeded-while-the-old-certificate-kept-serving.md",
|
||||
"exit": 0,
|
||||
"status": "PASS",
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"generation": 1,
|
||||
"at": "2026-09-21T02:24:34+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node .agents/skills/rewriting-technical-prose-naturally/scripts/check_prose.mjs --warn docs/virtualization/tech-log-studio/build-completion-judgment/case/case-renewal-succeeded-while-the-old-certificate-kept-serving.md",
|
||||
"exit": 0,
|
||||
"status": "PASS",
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"generation": 1,
|
||||
"at": "2026-09-21T02:24:34+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node --experimental-transform-types .agents/skills/writing-tech-log-records/scripts/check_body.mjs runs/virtualization/2026-09-21-0218-remediation-03-case-renewal-succeeded-while-the-old-certificate-kept-serving/stage/S6/studio-body.md --frontend /shared/codebase/tech-log-frontend",
|
||||
"exit": 0,
|
||||
"status": "PASS",
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"generation": 1,
|
||||
"at": "2026-09-21T02:24:35+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node .agents/skills/writing-tech-log-records/scripts/check_evidence.mjs virtualization --repo",
|
||||
"exit": 3,
|
||||
"status": "UNVERIFIABLE",
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"generation": 1,
|
||||
"at": "2026-09-21T02:24:35+00:00",
|
||||
"semanticId": "evidence-repo",
|
||||
"reason": "source repositories unavailable on current machine",
|
||||
"acceptedByProjectReview": true
|
||||
}
|
||||
],
|
||||
"notes": "상류 SSOT/evidence를 다시 보며 실제 작업 흔적이 없는 서사는 추가하지 않았다.",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-21T02:24:35+00:00",
|
||||
"elapsedSeconds": 1,
|
||||
"generation": 1,
|
||||
"owner": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"finishedBy": "chatgpt-virtualization-20260921-remediation-v5"
|
||||
},
|
||||
{
|
||||
"id": "S7",
|
||||
"name": "Studio 저장",
|
||||
"skill": "publishing-tech-log-to-studio",
|
||||
"runBy": "studio-validator",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "이번 remediation에서 Studio import/save는 요청되지 않았다. 저장소 산출물과 검증 영수증만 갱신한다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": "862e502af3e956b49ccd2ae0a8de3fd32f90df9c",
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"gates": [],
|
||||
"notes": "",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-21T02:24:35+00:00",
|
||||
"elapsedSeconds": 0,
|
||||
"finishedBy": "chatgpt-virtualization-20260921-remediation-v5"
|
||||
}
|
||||
],
|
||||
"qualityReviews": {
|
||||
"commandPedagogy": {
|
||||
"initialAnalysis": {
|
||||
"cmd": "python3 scripts/check-command-pedagogy.py --mode reference docs/virtualization/tech-log-studio/build-completion-judgment/case/case-renewal-succeeded-while-the-old-certificate-kept-serving.md -o runs/virtualization/2026-09-21-0218-remediation-03-case-renewal-succeeded-while-the-old-certificate-kept-serving/stage/S3/command-initial.json",
|
||||
"exit": 0,
|
||||
"shellBlocks": 6,
|
||||
"findings": 0,
|
||||
"majorFindings": 0,
|
||||
"artifact": {
|
||||
"path": "runs/virtualization/2026-09-21-0218-remediation-03-case-renewal-succeeded-while-the-old-certificate-kept-serving/stage/S3/command-initial.json",
|
||||
"sha256": "d885367db5399479374a692a921c8ca38b479586ac6b58ec97f36e11b2d0e7d9"
|
||||
}
|
||||
},
|
||||
"finalAnalysis": {
|
||||
"cmd": "python3 scripts/check-command-pedagogy.py --mode reference docs/virtualization/tech-log-studio/build-completion-judgment/case/case-renewal-succeeded-while-the-old-certificate-kept-serving.md -o runs/virtualization/2026-09-21-0218-remediation-03-case-renewal-succeeded-while-the-old-certificate-kept-serving/stage/S6/command-final.json",
|
||||
"exit": 0,
|
||||
"shellBlocks": 6,
|
||||
"findings": 0,
|
||||
"majorFindings": 0,
|
||||
"artifact": {
|
||||
"path": "runs/virtualization/2026-09-21-0218-remediation-03-case-renewal-succeeded-while-the-old-certificate-kept-serving/stage/S6/command-final.json",
|
||||
"sha256": "d885367db5399479374a692a921c8ca38b479586ac6b58ec97f36e11b2d0e7d9"
|
||||
}
|
||||
},
|
||||
"planner": {
|
||||
"runBy": "command-pedagogy-planner",
|
||||
"skill": "writing-practitioner-guides",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "initial deterministic analysis finding이 0이다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": null,
|
||||
"notes": "현재 reference-mode 분석을 확인했다.",
|
||||
"artifact": null
|
||||
},
|
||||
"editor": {
|
||||
"runBy": "command-pedagogy-editor",
|
||||
"skill": "writing-practitioner-guides",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "initial deterministic analysis finding이 0이다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": null,
|
||||
"notes": "현재 reference-mode 분석을 확인했다.",
|
||||
"artifact": null
|
||||
},
|
||||
"reviewer": {
|
||||
"runBy": "command-pedagogy-reviewer",
|
||||
"skill": "writing-practitioner-guides",
|
||||
"status": "DONE",
|
||||
"skipReason": "",
|
||||
"skillEcho": "**Optimize for human operation, not command compactness.**",
|
||||
"skillRevision": null,
|
||||
"verdict": "PASS",
|
||||
"notes": "project-level command audit와 현재 final analysis를 대조했다. final major finding은 0이다.",
|
||||
"artifact": {
|
||||
"path": "runs/virtualization/2026-09-21-0218-remediation-03-case-renewal-succeeded-while-the-old-certificate-kept-serving/stage/quality/command-review.json",
|
||||
"sha256": "36b2390ff6e8d99e64995f06f7f336e3ea9ec2795f0465e2825cb71d25fd1ceb"
|
||||
},
|
||||
"sourceSha256": "5ff02e971755dee147ef5abdde3b298272d058af333791bdaeb2d71081d58b8a"
|
||||
}
|
||||
},
|
||||
"technicalEvidence": {
|
||||
"runBy": "fact-reviewer",
|
||||
"status": "DONE",
|
||||
"skipReason": "",
|
||||
"verdict": "PASS",
|
||||
"notes": "current truth/evidence/provenance를 SSOT와 역대조했다. live source는 실제 --repo 결과 exit 3이며 UNVERIFIABLE로 보존했다.",
|
||||
"sourceSha256": "5ff02e971755dee147ef5abdde3b298272d058af333791bdaeb2d71081d58b8a",
|
||||
"liveSourceReconciliation": "UNVERIFIABLE",
|
||||
"liveSourceReason": "source repositories unavailable on current machine",
|
||||
"acceptedByProjectReview": true,
|
||||
"artifact": {
|
||||
"path": "runs/virtualization/2026-09-21-0218-remediation-03-case-renewal-succeeded-while-the-old-certificate-kept-serving/stage/quality/technical-evidence-review.json",
|
||||
"sha256": "6dec2c26aa7e0bc308fb065094462baf7339825248a428053f26d13afe028c4e"
|
||||
}
|
||||
}
|
||||
},
|
||||
"riders": [],
|
||||
"sessions": [
|
||||
{
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"openedAt": "2026-09-21T02:24:32+00:00"
|
||||
},
|
||||
{
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"stage": "S3",
|
||||
"generation": 1,
|
||||
"beganAt": "2026-09-21T02:24:33+00:00"
|
||||
},
|
||||
{
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"stage": "S5",
|
||||
"generation": 1,
|
||||
"beganAt": "2026-09-21T02:24:33+00:00"
|
||||
},
|
||||
{
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"stage": "S6",
|
||||
"generation": 1,
|
||||
"beganAt": "2026-09-21T02:24:34+00:00"
|
||||
}
|
||||
],
|
||||
"revision": 23,
|
||||
"updatedAt": "2026-09-21T02:24:35+00:00",
|
||||
"executionEnvironment": {
|
||||
"mode": "current-remediation-contract-replay",
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"agentToolAvailable": false,
|
||||
"note": "별도 subagent 실행 도구가 현재 환경에 없어 현재 세션이 각 .claude/agents 역할 정의와 연결된 SKILL/contract를 직접 읽고 역할을 분리해 수행했다. runBy는 계약 역할명이며 별도 프로세스 실행을 주장하지 않는다."
|
||||
}
|
||||
}
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"authority": "deterministic",
|
||||
"gate": "command-pedagogy-signals",
|
||||
"section_id": "docs/virtualization/tech-log-studio/build-completion-judgment/case/case-renewal-succeeded-while-the-old-certificate-kept-serving.md",
|
||||
"mode": "reference",
|
||||
"source_sha256": "5ff02e971755dee147ef5abdde3b298272d058af333791bdaeb2d71081d58b8a",
|
||||
"result": "PASS",
|
||||
"requires_editor": false,
|
||||
"blocks": [
|
||||
{
|
||||
"id": "command-001",
|
||||
"ordinal": 1,
|
||||
"language": "bash",
|
||||
"info_string": "",
|
||||
"label": null,
|
||||
"mode": "reference",
|
||||
"mode_explicit": false,
|
||||
"start": 3137,
|
||||
"end": 3184,
|
||||
"source_sha256": "8eded66a81332f36679c0e9762d2fb9a70c64993df95831bd9a5b85b701659fd",
|
||||
"body_sha256": "8a3b9f8bb3e86714350739aa7f0b561479847bc190be4c9bee7ae32f9053940e",
|
||||
"source": "```bash\nsystemctl cat certbot-renew.service\n```",
|
||||
"body": "systemctl cat certbot-renew.service\n"
|
||||
},
|
||||
{
|
||||
"id": "command-002",
|
||||
"ordinal": 2,
|
||||
"language": "sh",
|
||||
"info_string": "",
|
||||
"label": null,
|
||||
"mode": "reference",
|
||||
"mode_explicit": false,
|
||||
"start": 4100,
|
||||
"end": 4209,
|
||||
"source_sha256": "f45a6e95f5ecbe3355ed73d28ef80ffecb053f4548e7a3bbbcbc4ec880d03cf6",
|
||||
"body_sha256": "27b0d28f72948d27ad55d4360d0bbcc4635cddbf9d56105373c8fbd105f7aa07",
|
||||
"source": "```sh\n# file: /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh\n#!/bin/sh\nnginx -t && nginx -s reload\n```",
|
||||
"body": "# file: /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh\n#!/bin/sh\nnginx -t && nginx -s reload\n"
|
||||
},
|
||||
{
|
||||
"id": "command-003",
|
||||
"ordinal": 3,
|
||||
"language": "bash",
|
||||
"info_string": "",
|
||||
"label": null,
|
||||
"mode": "reference",
|
||||
"mode_explicit": false,
|
||||
"start": 4211,
|
||||
"end": 4290,
|
||||
"source_sha256": "4681b5b689943c23692c0b53656460a615f098e04391bf27b71ca1b95855c69c",
|
||||
"body_sha256": "f2767ee5bf9212eacf397619fec622dc939064147b734341845c5222fedae3bd",
|
||||
"source": "```bash\nsudo chmod +x /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh\n```",
|
||||
"body": "sudo chmod +x /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh\n"
|
||||
},
|
||||
{
|
||||
"id": "command-004",
|
||||
"ordinal": 4,
|
||||
"language": "bash",
|
||||
"info_string": "",
|
||||
"label": null,
|
||||
"mode": "reference",
|
||||
"mode_explicit": false,
|
||||
"start": 4610,
|
||||
"end": 4650,
|
||||
"source_sha256": "5ad5beae50e38cc6014d668f6a284a16d947b53b941366e04a21bf83dd419a7f",
|
||||
"body_sha256": "20d076d70206b21642508d0fda76c9b019b3860a477a19c6e81cc2a16f578da8",
|
||||
"source": "```bash\nsudo certbot renew --dry-run\n```",
|
||||
"body": "sudo certbot renew --dry-run\n"
|
||||
},
|
||||
{
|
||||
"id": "command-005",
|
||||
"ordinal": 5,
|
||||
"language": "bash",
|
||||
"info_string": "",
|
||||
"label": null,
|
||||
"mode": "reference",
|
||||
"mode_explicit": false,
|
||||
"start": 4835,
|
||||
"end": 5061,
|
||||
"source_sha256": "a2bad5f8d9c1757ad3716c398198712a488f8cc186816c21e427cf9299b66444",
|
||||
"body_sha256": "e0f1e9a45761b1e5aefbcd19e1fa8ea8e1f7276402d56c181f16275e3ec92f66",
|
||||
"source": "```bash\n# 강제 갱신 전에 워커 PID 를 적어 둔다\nps -eo pid,lstart,args | grep 'nginx: worker' | grep -v grep\n\nsudo certbot renew --force-renewal\n\n# 워커 PID 가 바뀌었으면 reload 된 것이다\nps -eo pid,lstart,args | grep 'nginx: worker' | grep -v grep\n```",
|
||||
"body": "# 강제 갱신 전에 워커 PID 를 적어 둔다\nps -eo pid,lstart,args | grep 'nginx: worker' | grep -v grep\n\nsudo certbot renew --force-renewal\n\n# 워커 PID 가 바뀌었으면 reload 된 것이다\nps -eo pid,lstart,args | grep 'nginx: worker' | grep -v grep\n"
|
||||
},
|
||||
{
|
||||
"id": "command-006",
|
||||
"ordinal": 6,
|
||||
"language": "bash",
|
||||
"info_string": "",
|
||||
"label": null,
|
||||
"mode": "reference",
|
||||
"mode_explicit": false,
|
||||
"start": 5797,
|
||||
"end": 6027,
|
||||
"source_sha256": "519aa5bbb424c16e2b51b65f4de056a83e7d29cb5968db00d734d92d03dcf3c5",
|
||||
"body_sha256": "ca5353f2edd2dea1854cee0dea07b9a49f930ac36aac9281a86a8507fb7b2be8",
|
||||
"source": "```bash\nA=$(date -u +%s.%N); B=$(ssh test-server 'date -u +%s.%N'); C=$(date -u +%s.%N)\n# 왜곡 ≈ B − (A+C)/2 , 어느 쪽이 맞는지는 외부 기준으로 가른다\ncurl -sI https://www.google.com | grep -i '^date:'\ntimedatectl show -p NTP -p NTPSynchronized\n```",
|
||||
"body": "A=$(date -u +%s.%N); B=$(ssh test-server 'date -u +%s.%N'); C=$(date -u +%s.%N)\n# 왜곡 ≈ B − (A+C)/2 , 어느 쪽이 맞는지는 외부 기준으로 가른다\ncurl -sI https://www.google.com | grep -i '^date:'\ntimedatectl show -p NTP -p NTPSynchronized\n"
|
||||
}
|
||||
],
|
||||
"findings": [],
|
||||
"extensions": {
|
||||
"command_like_text_blocks": []
|
||||
}
|
||||
}
|
||||
+186
@@ -0,0 +1,186 @@
|
||||
---
|
||||
kind: CASE
|
||||
slug: renewal-succeeded-while-the-old-certificate-kept-serving
|
||||
title: 갱신은 매번 SUCCESS 였고 옛 인증서가 계속 나갔다 — 2305초와 1~2초
|
||||
topic: build-completion-judgment
|
||||
topicName: 끝났다는 판정
|
||||
project: virtualization
|
||||
status: 게시 전
|
||||
sourceRevision: import-head 9465582b5d1630eb4ae7c4e078021486919bf6b6 · uncommitted-working-tree snapshot
|
||||
source:
|
||||
- final/document.md#190-단계-04-let's-encrypt-와-인증서-갱신
|
||||
- final/document.md#189-단계-03-엣지-nginx-라우팅과-호스트-dnat
|
||||
- final/document.md#194-이-부에서-파생될-open-question
|
||||
---
|
||||
|
||||
# 갱신은 매번 SUCCESS 였고 옛 인증서가 계속 나갔다 — 2305초와 1~2초
|
||||
|
||||
갱신은 매번 SUCCESS 로 끝나는데 nginx 는 옛 인증서를 계속 내보냈다. certbot-renew.service 에 훅이 없어 갱신에서 서빙까지 2305초 걸렸고, deploy 훅을 두니 1~2초였다. 만료 30일 전까지는 갱신을 안 해 88일 동안 아무 표시도 나지 않는다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **인증서는 DNS-01 로 받는다 — 실험대 주소가 공개 인터넷에 없어 HTTP-01 이 성립하지 않는다**
|
||||
이 인증서를 어떻게 받기로 했는지가 그 결정이고, 여기서는 받은 뒤의 갱신을 다룬다.
|
||||
- **도구가 낸 출력은 대상의 상태가 아니다 — 그 명령이 무엇을 세는지부터 가른다**
|
||||
갱신 로그의 SUCCESS 와 훅 로그의 error output 이 둘 다 상태를 잘못 말한 사건이다.
|
||||
- **가까운 층부터 한 층씩 건너뛰며 확인한다 — 층마다 성공 신호가 다르다**
|
||||
이 단계의 확인을 어느 기계에서 치느냐가 결과를 바꾸고, 층별 확인이 그 순서를 정한다.
|
||||
- **단계별 구축 문서는 작성 시점이 아니라 실행 순서로 검증한다**
|
||||
훅을 호스트에 두었다가 잃은 것도, 확인 명령을 엣지 안에서 친 것도 그 규칙이 받는 결함이다.
|
||||
- **가이드대로 다시 쳐서 이 실험대가 같은 상태로 서는가**
|
||||
2305초를 잰 배치와 지금 배치가 다르므로 다시 세운 실험대에서 재야 할 값이 여기 있다.
|
||||
|
||||
## 문제
|
||||
|
||||
인증서 갱신은 매번 성공으로 끝나는데 서버가 내보내는 인증서는 옛것이었다.
|
||||
|
||||
certbot-renew.timer : 정상
|
||||
certbot-renew.service 의 결과 : SUCCESS
|
||||
유닛의 ExecStartPost 또는 --deploy-hook : x
|
||||
nginx 가 새 인증서를 읽었나 : x
|
||||
갱신에서 서빙까지 : 2305초 (38분 25초)
|
||||
무엇이 reload 했나 : 사람이 직접
|
||||
|
||||
발현하는 날의 증상은 인증서 만료이고, 그날에도 로그에는 SUCCESS 라고 적혀 있다.
|
||||
|
||||
## 결론
|
||||
|
||||
certbot-renew.service 는 /usr/bin/certbot -q renew 한 줄이고 인증서를 새로 받는 데까지만 책임진다. nginx 는 인증서를 기동 시점에 읽어 메모리에 들고 있고 certbot 은 live/ 심볼릭 링크만 갈아 끼우므로, 경로는 그대로이고 내용만 바뀌어 nginx 는 바뀐 줄 모른다.
|
||||
|
||||
훅도 사람도 없으면 다음 nginx 재시작까지, 사실상 무기한으로 옛 인증서가 나간다.
|
||||
|
||||
해결 : /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh 에 nginx -t && nginx -s reload 를 두고 chmod +x 를 준다
|
||||
실행 권한이 없으면 : certbot 이 조용히 건너뛴다
|
||||
post/ 가 아니라 deploy/ 인 까닭 : post/ 는 갱신이 없어도 매번 돌아 하루 두 번 워커를 갈아치운다
|
||||
훅을 저장소에 두는 까닭 : 호스트에만 두었을 때는 호스트를 초기화하면 아무 오류 없이 사라졌다
|
||||
판정 : 로그 문구가 아니라 강제 갱신 전후의 nginx 워커 PID 로 한다
|
||||
|
||||
훅을 넣어도 안전했다. reload 는 무중단이었고 새 연결 8856건이 전부 200, p95 는 205.7ms 대 204.3ms 로 변화가 없었다.
|
||||
|
||||
## 검증 환경
|
||||
|
||||
엣지 게스트 : kc-lab-edge, 192.168.122.10, Debian 12
|
||||
nginx : nginx/1.22.1
|
||||
certbot 플러그인 : dns-cloudflare
|
||||
발급 대상 : -d hyeonworks.com -d '*.hyeonworks.com'
|
||||
lineage 디렉터리 : /etc/letsencrypt/live/auth.hyeonworks.com/ (2026-09-17 현재 관측)
|
||||
nginx 가 읽는 파일 : fullchain.pem · privkey.pem
|
||||
타이머 : certbot-renew.timer → certbot-renew.service
|
||||
인증서 유효기간 : 오늘 + 90일
|
||||
2305초를 잴 때의 배치 : 훅이 물리 호스트에 있었다
|
||||
지금의 배치 : certbot · 인증서 · 갱신 타이머 · deploy 훅이 전부 엣지 게스트에 있다
|
||||
|
||||
## 재현 조건
|
||||
|
||||
1. 엣지 게스트에 certbot 과 python3-certbot-dns-cloudflare 를 깔고 와일드카드 인증서를 받는다.
|
||||
2. nginx 443 블록에 fullchain.pem 과 privkey.pem 경로를 적고 reload 한다.
|
||||
3. systemctl cat certbot-renew.service 로 ExecStartPost 와 --deploy-hook 이 없는지 본다.
|
||||
4. ps -eo pid,lstart,args 로 nginx 워커 PID 를 적어 둔다.
|
||||
5. sudo certbot renew --force-renewal 을 친다.
|
||||
6. 워커 PID 를 다시 본다. 바뀌지 않았으면 옛 인증서가 계속 나가고 있다.
|
||||
7. deploy/reload-nginx.sh 를 두고 chmod +x 를 준 뒤 4번부터 6번까지를 다시 한다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
## 배포판 유닛이 어디까지 책임지나
|
||||
|
||||
Let's Encrypt 인증서는 90일짜리이고 갱신은 `certbot-renew.timer` 가 건다. 타이머가 부르는 유닛을 열어 보면 한 줄이다.
|
||||
|
||||
```bash
|
||||
systemctl cat certbot-renew.service
|
||||
```
|
||||
|
||||
```
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/usr/bin/certbot -q renew
|
||||
PrivateTmp=true
|
||||
```
|
||||
|
||||
`ExecStartPost` 도 `--deploy-hook` 도 없다. 이 유닛은 인증서를 새로 받는 데까지만 책임지고, 받은 것을 nginx 가 읽게 만드는 일은 아무도 하지 않는다. 배포판이 이렇게 준다는 것이 요점이라, 「기본값이니 괜찮겠지」가 이 결함이 사는 곳이다.
|
||||
|
||||
그래서 이 글의 훅이 모든 배포판에 필요하지는 않다. 위 유닛에 `ExecStartPost` 나 `--deploy-hook` 이 이미 적혀 있는 배포판이라면 훅을 새로 넣을 것이 아니라 거기 적힌 명령이 nginx 를 reload 하는지만 본다.
|
||||
|
||||
## nginx 는 인증서를 언제 읽나
|
||||
|
||||
nginx 는 `ssl_certificate` 에 적힌 파일을 기동과 reload 시점에 읽어 메모리에 들고 있고, certbot 은 `live/` 아래의 심볼릭 링크를 새 파일로 갈아 끼운다. 설정에 적힌 경로는 한 글자도 바뀌지 않고 그 경로가 가리키는 파일만 바뀌므로, nginx 쪽에서는 다시 읽을 계기가 생기지 않는다. 갱신이 끝난 뒤에 nginx 로 reload 를 걸어 주는 것이 없으면 옛 인증서가 계속 나간다.
|
||||
|
||||
## 88일 동안 이 결함이 보이지 않는다
|
||||
|
||||
타이머는 정상이고 갱신은 매번 `SUCCESS` 로 끝난다. 만료 30일 전까지는 갱신 자체를 하지 않으므로 88일 동안 발현할 기회가 없다. 발현하는 날의 증상은 인증서 만료이고, 그날에도 로그에는 `SUCCESS` 라고 적혀 있다.
|
||||
|
||||
훅을 저장소에 두는 까닭도 여기에 있다. 호스트에만 두었을 때는 호스트를 초기화하면 아무 오류 없이 사라졌고, 사라진 것도 갱신이 실제로 일어나는 날까지는 드러나지 않는다.
|
||||
|
||||
## 훅 두 줄과 실행 권한
|
||||
|
||||
```sh
|
||||
# file: /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
|
||||
#!/bin/sh
|
||||
nginx -t && nginx -s reload
|
||||
```
|
||||
|
||||
```bash
|
||||
sudo chmod +x /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
|
||||
```
|
||||
|
||||
실행 권한이 없으면 certbot 이 조용히 건너뛴다. `post/` 가 아니라 `deploy/` 에 넣는 것은 `post/` 가 갱신이 없어도 매번 돌아 하루 두 번 워커를 갈아치우기 때문이다.
|
||||
|
||||
이 실험대에서 잰 차이는 이렇다.
|
||||
|
||||
| | 훅 없음 | 훅 있음 |
|
||||
|---|---|---|
|
||||
| 갱신 → 서빙 | 2305초 (38분 25초) | 1~2초 |
|
||||
| 무엇이 reload 했나 | 사람이 직접 | certbot deploy 훅 |
|
||||
|
||||
훅도 사람도 없었다면 다음 nginx 재시작까지, 사실상 무기한이다.
|
||||
|
||||
## 판정은 로그 문구가 아니라 워커 PID 로 한다
|
||||
|
||||
```bash
|
||||
sudo certbot renew --dry-run
|
||||
```
|
||||
|
||||
dry-run 은 훅이 호출되는지까지만 말해 준다. 호출된 훅이 nginx 를 정말 갈아 끼웠는지는 따로 본다.
|
||||
|
||||
순서가 dry-run 먼저인 것은 뒤엣것이 상태를 바꾸기 때문이다. `--force-renewal` 은 인증서를 실제로 새로 받아 발급 한도(주당 중복 5장)를 깎으므로, 진짜 판정이 필요할 때 한 번만 쓴다.
|
||||
|
||||
```bash
|
||||
# 강제 갱신 전에 워커 PID 를 적어 둔다
|
||||
ps -eo pid,lstart,args | grep 'nginx: worker' | grep -v grep
|
||||
|
||||
sudo certbot renew --force-renewal
|
||||
|
||||
# 워커 PID 가 바뀌었으면 reload 된 것이다
|
||||
ps -eo pid,lstart,args | grep 'nginx: worker' | grep -v grep
|
||||
```
|
||||
|
||||
reload 는 마스터를 그대로 두고 워커만 갈아 끼우므로, 전후로 워커 PID 가 바뀌면 새 설정이 적용됐다. `lstart` 를 같이 뽑는 것은 PID 가 우연히 재사용됐을 때를 가르기 위해서다.
|
||||
|
||||
certbot 이 `Hook 'deploy-hook' ran with error output` 이라고 찍는데 실패가 아니다. nginx 의 `types_hash` 경고가 stderr 로 나갔을 뿐이고 내용은 `test is successful` 과 `signal process started` 다. 로그에서 `error` 를 grep 하는 감시를 걸면 성공한 훅을 실패로 오독한다.
|
||||
|
||||
## 훅을 넣어도 안전한가
|
||||
|
||||
갱신 때마다 reload 가 도는 것이 서비스에 영향을 주는지도 쟀다. 새 연결 8856건이 전부 200 이었고 p95 는 205.7ms 대 204.3ms 로 변화가 없었다. 845KB 를 20k/s 로 받는 중이던 요청은 전송 12초째에 reload 를 맞고도 845361바이트를 온전히 받았다(연결수 1). 옛 워커가 그 요청을 끝까지 책임진다.
|
||||
|
||||
## 수치를 내기 전에 시계를 쟀다
|
||||
|
||||
갱신 시각과 reload 시각이 다른 기계에 찍히므로, 두 시각을 빼기 전에 시계부터 쟀다. test-server 가 NTP 미동기로 106초 빨랐고, 보정하지 않은 첫 계산은 훅이 인증서 발급보다 104초 먼저 실행된 것이 되어 물리적으로 불가능했다. 음수 지연이 나오면 계산이 아니라 시계를 의심한다.
|
||||
|
||||
```bash
|
||||
A=$(date -u +%s.%N); B=$(ssh test-server 'date -u +%s.%N'); C=$(date -u +%s.%N)
|
||||
# 왜곡 ≈ B − (A+C)/2 , 어느 쪽이 맞는지는 외부 기준으로 가른다
|
||||
curl -sI https://www.google.com | grep -i '^date:'
|
||||
timedatectl show -p NTP -p NTPSynchronized
|
||||
```
|
||||
|
||||
## 확인하지 못한 것
|
||||
|
||||
2305초는 훅이 물리 호스트에만 있던 시절의 값이다. 지금은 certbot 과 인증서와 갱신 타이머와 deploy 훅이 전부 엣지 게스트에 있고, 그 배치에서 다시 재면 같은 수가 나오는지는 재지 않았다. 그래서 이 글의 2305초는 훅이 없으면 이만큼 벌어진다는 한 번의 측정이고 지금 배치의 값이 아니다. 1~2초 쪽도 같은 시기에 잰 값이다.
|
||||
|
||||
측정 출력을 `final/evidence/` 에 남기지 않았다. 2305초도 8856건도 p95 두 값도 845361바이트도 106초도 SSOT 본문에만 있다. 워커 PID 를 전후로 비교한 출력도 저장해 두지 않았다.
|
||||
|
||||
88일이라는 잠복 기간은 만료 30일 전에야 갱신을 시작한다는 동작에서 끌어낸 것이지, 한 주기를 실제로 돌려 본 값이 아니다. 훅을 뺀 채 만료일까지 가 본 기록은 없고 그런 기록이 있을 까닭도 없다.
|
||||
|
||||
<!-- body:end -->
|
||||
+186
@@ -0,0 +1,186 @@
|
||||
---
|
||||
kind: CASE
|
||||
slug: renewal-succeeded-while-the-old-certificate-kept-serving
|
||||
title: 갱신은 매번 SUCCESS 였고 옛 인증서가 계속 나갔다 — 2305초와 1~2초
|
||||
topic: build-completion-judgment
|
||||
topicName: 끝났다는 판정
|
||||
project: virtualization
|
||||
status: 게시 전
|
||||
sourceRevision: import-head 9465582b5d1630eb4ae7c4e078021486919bf6b6 · uncommitted-working-tree snapshot
|
||||
source:
|
||||
- final/document.md#190-단계-04-let's-encrypt-와-인증서-갱신
|
||||
- final/document.md#189-단계-03-엣지-nginx-라우팅과-호스트-dnat
|
||||
- final/document.md#194-이-부에서-파생될-open-question
|
||||
---
|
||||
|
||||
# 갱신은 매번 SUCCESS 였고 옛 인증서가 계속 나갔다 — 2305초와 1~2초
|
||||
|
||||
갱신은 매번 SUCCESS 로 끝나는데 nginx 는 옛 인증서를 계속 내보냈다. certbot-renew.service 에 훅이 없어 갱신에서 서빙까지 2305초 걸렸고, deploy 훅을 두니 1~2초였다. 만료 30일 전까지는 갱신을 안 해 88일 동안 아무 표시도 나지 않는다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **인증서는 DNS-01 로 받는다 — 실험대 주소가 공개 인터넷에 없어 HTTP-01 이 성립하지 않는다**
|
||||
이 인증서를 어떻게 받기로 했는지가 그 결정이고, 여기서는 받은 뒤의 갱신을 다룬다.
|
||||
- **도구가 낸 출력은 대상의 상태가 아니다 — 그 명령이 무엇을 세는지부터 가른다**
|
||||
갱신 로그의 SUCCESS 와 훅 로그의 error output 이 둘 다 상태를 잘못 말한 사건이다.
|
||||
- **가까운 층부터 한 층씩 건너뛰며 확인한다 — 층마다 성공 신호가 다르다**
|
||||
이 단계의 확인을 어느 기계에서 치느냐가 결과를 바꾸고, 층별 확인이 그 순서를 정한다.
|
||||
- **단계별 구축 문서는 작성 시점이 아니라 실행 순서로 검증한다**
|
||||
훅을 호스트에 두었다가 잃은 것도, 확인 명령을 엣지 안에서 친 것도 그 규칙이 받는 결함이다.
|
||||
- **가이드대로 다시 쳐서 이 실험대가 같은 상태로 서는가**
|
||||
2305초를 잰 배치와 지금 배치가 다르므로 다시 세운 실험대에서 재야 할 값이 여기 있다.
|
||||
|
||||
## 문제
|
||||
|
||||
인증서 갱신은 매번 성공으로 끝나는데 서버가 내보내는 인증서는 옛것이었다.
|
||||
|
||||
certbot-renew.timer : 정상
|
||||
certbot-renew.service 의 결과 : SUCCESS
|
||||
유닛의 ExecStartPost 또는 --deploy-hook : x
|
||||
nginx 가 새 인증서를 읽었나 : x
|
||||
갱신에서 서빙까지 : 2305초 (38분 25초)
|
||||
무엇이 reload 했나 : 사람이 직접
|
||||
|
||||
발현하는 날의 증상은 인증서 만료이고, 그날에도 로그에는 SUCCESS 라고 적혀 있다.
|
||||
|
||||
## 결론
|
||||
|
||||
certbot-renew.service 는 /usr/bin/certbot -q renew 한 줄이고 인증서를 새로 받는 데까지만 책임진다. nginx 는 인증서를 기동 시점에 읽어 메모리에 들고 있고 certbot 은 live/ 심볼릭 링크만 갈아 끼우므로, 경로는 그대로이고 내용만 바뀌어 nginx 는 바뀐 줄 모른다.
|
||||
|
||||
훅도 사람도 없으면 다음 nginx 재시작까지, 사실상 무기한으로 옛 인증서가 나간다.
|
||||
|
||||
해결 : /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh 에 nginx -t && nginx -s reload 를 두고 chmod +x 를 준다
|
||||
실행 권한이 없으면 : certbot 이 조용히 건너뛴다
|
||||
post/ 가 아니라 deploy/ 인 까닭 : post/ 는 갱신이 없어도 매번 돌아 하루 두 번 워커를 갈아치운다
|
||||
훅을 저장소에 두는 까닭 : 호스트에만 두었을 때는 호스트를 초기화하면 아무 오류 없이 사라졌다
|
||||
판정 : 로그 문구가 아니라 강제 갱신 전후의 nginx 워커 PID 로 한다
|
||||
|
||||
훅을 넣어도 안전했다. reload 는 무중단이었고 새 연결 8856건이 전부 200, p95 는 205.7ms 대 204.3ms 로 변화가 없었다.
|
||||
|
||||
## 검증 환경
|
||||
|
||||
엣지 게스트 : kc-lab-edge, 192.168.122.10, Debian 12
|
||||
nginx : nginx/1.22.1
|
||||
certbot 플러그인 : dns-cloudflare
|
||||
발급 대상 : -d hyeonworks.com -d '*.hyeonworks.com'
|
||||
lineage 디렉터리 : /etc/letsencrypt/live/auth.hyeonworks.com/ (2026-09-17 현재 관측)
|
||||
nginx 가 읽는 파일 : fullchain.pem · privkey.pem
|
||||
타이머 : certbot-renew.timer → certbot-renew.service
|
||||
인증서 유효기간 : 오늘 + 90일
|
||||
2305초를 잴 때의 배치 : 훅이 물리 호스트에 있었다
|
||||
지금의 배치 : certbot · 인증서 · 갱신 타이머 · deploy 훅이 전부 엣지 게스트에 있다
|
||||
|
||||
## 재현 조건
|
||||
|
||||
1. 엣지 게스트에 certbot 과 python3-certbot-dns-cloudflare 를 깔고 와일드카드 인증서를 받는다.
|
||||
2. nginx 443 블록에 fullchain.pem 과 privkey.pem 경로를 적고 reload 한다.
|
||||
3. systemctl cat certbot-renew.service 로 ExecStartPost 와 --deploy-hook 이 없는지 본다.
|
||||
4. ps -eo pid,lstart,args 로 nginx 워커 PID 를 적어 둔다.
|
||||
5. sudo certbot renew --force-renewal 을 친다.
|
||||
6. 워커 PID 를 다시 본다. 바뀌지 않았으면 옛 인증서가 계속 나가고 있다.
|
||||
7. deploy/reload-nginx.sh 를 두고 chmod +x 를 준 뒤 4번부터 6번까지를 다시 한다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
## 배포판 유닛이 어디까지 책임지나
|
||||
|
||||
Let's Encrypt 인증서는 90일짜리이고 갱신은 `certbot-renew.timer` 가 건다. 타이머가 부르는 유닛을 열어 보면 한 줄이다.
|
||||
|
||||
```bash
|
||||
systemctl cat certbot-renew.service
|
||||
```
|
||||
|
||||
```
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/usr/bin/certbot -q renew
|
||||
PrivateTmp=true
|
||||
```
|
||||
|
||||
`ExecStartPost` 도 `--deploy-hook` 도 없다. 이 유닛은 인증서를 새로 받는 데까지만 책임지고, 받은 것을 nginx 가 읽게 만드는 일은 아무도 하지 않는다. 배포판이 이렇게 준다는 것이 요점이라, 「기본값이니 괜찮겠지」가 이 결함이 사는 곳이다.
|
||||
|
||||
그래서 이 글의 훅이 모든 배포판에 필요하지는 않다. 위 유닛에 `ExecStartPost` 나 `--deploy-hook` 이 이미 적혀 있는 배포판이라면 훅을 새로 넣을 것이 아니라 거기 적힌 명령이 nginx 를 reload 하는지만 본다.
|
||||
|
||||
## nginx 는 인증서를 언제 읽나
|
||||
|
||||
nginx 는 `ssl_certificate` 에 적힌 파일을 기동과 reload 시점에 읽어 메모리에 들고 있고, certbot 은 `live/` 아래의 심볼릭 링크를 새 파일로 갈아 끼운다. 설정에 적힌 경로는 한 글자도 바뀌지 않고 그 경로가 가리키는 파일만 바뀌므로, nginx 쪽에서는 다시 읽을 계기가 생기지 않는다. 갱신이 끝난 뒤에 nginx 로 reload 를 걸어 주는 것이 없으면 옛 인증서가 계속 나간다.
|
||||
|
||||
## 88일 동안 이 결함이 보이지 않는다
|
||||
|
||||
타이머는 정상이고 갱신은 매번 `SUCCESS` 로 끝난다. 만료 30일 전까지는 갱신 자체를 하지 않으므로 88일 동안 발현할 기회가 없다. 발현하는 날의 증상은 인증서 만료이고, 그날에도 로그에는 `SUCCESS` 라고 적혀 있다.
|
||||
|
||||
훅을 저장소에 두는 까닭도 여기에 있다. 호스트에만 두었을 때는 호스트를 초기화하면 아무 오류 없이 사라졌고, 사라진 것도 갱신이 실제로 일어나는 날까지는 드러나지 않는다.
|
||||
|
||||
## 훅 두 줄과 실행 권한
|
||||
|
||||
```sh
|
||||
# file: /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
|
||||
#!/bin/sh
|
||||
nginx -t && nginx -s reload
|
||||
```
|
||||
|
||||
```bash
|
||||
sudo chmod +x /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
|
||||
```
|
||||
|
||||
실행 권한이 없으면 certbot 이 조용히 건너뛴다. `post/` 가 아니라 `deploy/` 에 넣는 것은 `post/` 가 갱신이 없어도 매번 돌아 하루 두 번 워커를 갈아치우기 때문이다.
|
||||
|
||||
이 실험대에서 잰 차이는 이렇다.
|
||||
|
||||
| | 훅 없음 | 훅 있음 |
|
||||
|---|---|---|
|
||||
| 갱신 → 서빙 | 2305초 (38분 25초) | 1~2초 |
|
||||
| 무엇이 reload 했나 | 사람이 직접 | certbot deploy 훅 |
|
||||
|
||||
훅도 사람도 없었다면 다음 nginx 재시작까지, 사실상 무기한이다.
|
||||
|
||||
## 판정은 로그 문구가 아니라 워커 PID 로 한다
|
||||
|
||||
```bash
|
||||
sudo certbot renew --dry-run
|
||||
```
|
||||
|
||||
dry-run 은 훅이 호출되는지까지만 말해 준다. 호출된 훅이 nginx 를 정말 갈아 끼웠는지는 따로 본다.
|
||||
|
||||
순서가 dry-run 먼저인 것은 뒤엣것이 상태를 바꾸기 때문이다. `--force-renewal` 은 인증서를 실제로 새로 받아 발급 한도(주당 중복 5장)를 깎으므로, 진짜 판정이 필요할 때 한 번만 쓴다.
|
||||
|
||||
```bash
|
||||
# 강제 갱신 전에 워커 PID 를 적어 둔다
|
||||
ps -eo pid,lstart,args | grep 'nginx: worker' | grep -v grep
|
||||
|
||||
sudo certbot renew --force-renewal
|
||||
|
||||
# 워커 PID 가 바뀌었으면 reload 된 것이다
|
||||
ps -eo pid,lstart,args | grep 'nginx: worker' | grep -v grep
|
||||
```
|
||||
|
||||
reload 는 마스터를 그대로 두고 워커만 갈아 끼우므로, 전후로 워커 PID 가 바뀌면 새 설정이 적용됐다. `lstart` 를 같이 뽑는 것은 PID 가 우연히 재사용됐을 때를 가르기 위해서다.
|
||||
|
||||
certbot 이 `Hook 'deploy-hook' ran with error output` 이라고 찍는데 실패가 아니다. nginx 의 `types_hash` 경고가 stderr 로 나갔을 뿐이고 내용은 `test is successful` 과 `signal process started` 다. 로그에서 `error` 를 grep 하는 감시를 걸면 성공한 훅을 실패로 오독한다.
|
||||
|
||||
## 훅을 넣어도 안전한가
|
||||
|
||||
갱신 때마다 reload 가 도는 것이 서비스에 영향을 주는지도 쟀다. 새 연결 8856건이 전부 200 이었고 p95 는 205.7ms 대 204.3ms 로 변화가 없었다. 845KB 를 20k/s 로 받는 중이던 요청은 전송 12초째에 reload 를 맞고도 845361바이트를 온전히 받았다(연결수 1). 옛 워커가 그 요청을 끝까지 책임진다.
|
||||
|
||||
## 수치를 내기 전에 시계를 쟀다
|
||||
|
||||
갱신 시각과 reload 시각이 다른 기계에 찍히므로, 두 시각을 빼기 전에 시계부터 쟀다. test-server 가 NTP 미동기로 106초 빨랐고, 보정하지 않은 첫 계산은 훅이 인증서 발급보다 104초 먼저 실행된 것이 되어 물리적으로 불가능했다. 음수 지연이 나오면 계산이 아니라 시계를 의심한다.
|
||||
|
||||
```bash
|
||||
A=$(date -u +%s.%N); B=$(ssh test-server 'date -u +%s.%N'); C=$(date -u +%s.%N)
|
||||
# 왜곡 ≈ B − (A+C)/2 , 어느 쪽이 맞는지는 외부 기준으로 가른다
|
||||
curl -sI https://www.google.com | grep -i '^date:'
|
||||
timedatectl show -p NTP -p NTPSynchronized
|
||||
```
|
||||
|
||||
## 확인하지 못한 것
|
||||
|
||||
2305초는 훅이 물리 호스트에만 있던 시절의 값이다. 지금은 certbot 과 인증서와 갱신 타이머와 deploy 훅이 전부 엣지 게스트에 있고, 그 배치에서 다시 재면 같은 수가 나오는지는 재지 않았다. 그래서 이 글의 2305초는 훅이 없으면 이만큼 벌어진다는 한 번의 측정이고 지금 배치의 값이 아니다. 1~2초 쪽도 같은 시기에 잰 값이다.
|
||||
|
||||
측정 출력을 `final/evidence/` 에 남기지 않았다. 2305초도 8856건도 p95 두 값도 845361바이트도 106초도 SSOT 본문에만 있다. 워커 PID 를 전후로 비교한 출력도 저장해 두지 않았다.
|
||||
|
||||
88일이라는 잠복 기간은 만료 30일 전에야 갱신을 시작한다는 동작에서 끌어낸 것이지, 한 주기를 실제로 돌려 본 값이 아니다. 훅을 뺀 채 만료일까지 가 본 기록은 없고 그런 기록이 있을 까닭도 없다.
|
||||
|
||||
<!-- body:end -->
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"authority": "deterministic",
|
||||
"gate": "command-pedagogy-signals",
|
||||
"section_id": "docs/virtualization/tech-log-studio/build-completion-judgment/case/case-renewal-succeeded-while-the-old-certificate-kept-serving.md",
|
||||
"mode": "reference",
|
||||
"source_sha256": "5ff02e971755dee147ef5abdde3b298272d058af333791bdaeb2d71081d58b8a",
|
||||
"result": "PASS",
|
||||
"requires_editor": false,
|
||||
"blocks": [
|
||||
{
|
||||
"id": "command-001",
|
||||
"ordinal": 1,
|
||||
"language": "bash",
|
||||
"info_string": "",
|
||||
"label": null,
|
||||
"mode": "reference",
|
||||
"mode_explicit": false,
|
||||
"start": 3137,
|
||||
"end": 3184,
|
||||
"source_sha256": "8eded66a81332f36679c0e9762d2fb9a70c64993df95831bd9a5b85b701659fd",
|
||||
"body_sha256": "8a3b9f8bb3e86714350739aa7f0b561479847bc190be4c9bee7ae32f9053940e",
|
||||
"source": "```bash\nsystemctl cat certbot-renew.service\n```",
|
||||
"body": "systemctl cat certbot-renew.service\n"
|
||||
},
|
||||
{
|
||||
"id": "command-002",
|
||||
"ordinal": 2,
|
||||
"language": "sh",
|
||||
"info_string": "",
|
||||
"label": null,
|
||||
"mode": "reference",
|
||||
"mode_explicit": false,
|
||||
"start": 4100,
|
||||
"end": 4209,
|
||||
"source_sha256": "f45a6e95f5ecbe3355ed73d28ef80ffecb053f4548e7a3bbbcbc4ec880d03cf6",
|
||||
"body_sha256": "27b0d28f72948d27ad55d4360d0bbcc4635cddbf9d56105373c8fbd105f7aa07",
|
||||
"source": "```sh\n# file: /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh\n#!/bin/sh\nnginx -t && nginx -s reload\n```",
|
||||
"body": "# file: /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh\n#!/bin/sh\nnginx -t && nginx -s reload\n"
|
||||
},
|
||||
{
|
||||
"id": "command-003",
|
||||
"ordinal": 3,
|
||||
"language": "bash",
|
||||
"info_string": "",
|
||||
"label": null,
|
||||
"mode": "reference",
|
||||
"mode_explicit": false,
|
||||
"start": 4211,
|
||||
"end": 4290,
|
||||
"source_sha256": "4681b5b689943c23692c0b53656460a615f098e04391bf27b71ca1b95855c69c",
|
||||
"body_sha256": "f2767ee5bf9212eacf397619fec622dc939064147b734341845c5222fedae3bd",
|
||||
"source": "```bash\nsudo chmod +x /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh\n```",
|
||||
"body": "sudo chmod +x /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh\n"
|
||||
},
|
||||
{
|
||||
"id": "command-004",
|
||||
"ordinal": 4,
|
||||
"language": "bash",
|
||||
"info_string": "",
|
||||
"label": null,
|
||||
"mode": "reference",
|
||||
"mode_explicit": false,
|
||||
"start": 4610,
|
||||
"end": 4650,
|
||||
"source_sha256": "5ad5beae50e38cc6014d668f6a284a16d947b53b941366e04a21bf83dd419a7f",
|
||||
"body_sha256": "20d076d70206b21642508d0fda76c9b019b3860a477a19c6e81cc2a16f578da8",
|
||||
"source": "```bash\nsudo certbot renew --dry-run\n```",
|
||||
"body": "sudo certbot renew --dry-run\n"
|
||||
},
|
||||
{
|
||||
"id": "command-005",
|
||||
"ordinal": 5,
|
||||
"language": "bash",
|
||||
"info_string": "",
|
||||
"label": null,
|
||||
"mode": "reference",
|
||||
"mode_explicit": false,
|
||||
"start": 4835,
|
||||
"end": 5061,
|
||||
"source_sha256": "a2bad5f8d9c1757ad3716c398198712a488f8cc186816c21e427cf9299b66444",
|
||||
"body_sha256": "e0f1e9a45761b1e5aefbcd19e1fa8ea8e1f7276402d56c181f16275e3ec92f66",
|
||||
"source": "```bash\n# 강제 갱신 전에 워커 PID 를 적어 둔다\nps -eo pid,lstart,args | grep 'nginx: worker' | grep -v grep\n\nsudo certbot renew --force-renewal\n\n# 워커 PID 가 바뀌었으면 reload 된 것이다\nps -eo pid,lstart,args | grep 'nginx: worker' | grep -v grep\n```",
|
||||
"body": "# 강제 갱신 전에 워커 PID 를 적어 둔다\nps -eo pid,lstart,args | grep 'nginx: worker' | grep -v grep\n\nsudo certbot renew --force-renewal\n\n# 워커 PID 가 바뀌었으면 reload 된 것이다\nps -eo pid,lstart,args | grep 'nginx: worker' | grep -v grep\n"
|
||||
},
|
||||
{
|
||||
"id": "command-006",
|
||||
"ordinal": 6,
|
||||
"language": "bash",
|
||||
"info_string": "",
|
||||
"label": null,
|
||||
"mode": "reference",
|
||||
"mode_explicit": false,
|
||||
"start": 5797,
|
||||
"end": 6027,
|
||||
"source_sha256": "519aa5bbb424c16e2b51b65f4de056a83e7d29cb5968db00d734d92d03dcf3c5",
|
||||
"body_sha256": "ca5353f2edd2dea1854cee0dea07b9a49f930ac36aac9281a86a8507fb7b2be8",
|
||||
"source": "```bash\nA=$(date -u +%s.%N); B=$(ssh test-server 'date -u +%s.%N'); C=$(date -u +%s.%N)\n# 왜곡 ≈ B − (A+C)/2 , 어느 쪽이 맞는지는 외부 기준으로 가른다\ncurl -sI https://www.google.com | grep -i '^date:'\ntimedatectl show -p NTP -p NTPSynchronized\n```",
|
||||
"body": "A=$(date -u +%s.%N); B=$(ssh test-server 'date -u +%s.%N'); C=$(date -u +%s.%N)\n# 왜곡 ≈ B − (A+C)/2 , 어느 쪽이 맞는지는 외부 기준으로 가른다\ncurl -sI https://www.google.com | grep -i '^date:'\ntimedatectl show -p NTP -p NTPSynchronized\n"
|
||||
}
|
||||
],
|
||||
"findings": [],
|
||||
"extensions": {
|
||||
"command_like_text_blocks": []
|
||||
}
|
||||
}
|
||||
+186
@@ -0,0 +1,186 @@
|
||||
---
|
||||
kind: CASE
|
||||
slug: renewal-succeeded-while-the-old-certificate-kept-serving
|
||||
title: 갱신은 매번 SUCCESS 였고 옛 인증서가 계속 나갔다 — 2305초와 1~2초
|
||||
topic: build-completion-judgment
|
||||
topicName: 끝났다는 판정
|
||||
project: virtualization
|
||||
status: 게시 전
|
||||
sourceRevision: import-head 9465582b5d1630eb4ae7c4e078021486919bf6b6 · uncommitted-working-tree snapshot
|
||||
source:
|
||||
- final/document.md#190-단계-04-let's-encrypt-와-인증서-갱신
|
||||
- final/document.md#189-단계-03-엣지-nginx-라우팅과-호스트-dnat
|
||||
- final/document.md#194-이-부에서-파생될-open-question
|
||||
---
|
||||
|
||||
# 갱신은 매번 SUCCESS 였고 옛 인증서가 계속 나갔다 — 2305초와 1~2초
|
||||
|
||||
갱신은 매번 SUCCESS 로 끝나는데 nginx 는 옛 인증서를 계속 내보냈다. certbot-renew.service 에 훅이 없어 갱신에서 서빙까지 2305초 걸렸고, deploy 훅을 두니 1~2초였다. 만료 30일 전까지는 갱신을 안 해 88일 동안 아무 표시도 나지 않는다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **인증서는 DNS-01 로 받는다 — 실험대 주소가 공개 인터넷에 없어 HTTP-01 이 성립하지 않는다**
|
||||
이 인증서를 어떻게 받기로 했는지가 그 결정이고, 여기서는 받은 뒤의 갱신을 다룬다.
|
||||
- **도구가 낸 출력은 대상의 상태가 아니다 — 그 명령이 무엇을 세는지부터 가른다**
|
||||
갱신 로그의 SUCCESS 와 훅 로그의 error output 이 둘 다 상태를 잘못 말한 사건이다.
|
||||
- **가까운 층부터 한 층씩 건너뛰며 확인한다 — 층마다 성공 신호가 다르다**
|
||||
이 단계의 확인을 어느 기계에서 치느냐가 결과를 바꾸고, 층별 확인이 그 순서를 정한다.
|
||||
- **단계별 구축 문서는 작성 시점이 아니라 실행 순서로 검증한다**
|
||||
훅을 호스트에 두었다가 잃은 것도, 확인 명령을 엣지 안에서 친 것도 그 규칙이 받는 결함이다.
|
||||
- **가이드대로 다시 쳐서 이 실험대가 같은 상태로 서는가**
|
||||
2305초를 잰 배치와 지금 배치가 다르므로 다시 세운 실험대에서 재야 할 값이 여기 있다.
|
||||
|
||||
## 문제
|
||||
|
||||
인증서 갱신은 매번 성공으로 끝나는데 서버가 내보내는 인증서는 옛것이었다.
|
||||
|
||||
certbot-renew.timer : 정상
|
||||
certbot-renew.service 의 결과 : SUCCESS
|
||||
유닛의 ExecStartPost 또는 --deploy-hook : x
|
||||
nginx 가 새 인증서를 읽었나 : x
|
||||
갱신에서 서빙까지 : 2305초 (38분 25초)
|
||||
무엇이 reload 했나 : 사람이 직접
|
||||
|
||||
발현하는 날의 증상은 인증서 만료이고, 그날에도 로그에는 SUCCESS 라고 적혀 있다.
|
||||
|
||||
## 결론
|
||||
|
||||
certbot-renew.service 는 /usr/bin/certbot -q renew 한 줄이고 인증서를 새로 받는 데까지만 책임진다. nginx 는 인증서를 기동 시점에 읽어 메모리에 들고 있고 certbot 은 live/ 심볼릭 링크만 갈아 끼우므로, 경로는 그대로이고 내용만 바뀌어 nginx 는 바뀐 줄 모른다.
|
||||
|
||||
훅도 사람도 없으면 다음 nginx 재시작까지, 사실상 무기한으로 옛 인증서가 나간다.
|
||||
|
||||
해결 : /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh 에 nginx -t && nginx -s reload 를 두고 chmod +x 를 준다
|
||||
실행 권한이 없으면 : certbot 이 조용히 건너뛴다
|
||||
post/ 가 아니라 deploy/ 인 까닭 : post/ 는 갱신이 없어도 매번 돌아 하루 두 번 워커를 갈아치운다
|
||||
훅을 저장소에 두는 까닭 : 호스트에만 두었을 때는 호스트를 초기화하면 아무 오류 없이 사라졌다
|
||||
판정 : 로그 문구가 아니라 강제 갱신 전후의 nginx 워커 PID 로 한다
|
||||
|
||||
훅을 넣어도 안전했다. reload 는 무중단이었고 새 연결 8856건이 전부 200, p95 는 205.7ms 대 204.3ms 로 변화가 없었다.
|
||||
|
||||
## 검증 환경
|
||||
|
||||
엣지 게스트 : kc-lab-edge, 192.168.122.10, Debian 12
|
||||
nginx : nginx/1.22.1
|
||||
certbot 플러그인 : dns-cloudflare
|
||||
발급 대상 : -d hyeonworks.com -d '*.hyeonworks.com'
|
||||
lineage 디렉터리 : /etc/letsencrypt/live/auth.hyeonworks.com/ (2026-09-17 현재 관측)
|
||||
nginx 가 읽는 파일 : fullchain.pem · privkey.pem
|
||||
타이머 : certbot-renew.timer → certbot-renew.service
|
||||
인증서 유효기간 : 오늘 + 90일
|
||||
2305초를 잴 때의 배치 : 훅이 물리 호스트에 있었다
|
||||
지금의 배치 : certbot · 인증서 · 갱신 타이머 · deploy 훅이 전부 엣지 게스트에 있다
|
||||
|
||||
## 재현 조건
|
||||
|
||||
1. 엣지 게스트에 certbot 과 python3-certbot-dns-cloudflare 를 깔고 와일드카드 인증서를 받는다.
|
||||
2. nginx 443 블록에 fullchain.pem 과 privkey.pem 경로를 적고 reload 한다.
|
||||
3. systemctl cat certbot-renew.service 로 ExecStartPost 와 --deploy-hook 이 없는지 본다.
|
||||
4. ps -eo pid,lstart,args 로 nginx 워커 PID 를 적어 둔다.
|
||||
5. sudo certbot renew --force-renewal 을 친다.
|
||||
6. 워커 PID 를 다시 본다. 바뀌지 않았으면 옛 인증서가 계속 나가고 있다.
|
||||
7. deploy/reload-nginx.sh 를 두고 chmod +x 를 준 뒤 4번부터 6번까지를 다시 한다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
## 배포판 유닛이 어디까지 책임지나
|
||||
|
||||
Let's Encrypt 인증서는 90일짜리이고 갱신은 `certbot-renew.timer` 가 건다. 타이머가 부르는 유닛을 열어 보면 한 줄이다.
|
||||
|
||||
```bash
|
||||
systemctl cat certbot-renew.service
|
||||
```
|
||||
|
||||
```
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/usr/bin/certbot -q renew
|
||||
PrivateTmp=true
|
||||
```
|
||||
|
||||
`ExecStartPost` 도 `--deploy-hook` 도 없다. 이 유닛은 인증서를 새로 받는 데까지만 책임지고, 받은 것을 nginx 가 읽게 만드는 일은 아무도 하지 않는다. 배포판이 이렇게 준다는 것이 요점이라, 「기본값이니 괜찮겠지」가 이 결함이 사는 곳이다.
|
||||
|
||||
그래서 이 글의 훅이 모든 배포판에 필요하지는 않다. 위 유닛에 `ExecStartPost` 나 `--deploy-hook` 이 이미 적혀 있는 배포판이라면 훅을 새로 넣을 것이 아니라 거기 적힌 명령이 nginx 를 reload 하는지만 본다.
|
||||
|
||||
## nginx 는 인증서를 언제 읽나
|
||||
|
||||
nginx 는 `ssl_certificate` 에 적힌 파일을 기동과 reload 시점에 읽어 메모리에 들고 있고, certbot 은 `live/` 아래의 심볼릭 링크를 새 파일로 갈아 끼운다. 설정에 적힌 경로는 한 글자도 바뀌지 않고 그 경로가 가리키는 파일만 바뀌므로, nginx 쪽에서는 다시 읽을 계기가 생기지 않는다. 갱신이 끝난 뒤에 nginx 로 reload 를 걸어 주는 것이 없으면 옛 인증서가 계속 나간다.
|
||||
|
||||
## 88일 동안 이 결함이 보이지 않는다
|
||||
|
||||
타이머는 정상이고 갱신은 매번 `SUCCESS` 로 끝난다. 만료 30일 전까지는 갱신 자체를 하지 않으므로 88일 동안 발현할 기회가 없다. 발현하는 날의 증상은 인증서 만료이고, 그날에도 로그에는 `SUCCESS` 라고 적혀 있다.
|
||||
|
||||
훅을 저장소에 두는 까닭도 여기에 있다. 호스트에만 두었을 때는 호스트를 초기화하면 아무 오류 없이 사라졌고, 사라진 것도 갱신이 실제로 일어나는 날까지는 드러나지 않는다.
|
||||
|
||||
## 훅 두 줄과 실행 권한
|
||||
|
||||
```sh
|
||||
# file: /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
|
||||
#!/bin/sh
|
||||
nginx -t && nginx -s reload
|
||||
```
|
||||
|
||||
```bash
|
||||
sudo chmod +x /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
|
||||
```
|
||||
|
||||
실행 권한이 없으면 certbot 이 조용히 건너뛴다. `post/` 가 아니라 `deploy/` 에 넣는 것은 `post/` 가 갱신이 없어도 매번 돌아 하루 두 번 워커를 갈아치우기 때문이다.
|
||||
|
||||
이 실험대에서 잰 차이는 이렇다.
|
||||
|
||||
| | 훅 없음 | 훅 있음 |
|
||||
|---|---|---|
|
||||
| 갱신 → 서빙 | 2305초 (38분 25초) | 1~2초 |
|
||||
| 무엇이 reload 했나 | 사람이 직접 | certbot deploy 훅 |
|
||||
|
||||
훅도 사람도 없었다면 다음 nginx 재시작까지, 사실상 무기한이다.
|
||||
|
||||
## 판정은 로그 문구가 아니라 워커 PID 로 한다
|
||||
|
||||
```bash
|
||||
sudo certbot renew --dry-run
|
||||
```
|
||||
|
||||
dry-run 은 훅이 호출되는지까지만 말해 준다. 호출된 훅이 nginx 를 정말 갈아 끼웠는지는 따로 본다.
|
||||
|
||||
순서가 dry-run 먼저인 것은 뒤엣것이 상태를 바꾸기 때문이다. `--force-renewal` 은 인증서를 실제로 새로 받아 발급 한도(주당 중복 5장)를 깎으므로, 진짜 판정이 필요할 때 한 번만 쓴다.
|
||||
|
||||
```bash
|
||||
# 강제 갱신 전에 워커 PID 를 적어 둔다
|
||||
ps -eo pid,lstart,args | grep 'nginx: worker' | grep -v grep
|
||||
|
||||
sudo certbot renew --force-renewal
|
||||
|
||||
# 워커 PID 가 바뀌었으면 reload 된 것이다
|
||||
ps -eo pid,lstart,args | grep 'nginx: worker' | grep -v grep
|
||||
```
|
||||
|
||||
reload 는 마스터를 그대로 두고 워커만 갈아 끼우므로, 전후로 워커 PID 가 바뀌면 새 설정이 적용됐다. `lstart` 를 같이 뽑는 것은 PID 가 우연히 재사용됐을 때를 가르기 위해서다.
|
||||
|
||||
certbot 이 `Hook 'deploy-hook' ran with error output` 이라고 찍는데 실패가 아니다. nginx 의 `types_hash` 경고가 stderr 로 나갔을 뿐이고 내용은 `test is successful` 과 `signal process started` 다. 로그에서 `error` 를 grep 하는 감시를 걸면 성공한 훅을 실패로 오독한다.
|
||||
|
||||
## 훅을 넣어도 안전한가
|
||||
|
||||
갱신 때마다 reload 가 도는 것이 서비스에 영향을 주는지도 쟀다. 새 연결 8856건이 전부 200 이었고 p95 는 205.7ms 대 204.3ms 로 변화가 없었다. 845KB 를 20k/s 로 받는 중이던 요청은 전송 12초째에 reload 를 맞고도 845361바이트를 온전히 받았다(연결수 1). 옛 워커가 그 요청을 끝까지 책임진다.
|
||||
|
||||
## 수치를 내기 전에 시계를 쟀다
|
||||
|
||||
갱신 시각과 reload 시각이 다른 기계에 찍히므로, 두 시각을 빼기 전에 시계부터 쟀다. test-server 가 NTP 미동기로 106초 빨랐고, 보정하지 않은 첫 계산은 훅이 인증서 발급보다 104초 먼저 실행된 것이 되어 물리적으로 불가능했다. 음수 지연이 나오면 계산이 아니라 시계를 의심한다.
|
||||
|
||||
```bash
|
||||
A=$(date -u +%s.%N); B=$(ssh test-server 'date -u +%s.%N'); C=$(date -u +%s.%N)
|
||||
# 왜곡 ≈ B − (A+C)/2 , 어느 쪽이 맞는지는 외부 기준으로 가른다
|
||||
curl -sI https://www.google.com | grep -i '^date:'
|
||||
timedatectl show -p NTP -p NTPSynchronized
|
||||
```
|
||||
|
||||
## 확인하지 못한 것
|
||||
|
||||
2305초는 훅이 물리 호스트에만 있던 시절의 값이다. 지금은 certbot 과 인증서와 갱신 타이머와 deploy 훅이 전부 엣지 게스트에 있고, 그 배치에서 다시 재면 같은 수가 나오는지는 재지 않았다. 그래서 이 글의 2305초는 훅이 없으면 이만큼 벌어진다는 한 번의 측정이고 지금 배치의 값이 아니다. 1~2초 쪽도 같은 시기에 잰 값이다.
|
||||
|
||||
측정 출력을 `final/evidence/` 에 남기지 않았다. 2305초도 8856건도 p95 두 값도 845361바이트도 106초도 SSOT 본문에만 있다. 워커 PID 를 전후로 비교한 출력도 저장해 두지 않았다.
|
||||
|
||||
88일이라는 잠복 기간은 만료 30일 전에야 갱신을 시작한다는 동작에서 끌어낸 것이지, 한 주기를 실제로 돌려 본 값이 아니다. 훅을 뺀 채 만료일까지 가 본 기록은 없고 그런 기록이 있을 까닭도 없다.
|
||||
|
||||
<!-- body:end -->
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"scope": "command-pedagogy",
|
||||
"reviewer": "command-pedagogy-reviewer",
|
||||
"verdict": "PASS",
|
||||
"source_sha256": "5ff02e971755dee147ef5abdde3b298272d058af333791bdaeb2d71081d58b8a",
|
||||
"findings": [],
|
||||
"notes": "reference mode final major finding은 0이다. historical compact command는 reference disposition으로 보존한다."
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"record": "docs/virtualization/tech-log-studio/build-completion-judgment/case/case-renewal-succeeded-while-the-old-certificate-kept-serving.md",
|
||||
"sourceSha256": "5ff02e971755dee147ef5abdde3b298272d058af333791bdaeb2d71081d58b8a",
|
||||
"verdict": "PASS",
|
||||
"liveSourceReconciliation": "UNVERIFIABLE",
|
||||
"liveSourceReason": "source repositories unavailable on current machine",
|
||||
"acceptedByProjectReview": true,
|
||||
"checks": [
|
||||
"local SSOT/Record evidence mismatch 0",
|
||||
"S3/S5/S6 check_evidence --repo invoked",
|
||||
"source checkout absence preserved as exit 3"
|
||||
],
|
||||
"note": "현재 project evidence gate는 불일치 0건·대조 불가 2건이다. source checkout 부재를 PASS로 위장하지 않고 UNVERIFIABLE로 보존했다."
|
||||
}
|
||||
+362
@@ -0,0 +1,362 @@
|
||||
{
|
||||
"schemaVersion": 5,
|
||||
"runId": "2026-09-21-0218-remediation-04-question-does-the-guide-rebuild-this-lab",
|
||||
"project": "virtualization",
|
||||
"record": "docs/virtualization/tech-log-studio/build-completion-judgment/question/question-does-the-guide-rebuild-this-lab.md",
|
||||
"startedAt": "2026-09-21T02:24:35+00:00",
|
||||
"finishedAt": "2026-09-21T02:24:37+00:00",
|
||||
"stages": [
|
||||
{
|
||||
"id": "S1",
|
||||
"name": "코드베이스 → SSOT",
|
||||
"skill": "analyzing-codebase-for-tech-log",
|
||||
"runBy": "ssot-analyst",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "이번 remediation은 새 evidence와 현재 SSOT/Record의 정합성·provenance·command/S4를 재검토한다. 코드베이스→SSOT 전면 분석은 다시 하지 않는다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": "edd45dfec66d155a621cd41186b83b5582f2244c",
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"gates": [],
|
||||
"notes": "",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-21T02:24:35+00:00",
|
||||
"elapsedSeconds": 0,
|
||||
"finishedBy": "chatgpt-virtualization-20260921-remediation-v5"
|
||||
},
|
||||
{
|
||||
"id": "S2",
|
||||
"name": "SSOT → 분해 계약",
|
||||
"skill": "deriving-tech-log-root-tree",
|
||||
"runBy": "tree-deriver",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "기존 91 Record 분해 계약은 유지한다. certbot Question의 RESOLVED 상태와 sourcePolicy는 project tree rebuild에서 별도로 동기화했다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": "ab59130196d79e947b32e3b5e6b75335a9e5c1eb",
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"gates": [],
|
||||
"notes": "",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-21T02:24:35+00:00",
|
||||
"elapsedSeconds": 0,
|
||||
"finishedBy": "chatgpt-virtualization-20260921-remediation-v5"
|
||||
},
|
||||
{
|
||||
"id": "S3",
|
||||
"name": "글감 → 기록",
|
||||
"skill": "writing-tech-log-records",
|
||||
"runBy": "record-writer",
|
||||
"status": "DONE",
|
||||
"skipReason": "",
|
||||
"skillEcho": "**인용한 줄은 SSOT 에서 찾아 대조한다.**",
|
||||
"skillRevision": null,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
"docs/virtualization/tech-log-studio/build-completion-judgment/question/question-does-the-guide-rebuild-this-lab.md"
|
||||
],
|
||||
"gates": [
|
||||
{
|
||||
"cmd": "python3 scripts/studio-body.py docs/virtualization/tech-log-studio/build-completion-judgment/question/question-does-the-guide-rebuild-this-lab.md -o runs/virtualization/2026-09-21-0218-remediation-04-question-does-the-guide-rebuild-this-lab/stage/S3/studio-body.md",
|
||||
"exit": 0,
|
||||
"status": "PASS",
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"generation": 1,
|
||||
"at": "2026-09-21T02:24:35+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node --experimental-transform-types .agents/skills/writing-tech-log-records/scripts/check_body.mjs runs/virtualization/2026-09-21-0218-remediation-04-question-does-the-guide-rebuild-this-lab/stage/S3/studio-body.md --frontend /shared/codebase/tech-log-frontend",
|
||||
"exit": 0,
|
||||
"status": "PASS",
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"generation": 1,
|
||||
"at": "2026-09-21T02:24:36+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node .agents/skills/rewriting-technical-prose-naturally/scripts/check_prose.mjs --warn docs/virtualization/tech-log-studio/build-completion-judgment/question/question-does-the-guide-rebuild-this-lab.md",
|
||||
"exit": 0,
|
||||
"status": "PASS",
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"generation": 1,
|
||||
"at": "2026-09-21T02:24:36+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node .agents/skills/writing-tech-log-records/scripts/check_evidence.mjs virtualization --repo",
|
||||
"exit": 3,
|
||||
"status": "UNVERIFIABLE",
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"generation": 1,
|
||||
"at": "2026-09-21T02:24:36+00:00",
|
||||
"semanticId": "evidence-repo",
|
||||
"reason": "source repositories unavailable on current machine",
|
||||
"acceptedByProjectReview": true
|
||||
}
|
||||
],
|
||||
"notes": "record-writer/fact 계약으로 새 evidence와 SSOT의 current truth를 대조했다. --repo 대조는 실제 실행했고 source checkout 부재만 UNVERIFIABLE로 남겼다.",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-21T02:24:36+00:00",
|
||||
"elapsedSeconds": 1,
|
||||
"generation": 1,
|
||||
"owner": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"finishedBy": "chatgpt-virtualization-20260921-remediation-v5"
|
||||
},
|
||||
{
|
||||
"id": "S4",
|
||||
"name": "기록 → 그림",
|
||||
"skill": "technical-visualizer",
|
||||
"runBy": "diagram-maker",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "이 Record 변경은 current truth/evidence/version/provenance 정리이며 새 관계 그림을 요구하지 않는다. 기존 그림은 project-wide 14장 검증에서 별도로 확인했다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": "edd45dfec66d155a621cd41186b83b5582f2244c",
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"gates": [],
|
||||
"notes": "",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-21T02:24:36+00:00",
|
||||
"elapsedSeconds": 0,
|
||||
"finishedBy": "chatgpt-virtualization-20260921-remediation-v5"
|
||||
},
|
||||
{
|
||||
"id": "S5",
|
||||
"name": "AI 티 제거",
|
||||
"skill": "rewriting-technical-prose-naturally",
|
||||
"runBy": "prose-rewriter",
|
||||
"status": "DONE",
|
||||
"skipReason": "",
|
||||
"skillEcho": "This is an **editorial** pass. The source's facts, evidence, causal chain, uncertainty, decision status, and technical depth are the contract.",
|
||||
"skillRevision": null,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
"docs/virtualization/tech-log-studio/build-completion-judgment/question/question-does-the-guide-rebuild-this-lab.md"
|
||||
],
|
||||
"gates": [
|
||||
{
|
||||
"cmd": "node .agents/skills/rewriting-technical-prose-naturally/scripts/check_prose.mjs --warn docs/virtualization/tech-log-studio/build-completion-judgment/question/question-does-the-guide-rebuild-this-lab.md",
|
||||
"exit": 0,
|
||||
"status": "PASS",
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"generation": 1,
|
||||
"at": "2026-09-21T02:24:36+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node .agents/skills/rewriting-technical-prose-naturally/scripts/style_profile.mjs docs/virtualization/tech-log-studio/build-completion-judgment/question/question-does-the-guide-rebuild-this-lab.md",
|
||||
"exit": 0,
|
||||
"status": "PASS",
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"generation": 1,
|
||||
"at": "2026-09-21T02:24:36+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node --experimental-transform-types .agents/skills/writing-tech-log-records/scripts/check_body.mjs runs/virtualization/2026-09-21-0218-remediation-04-question-does-the-guide-rebuild-this-lab/stage/S5/studio-body.md --frontend /shared/codebase/tech-log-frontend",
|
||||
"exit": 0,
|
||||
"status": "PASS",
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"generation": 1,
|
||||
"at": "2026-09-21T02:24:36+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node .agents/skills/writing-tech-log-records/scripts/check_evidence.mjs virtualization --repo",
|
||||
"exit": 3,
|
||||
"status": "UNVERIFIABLE",
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"generation": 1,
|
||||
"at": "2026-09-21T02:24:36+00:00",
|
||||
"semanticId": "evidence-repo",
|
||||
"reason": "source repositories unavailable on current machine",
|
||||
"acceptedByProjectReview": true
|
||||
}
|
||||
],
|
||||
"notes": "대규모 문체 재작성 없이 stale fact/relation만 현재 evidence에 맞췄다. style_profile은 측정값으로만 기록했다.",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-21T02:24:36+00:00",
|
||||
"elapsedSeconds": 0,
|
||||
"generation": 1,
|
||||
"owner": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"finishedBy": "chatgpt-virtualization-20260921-remediation-v5"
|
||||
},
|
||||
{
|
||||
"id": "S6",
|
||||
"name": "일한 사람의 목소리",
|
||||
"skill": "writing-as-the-person-who-did-it",
|
||||
"runBy": "voice-writer",
|
||||
"status": "DONE",
|
||||
"skipReason": "",
|
||||
"skillEcho": "찾은 것이 없으면 **이 스킬은 여기서 끝난다.** 없는 목소리를 채우지 않는다.",
|
||||
"skillRevision": "edd45dfec66d155a621cd41186b83b5582f2244c",
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
"docs/virtualization/tech-log-studio/build-completion-judgment/question/question-does-the-guide-rebuild-this-lab.md"
|
||||
],
|
||||
"gates": [
|
||||
{
|
||||
"cmd": "node .agents/skills/writing-as-the-person-who-did-it/scripts/check_voice.mjs docs/virtualization/tech-log-studio/build-completion-judgment/question/question-does-the-guide-rebuild-this-lab.md",
|
||||
"exit": 0,
|
||||
"status": "PASS",
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"generation": 1,
|
||||
"at": "2026-09-21T02:24:37+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node .agents/skills/rewriting-technical-prose-naturally/scripts/check_prose.mjs --warn docs/virtualization/tech-log-studio/build-completion-judgment/question/question-does-the-guide-rebuild-this-lab.md",
|
||||
"exit": 0,
|
||||
"status": "PASS",
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"generation": 1,
|
||||
"at": "2026-09-21T02:24:37+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node --experimental-transform-types .agents/skills/writing-tech-log-records/scripts/check_body.mjs runs/virtualization/2026-09-21-0218-remediation-04-question-does-the-guide-rebuild-this-lab/stage/S6/studio-body.md --frontend /shared/codebase/tech-log-frontend",
|
||||
"exit": 0,
|
||||
"status": "PASS",
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"generation": 1,
|
||||
"at": "2026-09-21T02:24:37+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node .agents/skills/writing-tech-log-records/scripts/check_evidence.mjs virtualization --repo",
|
||||
"exit": 3,
|
||||
"status": "UNVERIFIABLE",
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"generation": 1,
|
||||
"at": "2026-09-21T02:24:37+00:00",
|
||||
"semanticId": "evidence-repo",
|
||||
"reason": "source repositories unavailable on current machine",
|
||||
"acceptedByProjectReview": true
|
||||
}
|
||||
],
|
||||
"notes": "상류 SSOT/evidence를 다시 보며 실제 작업 흔적이 없는 서사는 추가하지 않았다.",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-21T02:24:37+00:00",
|
||||
"elapsedSeconds": 0,
|
||||
"generation": 1,
|
||||
"owner": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"finishedBy": "chatgpt-virtualization-20260921-remediation-v5"
|
||||
},
|
||||
{
|
||||
"id": "S7",
|
||||
"name": "Studio 저장",
|
||||
"skill": "publishing-tech-log-to-studio",
|
||||
"runBy": "studio-validator",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "이번 remediation에서 Studio import/save는 요청되지 않았다. 저장소 산출물과 검증 영수증만 갱신한다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": "862e502af3e956b49ccd2ae0a8de3fd32f90df9c",
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"gates": [],
|
||||
"notes": "",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-21T02:24:37+00:00",
|
||||
"elapsedSeconds": 0,
|
||||
"finishedBy": "chatgpt-virtualization-20260921-remediation-v5"
|
||||
}
|
||||
],
|
||||
"qualityReviews": {
|
||||
"commandPedagogy": {
|
||||
"initialAnalysis": {
|
||||
"cmd": "python3 scripts/check-command-pedagogy.py --mode reference docs/virtualization/tech-log-studio/build-completion-judgment/question/question-does-the-guide-rebuild-this-lab.md -o runs/virtualization/2026-09-21-0218-remediation-04-question-does-the-guide-rebuild-this-lab/stage/S3/command-initial.json",
|
||||
"exit": 0,
|
||||
"shellBlocks": 0,
|
||||
"findings": 0,
|
||||
"majorFindings": 0,
|
||||
"artifact": {
|
||||
"path": "runs/virtualization/2026-09-21-0218-remediation-04-question-does-the-guide-rebuild-this-lab/stage/S3/command-initial.json",
|
||||
"sha256": "64f265c58febe76c651526a666fadd665d9460316aee67b6b41119ffb6276bbc"
|
||||
}
|
||||
},
|
||||
"finalAnalysis": {
|
||||
"cmd": "python3 scripts/check-command-pedagogy.py --mode reference docs/virtualization/tech-log-studio/build-completion-judgment/question/question-does-the-guide-rebuild-this-lab.md -o runs/virtualization/2026-09-21-0218-remediation-04-question-does-the-guide-rebuild-this-lab/stage/S6/command-final.json",
|
||||
"exit": 0,
|
||||
"shellBlocks": 0,
|
||||
"findings": 0,
|
||||
"majorFindings": 0,
|
||||
"artifact": {
|
||||
"path": "runs/virtualization/2026-09-21-0218-remediation-04-question-does-the-guide-rebuild-this-lab/stage/S6/command-final.json",
|
||||
"sha256": "64f265c58febe76c651526a666fadd665d9460316aee67b6b41119ffb6276bbc"
|
||||
}
|
||||
},
|
||||
"planner": {
|
||||
"runBy": "command-pedagogy-planner",
|
||||
"skill": "writing-practitioner-guides",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "initial deterministic analysis finding이 0이다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": null,
|
||||
"notes": "현재 reference-mode 분석을 확인했다.",
|
||||
"artifact": null
|
||||
},
|
||||
"editor": {
|
||||
"runBy": "command-pedagogy-editor",
|
||||
"skill": "writing-practitioner-guides",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "initial deterministic analysis finding이 0이다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": null,
|
||||
"notes": "현재 reference-mode 분석을 확인했다.",
|
||||
"artifact": null
|
||||
},
|
||||
"reviewer": {
|
||||
"runBy": "command-pedagogy-reviewer",
|
||||
"skill": "writing-practitioner-guides",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "final publication에 shell/CLI block이 없다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": null,
|
||||
"verdict": null,
|
||||
"notes": "final deterministic analysis에서 shellBlocks=0을 확인했다.",
|
||||
"artifact": null,
|
||||
"sourceSha256": null
|
||||
}
|
||||
},
|
||||
"technicalEvidence": {
|
||||
"runBy": "fact-reviewer",
|
||||
"status": "DONE",
|
||||
"skipReason": "",
|
||||
"verdict": "PASS",
|
||||
"notes": "current truth/evidence/provenance를 SSOT와 역대조했다. live source는 실제 --repo 결과 exit 3이며 UNVERIFIABLE로 보존했다.",
|
||||
"sourceSha256": "1950afda3b2b23ca0e498d67645f19824b6861bd522c96854ad777b197f4c7b3",
|
||||
"liveSourceReconciliation": "UNVERIFIABLE",
|
||||
"liveSourceReason": "source repositories unavailable on current machine",
|
||||
"acceptedByProjectReview": true,
|
||||
"artifact": {
|
||||
"path": "runs/virtualization/2026-09-21-0218-remediation-04-question-does-the-guide-rebuild-this-lab/stage/quality/technical-evidence-review.json",
|
||||
"sha256": "b5fb2fde7a329a3c1d9e179dbb21d322f50b4bc95b510c7c3c887305ef423b12"
|
||||
}
|
||||
}
|
||||
},
|
||||
"riders": [],
|
||||
"sessions": [
|
||||
{
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"openedAt": "2026-09-21T02:24:35+00:00"
|
||||
},
|
||||
{
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"stage": "S3",
|
||||
"generation": 1,
|
||||
"beganAt": "2026-09-21T02:24:35+00:00"
|
||||
},
|
||||
{
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"stage": "S5",
|
||||
"generation": 1,
|
||||
"beganAt": "2026-09-21T02:24:36+00:00"
|
||||
},
|
||||
{
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"stage": "S6",
|
||||
"generation": 1,
|
||||
"beganAt": "2026-09-21T02:24:37+00:00"
|
||||
}
|
||||
],
|
||||
"revision": 23,
|
||||
"updatedAt": "2026-09-21T02:24:37+00:00",
|
||||
"executionEnvironment": {
|
||||
"mode": "current-remediation-contract-replay",
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"agentToolAvailable": false,
|
||||
"note": "별도 subagent 실행 도구가 현재 환경에 없어 현재 세션이 각 .claude/agents 역할 정의와 연결된 SKILL/contract를 직접 읽고 역할을 분리해 수행했다. runBy는 계약 역할명이며 별도 프로세스 실행을 주장하지 않는다."
|
||||
}
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"authority": "deterministic",
|
||||
"gate": "command-pedagogy-signals",
|
||||
"section_id": "docs/virtualization/tech-log-studio/build-completion-judgment/question/question-does-the-guide-rebuild-this-lab.md",
|
||||
"mode": "reference",
|
||||
"source_sha256": "1950afda3b2b23ca0e498d67645f19824b6861bd522c96854ad777b197f4c7b3",
|
||||
"result": "PASS",
|
||||
"requires_editor": false,
|
||||
"blocks": [],
|
||||
"findings": [],
|
||||
"extensions": {
|
||||
"command_like_text_blocks": []
|
||||
}
|
||||
}
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
---
|
||||
kind: QUESTION
|
||||
slug: does-the-guide-rebuild-this-lab
|
||||
title: 가이드대로 다시 쳐서 이 실험대가 같은 상태로 서는가
|
||||
topic: build-completion-judgment
|
||||
topicName: 끝났다는 판정
|
||||
project: virtualization
|
||||
status: 게시 전
|
||||
questionStatus: OPEN
|
||||
sourceRevision: import-head 9465582b5d1630eb4ae7c4e078021486919bf6b6 · uncommitted-working-tree snapshot
|
||||
source:
|
||||
- final/document.md#194-이-부에서-파생될-open-question
|
||||
- final/document.md#184-이-부의-출처와-범위
|
||||
---
|
||||
|
||||
# 가이드대로 다시 쳐서 이 실험대가 같은 상태로 서는가
|
||||
|
||||
생성 명령을 다시 쳐서 같은 실험대가 서는지는 확인된 적이 없다. 돌고 있는 실험대를 멈출 수 없어 구축할 때 쓴 명령을 옮기고 결과 상태를 확인하는 것으로 대신했다. 다시 세우는 데 필요한 매니페스트 둘과 cloud-init 템플릿도 저장소에 없다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **단계별 구축 문서는 작성 시점이 아니라 실행 순서로 검증한다**
|
||||
그 기준이 요구하는 검증을 이 물음이 실제로 실행한다. 그 기록은 스스로 「이 기준으로 가이드를 고친 뒤 처음부터 다시 따라가 본 기록이 아직 없다」고 적었다.
|
||||
- **WiFi 전용 호스트에서 대용량 qcow2 를 옮기는 데 실제로 몇 시간이 걸리는가**
|
||||
같은 물음의 다른 절반이다. 옮기는 것과 다시 세우는 것 가운데 어느 쪽이 복원 경로인지는 두 값이 다 나와야 정해진다.
|
||||
- **cloud-init 이 안 도는 원인은 넷인데 증상은 「SSH 가 안 붙는다」 하나였다**
|
||||
재구축에서 01 단계가 가장 먼저 걸린다. 그 기록이 센 네 원인이 다시 나오는지가 이 검증의 한 칸이다.
|
||||
- **빈 토큰이 조용히 흘러갔다 — 설치 출력은 성공이었고 agent 만 5초마다 다시 죽었다**
|
||||
02 단계에서 같은 일이 벌어진다. 길이 가드 한 줄이 실제로 빈 토큰을 잡아 본 기록이 없으므로, 재구축이 그 가드를 처음으로 시험하게 된다.
|
||||
- **도구가 낸 출력은 대상의 상태가 아니다 — 그 명령이 무엇을 세는지부터 가른다**
|
||||
다시 선 실험대가 같은 상태인지를 판정할 근거가 전부 그런 출력이다. 통과 조건 일곱을 그대로 믿을지 여러 층으로 견줄지가 그 기준에 걸려 있다.
|
||||
|
||||
## 사실
|
||||
|
||||
- §184 가 이 부의 검증 방식을 갈라 적었다. 읽기 전용 확인은 돌아가는 실험대에서 실제로 실행해 출력을 그대로 실었다.
|
||||
- 만드는 명령은 그렇게 하지 못했다. VM 을 다시 만들거나 k3s 를 다시 깔면 돌고 있는 실험대가 없어지므로, 구축할 때 쓴 명령을 그대로 옮기고 결과 상태를 확인하는 것으로 대신했다.
|
||||
- §186 부터 §192 까지의 생성 명령에는 unknown 이 붙어 있다. 「지금 다시 쳐도 같은 상태가 된다」가 확인되지 않았다.
|
||||
- 세울 대상과 단계마다의 통과 조건은 §185 의 표에 일곱 줄로 적혀 있다. virsh list 가 돈다 · 세 게스트에 SSH 가 붙는다 · kubectl get nodes 에 둘 다 Ready · 밖에서 요청이 파드까지 닿는다 · https 가 열리고 체인이 4단계 · 관리 콘솔 로그인이 된다 · vendor_cluster_size 가 2.
|
||||
- 다시 세울 때 필요한 것 가운데 일부가 저장소에 없다. 매니페스트 둘(keycloak-cluster.yaml · observability.yaml)과 cloud-init 템플릿 kc-lab.yaml.example 이 source/ 에 반입되지 않았고, 가이드가 화면에 옮겨 적은 만큼만 있다.
|
||||
- 가이드를 순서대로 따라가다 나온 결함 여섯을 §182 가 이미 표로 적었고, 공통 원인 하나를 inferred 로 붙였다.
|
||||
- 「단계별 구축 문서는 작성 시점이 아니라 실행 순서로 검증한다」가 스스로 밝혔다. 그 규칙으로 가이드를 고친 뒤 처음부터 다시 따라가 본 기록이 아직 없고, 규칙이 결함을 막아 냈다는 관측도 아직 없다.
|
||||
|
||||
## 가정
|
||||
|
||||
- 검증을 시작하는 판이 지금 source/ 에 있는 가이드라고 본다. 그 가이드가 §182 의 결함 여섯을 고친 판인지는 대조하지 않았다.
|
||||
- 밖에서 받아 오는 것들이 그때와 같은 판이라고 전제한다. Debian 12 genericcloud 이미지와 get.k3s.io 설치 스크립트와 apt 저장소의 nginx 와 certbot 이 그것이다. 판 번호가 달라지면 같은 명령이 다른 상태를 만든다.
|
||||
- 도메인과 Cloudflare 토큰과 tailnet 주소는 다시 세울 때도 그대로 쓴다고 본다. 04 단계 전체가 그 셋에 묶여 있다.
|
||||
- 새로 세우는 기계의 CPU 가 하드웨어 가상화를 지원한다고 전제한다. 아니면 00 단계부터 다른 이유로 막히고, 그 막힘은 가이드의 결함이 아니다.
|
||||
|
||||
## 미지수
|
||||
|
||||
- 지금의 가이드 7단계를 빈 호스트에서 처음부터 순서대로 쳤을 때 어느 단계에서 멈추는지. 멈춘다면 그것이 §182 가 이미 센 여섯 중 하나인지 그때는 안 보이던 새 결함인지.
|
||||
- source/ 에 없는 매니페스트 둘과 cloud-init 템플릿 없이 02 와 05 와 06 단계가 문서만으로 서는지.
|
||||
- 다시 선 실험대가 지금과 같은 상태인지를 무엇으로 판정할지. 통과 조건 일곱이 같은 값을 내는 것으로 충분한지, 판 번호까지 같아야 하는지 — libvirt 12.7.0 · QEMU emulator version 11.1.1 · v1.36.4+k3s1 · nginx/1.22.1 이 지금 값이다.
|
||||
|
||||
## 제약
|
||||
|
||||
- 지금 돌고 있는 실험대를 멈출 수 없다. §184 가 생성 명령을 재실행으로 검증하지 않은 이유가 그것이고, 이 물음도 같은 제약 아래에서 답해야 한다.
|
||||
- 이 호스트에 한 벌 더 세우기에는 메모리가 모자란다. §187 의 배치는 세 게스트 합이 10240MB 이고 §178 의 호스트 RAM 은 11,648MiB 다.
|
||||
- 04 단계는 공개 인터넷이 아니라 tailnet 과 Cloudflare 계정에 묶여 있다. 다른 기계에서 재려면 그 둘에 닿아야 한다.
|
||||
- Let's Encrypt 의 주당 중복 인증서 5장 한도가 있다. 재구축을 여러 번 돌리면 04 단계가 거기 걸린다.
|
||||
- 한 번 끝까지 따라가는 것으로 답한다. 여러 번 돌려 분포를 보는 것은 이 물음의 범위 밖이다.
|
||||
|
||||
## 선택지
|
||||
|
||||
### 1. 다른 기계에 빈 호스트를 두고 00 부터 06 까지 순서대로 친다
|
||||
|
||||
돌고 있는 실험대를 건드리지 않고 가이드만 시험한다. 하드웨어가 달라도 상관없는 대신, 막힌 단계가 가이드의 결함인지 하드웨어 차이인지를 가르는 일이 따로 붙는다.
|
||||
|
||||
결과는 네 줄로 적는다.
|
||||
|
||||
멈춘 단계 : 00 부터 06 까지 중 어디인가
|
||||
멈춘 이유 : 그 시점에 리소스가 없어서인가, 그 셸에서 안 도는 명령이라서인가, 둘 다 아닌가
|
||||
§182 의 여섯과 겹치나 : o 또는 x
|
||||
통과 조건 일곱 : 단계마다 같은 값이 나왔는가
|
||||
|
||||
### 2. 이 호스트에 게스트 세 대를 새 이름으로 한 벌 더 세운다
|
||||
|
||||
하드웨어 차이가 없으므로 막힌 단계를 가이드 쪽으로 좁힐 수 있다. 대신 §187 의 배치로는 메모리가 모자라니 게스트 크기를 줄여 돌리고, 그 사실을 결과에 함께 적는다. 크기를 줄인 채로 나온 값은 05 단계와 06 단계에서 지금 실험대와 다를 수 있다.
|
||||
|
||||
### 3. 문서만 읽어 빠진 단계를 찾는다 — 제외
|
||||
|
||||
§182 가 이미 답을 적었다. 개별 명령은 전부 실제로 돌았던 것이고 틀린 것은 명령이 아니라 그 명령이 놓인 위치라, 각 줄은 참인데 순서대로 따라가면 막힌다. 그런 결함은 문서를 읽어서는 안 나오고 실행해야 나온다.
|
||||
|
||||
## 다음 검증
|
||||
|
||||
1. 매니페스트 둘과 cloud-init 템플릿을 source/ 로 반입해 final/ 에 넣는다. 없이 시작하면 이 검증이 재는 것이 「가이드가 서는가」가 아니라 「빠진 파일을 다시 만들 수 있는가」로 바뀐다.
|
||||
2. 대상을 정한다. 다른 기계면 1번, 이 호스트에 한 벌 더면 2번이고, 2번을 고르면 게스트 메모리를 줄인 값을 먼저 적는다.
|
||||
3. 가이드 그대로 00 부터 06 까지 순서대로 친다. §184 가 명령을 두 이름으로 갈라 적어 둔 곳에서는 어느 쪽을 칠지부터 정한다 — 「이 실험대는 이렇게 했다」는 실제로 친 명령 그대로이고, 「따라 하는 사람은」 쪽은 이 실험대에서 한 번도 치지 않았다. 각 단계의 「이 단계가 끝나면」 명령을 치고 출력을 final/evidence/raw/ 에 원문으로 남기고, meta/ 에 명령과 cwd 와 실행 시각과 종료 코드를 적는다.
|
||||
4. 04 단계는 --dry-run 을 먼저 돌린다. 주당 중복 인증서 5장 한도를 dry-run 은 쓰지 않는다.
|
||||
5. 막힌 단계마다 무엇이 없어서 막혔는지를 §182 의 두 축으로 분류해 적는다. 그 시점에 리소스가 없었나, 그 셸에서 안 도는 명령이었나. 어느 축에도 안 들어가면 그것을 새 축으로 적는다.
|
||||
6. 끝나면 판 번호 넷을 적어 지금 실험대의 값과 나란히 둔다.
|
||||
|
||||
닫는 조건 : 00 부터 06 까지 통과 조건 일곱이 전부 같은 값을 내면 「가이드만으로 이 실험대가 다시 선다」고 적고 닫는다. 그러면 「WiFi 전용 호스트에서 대용량 qcow2 를 옮기는 데 실제로 몇 시간이 걸리는가」가 재는 이동 시간과 견줄 대상이 생겨, 옮기는 편이 빠른지 다시 세우는 편이 빠른지가 그때 정해진다. 옮기는 것이 현실적이지 않다면 이 실험대의 복원 경로는 문서 하나가 된다.
|
||||
|
||||
어느 단계에서든 막히면 그 단계를 §182 의 결함 표에 행으로 더하고, 「단계별 구축 문서는 작성 시점이 아니라 실행 순서로 검증한다」의 「규칙이 결함을 막아 냈다는 관측은 아직 얻지 못했다」를 그 결과로 바꾼다. 막힌 단계가 그 규칙의 두 축 안이면 규칙이 통한 것이고, 밖이면 축이 모자란 것이라 규칙을 고친다. 어느 쪽이든 §186 부터 §192 까지의 생성 명령에 붙은 unknown 이 그때 확인이나 반증으로 바뀐다.
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
---
|
||||
kind: QUESTION
|
||||
slug: does-the-guide-rebuild-this-lab
|
||||
title: 가이드대로 다시 쳐서 이 실험대가 같은 상태로 서는가
|
||||
topic: build-completion-judgment
|
||||
topicName: 끝났다는 판정
|
||||
project: virtualization
|
||||
status: 게시 전
|
||||
questionStatus: OPEN
|
||||
sourceRevision: import-head 9465582b5d1630eb4ae7c4e078021486919bf6b6 · uncommitted-working-tree snapshot
|
||||
source:
|
||||
- final/document.md#194-이-부에서-파생될-open-question
|
||||
- final/document.md#184-이-부의-출처와-범위
|
||||
---
|
||||
|
||||
# 가이드대로 다시 쳐서 이 실험대가 같은 상태로 서는가
|
||||
|
||||
생성 명령을 다시 쳐서 같은 실험대가 서는지는 확인된 적이 없다. 돌고 있는 실험대를 멈출 수 없어 구축할 때 쓴 명령을 옮기고 결과 상태를 확인하는 것으로 대신했다. 다시 세우는 데 필요한 매니페스트 둘과 cloud-init 템플릿도 저장소에 없다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **단계별 구축 문서는 작성 시점이 아니라 실행 순서로 검증한다**
|
||||
그 기준이 요구하는 검증을 이 물음이 실제로 실행한다. 그 기록은 스스로 「이 기준으로 가이드를 고친 뒤 처음부터 다시 따라가 본 기록이 아직 없다」고 적었다.
|
||||
- **WiFi 전용 호스트에서 대용량 qcow2 를 옮기는 데 실제로 몇 시간이 걸리는가**
|
||||
같은 물음의 다른 절반이다. 옮기는 것과 다시 세우는 것 가운데 어느 쪽이 복원 경로인지는 두 값이 다 나와야 정해진다.
|
||||
- **cloud-init 이 안 도는 원인은 넷인데 증상은 「SSH 가 안 붙는다」 하나였다**
|
||||
재구축에서 01 단계가 가장 먼저 걸린다. 그 기록이 센 네 원인이 다시 나오는지가 이 검증의 한 칸이다.
|
||||
- **빈 토큰이 조용히 흘러갔다 — 설치 출력은 성공이었고 agent 만 5초마다 다시 죽었다**
|
||||
02 단계에서 같은 일이 벌어진다. 길이 가드 한 줄이 실제로 빈 토큰을 잡아 본 기록이 없으므로, 재구축이 그 가드를 처음으로 시험하게 된다.
|
||||
- **도구가 낸 출력은 대상의 상태가 아니다 — 그 명령이 무엇을 세는지부터 가른다**
|
||||
다시 선 실험대가 같은 상태인지를 판정할 근거가 전부 그런 출력이다. 통과 조건 일곱을 그대로 믿을지 여러 층으로 견줄지가 그 기준에 걸려 있다.
|
||||
|
||||
## 사실
|
||||
|
||||
- §184 가 이 부의 검증 방식을 갈라 적었다. 읽기 전용 확인은 돌아가는 실험대에서 실제로 실행해 출력을 그대로 실었다.
|
||||
- 만드는 명령은 그렇게 하지 못했다. VM 을 다시 만들거나 k3s 를 다시 깔면 돌고 있는 실험대가 없어지므로, 구축할 때 쓴 명령을 그대로 옮기고 결과 상태를 확인하는 것으로 대신했다.
|
||||
- §186 부터 §192 까지의 생성 명령에는 unknown 이 붙어 있다. 「지금 다시 쳐도 같은 상태가 된다」가 확인되지 않았다.
|
||||
- 세울 대상과 단계마다의 통과 조건은 §185 의 표에 일곱 줄로 적혀 있다. virsh list 가 돈다 · 세 게스트에 SSH 가 붙는다 · kubectl get nodes 에 둘 다 Ready · 밖에서 요청이 파드까지 닿는다 · https 가 열리고 체인이 4단계 · 관리 콘솔 로그인이 된다 · vendor_cluster_size 가 2.
|
||||
- 다시 세울 때 필요한 것 가운데 일부가 저장소에 없다. 매니페스트 둘(keycloak-cluster.yaml · observability.yaml)과 cloud-init 템플릿 kc-lab.yaml.example 이 source/ 에 반입되지 않았고, 가이드가 화면에 옮겨 적은 만큼만 있다.
|
||||
- 가이드를 순서대로 따라가다 나온 결함 여섯을 §182 가 이미 표로 적었고, 공통 원인 하나를 inferred 로 붙였다.
|
||||
- 「단계별 구축 문서는 작성 시점이 아니라 실행 순서로 검증한다」가 스스로 밝혔다. 그 규칙으로 가이드를 고친 뒤 처음부터 다시 따라가 본 기록이 아직 없고, 규칙이 결함을 막아 냈다는 관측도 아직 없다.
|
||||
|
||||
## 가정
|
||||
|
||||
- 검증을 시작하는 판이 지금 source/ 에 있는 가이드라고 본다. 그 가이드가 §182 의 결함 여섯을 고친 판인지는 대조하지 않았다.
|
||||
- 밖에서 받아 오는 것들이 그때와 같은 판이라고 전제한다. Debian 12 genericcloud 이미지와 get.k3s.io 설치 스크립트와 apt 저장소의 nginx 와 certbot 이 그것이다. 판 번호가 달라지면 같은 명령이 다른 상태를 만든다.
|
||||
- 도메인과 Cloudflare 토큰과 tailnet 주소는 다시 세울 때도 그대로 쓴다고 본다. 04 단계 전체가 그 셋에 묶여 있다.
|
||||
- 새로 세우는 기계의 CPU 가 하드웨어 가상화를 지원한다고 전제한다. 아니면 00 단계부터 다른 이유로 막히고, 그 막힘은 가이드의 결함이 아니다.
|
||||
|
||||
## 미지수
|
||||
|
||||
- 지금의 가이드 7단계를 빈 호스트에서 처음부터 순서대로 쳤을 때 어느 단계에서 멈추는지. 멈춘다면 그것이 §182 가 이미 센 여섯 중 하나인지 그때는 안 보이던 새 결함인지.
|
||||
- source/ 에 없는 매니페스트 둘과 cloud-init 템플릿 없이 02 와 05 와 06 단계가 문서만으로 서는지.
|
||||
- 다시 선 실험대가 지금과 같은 상태인지를 무엇으로 판정할지. 통과 조건 일곱이 같은 값을 내는 것으로 충분한지, 판 번호까지 같아야 하는지 — libvirt 12.7.0 · QEMU emulator version 11.1.1 · v1.36.4+k3s1 · nginx/1.22.1 이 지금 값이다.
|
||||
|
||||
## 제약
|
||||
|
||||
- 지금 돌고 있는 실험대를 멈출 수 없다. §184 가 생성 명령을 재실행으로 검증하지 않은 이유가 그것이고, 이 물음도 같은 제약 아래에서 답해야 한다.
|
||||
- 이 호스트에 한 벌 더 세우기에는 메모리가 모자란다. §187 의 배치는 세 게스트 합이 10240MB 이고 §178 의 호스트 RAM 은 11,648MiB 다.
|
||||
- 04 단계는 공개 인터넷이 아니라 tailnet 과 Cloudflare 계정에 묶여 있다. 다른 기계에서 재려면 그 둘에 닿아야 한다.
|
||||
- Let's Encrypt 의 주당 중복 인증서 5장 한도가 있다. 재구축을 여러 번 돌리면 04 단계가 거기 걸린다.
|
||||
- 한 번 끝까지 따라가는 것으로 답한다. 여러 번 돌려 분포를 보는 것은 이 물음의 범위 밖이다.
|
||||
|
||||
## 선택지
|
||||
|
||||
### 1. 다른 기계에 빈 호스트를 두고 00 부터 06 까지 순서대로 친다
|
||||
|
||||
돌고 있는 실험대를 건드리지 않고 가이드만 시험한다. 하드웨어가 달라도 상관없는 대신, 막힌 단계가 가이드의 결함인지 하드웨어 차이인지를 가르는 일이 따로 붙는다.
|
||||
|
||||
결과는 네 줄로 적는다.
|
||||
|
||||
멈춘 단계 : 00 부터 06 까지 중 어디인가
|
||||
멈춘 이유 : 그 시점에 리소스가 없어서인가, 그 셸에서 안 도는 명령이라서인가, 둘 다 아닌가
|
||||
§182 의 여섯과 겹치나 : o 또는 x
|
||||
통과 조건 일곱 : 단계마다 같은 값이 나왔는가
|
||||
|
||||
### 2. 이 호스트에 게스트 세 대를 새 이름으로 한 벌 더 세운다
|
||||
|
||||
하드웨어 차이가 없으므로 막힌 단계를 가이드 쪽으로 좁힐 수 있다. 대신 §187 의 배치로는 메모리가 모자라니 게스트 크기를 줄여 돌리고, 그 사실을 결과에 함께 적는다. 크기를 줄인 채로 나온 값은 05 단계와 06 단계에서 지금 실험대와 다를 수 있다.
|
||||
|
||||
### 3. 문서만 읽어 빠진 단계를 찾는다 — 제외
|
||||
|
||||
§182 가 이미 답을 적었다. 개별 명령은 전부 실제로 돌았던 것이고 틀린 것은 명령이 아니라 그 명령이 놓인 위치라, 각 줄은 참인데 순서대로 따라가면 막힌다. 그런 결함은 문서를 읽어서는 안 나오고 실행해야 나온다.
|
||||
|
||||
## 다음 검증
|
||||
|
||||
1. 매니페스트 둘과 cloud-init 템플릿을 source/ 로 반입해 final/ 에 넣는다. 없이 시작하면 이 검증이 재는 것이 「가이드가 서는가」가 아니라 「빠진 파일을 다시 만들 수 있는가」로 바뀐다.
|
||||
2. 대상을 정한다. 다른 기계면 1번, 이 호스트에 한 벌 더면 2번이고, 2번을 고르면 게스트 메모리를 줄인 값을 먼저 적는다.
|
||||
3. 가이드 그대로 00 부터 06 까지 순서대로 친다. §184 가 명령을 두 이름으로 갈라 적어 둔 곳에서는 어느 쪽을 칠지부터 정한다 — 「이 실험대는 이렇게 했다」는 실제로 친 명령 그대로이고, 「따라 하는 사람은」 쪽은 이 실험대에서 한 번도 치지 않았다. 각 단계의 「이 단계가 끝나면」 명령을 치고 출력을 final/evidence/raw/ 에 원문으로 남기고, meta/ 에 명령과 cwd 와 실행 시각과 종료 코드를 적는다.
|
||||
4. 04 단계는 --dry-run 을 먼저 돌린다. 주당 중복 인증서 5장 한도를 dry-run 은 쓰지 않는다.
|
||||
5. 막힌 단계마다 무엇이 없어서 막혔는지를 §182 의 두 축으로 분류해 적는다. 그 시점에 리소스가 없었나, 그 셸에서 안 도는 명령이었나. 어느 축에도 안 들어가면 그것을 새 축으로 적는다.
|
||||
6. 끝나면 판 번호 넷을 적어 지금 실험대의 값과 나란히 둔다.
|
||||
|
||||
닫는 조건 : 00 부터 06 까지 통과 조건 일곱이 전부 같은 값을 내면 「가이드만으로 이 실험대가 다시 선다」고 적고 닫는다. 그러면 「WiFi 전용 호스트에서 대용량 qcow2 를 옮기는 데 실제로 몇 시간이 걸리는가」가 재는 이동 시간과 견줄 대상이 생겨, 옮기는 편이 빠른지 다시 세우는 편이 빠른지가 그때 정해진다. 옮기는 것이 현실적이지 않다면 이 실험대의 복원 경로는 문서 하나가 된다.
|
||||
|
||||
어느 단계에서든 막히면 그 단계를 §182 의 결함 표에 행으로 더하고, 「단계별 구축 문서는 작성 시점이 아니라 실행 순서로 검증한다」의 「규칙이 결함을 막아 냈다는 관측은 아직 얻지 못했다」를 그 결과로 바꾼다. 막힌 단계가 그 규칙의 두 축 안이면 규칙이 통한 것이고, 밖이면 축이 모자란 것이라 규칙을 고친다. 어느 쪽이든 §186 부터 §192 까지의 생성 명령에 붙은 unknown 이 그때 확인이나 반증으로 바뀐다.
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"authority": "deterministic",
|
||||
"gate": "command-pedagogy-signals",
|
||||
"section_id": "docs/virtualization/tech-log-studio/build-completion-judgment/question/question-does-the-guide-rebuild-this-lab.md",
|
||||
"mode": "reference",
|
||||
"source_sha256": "1950afda3b2b23ca0e498d67645f19824b6861bd522c96854ad777b197f4c7b3",
|
||||
"result": "PASS",
|
||||
"requires_editor": false,
|
||||
"blocks": [],
|
||||
"findings": [],
|
||||
"extensions": {
|
||||
"command_like_text_blocks": []
|
||||
}
|
||||
}
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
---
|
||||
kind: QUESTION
|
||||
slug: does-the-guide-rebuild-this-lab
|
||||
title: 가이드대로 다시 쳐서 이 실험대가 같은 상태로 서는가
|
||||
topic: build-completion-judgment
|
||||
topicName: 끝났다는 판정
|
||||
project: virtualization
|
||||
status: 게시 전
|
||||
questionStatus: OPEN
|
||||
sourceRevision: import-head 9465582b5d1630eb4ae7c4e078021486919bf6b6 · uncommitted-working-tree snapshot
|
||||
source:
|
||||
- final/document.md#194-이-부에서-파생될-open-question
|
||||
- final/document.md#184-이-부의-출처와-범위
|
||||
---
|
||||
|
||||
# 가이드대로 다시 쳐서 이 실험대가 같은 상태로 서는가
|
||||
|
||||
생성 명령을 다시 쳐서 같은 실험대가 서는지는 확인된 적이 없다. 돌고 있는 실험대를 멈출 수 없어 구축할 때 쓴 명령을 옮기고 결과 상태를 확인하는 것으로 대신했다. 다시 세우는 데 필요한 매니페스트 둘과 cloud-init 템플릿도 저장소에 없다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **단계별 구축 문서는 작성 시점이 아니라 실행 순서로 검증한다**
|
||||
그 기준이 요구하는 검증을 이 물음이 실제로 실행한다. 그 기록은 스스로 「이 기준으로 가이드를 고친 뒤 처음부터 다시 따라가 본 기록이 아직 없다」고 적었다.
|
||||
- **WiFi 전용 호스트에서 대용량 qcow2 를 옮기는 데 실제로 몇 시간이 걸리는가**
|
||||
같은 물음의 다른 절반이다. 옮기는 것과 다시 세우는 것 가운데 어느 쪽이 복원 경로인지는 두 값이 다 나와야 정해진다.
|
||||
- **cloud-init 이 안 도는 원인은 넷인데 증상은 「SSH 가 안 붙는다」 하나였다**
|
||||
재구축에서 01 단계가 가장 먼저 걸린다. 그 기록이 센 네 원인이 다시 나오는지가 이 검증의 한 칸이다.
|
||||
- **빈 토큰이 조용히 흘러갔다 — 설치 출력은 성공이었고 agent 만 5초마다 다시 죽었다**
|
||||
02 단계에서 같은 일이 벌어진다. 길이 가드 한 줄이 실제로 빈 토큰을 잡아 본 기록이 없으므로, 재구축이 그 가드를 처음으로 시험하게 된다.
|
||||
- **도구가 낸 출력은 대상의 상태가 아니다 — 그 명령이 무엇을 세는지부터 가른다**
|
||||
다시 선 실험대가 같은 상태인지를 판정할 근거가 전부 그런 출력이다. 통과 조건 일곱을 그대로 믿을지 여러 층으로 견줄지가 그 기준에 걸려 있다.
|
||||
|
||||
## 사실
|
||||
|
||||
- §184 가 이 부의 검증 방식을 갈라 적었다. 읽기 전용 확인은 돌아가는 실험대에서 실제로 실행해 출력을 그대로 실었다.
|
||||
- 만드는 명령은 그렇게 하지 못했다. VM 을 다시 만들거나 k3s 를 다시 깔면 돌고 있는 실험대가 없어지므로, 구축할 때 쓴 명령을 그대로 옮기고 결과 상태를 확인하는 것으로 대신했다.
|
||||
- §186 부터 §192 까지의 생성 명령에는 unknown 이 붙어 있다. 「지금 다시 쳐도 같은 상태가 된다」가 확인되지 않았다.
|
||||
- 세울 대상과 단계마다의 통과 조건은 §185 의 표에 일곱 줄로 적혀 있다. virsh list 가 돈다 · 세 게스트에 SSH 가 붙는다 · kubectl get nodes 에 둘 다 Ready · 밖에서 요청이 파드까지 닿는다 · https 가 열리고 체인이 4단계 · 관리 콘솔 로그인이 된다 · vendor_cluster_size 가 2.
|
||||
- 다시 세울 때 필요한 것 가운데 일부가 저장소에 없다. 매니페스트 둘(keycloak-cluster.yaml · observability.yaml)과 cloud-init 템플릿 kc-lab.yaml.example 이 source/ 에 반입되지 않았고, 가이드가 화면에 옮겨 적은 만큼만 있다.
|
||||
- 가이드를 순서대로 따라가다 나온 결함 여섯을 §182 가 이미 표로 적었고, 공통 원인 하나를 inferred 로 붙였다.
|
||||
- 「단계별 구축 문서는 작성 시점이 아니라 실행 순서로 검증한다」가 스스로 밝혔다. 그 규칙으로 가이드를 고친 뒤 처음부터 다시 따라가 본 기록이 아직 없고, 규칙이 결함을 막아 냈다는 관측도 아직 없다.
|
||||
|
||||
## 가정
|
||||
|
||||
- 검증을 시작하는 판이 지금 source/ 에 있는 가이드라고 본다. 그 가이드가 §182 의 결함 여섯을 고친 판인지는 대조하지 않았다.
|
||||
- 밖에서 받아 오는 것들이 그때와 같은 판이라고 전제한다. Debian 12 genericcloud 이미지와 get.k3s.io 설치 스크립트와 apt 저장소의 nginx 와 certbot 이 그것이다. 판 번호가 달라지면 같은 명령이 다른 상태를 만든다.
|
||||
- 도메인과 Cloudflare 토큰과 tailnet 주소는 다시 세울 때도 그대로 쓴다고 본다. 04 단계 전체가 그 셋에 묶여 있다.
|
||||
- 새로 세우는 기계의 CPU 가 하드웨어 가상화를 지원한다고 전제한다. 아니면 00 단계부터 다른 이유로 막히고, 그 막힘은 가이드의 결함이 아니다.
|
||||
|
||||
## 미지수
|
||||
|
||||
- 지금의 가이드 7단계를 빈 호스트에서 처음부터 순서대로 쳤을 때 어느 단계에서 멈추는지. 멈춘다면 그것이 §182 가 이미 센 여섯 중 하나인지 그때는 안 보이던 새 결함인지.
|
||||
- source/ 에 없는 매니페스트 둘과 cloud-init 템플릿 없이 02 와 05 와 06 단계가 문서만으로 서는지.
|
||||
- 다시 선 실험대가 지금과 같은 상태인지를 무엇으로 판정할지. 통과 조건 일곱이 같은 값을 내는 것으로 충분한지, 판 번호까지 같아야 하는지 — libvirt 12.7.0 · QEMU emulator version 11.1.1 · v1.36.4+k3s1 · nginx/1.22.1 이 지금 값이다.
|
||||
|
||||
## 제약
|
||||
|
||||
- 지금 돌고 있는 실험대를 멈출 수 없다. §184 가 생성 명령을 재실행으로 검증하지 않은 이유가 그것이고, 이 물음도 같은 제약 아래에서 답해야 한다.
|
||||
- 이 호스트에 한 벌 더 세우기에는 메모리가 모자란다. §187 의 배치는 세 게스트 합이 10240MB 이고 §178 의 호스트 RAM 은 11,648MiB 다.
|
||||
- 04 단계는 공개 인터넷이 아니라 tailnet 과 Cloudflare 계정에 묶여 있다. 다른 기계에서 재려면 그 둘에 닿아야 한다.
|
||||
- Let's Encrypt 의 주당 중복 인증서 5장 한도가 있다. 재구축을 여러 번 돌리면 04 단계가 거기 걸린다.
|
||||
- 한 번 끝까지 따라가는 것으로 답한다. 여러 번 돌려 분포를 보는 것은 이 물음의 범위 밖이다.
|
||||
|
||||
## 선택지
|
||||
|
||||
### 1. 다른 기계에 빈 호스트를 두고 00 부터 06 까지 순서대로 친다
|
||||
|
||||
돌고 있는 실험대를 건드리지 않고 가이드만 시험한다. 하드웨어가 달라도 상관없는 대신, 막힌 단계가 가이드의 결함인지 하드웨어 차이인지를 가르는 일이 따로 붙는다.
|
||||
|
||||
결과는 네 줄로 적는다.
|
||||
|
||||
멈춘 단계 : 00 부터 06 까지 중 어디인가
|
||||
멈춘 이유 : 그 시점에 리소스가 없어서인가, 그 셸에서 안 도는 명령이라서인가, 둘 다 아닌가
|
||||
§182 의 여섯과 겹치나 : o 또는 x
|
||||
통과 조건 일곱 : 단계마다 같은 값이 나왔는가
|
||||
|
||||
### 2. 이 호스트에 게스트 세 대를 새 이름으로 한 벌 더 세운다
|
||||
|
||||
하드웨어 차이가 없으므로 막힌 단계를 가이드 쪽으로 좁힐 수 있다. 대신 §187 의 배치로는 메모리가 모자라니 게스트 크기를 줄여 돌리고, 그 사실을 결과에 함께 적는다. 크기를 줄인 채로 나온 값은 05 단계와 06 단계에서 지금 실험대와 다를 수 있다.
|
||||
|
||||
### 3. 문서만 읽어 빠진 단계를 찾는다 — 제외
|
||||
|
||||
§182 가 이미 답을 적었다. 개별 명령은 전부 실제로 돌았던 것이고 틀린 것은 명령이 아니라 그 명령이 놓인 위치라, 각 줄은 참인데 순서대로 따라가면 막힌다. 그런 결함은 문서를 읽어서는 안 나오고 실행해야 나온다.
|
||||
|
||||
## 다음 검증
|
||||
|
||||
1. 매니페스트 둘과 cloud-init 템플릿을 source/ 로 반입해 final/ 에 넣는다. 없이 시작하면 이 검증이 재는 것이 「가이드가 서는가」가 아니라 「빠진 파일을 다시 만들 수 있는가」로 바뀐다.
|
||||
2. 대상을 정한다. 다른 기계면 1번, 이 호스트에 한 벌 더면 2번이고, 2번을 고르면 게스트 메모리를 줄인 값을 먼저 적는다.
|
||||
3. 가이드 그대로 00 부터 06 까지 순서대로 친다. §184 가 명령을 두 이름으로 갈라 적어 둔 곳에서는 어느 쪽을 칠지부터 정한다 — 「이 실험대는 이렇게 했다」는 실제로 친 명령 그대로이고, 「따라 하는 사람은」 쪽은 이 실험대에서 한 번도 치지 않았다. 각 단계의 「이 단계가 끝나면」 명령을 치고 출력을 final/evidence/raw/ 에 원문으로 남기고, meta/ 에 명령과 cwd 와 실행 시각과 종료 코드를 적는다.
|
||||
4. 04 단계는 --dry-run 을 먼저 돌린다. 주당 중복 인증서 5장 한도를 dry-run 은 쓰지 않는다.
|
||||
5. 막힌 단계마다 무엇이 없어서 막혔는지를 §182 의 두 축으로 분류해 적는다. 그 시점에 리소스가 없었나, 그 셸에서 안 도는 명령이었나. 어느 축에도 안 들어가면 그것을 새 축으로 적는다.
|
||||
6. 끝나면 판 번호 넷을 적어 지금 실험대의 값과 나란히 둔다.
|
||||
|
||||
닫는 조건 : 00 부터 06 까지 통과 조건 일곱이 전부 같은 값을 내면 「가이드만으로 이 실험대가 다시 선다」고 적고 닫는다. 그러면 「WiFi 전용 호스트에서 대용량 qcow2 를 옮기는 데 실제로 몇 시간이 걸리는가」가 재는 이동 시간과 견줄 대상이 생겨, 옮기는 편이 빠른지 다시 세우는 편이 빠른지가 그때 정해진다. 옮기는 것이 현실적이지 않다면 이 실험대의 복원 경로는 문서 하나가 된다.
|
||||
|
||||
어느 단계에서든 막히면 그 단계를 §182 의 결함 표에 행으로 더하고, 「단계별 구축 문서는 작성 시점이 아니라 실행 순서로 검증한다」의 「규칙이 결함을 막아 냈다는 관측은 아직 얻지 못했다」를 그 결과로 바꾼다. 막힌 단계가 그 규칙의 두 축 안이면 규칙이 통한 것이고, 밖이면 축이 모자란 것이라 규칙을 고친다. 어느 쪽이든 §186 부터 §192 까지의 생성 명령에 붙은 unknown 이 그때 확인이나 반증으로 바뀐다.
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"record": "docs/virtualization/tech-log-studio/build-completion-judgment/question/question-does-the-guide-rebuild-this-lab.md",
|
||||
"sourceSha256": "1950afda3b2b23ca0e498d67645f19824b6861bd522c96854ad777b197f4c7b3",
|
||||
"verdict": "PASS",
|
||||
"liveSourceReconciliation": "UNVERIFIABLE",
|
||||
"liveSourceReason": "source repositories unavailable on current machine",
|
||||
"acceptedByProjectReview": true,
|
||||
"checks": [
|
||||
"local SSOT/Record evidence mismatch 0",
|
||||
"S3/S5/S6 check_evidence --repo invoked",
|
||||
"source checkout absence preserved as exit 3"
|
||||
],
|
||||
"note": "현재 project evidence gate는 불일치 0건·대조 불가 2건이다. source checkout 부재를 PASS로 위장하지 않고 UNVERIFIABLE로 보존했다."
|
||||
}
|
||||
+362
@@ -0,0 +1,362 @@
|
||||
{
|
||||
"schemaVersion": 5,
|
||||
"runId": "2026-09-21-0218-remediation-05-reference-check-the-nearest-layer-first",
|
||||
"project": "virtualization",
|
||||
"record": "docs/virtualization/tech-log-studio/build-completion-judgment/reference/reference-check-the-nearest-layer-first.md",
|
||||
"startedAt": "2026-09-21T02:24:37+00:00",
|
||||
"finishedAt": "2026-09-21T02:24:39+00:00",
|
||||
"stages": [
|
||||
{
|
||||
"id": "S1",
|
||||
"name": "코드베이스 → SSOT",
|
||||
"skill": "analyzing-codebase-for-tech-log",
|
||||
"runBy": "ssot-analyst",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "이번 remediation은 새 evidence와 현재 SSOT/Record의 정합성·provenance·command/S4를 재검토한다. 코드베이스→SSOT 전면 분석은 다시 하지 않는다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": "edd45dfec66d155a621cd41186b83b5582f2244c",
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"gates": [],
|
||||
"notes": "",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-21T02:24:37+00:00",
|
||||
"elapsedSeconds": 0,
|
||||
"finishedBy": "chatgpt-virtualization-20260921-remediation-v5"
|
||||
},
|
||||
{
|
||||
"id": "S2",
|
||||
"name": "SSOT → 분해 계약",
|
||||
"skill": "deriving-tech-log-root-tree",
|
||||
"runBy": "tree-deriver",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "기존 91 Record 분해 계약은 유지한다. certbot Question의 RESOLVED 상태와 sourcePolicy는 project tree rebuild에서 별도로 동기화했다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": "ab59130196d79e947b32e3b5e6b75335a9e5c1eb",
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"gates": [],
|
||||
"notes": "",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-21T02:24:37+00:00",
|
||||
"elapsedSeconds": 0,
|
||||
"finishedBy": "chatgpt-virtualization-20260921-remediation-v5"
|
||||
},
|
||||
{
|
||||
"id": "S3",
|
||||
"name": "글감 → 기록",
|
||||
"skill": "writing-tech-log-records",
|
||||
"runBy": "record-writer",
|
||||
"status": "DONE",
|
||||
"skipReason": "",
|
||||
"skillEcho": "**인용한 줄은 SSOT 에서 찾아 대조한다.**",
|
||||
"skillRevision": null,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
"docs/virtualization/tech-log-studio/build-completion-judgment/reference/reference-check-the-nearest-layer-first.md"
|
||||
],
|
||||
"gates": [
|
||||
{
|
||||
"cmd": "python3 scripts/studio-body.py docs/virtualization/tech-log-studio/build-completion-judgment/reference/reference-check-the-nearest-layer-first.md -o runs/virtualization/2026-09-21-0218-remediation-05-reference-check-the-nearest-layer-first/stage/S3/studio-body.md",
|
||||
"exit": 0,
|
||||
"status": "PASS",
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"generation": 1,
|
||||
"at": "2026-09-21T02:24:38+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node --experimental-transform-types .agents/skills/writing-tech-log-records/scripts/check_body.mjs runs/virtualization/2026-09-21-0218-remediation-05-reference-check-the-nearest-layer-first/stage/S3/studio-body.md --frontend /shared/codebase/tech-log-frontend",
|
||||
"exit": 0,
|
||||
"status": "PASS",
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"generation": 1,
|
||||
"at": "2026-09-21T02:24:38+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node .agents/skills/rewriting-technical-prose-naturally/scripts/check_prose.mjs --warn docs/virtualization/tech-log-studio/build-completion-judgment/reference/reference-check-the-nearest-layer-first.md",
|
||||
"exit": 0,
|
||||
"status": "PASS",
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"generation": 1,
|
||||
"at": "2026-09-21T02:24:38+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node .agents/skills/writing-tech-log-records/scripts/check_evidence.mjs virtualization --repo",
|
||||
"exit": 3,
|
||||
"status": "UNVERIFIABLE",
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"generation": 1,
|
||||
"at": "2026-09-21T02:24:38+00:00",
|
||||
"semanticId": "evidence-repo",
|
||||
"reason": "source repositories unavailable on current machine",
|
||||
"acceptedByProjectReview": true
|
||||
}
|
||||
],
|
||||
"notes": "record-writer/fact 계약으로 새 evidence와 SSOT의 current truth를 대조했다. --repo 대조는 실제 실행했고 source checkout 부재만 UNVERIFIABLE로 남겼다.",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-21T02:24:38+00:00",
|
||||
"elapsedSeconds": 1,
|
||||
"generation": 1,
|
||||
"owner": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"finishedBy": "chatgpt-virtualization-20260921-remediation-v5"
|
||||
},
|
||||
{
|
||||
"id": "S4",
|
||||
"name": "기록 → 그림",
|
||||
"skill": "technical-visualizer",
|
||||
"runBy": "diagram-maker",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "이 Record 변경은 current truth/evidence/version/provenance 정리이며 새 관계 그림을 요구하지 않는다. 기존 그림은 project-wide 14장 검증에서 별도로 확인했다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": "edd45dfec66d155a621cd41186b83b5582f2244c",
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"gates": [],
|
||||
"notes": "",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-21T02:24:38+00:00",
|
||||
"elapsedSeconds": 0,
|
||||
"finishedBy": "chatgpt-virtualization-20260921-remediation-v5"
|
||||
},
|
||||
{
|
||||
"id": "S5",
|
||||
"name": "AI 티 제거",
|
||||
"skill": "rewriting-technical-prose-naturally",
|
||||
"runBy": "prose-rewriter",
|
||||
"status": "DONE",
|
||||
"skipReason": "",
|
||||
"skillEcho": "This is an **editorial** pass. The source's facts, evidence, causal chain, uncertainty, decision status, and technical depth are the contract.",
|
||||
"skillRevision": null,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
"docs/virtualization/tech-log-studio/build-completion-judgment/reference/reference-check-the-nearest-layer-first.md"
|
||||
],
|
||||
"gates": [
|
||||
{
|
||||
"cmd": "node .agents/skills/rewriting-technical-prose-naturally/scripts/check_prose.mjs --warn docs/virtualization/tech-log-studio/build-completion-judgment/reference/reference-check-the-nearest-layer-first.md",
|
||||
"exit": 0,
|
||||
"status": "PASS",
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"generation": 1,
|
||||
"at": "2026-09-21T02:24:38+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node .agents/skills/rewriting-technical-prose-naturally/scripts/style_profile.mjs docs/virtualization/tech-log-studio/build-completion-judgment/reference/reference-check-the-nearest-layer-first.md",
|
||||
"exit": 0,
|
||||
"status": "PASS",
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"generation": 1,
|
||||
"at": "2026-09-21T02:24:38+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node --experimental-transform-types .agents/skills/writing-tech-log-records/scripts/check_body.mjs runs/virtualization/2026-09-21-0218-remediation-05-reference-check-the-nearest-layer-first/stage/S5/studio-body.md --frontend /shared/codebase/tech-log-frontend",
|
||||
"exit": 0,
|
||||
"status": "PASS",
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"generation": 1,
|
||||
"at": "2026-09-21T02:24:39+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node .agents/skills/writing-tech-log-records/scripts/check_evidence.mjs virtualization --repo",
|
||||
"exit": 3,
|
||||
"status": "UNVERIFIABLE",
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"generation": 1,
|
||||
"at": "2026-09-21T02:24:39+00:00",
|
||||
"semanticId": "evidence-repo",
|
||||
"reason": "source repositories unavailable on current machine",
|
||||
"acceptedByProjectReview": true
|
||||
}
|
||||
],
|
||||
"notes": "대규모 문체 재작성 없이 stale fact/relation만 현재 evidence에 맞췄다. style_profile은 측정값으로만 기록했다.",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-21T02:24:39+00:00",
|
||||
"elapsedSeconds": 1,
|
||||
"generation": 1,
|
||||
"owner": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"finishedBy": "chatgpt-virtualization-20260921-remediation-v5"
|
||||
},
|
||||
{
|
||||
"id": "S6",
|
||||
"name": "일한 사람의 목소리",
|
||||
"skill": "writing-as-the-person-who-did-it",
|
||||
"runBy": "voice-writer",
|
||||
"status": "DONE",
|
||||
"skipReason": "",
|
||||
"skillEcho": "찾은 것이 없으면 **이 스킬은 여기서 끝난다.** 없는 목소리를 채우지 않는다.",
|
||||
"skillRevision": "edd45dfec66d155a621cd41186b83b5582f2244c",
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
"docs/virtualization/tech-log-studio/build-completion-judgment/reference/reference-check-the-nearest-layer-first.md"
|
||||
],
|
||||
"gates": [
|
||||
{
|
||||
"cmd": "node .agents/skills/writing-as-the-person-who-did-it/scripts/check_voice.mjs docs/virtualization/tech-log-studio/build-completion-judgment/reference/reference-check-the-nearest-layer-first.md",
|
||||
"exit": 0,
|
||||
"status": "PASS",
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"generation": 1,
|
||||
"at": "2026-09-21T02:24:39+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node .agents/skills/rewriting-technical-prose-naturally/scripts/check_prose.mjs --warn docs/virtualization/tech-log-studio/build-completion-judgment/reference/reference-check-the-nearest-layer-first.md",
|
||||
"exit": 0,
|
||||
"status": "PASS",
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"generation": 1,
|
||||
"at": "2026-09-21T02:24:39+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node --experimental-transform-types .agents/skills/writing-tech-log-records/scripts/check_body.mjs runs/virtualization/2026-09-21-0218-remediation-05-reference-check-the-nearest-layer-first/stage/S6/studio-body.md --frontend /shared/codebase/tech-log-frontend",
|
||||
"exit": 0,
|
||||
"status": "PASS",
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"generation": 1,
|
||||
"at": "2026-09-21T02:24:39+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node .agents/skills/writing-tech-log-records/scripts/check_evidence.mjs virtualization --repo",
|
||||
"exit": 3,
|
||||
"status": "UNVERIFIABLE",
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"generation": 1,
|
||||
"at": "2026-09-21T02:24:39+00:00",
|
||||
"semanticId": "evidence-repo",
|
||||
"reason": "source repositories unavailable on current machine",
|
||||
"acceptedByProjectReview": true
|
||||
}
|
||||
],
|
||||
"notes": "상류 SSOT/evidence를 다시 보며 실제 작업 흔적이 없는 서사는 추가하지 않았다.",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-21T02:24:39+00:00",
|
||||
"elapsedSeconds": 0,
|
||||
"generation": 1,
|
||||
"owner": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"finishedBy": "chatgpt-virtualization-20260921-remediation-v5"
|
||||
},
|
||||
{
|
||||
"id": "S7",
|
||||
"name": "Studio 저장",
|
||||
"skill": "publishing-tech-log-to-studio",
|
||||
"runBy": "studio-validator",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "이번 remediation에서 Studio import/save는 요청되지 않았다. 저장소 산출물과 검증 영수증만 갱신한다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": "862e502af3e956b49ccd2ae0a8de3fd32f90df9c",
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"gates": [],
|
||||
"notes": "",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-21T02:24:39+00:00",
|
||||
"elapsedSeconds": 0,
|
||||
"finishedBy": "chatgpt-virtualization-20260921-remediation-v5"
|
||||
}
|
||||
],
|
||||
"qualityReviews": {
|
||||
"commandPedagogy": {
|
||||
"initialAnalysis": {
|
||||
"cmd": "python3 scripts/check-command-pedagogy.py --mode reference docs/virtualization/tech-log-studio/build-completion-judgment/reference/reference-check-the-nearest-layer-first.md -o runs/virtualization/2026-09-21-0218-remediation-05-reference-check-the-nearest-layer-first/stage/S3/command-initial.json",
|
||||
"exit": 0,
|
||||
"shellBlocks": 0,
|
||||
"findings": 0,
|
||||
"majorFindings": 0,
|
||||
"artifact": {
|
||||
"path": "runs/virtualization/2026-09-21-0218-remediation-05-reference-check-the-nearest-layer-first/stage/S3/command-initial.json",
|
||||
"sha256": "7a808b599a502830a6eda69a20f409171f4e84b9918c900d163f132063c4bb24"
|
||||
}
|
||||
},
|
||||
"finalAnalysis": {
|
||||
"cmd": "python3 scripts/check-command-pedagogy.py --mode reference docs/virtualization/tech-log-studio/build-completion-judgment/reference/reference-check-the-nearest-layer-first.md -o runs/virtualization/2026-09-21-0218-remediation-05-reference-check-the-nearest-layer-first/stage/S6/command-final.json",
|
||||
"exit": 0,
|
||||
"shellBlocks": 0,
|
||||
"findings": 0,
|
||||
"majorFindings": 0,
|
||||
"artifact": {
|
||||
"path": "runs/virtualization/2026-09-21-0218-remediation-05-reference-check-the-nearest-layer-first/stage/S6/command-final.json",
|
||||
"sha256": "7a808b599a502830a6eda69a20f409171f4e84b9918c900d163f132063c4bb24"
|
||||
}
|
||||
},
|
||||
"planner": {
|
||||
"runBy": "command-pedagogy-planner",
|
||||
"skill": "writing-practitioner-guides",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "initial deterministic analysis finding이 0이다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": null,
|
||||
"notes": "현재 reference-mode 분석을 확인했다.",
|
||||
"artifact": null
|
||||
},
|
||||
"editor": {
|
||||
"runBy": "command-pedagogy-editor",
|
||||
"skill": "writing-practitioner-guides",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "initial deterministic analysis finding이 0이다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": null,
|
||||
"notes": "현재 reference-mode 분석을 확인했다.",
|
||||
"artifact": null
|
||||
},
|
||||
"reviewer": {
|
||||
"runBy": "command-pedagogy-reviewer",
|
||||
"skill": "writing-practitioner-guides",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "final publication에 shell/CLI block이 없다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": null,
|
||||
"verdict": null,
|
||||
"notes": "final deterministic analysis에서 shellBlocks=0을 확인했다.",
|
||||
"artifact": null,
|
||||
"sourceSha256": null
|
||||
}
|
||||
},
|
||||
"technicalEvidence": {
|
||||
"runBy": "fact-reviewer",
|
||||
"status": "DONE",
|
||||
"skipReason": "",
|
||||
"verdict": "PASS",
|
||||
"notes": "current truth/evidence/provenance를 SSOT와 역대조했다. live source는 실제 --repo 결과 exit 3이며 UNVERIFIABLE로 보존했다.",
|
||||
"sourceSha256": "65e4c80f04b2af28cca927a2d21846440c27e65d7055f87c31b5bc9175c1b0f5",
|
||||
"liveSourceReconciliation": "UNVERIFIABLE",
|
||||
"liveSourceReason": "source repositories unavailable on current machine",
|
||||
"acceptedByProjectReview": true,
|
||||
"artifact": {
|
||||
"path": "runs/virtualization/2026-09-21-0218-remediation-05-reference-check-the-nearest-layer-first/stage/quality/technical-evidence-review.json",
|
||||
"sha256": "b81db96a6bb8afc381120f4c84ce2b87eae2d99d45c618a720c5d9381f419e67"
|
||||
}
|
||||
}
|
||||
},
|
||||
"riders": [],
|
||||
"sessions": [
|
||||
{
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"openedAt": "2026-09-21T02:24:37+00:00"
|
||||
},
|
||||
{
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"stage": "S3",
|
||||
"generation": 1,
|
||||
"beganAt": "2026-09-21T02:24:37+00:00"
|
||||
},
|
||||
{
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"stage": "S5",
|
||||
"generation": 1,
|
||||
"beganAt": "2026-09-21T02:24:38+00:00"
|
||||
},
|
||||
{
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"stage": "S6",
|
||||
"generation": 1,
|
||||
"beganAt": "2026-09-21T02:24:39+00:00"
|
||||
}
|
||||
],
|
||||
"revision": 23,
|
||||
"updatedAt": "2026-09-21T02:24:39+00:00",
|
||||
"executionEnvironment": {
|
||||
"mode": "current-remediation-contract-replay",
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"agentToolAvailable": false,
|
||||
"note": "별도 subagent 실행 도구가 현재 환경에 없어 현재 세션이 각 .claude/agents 역할 정의와 연결된 SKILL/contract를 직접 읽고 역할을 분리해 수행했다. runBy는 계약 역할명이며 별도 프로세스 실행을 주장하지 않는다."
|
||||
}
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"authority": "deterministic",
|
||||
"gate": "command-pedagogy-signals",
|
||||
"section_id": "docs/virtualization/tech-log-studio/build-completion-judgment/reference/reference-check-the-nearest-layer-first.md",
|
||||
"mode": "reference",
|
||||
"source_sha256": "65e4c80f04b2af28cca927a2d21846440c27e65d7055f87c31b5bc9175c1b0f5",
|
||||
"result": "PASS",
|
||||
"requires_editor": false,
|
||||
"blocks": [],
|
||||
"findings": [],
|
||||
"extensions": {
|
||||
"command_like_text_blocks": []
|
||||
}
|
||||
}
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
---
|
||||
kind: REFERENCE
|
||||
slug: check-the-nearest-layer-first
|
||||
title: 가까운 층부터 한 층씩 건너뛰며 확인한다 — 층마다 성공 신호가 다르다
|
||||
topic: build-completion-judgment
|
||||
topicName: 끝났다는 판정
|
||||
project: virtualization
|
||||
status: 게시 전
|
||||
sourceRevision: import-head 9465582b5d1630eb4ae7c4e078021486919bf6b6 · uncommitted-working-tree snapshot
|
||||
source:
|
||||
- final/document.md#189-단계-03-엣지-nginx-라우팅과-호스트-dnat
|
||||
- final/document.md#185-가이드-묶음이-스스로-정한-규약
|
||||
- final/document.md#191-단계-05-keycloak-2노드와-postgresql
|
||||
---
|
||||
|
||||
# 가까운 층부터 한 층씩 건너뛰며 확인한다 — 층마다 성공 신호가 다르다
|
||||
|
||||
가장 가까운 층부터 치고 한 칸씩 밖으로 나오며, 층마다 성공 신호를 미리 적는다. 200 이 아닌 층이 있다 — 03 단계 네 칸의 실측은 404 와 301 과 301 과 200 이었다. 밖에서 한 번 쳐서 받은 값 하나로는 여섯 층 가운데 어디서 끊겼는지가 나오지 않는다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **빈 토큰이 조용히 흘러갔다 — 설치 출력은 성공이었고 agent 만 5초마다 다시 죽었다**
|
||||
가장 안쪽 칸에서 연결 거부나 타임아웃이 나오면 02 단계로 돌아가는데, 그 기록이 거기서 노드가 안 붙는 이유 하나를 끝까지 따라간다.
|
||||
- **갱신은 매번 SUCCESS 였고 옛 인증서가 계속 나갔다 — 2305초와 1~2초**
|
||||
04 단계의 판정도 같은 모양으로 갈린다. 그 기록은 로그 문구가 아니라 워커 PID 로 판정하라는 결론을 낸다.
|
||||
- **도구가 낸 출력은 대상의 상태가 아니다 — 그 명령이 무엇을 세는지부터 가른다**
|
||||
여기서는 어디서 끊겼는지까지만 좁힌다. 좁힌 층의 출력을 어떻게 읽고 무엇을 잘못 읽기 쉬운지는 그쪽에 적혀 있다.
|
||||
- **packet 이 어디서 끊겼는지는 계층마다 capture 해서 가른다**
|
||||
같은 「어디서 끊겼나」를 응답 코드가 아니라 네 지점의 capture 로 좁힌다. 응답 코드로는 가릴 수 없을 때 그쪽으로 넘어간다.
|
||||
- **호스트 안에서는 404, 밖에서는 connection refused — 앞 체인의 accept 가 libvirt 의 reject 를 막지 못했다**
|
||||
가장 안쪽 칸이 404 인데 밖에서만 막히는 상태를 그 기록이 다룬다. 층을 좁힌 뒤 남은 원인이 방화벽이었던 경우다.
|
||||
|
||||
## 목적
|
||||
|
||||
밖에서 한 번 친 값 하나로는 고칠 층을 고를 수 없다. §189 은 03 단계의 확인을 네 칸으로 나누고 칸마다 건너뛰는 층을 하나씩 늘렸다. 첫 칸은 엣지 nginx 를 건너뛰고 게스트의 80 을 직접 치고, 둘째 칸은 호스트 DNAT 을 건너뛰고 엣지를 직접 치고, 셋째 칸은 밖에서 도메인으로 치고, 넷째 칸은 TLS 가 붙은 뒤를 친다. 둘째 칸이 통과하는데 셋째 칸이 안 되면 막힌 곳은 DNAT 이고, 둘째 칸에서 막히면 막힌 곳은 엣지 안이다. 이 한 칸을 끼워 두면 그 둘이 섞이지 않는다.
|
||||
|
||||
이 순서를 쓰려면 층마다 무엇이 성공인지를 먼저 적어야 한다. 첫 칸의 404 는 게스트의 80 을 Traefik 이 듣고 있고 매칭되는 Ingress 규칙이 없다고 답한 것이라 성공이다. 502 면 Traefik 은 떴는데 뒤에 백엔드가 없는 것이고, 연결 거부나 타임아웃이면 02 단계의 노드 상태로 돌아간다. 미리 적어 두지 않으면 404 를 보고 nginx 설정부터 고치기 시작한다. 성공 신호를 적어 두지 않아 통과한 출력을 실패로 읽는 일은 이 실험대에서 이미 있었다. §189 은 nginx -t 가 Debian 12 에서 늘 같이 내놓는 경고 한 줄을 04 단계에서 실패로 오독하는 일이 실제로 벌어졌다고 적고, 그래서 경고와 오류를 구분하는 눈을 03 단계에서 들여 둔다.
|
||||
|
||||
05 단계도 같은 순서로 판정한다. 밖에서 200 이면 nginx 에서 Traefik, Ingress, Service 를 지나 파드까지 전부 이어졌다. 502 나 503 이면 Ingress 가 있는지, Service 뒤에 파드가 있는지, 파드가 Ready 인지를 뒤에서부터 되짚는다.
|
||||
|
||||
## 규칙
|
||||
|
||||
### 1. 가장 가까운 층에서 시작해 한 칸씩 밖으로 나오며 친다
|
||||
|
||||
밖에서 시작하면 응답 하나에 여섯 층이 전부 들어가 있어 어느 층이 답한 것인지 가릴 수 없다. 안쪽에서 시작해 한 칸씩 층을 더하면, 값이 처음 달라지는 칸에서 더한 층이 끊긴 층이다.
|
||||
|
||||
### 2. 층마다 성공 신호를 미리 적는다. 200 이 아닌 층이 있다
|
||||
|
||||
03 단계 네 칸의 실측은 404 와 301 과 301 과 200 이었다. 첫 칸의 404 와 둘째·셋째 칸의 301 은 그 층이 제 일을 했다는 뜻이라, 성공 신호를 적어 두지 않으면 이 셋이 전부 실패로 읽힌다.
|
||||
|
||||
### 3. 무엇이 잘못됐는지 모르는 동안에는 값만 뽑는 명령을 쓰지 않는다
|
||||
|
||||
§185 의 ① 이 확인 명령을 두 종류로 갈라 적는다. 실무자가 한 번 볼 때 치는 curl -I 는 헤더를 통째로 내놓고, 여러 번 재서 비교할 때 쓰는 curl -s -o /dev/null -w 는 골라 놓은 한 칸 말고 전부 버린다. 그래서 03 단계의 확인이 첫 칸에서 -I 로 시작해 넷째 칸에서 http_code 한 칸으로 줄어든다. 순서가 반대면 첫 칸에서 무엇이 잘못됐는지 알려 줄 헤더를 스스로 버리게 된다.
|
||||
|
||||
### 4. 층을 좁힌 뒤에는 그 층이 내는 문구와 errno 와 종료 코드를 읽는다
|
||||
|
||||
같은 「안 된다」가 층마다 다른 낱말로 나온다. nginx upstream 의 connect() failed (113: No route to host) 는 네트워크 쪽이고, (111: Connection refused) 는 프로세스 쪽이며, no live upstreams 는 둘 다 죽었다는 판단이다. 노드를 잃었을 때 이 세 줄이 1분 안에 순서대로 나왔다. k3s agent 노드의 dial tcp [::1]:8080: connect: connection refused 는 네트워크 문제가 아니라 kubeconfig 을 하나도 못 찾아 하드코딩된 기본값으로 넘어간 것이다. 파드의 Exit Code 도 그것만으로 말이 된다 — 137 은 OOM 이나 강제 종료, 1 은 애플리케이션이 스스로 끝낸 것, 127 은 명령을 못 찾은 것이다.
|
||||
|
||||
### 5. 그 층 안에 물어볼 도구가 없으면 밖에서 묻는다
|
||||
|
||||
Keycloak 컨테이너에는 curl 이 없다. 공식 이미지가 최소 구성이라 wget 도 nc 도 없고, 그때 나오는 것이 curl: command not found 와 command terminated with exit code 127 이다. 127 을 규칙 4 대로 읽으면 서버가 내려간 것이 아니라 명령이 없는 것이므로, 안에서 묻기를 그만두고 Prometheus 로 묻거나 curlimages/curl 임시 파드를 띄워 밖에서 묻는다.
|
||||
|
||||
## 적용 조건
|
||||
|
||||
- 프록시나 컨트롤러가 겹쳐 있어 밖에서 한 번 쳐서는 어디서 끊겼는지 알 수 없는 스택. 이 실험대의 요청 경로는 여섯 층이다
|
||||
- 층마다 성공 신호를 미리 적을 수 있을 때. 모르면 그것부터 한 층씩 재서 적는다
|
||||
- 각 칸을 어느 기계에서 치는지가 이미 정해져 있을 때
|
||||
- 밖에서 200 이 나오면 전부 이어졌다고 말할 수 있는 스택. 05 단계가 그렇다
|
||||
|
||||
## 예외
|
||||
|
||||
치는 위치가 틀리면 층 판정이 통째로 무의미해진다. 04 단계의 확인을 엣지 게스트 안에서 치면 connect to 100.83.212.4 port 443 failed: Connection refused 가 돌아오는데, 이것은 어느 층의 답도 아니다. 엣지에서 나간 패킷은 호스트의 virbr0 으로 들어가고 DNAT 규칙은 tailscale0 으로 들어온 것만 매칭하므로 규칙에 안 걸리고, 호스트 443 에 리스너가 없어 거절된다. 그래서 이 절차를 쓰기 전에 각 칸을 어느 기계에서 치는지가 정해져 있어야 한다. 문서가 그것을 매번 적게 만드는 방법은 「단계별 구축 문서는 작성 시점이 아니라 실행 순서로 검증한다」에 있다.
|
||||
|
||||
네 칸이 다 통과하고도 나중에 터지는 것이 있다. DNAT 규칙의 443 을 433 으로 친 오타가 이 실험대에서 실제로 나왔는데, 433 도 유효한 포트라 nft 가 군말 없이 받고 80 은 멀쩡히 넘어가므로 03 단계의 확인은 다 통과하고 04 단계에서 443 쪽만 안 되는 형태로 뒤늦게 터진다.
|
||||
|
||||
이 절차는 어디서 끊겼는지를 좁힐 뿐 왜 끊겼는지를 말하지 않는다. 첫 칸에서 404 가 나와도 그 뒤의 값이 틀렸을 수 있고, 02 단계의 INTERNAL-IP 가 그렇다 — 두 노드가 Ready 인데 보고된 IP 가 우리가 준 값과 다르면 지금은 아무 증상이 없다가 03 단계의 upstream 에서 어긋난다. 로그를 읽어 좁히려다 잘린 문구를 붙들 수도 있다. nginx 에러 로그는 2048바이트에서 잘린다. 이 실험대에서도 502 원인이 error 로그에서는 잘린 채로 있었고, access 로그에는 3492자로 온전히 남아 있었다. 그쪽은 「도구가 낸 출력은 대상의 상태가 아니다」가 받는다.
|
||||
|
||||
제3부의 「packet 이 어디서 끊겼는지는 계층마다 capture 해서 가른다」와는 재는 것이 다르다. 그쪽은 네 지점에서 capture 를 떠 패킷이 사라진 구간을 좁히고, 이쪽은 층을 건너뛴 요청의 응답 코드로 좁힌다. 응답이 아예 안 돌아오고 패킷도 안 보이는 상태에서는 이 절차가 답을 못 내므로 그때 그쪽으로 넘어간다.
|
||||
|
||||
## 예시
|
||||
|
||||
- 첫 칸 : nginx 를 건너뛰고 curl -I http://192.168.122.11 을 쳐서 404. 이것이 성공 신호다
|
||||
- 둘째 칸 : DNAT 을 건너뛰고 http://192.168.122.10 을 쳐서 301
|
||||
- 셋째 칸 : 밖에서 http://auth.hyeonworks.com 을 쳐서 301 https://auth.hyeonworks.com/
|
||||
- 넷째 칸 : TLS 이후 https://auth.hyeonworks.com/realms/master 를 쳐서 200
|
||||
- 둘째 칸은 되는데 셋째 칸이 안 된다 : 막힌 곳이 DNAT 이다
|
||||
- 둘째 칸에서 막힌다 : 막힌 곳이 엣지 안이다
|
||||
- 첫 칸이 502 : Traefik 은 떴고 뒤에 백엔드가 없다
|
||||
- 첫 칸이 연결 거부나 타임아웃 : 02 단계의 노드 상태로 돌아간다
|
||||
- 노드를 잃었을 때 1분 안에 순서대로 나온 세 줄 : 113 은 네트워크, 111 은 프로세스, no live upstreams 는 둘 다 죽었다는 판단
|
||||
- Keycloak 컨테이너 안에서 curl 이 exit code 127 : 서버가 아니라 명령이 없다. 밖에서 묻는다
|
||||
- 04 단계 확인을 엣지 안에서 쳤을 때의 connection refused : 층의 답이 아니라 친 위치의 답이다
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
---
|
||||
kind: REFERENCE
|
||||
slug: check-the-nearest-layer-first
|
||||
title: 가까운 층부터 한 층씩 건너뛰며 확인한다 — 층마다 성공 신호가 다르다
|
||||
topic: build-completion-judgment
|
||||
topicName: 끝났다는 판정
|
||||
project: virtualization
|
||||
status: 게시 전
|
||||
sourceRevision: import-head 9465582b5d1630eb4ae7c4e078021486919bf6b6 · uncommitted-working-tree snapshot
|
||||
source:
|
||||
- final/document.md#189-단계-03-엣지-nginx-라우팅과-호스트-dnat
|
||||
- final/document.md#185-가이드-묶음이-스스로-정한-규약
|
||||
- final/document.md#191-단계-05-keycloak-2노드와-postgresql
|
||||
---
|
||||
|
||||
# 가까운 층부터 한 층씩 건너뛰며 확인한다 — 층마다 성공 신호가 다르다
|
||||
|
||||
가장 가까운 층부터 치고 한 칸씩 밖으로 나오며, 층마다 성공 신호를 미리 적는다. 200 이 아닌 층이 있다 — 03 단계 네 칸의 실측은 404 와 301 과 301 과 200 이었다. 밖에서 한 번 쳐서 받은 값 하나로는 여섯 층 가운데 어디서 끊겼는지가 나오지 않는다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **빈 토큰이 조용히 흘러갔다 — 설치 출력은 성공이었고 agent 만 5초마다 다시 죽었다**
|
||||
가장 안쪽 칸에서 연결 거부나 타임아웃이 나오면 02 단계로 돌아가는데, 그 기록이 거기서 노드가 안 붙는 이유 하나를 끝까지 따라간다.
|
||||
- **갱신은 매번 SUCCESS 였고 옛 인증서가 계속 나갔다 — 2305초와 1~2초**
|
||||
04 단계의 판정도 같은 모양으로 갈린다. 그 기록은 로그 문구가 아니라 워커 PID 로 판정하라는 결론을 낸다.
|
||||
- **도구가 낸 출력은 대상의 상태가 아니다 — 그 명령이 무엇을 세는지부터 가른다**
|
||||
여기서는 어디서 끊겼는지까지만 좁힌다. 좁힌 층의 출력을 어떻게 읽고 무엇을 잘못 읽기 쉬운지는 그쪽에 적혀 있다.
|
||||
- **packet 이 어디서 끊겼는지는 계층마다 capture 해서 가른다**
|
||||
같은 「어디서 끊겼나」를 응답 코드가 아니라 네 지점의 capture 로 좁힌다. 응답 코드로는 가릴 수 없을 때 그쪽으로 넘어간다.
|
||||
- **호스트 안에서는 404, 밖에서는 connection refused — 앞 체인의 accept 가 libvirt 의 reject 를 막지 못했다**
|
||||
가장 안쪽 칸이 404 인데 밖에서만 막히는 상태를 그 기록이 다룬다. 층을 좁힌 뒤 남은 원인이 방화벽이었던 경우다.
|
||||
|
||||
## 목적
|
||||
|
||||
밖에서 한 번 친 값 하나로는 고칠 층을 고를 수 없다. §189 은 03 단계의 확인을 네 칸으로 나누고 칸마다 건너뛰는 층을 하나씩 늘렸다. 첫 칸은 엣지 nginx 를 건너뛰고 게스트의 80 을 직접 치고, 둘째 칸은 호스트 DNAT 을 건너뛰고 엣지를 직접 치고, 셋째 칸은 밖에서 도메인으로 치고, 넷째 칸은 TLS 가 붙은 뒤를 친다. 둘째 칸이 통과하는데 셋째 칸이 안 되면 막힌 곳은 DNAT 이고, 둘째 칸에서 막히면 막힌 곳은 엣지 안이다. 이 한 칸을 끼워 두면 그 둘이 섞이지 않는다.
|
||||
|
||||
이 순서를 쓰려면 층마다 무엇이 성공인지를 먼저 적어야 한다. 첫 칸의 404 는 게스트의 80 을 Traefik 이 듣고 있고 매칭되는 Ingress 규칙이 없다고 답한 것이라 성공이다. 502 면 Traefik 은 떴는데 뒤에 백엔드가 없는 것이고, 연결 거부나 타임아웃이면 02 단계의 노드 상태로 돌아간다. 미리 적어 두지 않으면 404 를 보고 nginx 설정부터 고치기 시작한다. 성공 신호를 적어 두지 않아 통과한 출력을 실패로 읽는 일은 이 실험대에서 이미 있었다. §189 은 nginx -t 가 Debian 12 에서 늘 같이 내놓는 경고 한 줄을 04 단계에서 실패로 오독하는 일이 실제로 벌어졌다고 적고, 그래서 경고와 오류를 구분하는 눈을 03 단계에서 들여 둔다.
|
||||
|
||||
05 단계도 같은 순서로 판정한다. 밖에서 200 이면 nginx 에서 Traefik, Ingress, Service 를 지나 파드까지 전부 이어졌다. 502 나 503 이면 Ingress 가 있는지, Service 뒤에 파드가 있는지, 파드가 Ready 인지를 뒤에서부터 되짚는다.
|
||||
|
||||
## 규칙
|
||||
|
||||
### 1. 가장 가까운 층에서 시작해 한 칸씩 밖으로 나오며 친다
|
||||
|
||||
밖에서 시작하면 응답 하나에 여섯 층이 전부 들어가 있어 어느 층이 답한 것인지 가릴 수 없다. 안쪽에서 시작해 한 칸씩 층을 더하면, 값이 처음 달라지는 칸에서 더한 층이 끊긴 층이다.
|
||||
|
||||
### 2. 층마다 성공 신호를 미리 적는다. 200 이 아닌 층이 있다
|
||||
|
||||
03 단계 네 칸의 실측은 404 와 301 과 301 과 200 이었다. 첫 칸의 404 와 둘째·셋째 칸의 301 은 그 층이 제 일을 했다는 뜻이라, 성공 신호를 적어 두지 않으면 이 셋이 전부 실패로 읽힌다.
|
||||
|
||||
### 3. 무엇이 잘못됐는지 모르는 동안에는 값만 뽑는 명령을 쓰지 않는다
|
||||
|
||||
§185 의 ① 이 확인 명령을 두 종류로 갈라 적는다. 실무자가 한 번 볼 때 치는 curl -I 는 헤더를 통째로 내놓고, 여러 번 재서 비교할 때 쓰는 curl -s -o /dev/null -w 는 골라 놓은 한 칸 말고 전부 버린다. 그래서 03 단계의 확인이 첫 칸에서 -I 로 시작해 넷째 칸에서 http_code 한 칸으로 줄어든다. 순서가 반대면 첫 칸에서 무엇이 잘못됐는지 알려 줄 헤더를 스스로 버리게 된다.
|
||||
|
||||
### 4. 층을 좁힌 뒤에는 그 층이 내는 문구와 errno 와 종료 코드를 읽는다
|
||||
|
||||
같은 「안 된다」가 층마다 다른 낱말로 나온다. nginx upstream 의 connect() failed (113: No route to host) 는 네트워크 쪽이고, (111: Connection refused) 는 프로세스 쪽이며, no live upstreams 는 둘 다 죽었다는 판단이다. 노드를 잃었을 때 이 세 줄이 1분 안에 순서대로 나왔다. k3s agent 노드의 dial tcp [::1]:8080: connect: connection refused 는 네트워크 문제가 아니라 kubeconfig 을 하나도 못 찾아 하드코딩된 기본값으로 넘어간 것이다. 파드의 Exit Code 도 그것만으로 말이 된다 — 137 은 OOM 이나 강제 종료, 1 은 애플리케이션이 스스로 끝낸 것, 127 은 명령을 못 찾은 것이다.
|
||||
|
||||
### 5. 그 층 안에 물어볼 도구가 없으면 밖에서 묻는다
|
||||
|
||||
Keycloak 컨테이너에는 curl 이 없다. 공식 이미지가 최소 구성이라 wget 도 nc 도 없고, 그때 나오는 것이 curl: command not found 와 command terminated with exit code 127 이다. 127 을 규칙 4 대로 읽으면 서버가 내려간 것이 아니라 명령이 없는 것이므로, 안에서 묻기를 그만두고 Prometheus 로 묻거나 curlimages/curl 임시 파드를 띄워 밖에서 묻는다.
|
||||
|
||||
## 적용 조건
|
||||
|
||||
- 프록시나 컨트롤러가 겹쳐 있어 밖에서 한 번 쳐서는 어디서 끊겼는지 알 수 없는 스택. 이 실험대의 요청 경로는 여섯 층이다
|
||||
- 층마다 성공 신호를 미리 적을 수 있을 때. 모르면 그것부터 한 층씩 재서 적는다
|
||||
- 각 칸을 어느 기계에서 치는지가 이미 정해져 있을 때
|
||||
- 밖에서 200 이 나오면 전부 이어졌다고 말할 수 있는 스택. 05 단계가 그렇다
|
||||
|
||||
## 예외
|
||||
|
||||
치는 위치가 틀리면 층 판정이 통째로 무의미해진다. 04 단계의 확인을 엣지 게스트 안에서 치면 connect to 100.83.212.4 port 443 failed: Connection refused 가 돌아오는데, 이것은 어느 층의 답도 아니다. 엣지에서 나간 패킷은 호스트의 virbr0 으로 들어가고 DNAT 규칙은 tailscale0 으로 들어온 것만 매칭하므로 규칙에 안 걸리고, 호스트 443 에 리스너가 없어 거절된다. 그래서 이 절차를 쓰기 전에 각 칸을 어느 기계에서 치는지가 정해져 있어야 한다. 문서가 그것을 매번 적게 만드는 방법은 「단계별 구축 문서는 작성 시점이 아니라 실행 순서로 검증한다」에 있다.
|
||||
|
||||
네 칸이 다 통과하고도 나중에 터지는 것이 있다. DNAT 규칙의 443 을 433 으로 친 오타가 이 실험대에서 실제로 나왔는데, 433 도 유효한 포트라 nft 가 군말 없이 받고 80 은 멀쩡히 넘어가므로 03 단계의 확인은 다 통과하고 04 단계에서 443 쪽만 안 되는 형태로 뒤늦게 터진다.
|
||||
|
||||
이 절차는 어디서 끊겼는지를 좁힐 뿐 왜 끊겼는지를 말하지 않는다. 첫 칸에서 404 가 나와도 그 뒤의 값이 틀렸을 수 있고, 02 단계의 INTERNAL-IP 가 그렇다 — 두 노드가 Ready 인데 보고된 IP 가 우리가 준 값과 다르면 지금은 아무 증상이 없다가 03 단계의 upstream 에서 어긋난다. 로그를 읽어 좁히려다 잘린 문구를 붙들 수도 있다. nginx 에러 로그는 2048바이트에서 잘린다. 이 실험대에서도 502 원인이 error 로그에서는 잘린 채로 있었고, access 로그에는 3492자로 온전히 남아 있었다. 그쪽은 「도구가 낸 출력은 대상의 상태가 아니다」가 받는다.
|
||||
|
||||
제3부의 「packet 이 어디서 끊겼는지는 계층마다 capture 해서 가른다」와는 재는 것이 다르다. 그쪽은 네 지점에서 capture 를 떠 패킷이 사라진 구간을 좁히고, 이쪽은 층을 건너뛴 요청의 응답 코드로 좁힌다. 응답이 아예 안 돌아오고 패킷도 안 보이는 상태에서는 이 절차가 답을 못 내므로 그때 그쪽으로 넘어간다.
|
||||
|
||||
## 예시
|
||||
|
||||
- 첫 칸 : nginx 를 건너뛰고 curl -I http://192.168.122.11 을 쳐서 404. 이것이 성공 신호다
|
||||
- 둘째 칸 : DNAT 을 건너뛰고 http://192.168.122.10 을 쳐서 301
|
||||
- 셋째 칸 : 밖에서 http://auth.hyeonworks.com 을 쳐서 301 https://auth.hyeonworks.com/
|
||||
- 넷째 칸 : TLS 이후 https://auth.hyeonworks.com/realms/master 를 쳐서 200
|
||||
- 둘째 칸은 되는데 셋째 칸이 안 된다 : 막힌 곳이 DNAT 이다
|
||||
- 둘째 칸에서 막힌다 : 막힌 곳이 엣지 안이다
|
||||
- 첫 칸이 502 : Traefik 은 떴고 뒤에 백엔드가 없다
|
||||
- 첫 칸이 연결 거부나 타임아웃 : 02 단계의 노드 상태로 돌아간다
|
||||
- 노드를 잃었을 때 1분 안에 순서대로 나온 세 줄 : 113 은 네트워크, 111 은 프로세스, no live upstreams 는 둘 다 죽었다는 판단
|
||||
- Keycloak 컨테이너 안에서 curl 이 exit code 127 : 서버가 아니라 명령이 없다. 밖에서 묻는다
|
||||
- 04 단계 확인을 엣지 안에서 쳤을 때의 connection refused : 층의 답이 아니라 친 위치의 답이다
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"authority": "deterministic",
|
||||
"gate": "command-pedagogy-signals",
|
||||
"section_id": "docs/virtualization/tech-log-studio/build-completion-judgment/reference/reference-check-the-nearest-layer-first.md",
|
||||
"mode": "reference",
|
||||
"source_sha256": "65e4c80f04b2af28cca927a2d21846440c27e65d7055f87c31b5bc9175c1b0f5",
|
||||
"result": "PASS",
|
||||
"requires_editor": false,
|
||||
"blocks": [],
|
||||
"findings": [],
|
||||
"extensions": {
|
||||
"command_like_text_blocks": []
|
||||
}
|
||||
}
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
---
|
||||
kind: REFERENCE
|
||||
slug: check-the-nearest-layer-first
|
||||
title: 가까운 층부터 한 층씩 건너뛰며 확인한다 — 층마다 성공 신호가 다르다
|
||||
topic: build-completion-judgment
|
||||
topicName: 끝났다는 판정
|
||||
project: virtualization
|
||||
status: 게시 전
|
||||
sourceRevision: import-head 9465582b5d1630eb4ae7c4e078021486919bf6b6 · uncommitted-working-tree snapshot
|
||||
source:
|
||||
- final/document.md#189-단계-03-엣지-nginx-라우팅과-호스트-dnat
|
||||
- final/document.md#185-가이드-묶음이-스스로-정한-규약
|
||||
- final/document.md#191-단계-05-keycloak-2노드와-postgresql
|
||||
---
|
||||
|
||||
# 가까운 층부터 한 층씩 건너뛰며 확인한다 — 층마다 성공 신호가 다르다
|
||||
|
||||
가장 가까운 층부터 치고 한 칸씩 밖으로 나오며, 층마다 성공 신호를 미리 적는다. 200 이 아닌 층이 있다 — 03 단계 네 칸의 실측은 404 와 301 과 301 과 200 이었다. 밖에서 한 번 쳐서 받은 값 하나로는 여섯 층 가운데 어디서 끊겼는지가 나오지 않는다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **빈 토큰이 조용히 흘러갔다 — 설치 출력은 성공이었고 agent 만 5초마다 다시 죽었다**
|
||||
가장 안쪽 칸에서 연결 거부나 타임아웃이 나오면 02 단계로 돌아가는데, 그 기록이 거기서 노드가 안 붙는 이유 하나를 끝까지 따라간다.
|
||||
- **갱신은 매번 SUCCESS 였고 옛 인증서가 계속 나갔다 — 2305초와 1~2초**
|
||||
04 단계의 판정도 같은 모양으로 갈린다. 그 기록은 로그 문구가 아니라 워커 PID 로 판정하라는 결론을 낸다.
|
||||
- **도구가 낸 출력은 대상의 상태가 아니다 — 그 명령이 무엇을 세는지부터 가른다**
|
||||
여기서는 어디서 끊겼는지까지만 좁힌다. 좁힌 층의 출력을 어떻게 읽고 무엇을 잘못 읽기 쉬운지는 그쪽에 적혀 있다.
|
||||
- **packet 이 어디서 끊겼는지는 계층마다 capture 해서 가른다**
|
||||
같은 「어디서 끊겼나」를 응답 코드가 아니라 네 지점의 capture 로 좁힌다. 응답 코드로는 가릴 수 없을 때 그쪽으로 넘어간다.
|
||||
- **호스트 안에서는 404, 밖에서는 connection refused — 앞 체인의 accept 가 libvirt 의 reject 를 막지 못했다**
|
||||
가장 안쪽 칸이 404 인데 밖에서만 막히는 상태를 그 기록이 다룬다. 층을 좁힌 뒤 남은 원인이 방화벽이었던 경우다.
|
||||
|
||||
## 목적
|
||||
|
||||
밖에서 한 번 친 값 하나로는 고칠 층을 고를 수 없다. §189 은 03 단계의 확인을 네 칸으로 나누고 칸마다 건너뛰는 층을 하나씩 늘렸다. 첫 칸은 엣지 nginx 를 건너뛰고 게스트의 80 을 직접 치고, 둘째 칸은 호스트 DNAT 을 건너뛰고 엣지를 직접 치고, 셋째 칸은 밖에서 도메인으로 치고, 넷째 칸은 TLS 가 붙은 뒤를 친다. 둘째 칸이 통과하는데 셋째 칸이 안 되면 막힌 곳은 DNAT 이고, 둘째 칸에서 막히면 막힌 곳은 엣지 안이다. 이 한 칸을 끼워 두면 그 둘이 섞이지 않는다.
|
||||
|
||||
이 순서를 쓰려면 층마다 무엇이 성공인지를 먼저 적어야 한다. 첫 칸의 404 는 게스트의 80 을 Traefik 이 듣고 있고 매칭되는 Ingress 규칙이 없다고 답한 것이라 성공이다. 502 면 Traefik 은 떴는데 뒤에 백엔드가 없는 것이고, 연결 거부나 타임아웃이면 02 단계의 노드 상태로 돌아간다. 미리 적어 두지 않으면 404 를 보고 nginx 설정부터 고치기 시작한다. 성공 신호를 적어 두지 않아 통과한 출력을 실패로 읽는 일은 이 실험대에서 이미 있었다. §189 은 nginx -t 가 Debian 12 에서 늘 같이 내놓는 경고 한 줄을 04 단계에서 실패로 오독하는 일이 실제로 벌어졌다고 적고, 그래서 경고와 오류를 구분하는 눈을 03 단계에서 들여 둔다.
|
||||
|
||||
05 단계도 같은 순서로 판정한다. 밖에서 200 이면 nginx 에서 Traefik, Ingress, Service 를 지나 파드까지 전부 이어졌다. 502 나 503 이면 Ingress 가 있는지, Service 뒤에 파드가 있는지, 파드가 Ready 인지를 뒤에서부터 되짚는다.
|
||||
|
||||
## 규칙
|
||||
|
||||
### 1. 가장 가까운 층에서 시작해 한 칸씩 밖으로 나오며 친다
|
||||
|
||||
밖에서 시작하면 응답 하나에 여섯 층이 전부 들어가 있어 어느 층이 답한 것인지 가릴 수 없다. 안쪽에서 시작해 한 칸씩 층을 더하면, 값이 처음 달라지는 칸에서 더한 층이 끊긴 층이다.
|
||||
|
||||
### 2. 층마다 성공 신호를 미리 적는다. 200 이 아닌 층이 있다
|
||||
|
||||
03 단계 네 칸의 실측은 404 와 301 과 301 과 200 이었다. 첫 칸의 404 와 둘째·셋째 칸의 301 은 그 층이 제 일을 했다는 뜻이라, 성공 신호를 적어 두지 않으면 이 셋이 전부 실패로 읽힌다.
|
||||
|
||||
### 3. 무엇이 잘못됐는지 모르는 동안에는 값만 뽑는 명령을 쓰지 않는다
|
||||
|
||||
§185 의 ① 이 확인 명령을 두 종류로 갈라 적는다. 실무자가 한 번 볼 때 치는 curl -I 는 헤더를 통째로 내놓고, 여러 번 재서 비교할 때 쓰는 curl -s -o /dev/null -w 는 골라 놓은 한 칸 말고 전부 버린다. 그래서 03 단계의 확인이 첫 칸에서 -I 로 시작해 넷째 칸에서 http_code 한 칸으로 줄어든다. 순서가 반대면 첫 칸에서 무엇이 잘못됐는지 알려 줄 헤더를 스스로 버리게 된다.
|
||||
|
||||
### 4. 층을 좁힌 뒤에는 그 층이 내는 문구와 errno 와 종료 코드를 읽는다
|
||||
|
||||
같은 「안 된다」가 층마다 다른 낱말로 나온다. nginx upstream 의 connect() failed (113: No route to host) 는 네트워크 쪽이고, (111: Connection refused) 는 프로세스 쪽이며, no live upstreams 는 둘 다 죽었다는 판단이다. 노드를 잃었을 때 이 세 줄이 1분 안에 순서대로 나왔다. k3s agent 노드의 dial tcp [::1]:8080: connect: connection refused 는 네트워크 문제가 아니라 kubeconfig 을 하나도 못 찾아 하드코딩된 기본값으로 넘어간 것이다. 파드의 Exit Code 도 그것만으로 말이 된다 — 137 은 OOM 이나 강제 종료, 1 은 애플리케이션이 스스로 끝낸 것, 127 은 명령을 못 찾은 것이다.
|
||||
|
||||
### 5. 그 층 안에 물어볼 도구가 없으면 밖에서 묻는다
|
||||
|
||||
Keycloak 컨테이너에는 curl 이 없다. 공식 이미지가 최소 구성이라 wget 도 nc 도 없고, 그때 나오는 것이 curl: command not found 와 command terminated with exit code 127 이다. 127 을 규칙 4 대로 읽으면 서버가 내려간 것이 아니라 명령이 없는 것이므로, 안에서 묻기를 그만두고 Prometheus 로 묻거나 curlimages/curl 임시 파드를 띄워 밖에서 묻는다.
|
||||
|
||||
## 적용 조건
|
||||
|
||||
- 프록시나 컨트롤러가 겹쳐 있어 밖에서 한 번 쳐서는 어디서 끊겼는지 알 수 없는 스택. 이 실험대의 요청 경로는 여섯 층이다
|
||||
- 층마다 성공 신호를 미리 적을 수 있을 때. 모르면 그것부터 한 층씩 재서 적는다
|
||||
- 각 칸을 어느 기계에서 치는지가 이미 정해져 있을 때
|
||||
- 밖에서 200 이 나오면 전부 이어졌다고 말할 수 있는 스택. 05 단계가 그렇다
|
||||
|
||||
## 예외
|
||||
|
||||
치는 위치가 틀리면 층 판정이 통째로 무의미해진다. 04 단계의 확인을 엣지 게스트 안에서 치면 connect to 100.83.212.4 port 443 failed: Connection refused 가 돌아오는데, 이것은 어느 층의 답도 아니다. 엣지에서 나간 패킷은 호스트의 virbr0 으로 들어가고 DNAT 규칙은 tailscale0 으로 들어온 것만 매칭하므로 규칙에 안 걸리고, 호스트 443 에 리스너가 없어 거절된다. 그래서 이 절차를 쓰기 전에 각 칸을 어느 기계에서 치는지가 정해져 있어야 한다. 문서가 그것을 매번 적게 만드는 방법은 「단계별 구축 문서는 작성 시점이 아니라 실행 순서로 검증한다」에 있다.
|
||||
|
||||
네 칸이 다 통과하고도 나중에 터지는 것이 있다. DNAT 규칙의 443 을 433 으로 친 오타가 이 실험대에서 실제로 나왔는데, 433 도 유효한 포트라 nft 가 군말 없이 받고 80 은 멀쩡히 넘어가므로 03 단계의 확인은 다 통과하고 04 단계에서 443 쪽만 안 되는 형태로 뒤늦게 터진다.
|
||||
|
||||
이 절차는 어디서 끊겼는지를 좁힐 뿐 왜 끊겼는지를 말하지 않는다. 첫 칸에서 404 가 나와도 그 뒤의 값이 틀렸을 수 있고, 02 단계의 INTERNAL-IP 가 그렇다 — 두 노드가 Ready 인데 보고된 IP 가 우리가 준 값과 다르면 지금은 아무 증상이 없다가 03 단계의 upstream 에서 어긋난다. 로그를 읽어 좁히려다 잘린 문구를 붙들 수도 있다. nginx 에러 로그는 2048바이트에서 잘린다. 이 실험대에서도 502 원인이 error 로그에서는 잘린 채로 있었고, access 로그에는 3492자로 온전히 남아 있었다. 그쪽은 「도구가 낸 출력은 대상의 상태가 아니다」가 받는다.
|
||||
|
||||
제3부의 「packet 이 어디서 끊겼는지는 계층마다 capture 해서 가른다」와는 재는 것이 다르다. 그쪽은 네 지점에서 capture 를 떠 패킷이 사라진 구간을 좁히고, 이쪽은 층을 건너뛴 요청의 응답 코드로 좁힌다. 응답이 아예 안 돌아오고 패킷도 안 보이는 상태에서는 이 절차가 답을 못 내므로 그때 그쪽으로 넘어간다.
|
||||
|
||||
## 예시
|
||||
|
||||
- 첫 칸 : nginx 를 건너뛰고 curl -I http://192.168.122.11 을 쳐서 404. 이것이 성공 신호다
|
||||
- 둘째 칸 : DNAT 을 건너뛰고 http://192.168.122.10 을 쳐서 301
|
||||
- 셋째 칸 : 밖에서 http://auth.hyeonworks.com 을 쳐서 301 https://auth.hyeonworks.com/
|
||||
- 넷째 칸 : TLS 이후 https://auth.hyeonworks.com/realms/master 를 쳐서 200
|
||||
- 둘째 칸은 되는데 셋째 칸이 안 된다 : 막힌 곳이 DNAT 이다
|
||||
- 둘째 칸에서 막힌다 : 막힌 곳이 엣지 안이다
|
||||
- 첫 칸이 502 : Traefik 은 떴고 뒤에 백엔드가 없다
|
||||
- 첫 칸이 연결 거부나 타임아웃 : 02 단계의 노드 상태로 돌아간다
|
||||
- 노드를 잃었을 때 1분 안에 순서대로 나온 세 줄 : 113 은 네트워크, 111 은 프로세스, no live upstreams 는 둘 다 죽었다는 판단
|
||||
- Keycloak 컨테이너 안에서 curl 이 exit code 127 : 서버가 아니라 명령이 없다. 밖에서 묻는다
|
||||
- 04 단계 확인을 엣지 안에서 쳤을 때의 connection refused : 층의 답이 아니라 친 위치의 답이다
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"record": "docs/virtualization/tech-log-studio/build-completion-judgment/reference/reference-check-the-nearest-layer-first.md",
|
||||
"sourceSha256": "65e4c80f04b2af28cca927a2d21846440c27e65d7055f87c31b5bc9175c1b0f5",
|
||||
"verdict": "PASS",
|
||||
"liveSourceReconciliation": "UNVERIFIABLE",
|
||||
"liveSourceReason": "source repositories unavailable on current machine",
|
||||
"acceptedByProjectReview": true,
|
||||
"checks": [
|
||||
"local SSOT/Record evidence mismatch 0",
|
||||
"S3/S5/S6 check_evidence --repo invoked",
|
||||
"source checkout absence preserved as exit 3"
|
||||
],
|
||||
"note": "현재 project evidence gate는 불일치 0건·대조 불가 2건이다. source checkout 부재를 PASS로 위장하지 않고 UNVERIFIABLE로 보존했다."
|
||||
}
|
||||
+362
@@ -0,0 +1,362 @@
|
||||
{
|
||||
"schemaVersion": 5,
|
||||
"runId": "2026-09-21-0218-remediation-06-reference-tool-output-is-not-the-subject-state",
|
||||
"project": "virtualization",
|
||||
"record": "docs/virtualization/tech-log-studio/build-completion-judgment/reference/reference-tool-output-is-not-the-subject-state.md",
|
||||
"startedAt": "2026-09-21T02:24:40+00:00",
|
||||
"finishedAt": "2026-09-21T02:24:42+00:00",
|
||||
"stages": [
|
||||
{
|
||||
"id": "S1",
|
||||
"name": "코드베이스 → SSOT",
|
||||
"skill": "analyzing-codebase-for-tech-log",
|
||||
"runBy": "ssot-analyst",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "이번 remediation은 새 evidence와 현재 SSOT/Record의 정합성·provenance·command/S4를 재검토한다. 코드베이스→SSOT 전면 분석은 다시 하지 않는다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": "edd45dfec66d155a621cd41186b83b5582f2244c",
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"gates": [],
|
||||
"notes": "",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-21T02:24:40+00:00",
|
||||
"elapsedSeconds": 0,
|
||||
"finishedBy": "chatgpt-virtualization-20260921-remediation-v5"
|
||||
},
|
||||
{
|
||||
"id": "S2",
|
||||
"name": "SSOT → 분해 계약",
|
||||
"skill": "deriving-tech-log-root-tree",
|
||||
"runBy": "tree-deriver",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "기존 91 Record 분해 계약은 유지한다. certbot Question의 RESOLVED 상태와 sourcePolicy는 project tree rebuild에서 별도로 동기화했다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": "ab59130196d79e947b32e3b5e6b75335a9e5c1eb",
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"gates": [],
|
||||
"notes": "",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-21T02:24:40+00:00",
|
||||
"elapsedSeconds": 0,
|
||||
"finishedBy": "chatgpt-virtualization-20260921-remediation-v5"
|
||||
},
|
||||
{
|
||||
"id": "S3",
|
||||
"name": "글감 → 기록",
|
||||
"skill": "writing-tech-log-records",
|
||||
"runBy": "record-writer",
|
||||
"status": "DONE",
|
||||
"skipReason": "",
|
||||
"skillEcho": "**인용한 줄은 SSOT 에서 찾아 대조한다.**",
|
||||
"skillRevision": null,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
"docs/virtualization/tech-log-studio/build-completion-judgment/reference/reference-tool-output-is-not-the-subject-state.md"
|
||||
],
|
||||
"gates": [
|
||||
{
|
||||
"cmd": "python3 scripts/studio-body.py docs/virtualization/tech-log-studio/build-completion-judgment/reference/reference-tool-output-is-not-the-subject-state.md -o runs/virtualization/2026-09-21-0218-remediation-06-reference-tool-output-is-not-the-subject-state/stage/S3/studio-body.md",
|
||||
"exit": 0,
|
||||
"status": "PASS",
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"generation": 1,
|
||||
"at": "2026-09-21T02:24:40+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node --experimental-transform-types .agents/skills/writing-tech-log-records/scripts/check_body.mjs runs/virtualization/2026-09-21-0218-remediation-06-reference-tool-output-is-not-the-subject-state/stage/S3/studio-body.md --frontend /shared/codebase/tech-log-frontend",
|
||||
"exit": 0,
|
||||
"status": "PASS",
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"generation": 1,
|
||||
"at": "2026-09-21T02:24:40+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node .agents/skills/rewriting-technical-prose-naturally/scripts/check_prose.mjs --warn docs/virtualization/tech-log-studio/build-completion-judgment/reference/reference-tool-output-is-not-the-subject-state.md",
|
||||
"exit": 0,
|
||||
"status": "PASS",
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"generation": 1,
|
||||
"at": "2026-09-21T02:24:40+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node .agents/skills/writing-tech-log-records/scripts/check_evidence.mjs virtualization --repo",
|
||||
"exit": 3,
|
||||
"status": "UNVERIFIABLE",
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"generation": 1,
|
||||
"at": "2026-09-21T02:24:40+00:00",
|
||||
"semanticId": "evidence-repo",
|
||||
"reason": "source repositories unavailable on current machine",
|
||||
"acceptedByProjectReview": true
|
||||
}
|
||||
],
|
||||
"notes": "record-writer/fact 계약으로 새 evidence와 SSOT의 current truth를 대조했다. --repo 대조는 실제 실행했고 source checkout 부재만 UNVERIFIABLE로 남겼다.",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-21T02:24:40+00:00",
|
||||
"elapsedSeconds": 0,
|
||||
"generation": 1,
|
||||
"owner": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"finishedBy": "chatgpt-virtualization-20260921-remediation-v5"
|
||||
},
|
||||
{
|
||||
"id": "S4",
|
||||
"name": "기록 → 그림",
|
||||
"skill": "technical-visualizer",
|
||||
"runBy": "diagram-maker",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "이 Record 변경은 current truth/evidence/version/provenance 정리이며 새 관계 그림을 요구하지 않는다. 기존 그림은 project-wide 14장 검증에서 별도로 확인했다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": "edd45dfec66d155a621cd41186b83b5582f2244c",
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"gates": [],
|
||||
"notes": "",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-21T02:24:40+00:00",
|
||||
"elapsedSeconds": 0,
|
||||
"finishedBy": "chatgpt-virtualization-20260921-remediation-v5"
|
||||
},
|
||||
{
|
||||
"id": "S5",
|
||||
"name": "AI 티 제거",
|
||||
"skill": "rewriting-technical-prose-naturally",
|
||||
"runBy": "prose-rewriter",
|
||||
"status": "DONE",
|
||||
"skipReason": "",
|
||||
"skillEcho": "This is an **editorial** pass. The source's facts, evidence, causal chain, uncertainty, decision status, and technical depth are the contract.",
|
||||
"skillRevision": null,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
"docs/virtualization/tech-log-studio/build-completion-judgment/reference/reference-tool-output-is-not-the-subject-state.md"
|
||||
],
|
||||
"gates": [
|
||||
{
|
||||
"cmd": "node .agents/skills/rewriting-technical-prose-naturally/scripts/check_prose.mjs --warn docs/virtualization/tech-log-studio/build-completion-judgment/reference/reference-tool-output-is-not-the-subject-state.md",
|
||||
"exit": 0,
|
||||
"status": "PASS",
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"generation": 1,
|
||||
"at": "2026-09-21T02:24:40+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node .agents/skills/rewriting-technical-prose-naturally/scripts/style_profile.mjs docs/virtualization/tech-log-studio/build-completion-judgment/reference/reference-tool-output-is-not-the-subject-state.md",
|
||||
"exit": 0,
|
||||
"status": "PASS",
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"generation": 1,
|
||||
"at": "2026-09-21T02:24:41+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node --experimental-transform-types .agents/skills/writing-tech-log-records/scripts/check_body.mjs runs/virtualization/2026-09-21-0218-remediation-06-reference-tool-output-is-not-the-subject-state/stage/S5/studio-body.md --frontend /shared/codebase/tech-log-frontend",
|
||||
"exit": 0,
|
||||
"status": "PASS",
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"generation": 1,
|
||||
"at": "2026-09-21T02:24:41+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node .agents/skills/writing-tech-log-records/scripts/check_evidence.mjs virtualization --repo",
|
||||
"exit": 3,
|
||||
"status": "UNVERIFIABLE",
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"generation": 1,
|
||||
"at": "2026-09-21T02:24:41+00:00",
|
||||
"semanticId": "evidence-repo",
|
||||
"reason": "source repositories unavailable on current machine",
|
||||
"acceptedByProjectReview": true
|
||||
}
|
||||
],
|
||||
"notes": "대규모 문체 재작성 없이 stale fact/relation만 현재 evidence에 맞췄다. style_profile은 측정값으로만 기록했다.",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-21T02:24:41+00:00",
|
||||
"elapsedSeconds": 1,
|
||||
"generation": 1,
|
||||
"owner": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"finishedBy": "chatgpt-virtualization-20260921-remediation-v5"
|
||||
},
|
||||
{
|
||||
"id": "S6",
|
||||
"name": "일한 사람의 목소리",
|
||||
"skill": "writing-as-the-person-who-did-it",
|
||||
"runBy": "voice-writer",
|
||||
"status": "DONE",
|
||||
"skipReason": "",
|
||||
"skillEcho": "찾은 것이 없으면 **이 스킬은 여기서 끝난다.** 없는 목소리를 채우지 않는다.",
|
||||
"skillRevision": "edd45dfec66d155a621cd41186b83b5582f2244c",
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
"docs/virtualization/tech-log-studio/build-completion-judgment/reference/reference-tool-output-is-not-the-subject-state.md"
|
||||
],
|
||||
"gates": [
|
||||
{
|
||||
"cmd": "node .agents/skills/writing-as-the-person-who-did-it/scripts/check_voice.mjs docs/virtualization/tech-log-studio/build-completion-judgment/reference/reference-tool-output-is-not-the-subject-state.md",
|
||||
"exit": 0,
|
||||
"status": "PASS",
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"generation": 1,
|
||||
"at": "2026-09-21T02:24:41+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node .agents/skills/rewriting-technical-prose-naturally/scripts/check_prose.mjs --warn docs/virtualization/tech-log-studio/build-completion-judgment/reference/reference-tool-output-is-not-the-subject-state.md",
|
||||
"exit": 0,
|
||||
"status": "PASS",
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"generation": 1,
|
||||
"at": "2026-09-21T02:24:41+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node --experimental-transform-types .agents/skills/writing-tech-log-records/scripts/check_body.mjs runs/virtualization/2026-09-21-0218-remediation-06-reference-tool-output-is-not-the-subject-state/stage/S6/studio-body.md --frontend /shared/codebase/tech-log-frontend",
|
||||
"exit": 0,
|
||||
"status": "PASS",
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"generation": 1,
|
||||
"at": "2026-09-21T02:24:41+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node .agents/skills/writing-tech-log-records/scripts/check_evidence.mjs virtualization --repo",
|
||||
"exit": 3,
|
||||
"status": "UNVERIFIABLE",
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"generation": 1,
|
||||
"at": "2026-09-21T02:24:42+00:00",
|
||||
"semanticId": "evidence-repo",
|
||||
"reason": "source repositories unavailable on current machine",
|
||||
"acceptedByProjectReview": true
|
||||
}
|
||||
],
|
||||
"notes": "상류 SSOT/evidence를 다시 보며 실제 작업 흔적이 없는 서사는 추가하지 않았다.",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-21T02:24:42+00:00",
|
||||
"elapsedSeconds": 1,
|
||||
"generation": 1,
|
||||
"owner": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"finishedBy": "chatgpt-virtualization-20260921-remediation-v5"
|
||||
},
|
||||
{
|
||||
"id": "S7",
|
||||
"name": "Studio 저장",
|
||||
"skill": "publishing-tech-log-to-studio",
|
||||
"runBy": "studio-validator",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "이번 remediation에서 Studio import/save는 요청되지 않았다. 저장소 산출물과 검증 영수증만 갱신한다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": "862e502af3e956b49ccd2ae0a8de3fd32f90df9c",
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"gates": [],
|
||||
"notes": "",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-21T02:24:42+00:00",
|
||||
"elapsedSeconds": 0,
|
||||
"finishedBy": "chatgpt-virtualization-20260921-remediation-v5"
|
||||
}
|
||||
],
|
||||
"qualityReviews": {
|
||||
"commandPedagogy": {
|
||||
"initialAnalysis": {
|
||||
"cmd": "python3 scripts/check-command-pedagogy.py --mode reference docs/virtualization/tech-log-studio/build-completion-judgment/reference/reference-tool-output-is-not-the-subject-state.md -o runs/virtualization/2026-09-21-0218-remediation-06-reference-tool-output-is-not-the-subject-state/stage/S3/command-initial.json",
|
||||
"exit": 0,
|
||||
"shellBlocks": 0,
|
||||
"findings": 0,
|
||||
"majorFindings": 0,
|
||||
"artifact": {
|
||||
"path": "runs/virtualization/2026-09-21-0218-remediation-06-reference-tool-output-is-not-the-subject-state/stage/S3/command-initial.json",
|
||||
"sha256": "cacd8ff65f1a4d049781afcbe9896e029a1c0657497ea171c9fb248a283d6ce7"
|
||||
}
|
||||
},
|
||||
"finalAnalysis": {
|
||||
"cmd": "python3 scripts/check-command-pedagogy.py --mode reference docs/virtualization/tech-log-studio/build-completion-judgment/reference/reference-tool-output-is-not-the-subject-state.md -o runs/virtualization/2026-09-21-0218-remediation-06-reference-tool-output-is-not-the-subject-state/stage/S6/command-final.json",
|
||||
"exit": 0,
|
||||
"shellBlocks": 0,
|
||||
"findings": 0,
|
||||
"majorFindings": 0,
|
||||
"artifact": {
|
||||
"path": "runs/virtualization/2026-09-21-0218-remediation-06-reference-tool-output-is-not-the-subject-state/stage/S6/command-final.json",
|
||||
"sha256": "cacd8ff65f1a4d049781afcbe9896e029a1c0657497ea171c9fb248a283d6ce7"
|
||||
}
|
||||
},
|
||||
"planner": {
|
||||
"runBy": "command-pedagogy-planner",
|
||||
"skill": "writing-practitioner-guides",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "initial deterministic analysis finding이 0이다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": null,
|
||||
"notes": "현재 reference-mode 분석을 확인했다.",
|
||||
"artifact": null
|
||||
},
|
||||
"editor": {
|
||||
"runBy": "command-pedagogy-editor",
|
||||
"skill": "writing-practitioner-guides",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "initial deterministic analysis finding이 0이다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": null,
|
||||
"notes": "현재 reference-mode 분석을 확인했다.",
|
||||
"artifact": null
|
||||
},
|
||||
"reviewer": {
|
||||
"runBy": "command-pedagogy-reviewer",
|
||||
"skill": "writing-practitioner-guides",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "final publication에 shell/CLI block이 없다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": null,
|
||||
"verdict": null,
|
||||
"notes": "final deterministic analysis에서 shellBlocks=0을 확인했다.",
|
||||
"artifact": null,
|
||||
"sourceSha256": null
|
||||
}
|
||||
},
|
||||
"technicalEvidence": {
|
||||
"runBy": "fact-reviewer",
|
||||
"status": "DONE",
|
||||
"skipReason": "",
|
||||
"verdict": "PASS",
|
||||
"notes": "current truth/evidence/provenance를 SSOT와 역대조했다. live source는 실제 --repo 결과 exit 3이며 UNVERIFIABLE로 보존했다.",
|
||||
"sourceSha256": "0284b7560849381fd172f35593f3f30445f5aa7d2b97875757c2270d7e32bac2",
|
||||
"liveSourceReconciliation": "UNVERIFIABLE",
|
||||
"liveSourceReason": "source repositories unavailable on current machine",
|
||||
"acceptedByProjectReview": true,
|
||||
"artifact": {
|
||||
"path": "runs/virtualization/2026-09-21-0218-remediation-06-reference-tool-output-is-not-the-subject-state/stage/quality/technical-evidence-review.json",
|
||||
"sha256": "5f82efaf9b552fff67c975e91b75dc32dfce15c4ff517a088a6fca229eb45284"
|
||||
}
|
||||
}
|
||||
},
|
||||
"riders": [],
|
||||
"sessions": [
|
||||
{
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"openedAt": "2026-09-21T02:24:40+00:00"
|
||||
},
|
||||
{
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"stage": "S3",
|
||||
"generation": 1,
|
||||
"beganAt": "2026-09-21T02:24:40+00:00"
|
||||
},
|
||||
{
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"stage": "S5",
|
||||
"generation": 1,
|
||||
"beganAt": "2026-09-21T02:24:40+00:00"
|
||||
},
|
||||
{
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"stage": "S6",
|
||||
"generation": 1,
|
||||
"beganAt": "2026-09-21T02:24:41+00:00"
|
||||
}
|
||||
],
|
||||
"revision": 23,
|
||||
"updatedAt": "2026-09-21T02:24:42+00:00",
|
||||
"executionEnvironment": {
|
||||
"mode": "current-remediation-contract-replay",
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"agentToolAvailable": false,
|
||||
"note": "별도 subagent 실행 도구가 현재 환경에 없어 현재 세션이 각 .claude/agents 역할 정의와 연결된 SKILL/contract를 직접 읽고 역할을 분리해 수행했다. runBy는 계약 역할명이며 별도 프로세스 실행을 주장하지 않는다."
|
||||
}
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"authority": "deterministic",
|
||||
"gate": "command-pedagogy-signals",
|
||||
"section_id": "docs/virtualization/tech-log-studio/build-completion-judgment/reference/reference-tool-output-is-not-the-subject-state.md",
|
||||
"mode": "reference",
|
||||
"source_sha256": "0284b7560849381fd172f35593f3f30445f5aa7d2b97875757c2270d7e32bac2",
|
||||
"result": "PASS",
|
||||
"requires_editor": false,
|
||||
"blocks": [],
|
||||
"findings": [],
|
||||
"extensions": {
|
||||
"command_like_text_blocks": []
|
||||
}
|
||||
}
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
---
|
||||
kind: REFERENCE
|
||||
slug: tool-output-is-not-the-subject-state
|
||||
title: 도구가 낸 출력은 대상의 상태가 아니다 — 그 명령이 무엇을 세는지부터 가른다
|
||||
topic: build-completion-judgment
|
||||
topicName: 끝났다는 판정
|
||||
project: virtualization
|
||||
status: 게시 전
|
||||
sourceRevision: import-head 9465582b5d1630eb4ae7c4e078021486919bf6b6 · uncommitted-working-tree snapshot
|
||||
source:
|
||||
- final/document.md#191-단계-05-keycloak-2노드와-postgresql
|
||||
- final/document.md#192-단계-06-prometheus-와-grafana
|
||||
- final/document.md#186-단계-00-lab-host-가상화-준비
|
||||
---
|
||||
|
||||
# 도구가 낸 출력은 대상의 상태가 아니다 — 그 명령이 무엇을 세는지부터 가른다
|
||||
|
||||
상태를 묻는 명령의 출력을 대상의 상태로 바로 읽지 않고, 그 명령이 무엇을 세고 무엇을 안 세는지 먼저 적는다. 7800 포트를 끊었을 때 외부 응답은 전부 200 이었고, 503 이 나는 동안에도 up 은 1 이었다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **빈 토큰이 조용히 흘러갔다 — 설치 출력은 성공이었고 agent 만 5초마다 다시 죽었다**
|
||||
설치 명령의 출력과 설치된 상태가 어긋났다. 출력은 끝까지 성공이었고 실패는 journalctl 안에만 있었다.
|
||||
- **cloud-init 이 안 도는 원인은 넷인데 증상은 「SSH 가 안 붙는다」 하나였다**
|
||||
검사가 통과해도 안 도는 쪽과 검사에 걸려도 도는 쪽이 한 사건 안에 같이 있다. 검사 결과를 상태로 읽으면 어느 방향이든 틀린다는 것이 그 기록의 결론이다.
|
||||
- **갱신은 매번 SUCCESS 였고 옛 인증서가 계속 나갔다 — 2305초와 1~2초**
|
||||
로그 문구가 SUCCESS 인데 서빙되는 인증서는 옛것이었다. 판정을 문구가 아니라 워커 PID 로 옮긴 기록이다.
|
||||
- **가까운 층부터 한 층씩 건너뛰며 확인한다 — 층마다 성공 신호가 다르다**
|
||||
어디서 끊겼는지를 그 기준이 좁히고, 좁힌 층의 출력을 어떻게 읽는지를 이쪽이 받는다.
|
||||
- **가이드대로 다시 쳐서 이 실험대가 같은 상태로 서는가**
|
||||
단계마다의 통과 조건 일곱이 전부 이런 출력이라, 다시 세운 실험대가 같은 상태인지를 무엇으로 판정할지가 그 물음에 걸려 있다.
|
||||
|
||||
## 목적
|
||||
|
||||
판정을 밖에서만 하면 놓친다. §192 는 그래서 클러스터 안을 보는 관측대(Prometheus 와 Grafana)를 따로 세웠다. 7800 포트를 끊었을 때 외부 응답이 전부 200 이었고, 분단된 노드가 스스로 로드밸런서에서 빠져 밖에서는 아무 일도 없어 보였다.
|
||||
|
||||
안쪽에 세운 관측대도 지표 하나로는 같은 실패를 되풀이한다. 503 이 나는 동안에도 up 은 1 이었다. 프로세스가 살아 있고 metrics 경로가 응답하기만 하면 1 이 되므로, 살아 있지만 쓸모없는 상태를 up 은 보지 못한다. 경보를 up 이 0 인지 하나로 걸면 그 상태를 통째로 놓친다.
|
||||
|
||||
같은 일이 구축 7단계 전체에서 되풀이된다. 목록이 비어 있어서 없다고 읽은 것, 검사기가 통과해서 동작한다고 읽은 것, 아무것도 안 찍혀서 멈췄다고 읽은 것이 전부 같은 오독이다. 이 기준은 그 셋을 하나로 묶고, 판정하기 전에 그 명령이 무엇을 세는지 적게 한다.
|
||||
|
||||
## 규칙
|
||||
|
||||
### 1. 그 명령이 무엇을 세는지 먼저 적는다
|
||||
|
||||
어느 연결에 붙어 있나. virsh 는 기본으로 qemu:///session 에 붙는데 VM 은 qemu:///system 에 만들므로, 어긋나면 VM 은 만들어졌는데 virsh list 에 안 나온다. 빈 목록이 VM 의 부재가 아니라 다른 연결을 보고 있다는 뜻이다.
|
||||
|
||||
꺼진 것도 세나. net-list 는 --all 을 빼면 inactive 인 네트워크가 아예 안 나와 「없음」과 「꺼짐」이 구분되지 않는다. 이 실험대에서 default 네트워크의 autostart 가 no 면 지금은 되고 호스트를 재부팅한 다음 01 단계의 SSH 가 전부 실패하는데, 그때는 원인을 게스트에서 찾게 된다.
|
||||
|
||||
이름대로 다 내놓나. kubectl get all 은 이름과 달리 Secret 과 ConfigMap 과 PVC 와 Ingress 를 내놓지 않으므로, 그 넷이 빠진 줄 모르고 다 만들어졌다고 판정하게 된다. -l app=postgres 에 Deployment 줄이 없는 것도 라벨을 파드 템플릿에만 달았기 때문이지 Deployment 가 없는 것이 아니다.
|
||||
|
||||
어디까지 남기나. nginx 에러 로그는 2048바이트에서 잘리고 쿠버네티스 이벤트는 기본 한 시간만 남는다. 이 실험대에서 502 원인이 잘린 채로 error 로그에 있었고 access 로그에는 3492자로 온전히 남아 있었다.
|
||||
|
||||
### 2. 빈 출력을 낼 때 「없다」와 「못 봤다」를 갈라 적는다
|
||||
|
||||
Prometheus 질의가 빈 배열을 내면 그 값이 0 이라는 뜻이 아니라 그런 지표가 없다는 뜻이다. 스크레이프 대상 목록에서는 거기 없는 이름이 답을 준다 — 이 실험대는 Redis 와 BFF 와 PostgreSQL 을 긁지 않으므로 그 지표가 안 나오는 것이 측정 실패가 아니라 측정된 공백이다. §192 는 그것을 스크린샷 누락이 아니라 측정된 공백으로 적었다. grep 이 아무것도 안 내놓을 때도 같다. ip-dhcp-host 로 grep 하면 DHCP 예약이 멀쩡히 들어가 있어도 아무것도 안 나오는데, 그것은 net-update 의 섹션 이름이라 XML 안에 그 문자열이 없기 때문이다. 이벤트가 하나도 없는 것도 무사하다는 뜻이 아니라 한 시간이 지났다는 뜻일 수 있다.
|
||||
|
||||
### 3. 한 근거로 판정하지 않고 시제나 층이 다른 것을 함께 본다
|
||||
|
||||
§191 이 클러스터가 섰는지를 근거 셋으로 보고, 그 셋이 서로 다른 것을 본다고 적었다. 로그 ISPN000094 는 「그때 그렇게 보였다」이고, 테이블 jgroups_ping 은 「지금 등록되어 있다」이며, 지표 vendor_cluster_size 는 「지금 그 노드가 그렇게 안다」다. 테이블에는 둘 다 있는데 로그가 (1) 이면 서로를 찾기는 했는데 7800 포트로 메시지가 안 가는 것이고, 이 실험대에서 실제로 그 일이 벌어졌다. 각 노드가 자기가 아는 멤버 수를 보고하므로 한 노드만 보면 분단을 놓친다.
|
||||
|
||||
저장과 주입도 다른 층이다. describe 가 보여 주는 19 bytes 와 22 bytes 는 Secret 에 저장된 값이고, 파드 안에서 잰 길이 19 는 그 파드가 받은 값이다. 두 수가 같아야 Secret 에서 파드 환경변수까지 이어진 것이고, 길이가 0 이면 Secret 에는 있는데 이 파드가 그것을 안 받았다.
|
||||
|
||||
보고된 값과 준 값도 다르다. kubectl get nodes -o wide 의 INTERNAL-IP 는 k3s 가 보고한 값이고 systemctl cat 의 ExecStart 줄은 우리가 준 값이라 둘을 견준다. 값을 안 찍고 길이만으로 확인하는 §185 의 ② 도 같은 갈래다. 비밀은 값을 보지 않고 0 이 아니라는 것만 확인한다.
|
||||
|
||||
### 4. 검사기가 통과한 것을 동작하는 상태로 읽지 않는다
|
||||
|
||||
sites-available 을 site-available 로 잘못 치면 빈 새 파일이 열리고, 저장해도 nginx 는 그 파일을 읽지 않는데 nginx -t 는 멀쩡히 통과한다. 아무 에러 없이 아무 일도 안 일어나므로, 설정을 썼는데 변화가 없으면 경로 오타부터 의심한다. nft 도 같다. .nft 의 포트를 433 으로 쳐도 433 이 유효한 포트라 군말 없이 받고 80 은 멀쩡히 넘어가므로, 03 단계는 다 통과한 뒤 04 단계에서 HTTPS 만 안 되는 형태로 드러난다. 이 실험대에서 실제로 나왔던 오타다.
|
||||
|
||||
노드도 그렇다. kubectl get nodes 두 줄이 Ready 여도 -o wide 의 INTERNAL-IP 는 --node-ip 로 준 값과 다를 수 있다. 그때는 지금 아무 증상이 없다가 03 단계의 upstream 과 노드 상실 실험에서 어긋난다. 유닛 이름이 노드마다 달라 agent 노드에서 systemctl stop k3s 를 치면 아무 일도 일어나지 않고, 그것이 「주입했는데 증상이 없다」로 읽힌다.
|
||||
|
||||
파드 둘이 Running 이어도 Endpoints 가 하나면 트래픽은 이미 한쪽으로만 가고 있고, 그 상태에서 이중화 실험을 하면 그것을 이중화 실패로 오독하게 된다. node-exporter 로 시작하는 줄이 하나뿐일 때도 같다. 그 노드의 CPU 와 메모리와 디스크 지표가 통째로 없는 채로 실험을 하게 된다.
|
||||
|
||||
인증서도 같다. cert.pem 을 쓰면 중간 인증서가 빠져 체인이 끊긴다. 그런데 브라우저는 대개 캐시나 AIA(Authority Information Access) 로 보완해서 정상으로 보이고, 캐시가 없는 클라이언트에서만 깨진다. 그래서 체인이 이어졌는지는 openssl s_client 의 단계 수로 판정한다. 이 실험대의 실측은 0 부터 3 까지 네 단계와 Verify return code: 0 (ok) 였고, 단계가 1개면 cert.pem 을 쓴 것이다.
|
||||
|
||||
### 5. 침묵과 경고도 상태가 아니다
|
||||
|
||||
kubectl rollout status 는 끝날 때까지 아무것도 안 찍고 그 침묵이 정상이다. StatefulSet 은 파드를 하나씩 순서대로 띄우므로 중간에 0/2 로 한참 멈춰 있는 것도 정상이고, 300초를 다 쓰고 타임아웃으로 끝나면 그것도 답이다 — 안 떴다가 확정된다. 반대쪽에서는 경고가 실패로 읽힌다. nginx -t 의 [warn] could not build optimal types_hash 줄은 통과를 막지 않고, 실패는 [emerg] 줄에 파일과 줄 번호로 나온다. 04 단계에서 이 경고를 실패로 오독하는 일이 실제로 벌어졌다.
|
||||
|
||||
## 적용 조건
|
||||
|
||||
- 상태를 묻는 명령의 출력으로 구축 단계의 통과를 판정할 때
|
||||
- 같은 대상을 보는 명령이 여럿이고 서로 다른 시제나 층을 볼 때. 로그와 테이블과 지표가 그런 셋이다
|
||||
- 검사기나 문법 검사가 앞에 있는 단계. nginx -t 와 nft 와 cloud-init 스키마 검사기가 그렇다
|
||||
- 목록이나 질의 결과가 비어 있을 때. 판정하기 전에 그 명령이 무엇을 세는지 먼저 적는다
|
||||
|
||||
## 예외
|
||||
|
||||
이 기준은 출력을 상태로 읽는 오독을 잡고, 출력 자체가 정확한지는 보지 않는다. 세 근거가 다 통과로 나와도 그 셋이 다 같은 층에서 나왔으면 여전히 한 근거다. 밖에서 친 200 이 분단을 가린 것이 그런 경우이고, 그래서 관측대를 클러스터 안쪽에 따로 세웠다. 그 관측대도 up 하나로는 같은 실패를 되풀이하므로 기능 지표를 함께 본다.
|
||||
|
||||
근거를 늘리는 데는 비용이 든다. 명령이 늘고 손으로 치는 선을 넘으면 파서를 짜게 되는데, §192 는 그 선을 grep -o 와 tr 로 쉼표마다 줄을 나누는 데까지로 그었다. 그 이상 가공해야 하면 파서를 짜지 않고 화면에 나온 JSON 을 그대로 읽는다.
|
||||
|
||||
「없다」와 「못 봤다」를 가르는 일도 도구가 대신해 주지 않는다. 스크레이프 대상 목록에서 없는 이름을 알아보려면 사람이 그 이름을 미리 알고 있어야 한다. Redis 와 BFF 와 PostgreSQL 이 빠진 것을 그 목록만 보고 알아낼 방법은 없다.
|
||||
|
||||
이 기준의 근거는 대부분 이 실험대 한 대에서 한 번씩 본 것이다. 다른 판 번호나 다른 배포판에서 같은 명령이 같은 것을 세는지는 재지 않았다.
|
||||
|
||||
가이드에 실린 출력이 이 호스트의 것인지도 한 군데에서 어긋난다. §186 의 실측 줄은 이 호스트가 16 코어 전부에서 지원한다고 적었는데 §178 의 대상 환경은 논리 코어 8(i5-1135G7)이다. 어느 쪽이 이 호스트의 값인지는 재지 않았다.
|
||||
|
||||
## 예시
|
||||
|
||||
- virsh list 가 비었다 : VM 이 없는 것인가 qemu:///session 에 붙은 것인가. virsh uri 로 가른다
|
||||
- net-list 에 그 네트워크가 없다 : --all 을 줬는가. 없음과 꺼짐이 구분되나
|
||||
- kubectl get all 이 다 나왔다 : Secret · ConfigMap · PVC · Ingress 는 거기 없다. 따로 한 번 더 친다
|
||||
- Prometheus 질의가 빈 배열 : 0 이 아니라 그런 지표가 없다
|
||||
- 스크레이프 대상에 Redis · BFF · PostgreSQL 이 없다 : 측정 실패가 아니라 측정된 공백이다
|
||||
- ip-dhcp-host 로 grep 해서 아무것도 안 나온다 : 섹션 이름이라 XML 에 그 문자열이 없다. host mac 으로 찾는다
|
||||
- 이벤트가 하나도 없다 : 기본 한 시간만 남는다. 무사하다는 뜻이 아니다
|
||||
- 로그는 (1) 인데 jgroups_ping 에는 둘 다 있다 : 서로를 찾았고 7800 으로 메시지가 안 간다
|
||||
- describe 가 19 bytes 인데 파드 안 길이가 0 : 저장은 됐고 주입이 안 됐다
|
||||
- 두 노드가 Ready 인데 INTERNAL-IP 가 --node-ip 와 다르다 : 지금 증상 없음. 03 과 노드 상실 실험에서 터진다
|
||||
- nginx -t 통과 : site-available 로 잘못 쳐도 통과한다. 설정을 썼는데 변화가 없으면 경로 오타다
|
||||
- nft 가 433 을 받았다 : 80 은 되고 04 에서 HTTPS 만 안 된다
|
||||
- 파드 둘이 Running 인데 Endpoints 가 하나 : 이미 한쪽으로만 가고 있다
|
||||
- node-exporter 줄이 하나 : 그 노드의 지표가 통째로 없다
|
||||
- up 이 1 : 503 중에도 1 이었다. 기능 지표를 함께 본다
|
||||
- rollout status 가 아무것도 안 찍는다 : 정상이다. 타임아웃으로 끝나는 것도 답이다
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
---
|
||||
kind: REFERENCE
|
||||
slug: tool-output-is-not-the-subject-state
|
||||
title: 도구가 낸 출력은 대상의 상태가 아니다 — 그 명령이 무엇을 세는지부터 가른다
|
||||
topic: build-completion-judgment
|
||||
topicName: 끝났다는 판정
|
||||
project: virtualization
|
||||
status: 게시 전
|
||||
sourceRevision: import-head 9465582b5d1630eb4ae7c4e078021486919bf6b6 · uncommitted-working-tree snapshot
|
||||
source:
|
||||
- final/document.md#191-단계-05-keycloak-2노드와-postgresql
|
||||
- final/document.md#192-단계-06-prometheus-와-grafana
|
||||
- final/document.md#186-단계-00-lab-host-가상화-준비
|
||||
---
|
||||
|
||||
# 도구가 낸 출력은 대상의 상태가 아니다 — 그 명령이 무엇을 세는지부터 가른다
|
||||
|
||||
상태를 묻는 명령의 출력을 대상의 상태로 바로 읽지 않고, 그 명령이 무엇을 세고 무엇을 안 세는지 먼저 적는다. 7800 포트를 끊었을 때 외부 응답은 전부 200 이었고, 503 이 나는 동안에도 up 은 1 이었다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **빈 토큰이 조용히 흘러갔다 — 설치 출력은 성공이었고 agent 만 5초마다 다시 죽었다**
|
||||
설치 명령의 출력과 설치된 상태가 어긋났다. 출력은 끝까지 성공이었고 실패는 journalctl 안에만 있었다.
|
||||
- **cloud-init 이 안 도는 원인은 넷인데 증상은 「SSH 가 안 붙는다」 하나였다**
|
||||
검사가 통과해도 안 도는 쪽과 검사에 걸려도 도는 쪽이 한 사건 안에 같이 있다. 검사 결과를 상태로 읽으면 어느 방향이든 틀린다는 것이 그 기록의 결론이다.
|
||||
- **갱신은 매번 SUCCESS 였고 옛 인증서가 계속 나갔다 — 2305초와 1~2초**
|
||||
로그 문구가 SUCCESS 인데 서빙되는 인증서는 옛것이었다. 판정을 문구가 아니라 워커 PID 로 옮긴 기록이다.
|
||||
- **가까운 층부터 한 층씩 건너뛰며 확인한다 — 층마다 성공 신호가 다르다**
|
||||
어디서 끊겼는지를 그 기준이 좁히고, 좁힌 층의 출력을 어떻게 읽는지를 이쪽이 받는다.
|
||||
- **가이드대로 다시 쳐서 이 실험대가 같은 상태로 서는가**
|
||||
단계마다의 통과 조건 일곱이 전부 이런 출력이라, 다시 세운 실험대가 같은 상태인지를 무엇으로 판정할지가 그 물음에 걸려 있다.
|
||||
|
||||
## 목적
|
||||
|
||||
판정을 밖에서만 하면 놓친다. §192 는 그래서 클러스터 안을 보는 관측대(Prometheus 와 Grafana)를 따로 세웠다. 7800 포트를 끊었을 때 외부 응답이 전부 200 이었고, 분단된 노드가 스스로 로드밸런서에서 빠져 밖에서는 아무 일도 없어 보였다.
|
||||
|
||||
안쪽에 세운 관측대도 지표 하나로는 같은 실패를 되풀이한다. 503 이 나는 동안에도 up 은 1 이었다. 프로세스가 살아 있고 metrics 경로가 응답하기만 하면 1 이 되므로, 살아 있지만 쓸모없는 상태를 up 은 보지 못한다. 경보를 up 이 0 인지 하나로 걸면 그 상태를 통째로 놓친다.
|
||||
|
||||
같은 일이 구축 7단계 전체에서 되풀이된다. 목록이 비어 있어서 없다고 읽은 것, 검사기가 통과해서 동작한다고 읽은 것, 아무것도 안 찍혀서 멈췄다고 읽은 것이 전부 같은 오독이다. 이 기준은 그 셋을 하나로 묶고, 판정하기 전에 그 명령이 무엇을 세는지 적게 한다.
|
||||
|
||||
## 규칙
|
||||
|
||||
### 1. 그 명령이 무엇을 세는지 먼저 적는다
|
||||
|
||||
어느 연결에 붙어 있나. virsh 는 기본으로 qemu:///session 에 붙는데 VM 은 qemu:///system 에 만들므로, 어긋나면 VM 은 만들어졌는데 virsh list 에 안 나온다. 빈 목록이 VM 의 부재가 아니라 다른 연결을 보고 있다는 뜻이다.
|
||||
|
||||
꺼진 것도 세나. net-list 는 --all 을 빼면 inactive 인 네트워크가 아예 안 나와 「없음」과 「꺼짐」이 구분되지 않는다. 이 실험대에서 default 네트워크의 autostart 가 no 면 지금은 되고 호스트를 재부팅한 다음 01 단계의 SSH 가 전부 실패하는데, 그때는 원인을 게스트에서 찾게 된다.
|
||||
|
||||
이름대로 다 내놓나. kubectl get all 은 이름과 달리 Secret 과 ConfigMap 과 PVC 와 Ingress 를 내놓지 않으므로, 그 넷이 빠진 줄 모르고 다 만들어졌다고 판정하게 된다. -l app=postgres 에 Deployment 줄이 없는 것도 라벨을 파드 템플릿에만 달았기 때문이지 Deployment 가 없는 것이 아니다.
|
||||
|
||||
어디까지 남기나. nginx 에러 로그는 2048바이트에서 잘리고 쿠버네티스 이벤트는 기본 한 시간만 남는다. 이 실험대에서 502 원인이 잘린 채로 error 로그에 있었고 access 로그에는 3492자로 온전히 남아 있었다.
|
||||
|
||||
### 2. 빈 출력을 낼 때 「없다」와 「못 봤다」를 갈라 적는다
|
||||
|
||||
Prometheus 질의가 빈 배열을 내면 그 값이 0 이라는 뜻이 아니라 그런 지표가 없다는 뜻이다. 스크레이프 대상 목록에서는 거기 없는 이름이 답을 준다 — 이 실험대는 Redis 와 BFF 와 PostgreSQL 을 긁지 않으므로 그 지표가 안 나오는 것이 측정 실패가 아니라 측정된 공백이다. §192 는 그것을 스크린샷 누락이 아니라 측정된 공백으로 적었다. grep 이 아무것도 안 내놓을 때도 같다. ip-dhcp-host 로 grep 하면 DHCP 예약이 멀쩡히 들어가 있어도 아무것도 안 나오는데, 그것은 net-update 의 섹션 이름이라 XML 안에 그 문자열이 없기 때문이다. 이벤트가 하나도 없는 것도 무사하다는 뜻이 아니라 한 시간이 지났다는 뜻일 수 있다.
|
||||
|
||||
### 3. 한 근거로 판정하지 않고 시제나 층이 다른 것을 함께 본다
|
||||
|
||||
§191 이 클러스터가 섰는지를 근거 셋으로 보고, 그 셋이 서로 다른 것을 본다고 적었다. 로그 ISPN000094 는 「그때 그렇게 보였다」이고, 테이블 jgroups_ping 은 「지금 등록되어 있다」이며, 지표 vendor_cluster_size 는 「지금 그 노드가 그렇게 안다」다. 테이블에는 둘 다 있는데 로그가 (1) 이면 서로를 찾기는 했는데 7800 포트로 메시지가 안 가는 것이고, 이 실험대에서 실제로 그 일이 벌어졌다. 각 노드가 자기가 아는 멤버 수를 보고하므로 한 노드만 보면 분단을 놓친다.
|
||||
|
||||
저장과 주입도 다른 층이다. describe 가 보여 주는 19 bytes 와 22 bytes 는 Secret 에 저장된 값이고, 파드 안에서 잰 길이 19 는 그 파드가 받은 값이다. 두 수가 같아야 Secret 에서 파드 환경변수까지 이어진 것이고, 길이가 0 이면 Secret 에는 있는데 이 파드가 그것을 안 받았다.
|
||||
|
||||
보고된 값과 준 값도 다르다. kubectl get nodes -o wide 의 INTERNAL-IP 는 k3s 가 보고한 값이고 systemctl cat 의 ExecStart 줄은 우리가 준 값이라 둘을 견준다. 값을 안 찍고 길이만으로 확인하는 §185 의 ② 도 같은 갈래다. 비밀은 값을 보지 않고 0 이 아니라는 것만 확인한다.
|
||||
|
||||
### 4. 검사기가 통과한 것을 동작하는 상태로 읽지 않는다
|
||||
|
||||
sites-available 을 site-available 로 잘못 치면 빈 새 파일이 열리고, 저장해도 nginx 는 그 파일을 읽지 않는데 nginx -t 는 멀쩡히 통과한다. 아무 에러 없이 아무 일도 안 일어나므로, 설정을 썼는데 변화가 없으면 경로 오타부터 의심한다. nft 도 같다. .nft 의 포트를 433 으로 쳐도 433 이 유효한 포트라 군말 없이 받고 80 은 멀쩡히 넘어가므로, 03 단계는 다 통과한 뒤 04 단계에서 HTTPS 만 안 되는 형태로 드러난다. 이 실험대에서 실제로 나왔던 오타다.
|
||||
|
||||
노드도 그렇다. kubectl get nodes 두 줄이 Ready 여도 -o wide 의 INTERNAL-IP 는 --node-ip 로 준 값과 다를 수 있다. 그때는 지금 아무 증상이 없다가 03 단계의 upstream 과 노드 상실 실험에서 어긋난다. 유닛 이름이 노드마다 달라 agent 노드에서 systemctl stop k3s 를 치면 아무 일도 일어나지 않고, 그것이 「주입했는데 증상이 없다」로 읽힌다.
|
||||
|
||||
파드 둘이 Running 이어도 Endpoints 가 하나면 트래픽은 이미 한쪽으로만 가고 있고, 그 상태에서 이중화 실험을 하면 그것을 이중화 실패로 오독하게 된다. node-exporter 로 시작하는 줄이 하나뿐일 때도 같다. 그 노드의 CPU 와 메모리와 디스크 지표가 통째로 없는 채로 실험을 하게 된다.
|
||||
|
||||
인증서도 같다. cert.pem 을 쓰면 중간 인증서가 빠져 체인이 끊긴다. 그런데 브라우저는 대개 캐시나 AIA(Authority Information Access) 로 보완해서 정상으로 보이고, 캐시가 없는 클라이언트에서만 깨진다. 그래서 체인이 이어졌는지는 openssl s_client 의 단계 수로 판정한다. 이 실험대의 실측은 0 부터 3 까지 네 단계와 Verify return code: 0 (ok) 였고, 단계가 1개면 cert.pem 을 쓴 것이다.
|
||||
|
||||
### 5. 침묵과 경고도 상태가 아니다
|
||||
|
||||
kubectl rollout status 는 끝날 때까지 아무것도 안 찍고 그 침묵이 정상이다. StatefulSet 은 파드를 하나씩 순서대로 띄우므로 중간에 0/2 로 한참 멈춰 있는 것도 정상이고, 300초를 다 쓰고 타임아웃으로 끝나면 그것도 답이다 — 안 떴다가 확정된다. 반대쪽에서는 경고가 실패로 읽힌다. nginx -t 의 [warn] could not build optimal types_hash 줄은 통과를 막지 않고, 실패는 [emerg] 줄에 파일과 줄 번호로 나온다. 04 단계에서 이 경고를 실패로 오독하는 일이 실제로 벌어졌다.
|
||||
|
||||
## 적용 조건
|
||||
|
||||
- 상태를 묻는 명령의 출력으로 구축 단계의 통과를 판정할 때
|
||||
- 같은 대상을 보는 명령이 여럿이고 서로 다른 시제나 층을 볼 때. 로그와 테이블과 지표가 그런 셋이다
|
||||
- 검사기나 문법 검사가 앞에 있는 단계. nginx -t 와 nft 와 cloud-init 스키마 검사기가 그렇다
|
||||
- 목록이나 질의 결과가 비어 있을 때. 판정하기 전에 그 명령이 무엇을 세는지 먼저 적는다
|
||||
|
||||
## 예외
|
||||
|
||||
이 기준은 출력을 상태로 읽는 오독을 잡고, 출력 자체가 정확한지는 보지 않는다. 세 근거가 다 통과로 나와도 그 셋이 다 같은 층에서 나왔으면 여전히 한 근거다. 밖에서 친 200 이 분단을 가린 것이 그런 경우이고, 그래서 관측대를 클러스터 안쪽에 따로 세웠다. 그 관측대도 up 하나로는 같은 실패를 되풀이하므로 기능 지표를 함께 본다.
|
||||
|
||||
근거를 늘리는 데는 비용이 든다. 명령이 늘고 손으로 치는 선을 넘으면 파서를 짜게 되는데, §192 는 그 선을 grep -o 와 tr 로 쉼표마다 줄을 나누는 데까지로 그었다. 그 이상 가공해야 하면 파서를 짜지 않고 화면에 나온 JSON 을 그대로 읽는다.
|
||||
|
||||
「없다」와 「못 봤다」를 가르는 일도 도구가 대신해 주지 않는다. 스크레이프 대상 목록에서 없는 이름을 알아보려면 사람이 그 이름을 미리 알고 있어야 한다. Redis 와 BFF 와 PostgreSQL 이 빠진 것을 그 목록만 보고 알아낼 방법은 없다.
|
||||
|
||||
이 기준의 근거는 대부분 이 실험대 한 대에서 한 번씩 본 것이다. 다른 판 번호나 다른 배포판에서 같은 명령이 같은 것을 세는지는 재지 않았다.
|
||||
|
||||
가이드에 실린 출력이 이 호스트의 것인지도 한 군데에서 어긋난다. §186 의 실측 줄은 이 호스트가 16 코어 전부에서 지원한다고 적었는데 §178 의 대상 환경은 논리 코어 8(i5-1135G7)이다. 어느 쪽이 이 호스트의 값인지는 재지 않았다.
|
||||
|
||||
## 예시
|
||||
|
||||
- virsh list 가 비었다 : VM 이 없는 것인가 qemu:///session 에 붙은 것인가. virsh uri 로 가른다
|
||||
- net-list 에 그 네트워크가 없다 : --all 을 줬는가. 없음과 꺼짐이 구분되나
|
||||
- kubectl get all 이 다 나왔다 : Secret · ConfigMap · PVC · Ingress 는 거기 없다. 따로 한 번 더 친다
|
||||
- Prometheus 질의가 빈 배열 : 0 이 아니라 그런 지표가 없다
|
||||
- 스크레이프 대상에 Redis · BFF · PostgreSQL 이 없다 : 측정 실패가 아니라 측정된 공백이다
|
||||
- ip-dhcp-host 로 grep 해서 아무것도 안 나온다 : 섹션 이름이라 XML 에 그 문자열이 없다. host mac 으로 찾는다
|
||||
- 이벤트가 하나도 없다 : 기본 한 시간만 남는다. 무사하다는 뜻이 아니다
|
||||
- 로그는 (1) 인데 jgroups_ping 에는 둘 다 있다 : 서로를 찾았고 7800 으로 메시지가 안 간다
|
||||
- describe 가 19 bytes 인데 파드 안 길이가 0 : 저장은 됐고 주입이 안 됐다
|
||||
- 두 노드가 Ready 인데 INTERNAL-IP 가 --node-ip 와 다르다 : 지금 증상 없음. 03 과 노드 상실 실험에서 터진다
|
||||
- nginx -t 통과 : site-available 로 잘못 쳐도 통과한다. 설정을 썼는데 변화가 없으면 경로 오타다
|
||||
- nft 가 433 을 받았다 : 80 은 되고 04 에서 HTTPS 만 안 된다
|
||||
- 파드 둘이 Running 인데 Endpoints 가 하나 : 이미 한쪽으로만 가고 있다
|
||||
- node-exporter 줄이 하나 : 그 노드의 지표가 통째로 없다
|
||||
- up 이 1 : 503 중에도 1 이었다. 기능 지표를 함께 본다
|
||||
- rollout status 가 아무것도 안 찍는다 : 정상이다. 타임아웃으로 끝나는 것도 답이다
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"authority": "deterministic",
|
||||
"gate": "command-pedagogy-signals",
|
||||
"section_id": "docs/virtualization/tech-log-studio/build-completion-judgment/reference/reference-tool-output-is-not-the-subject-state.md",
|
||||
"mode": "reference",
|
||||
"source_sha256": "0284b7560849381fd172f35593f3f30445f5aa7d2b97875757c2270d7e32bac2",
|
||||
"result": "PASS",
|
||||
"requires_editor": false,
|
||||
"blocks": [],
|
||||
"findings": [],
|
||||
"extensions": {
|
||||
"command_like_text_blocks": []
|
||||
}
|
||||
}
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
---
|
||||
kind: REFERENCE
|
||||
slug: tool-output-is-not-the-subject-state
|
||||
title: 도구가 낸 출력은 대상의 상태가 아니다 — 그 명령이 무엇을 세는지부터 가른다
|
||||
topic: build-completion-judgment
|
||||
topicName: 끝났다는 판정
|
||||
project: virtualization
|
||||
status: 게시 전
|
||||
sourceRevision: import-head 9465582b5d1630eb4ae7c4e078021486919bf6b6 · uncommitted-working-tree snapshot
|
||||
source:
|
||||
- final/document.md#191-단계-05-keycloak-2노드와-postgresql
|
||||
- final/document.md#192-단계-06-prometheus-와-grafana
|
||||
- final/document.md#186-단계-00-lab-host-가상화-준비
|
||||
---
|
||||
|
||||
# 도구가 낸 출력은 대상의 상태가 아니다 — 그 명령이 무엇을 세는지부터 가른다
|
||||
|
||||
상태를 묻는 명령의 출력을 대상의 상태로 바로 읽지 않고, 그 명령이 무엇을 세고 무엇을 안 세는지 먼저 적는다. 7800 포트를 끊었을 때 외부 응답은 전부 200 이었고, 503 이 나는 동안에도 up 은 1 이었다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **빈 토큰이 조용히 흘러갔다 — 설치 출력은 성공이었고 agent 만 5초마다 다시 죽었다**
|
||||
설치 명령의 출력과 설치된 상태가 어긋났다. 출력은 끝까지 성공이었고 실패는 journalctl 안에만 있었다.
|
||||
- **cloud-init 이 안 도는 원인은 넷인데 증상은 「SSH 가 안 붙는다」 하나였다**
|
||||
검사가 통과해도 안 도는 쪽과 검사에 걸려도 도는 쪽이 한 사건 안에 같이 있다. 검사 결과를 상태로 읽으면 어느 방향이든 틀린다는 것이 그 기록의 결론이다.
|
||||
- **갱신은 매번 SUCCESS 였고 옛 인증서가 계속 나갔다 — 2305초와 1~2초**
|
||||
로그 문구가 SUCCESS 인데 서빙되는 인증서는 옛것이었다. 판정을 문구가 아니라 워커 PID 로 옮긴 기록이다.
|
||||
- **가까운 층부터 한 층씩 건너뛰며 확인한다 — 층마다 성공 신호가 다르다**
|
||||
어디서 끊겼는지를 그 기준이 좁히고, 좁힌 층의 출력을 어떻게 읽는지를 이쪽이 받는다.
|
||||
- **가이드대로 다시 쳐서 이 실험대가 같은 상태로 서는가**
|
||||
단계마다의 통과 조건 일곱이 전부 이런 출력이라, 다시 세운 실험대가 같은 상태인지를 무엇으로 판정할지가 그 물음에 걸려 있다.
|
||||
|
||||
## 목적
|
||||
|
||||
판정을 밖에서만 하면 놓친다. §192 는 그래서 클러스터 안을 보는 관측대(Prometheus 와 Grafana)를 따로 세웠다. 7800 포트를 끊었을 때 외부 응답이 전부 200 이었고, 분단된 노드가 스스로 로드밸런서에서 빠져 밖에서는 아무 일도 없어 보였다.
|
||||
|
||||
안쪽에 세운 관측대도 지표 하나로는 같은 실패를 되풀이한다. 503 이 나는 동안에도 up 은 1 이었다. 프로세스가 살아 있고 metrics 경로가 응답하기만 하면 1 이 되므로, 살아 있지만 쓸모없는 상태를 up 은 보지 못한다. 경보를 up 이 0 인지 하나로 걸면 그 상태를 통째로 놓친다.
|
||||
|
||||
같은 일이 구축 7단계 전체에서 되풀이된다. 목록이 비어 있어서 없다고 읽은 것, 검사기가 통과해서 동작한다고 읽은 것, 아무것도 안 찍혀서 멈췄다고 읽은 것이 전부 같은 오독이다. 이 기준은 그 셋을 하나로 묶고, 판정하기 전에 그 명령이 무엇을 세는지 적게 한다.
|
||||
|
||||
## 규칙
|
||||
|
||||
### 1. 그 명령이 무엇을 세는지 먼저 적는다
|
||||
|
||||
어느 연결에 붙어 있나. virsh 는 기본으로 qemu:///session 에 붙는데 VM 은 qemu:///system 에 만들므로, 어긋나면 VM 은 만들어졌는데 virsh list 에 안 나온다. 빈 목록이 VM 의 부재가 아니라 다른 연결을 보고 있다는 뜻이다.
|
||||
|
||||
꺼진 것도 세나. net-list 는 --all 을 빼면 inactive 인 네트워크가 아예 안 나와 「없음」과 「꺼짐」이 구분되지 않는다. 이 실험대에서 default 네트워크의 autostart 가 no 면 지금은 되고 호스트를 재부팅한 다음 01 단계의 SSH 가 전부 실패하는데, 그때는 원인을 게스트에서 찾게 된다.
|
||||
|
||||
이름대로 다 내놓나. kubectl get all 은 이름과 달리 Secret 과 ConfigMap 과 PVC 와 Ingress 를 내놓지 않으므로, 그 넷이 빠진 줄 모르고 다 만들어졌다고 판정하게 된다. -l app=postgres 에 Deployment 줄이 없는 것도 라벨을 파드 템플릿에만 달았기 때문이지 Deployment 가 없는 것이 아니다.
|
||||
|
||||
어디까지 남기나. nginx 에러 로그는 2048바이트에서 잘리고 쿠버네티스 이벤트는 기본 한 시간만 남는다. 이 실험대에서 502 원인이 잘린 채로 error 로그에 있었고 access 로그에는 3492자로 온전히 남아 있었다.
|
||||
|
||||
### 2. 빈 출력을 낼 때 「없다」와 「못 봤다」를 갈라 적는다
|
||||
|
||||
Prometheus 질의가 빈 배열을 내면 그 값이 0 이라는 뜻이 아니라 그런 지표가 없다는 뜻이다. 스크레이프 대상 목록에서는 거기 없는 이름이 답을 준다 — 이 실험대는 Redis 와 BFF 와 PostgreSQL 을 긁지 않으므로 그 지표가 안 나오는 것이 측정 실패가 아니라 측정된 공백이다. §192 는 그것을 스크린샷 누락이 아니라 측정된 공백으로 적었다. grep 이 아무것도 안 내놓을 때도 같다. ip-dhcp-host 로 grep 하면 DHCP 예약이 멀쩡히 들어가 있어도 아무것도 안 나오는데, 그것은 net-update 의 섹션 이름이라 XML 안에 그 문자열이 없기 때문이다. 이벤트가 하나도 없는 것도 무사하다는 뜻이 아니라 한 시간이 지났다는 뜻일 수 있다.
|
||||
|
||||
### 3. 한 근거로 판정하지 않고 시제나 층이 다른 것을 함께 본다
|
||||
|
||||
§191 이 클러스터가 섰는지를 근거 셋으로 보고, 그 셋이 서로 다른 것을 본다고 적었다. 로그 ISPN000094 는 「그때 그렇게 보였다」이고, 테이블 jgroups_ping 은 「지금 등록되어 있다」이며, 지표 vendor_cluster_size 는 「지금 그 노드가 그렇게 안다」다. 테이블에는 둘 다 있는데 로그가 (1) 이면 서로를 찾기는 했는데 7800 포트로 메시지가 안 가는 것이고, 이 실험대에서 실제로 그 일이 벌어졌다. 각 노드가 자기가 아는 멤버 수를 보고하므로 한 노드만 보면 분단을 놓친다.
|
||||
|
||||
저장과 주입도 다른 층이다. describe 가 보여 주는 19 bytes 와 22 bytes 는 Secret 에 저장된 값이고, 파드 안에서 잰 길이 19 는 그 파드가 받은 값이다. 두 수가 같아야 Secret 에서 파드 환경변수까지 이어진 것이고, 길이가 0 이면 Secret 에는 있는데 이 파드가 그것을 안 받았다.
|
||||
|
||||
보고된 값과 준 값도 다르다. kubectl get nodes -o wide 의 INTERNAL-IP 는 k3s 가 보고한 값이고 systemctl cat 의 ExecStart 줄은 우리가 준 값이라 둘을 견준다. 값을 안 찍고 길이만으로 확인하는 §185 의 ② 도 같은 갈래다. 비밀은 값을 보지 않고 0 이 아니라는 것만 확인한다.
|
||||
|
||||
### 4. 검사기가 통과한 것을 동작하는 상태로 읽지 않는다
|
||||
|
||||
sites-available 을 site-available 로 잘못 치면 빈 새 파일이 열리고, 저장해도 nginx 는 그 파일을 읽지 않는데 nginx -t 는 멀쩡히 통과한다. 아무 에러 없이 아무 일도 안 일어나므로, 설정을 썼는데 변화가 없으면 경로 오타부터 의심한다. nft 도 같다. .nft 의 포트를 433 으로 쳐도 433 이 유효한 포트라 군말 없이 받고 80 은 멀쩡히 넘어가므로, 03 단계는 다 통과한 뒤 04 단계에서 HTTPS 만 안 되는 형태로 드러난다. 이 실험대에서 실제로 나왔던 오타다.
|
||||
|
||||
노드도 그렇다. kubectl get nodes 두 줄이 Ready 여도 -o wide 의 INTERNAL-IP 는 --node-ip 로 준 값과 다를 수 있다. 그때는 지금 아무 증상이 없다가 03 단계의 upstream 과 노드 상실 실험에서 어긋난다. 유닛 이름이 노드마다 달라 agent 노드에서 systemctl stop k3s 를 치면 아무 일도 일어나지 않고, 그것이 「주입했는데 증상이 없다」로 읽힌다.
|
||||
|
||||
파드 둘이 Running 이어도 Endpoints 가 하나면 트래픽은 이미 한쪽으로만 가고 있고, 그 상태에서 이중화 실험을 하면 그것을 이중화 실패로 오독하게 된다. node-exporter 로 시작하는 줄이 하나뿐일 때도 같다. 그 노드의 CPU 와 메모리와 디스크 지표가 통째로 없는 채로 실험을 하게 된다.
|
||||
|
||||
인증서도 같다. cert.pem 을 쓰면 중간 인증서가 빠져 체인이 끊긴다. 그런데 브라우저는 대개 캐시나 AIA(Authority Information Access) 로 보완해서 정상으로 보이고, 캐시가 없는 클라이언트에서만 깨진다. 그래서 체인이 이어졌는지는 openssl s_client 의 단계 수로 판정한다. 이 실험대의 실측은 0 부터 3 까지 네 단계와 Verify return code: 0 (ok) 였고, 단계가 1개면 cert.pem 을 쓴 것이다.
|
||||
|
||||
### 5. 침묵과 경고도 상태가 아니다
|
||||
|
||||
kubectl rollout status 는 끝날 때까지 아무것도 안 찍고 그 침묵이 정상이다. StatefulSet 은 파드를 하나씩 순서대로 띄우므로 중간에 0/2 로 한참 멈춰 있는 것도 정상이고, 300초를 다 쓰고 타임아웃으로 끝나면 그것도 답이다 — 안 떴다가 확정된다. 반대쪽에서는 경고가 실패로 읽힌다. nginx -t 의 [warn] could not build optimal types_hash 줄은 통과를 막지 않고, 실패는 [emerg] 줄에 파일과 줄 번호로 나온다. 04 단계에서 이 경고를 실패로 오독하는 일이 실제로 벌어졌다.
|
||||
|
||||
## 적용 조건
|
||||
|
||||
- 상태를 묻는 명령의 출력으로 구축 단계의 통과를 판정할 때
|
||||
- 같은 대상을 보는 명령이 여럿이고 서로 다른 시제나 층을 볼 때. 로그와 테이블과 지표가 그런 셋이다
|
||||
- 검사기나 문법 검사가 앞에 있는 단계. nginx -t 와 nft 와 cloud-init 스키마 검사기가 그렇다
|
||||
- 목록이나 질의 결과가 비어 있을 때. 판정하기 전에 그 명령이 무엇을 세는지 먼저 적는다
|
||||
|
||||
## 예외
|
||||
|
||||
이 기준은 출력을 상태로 읽는 오독을 잡고, 출력 자체가 정확한지는 보지 않는다. 세 근거가 다 통과로 나와도 그 셋이 다 같은 층에서 나왔으면 여전히 한 근거다. 밖에서 친 200 이 분단을 가린 것이 그런 경우이고, 그래서 관측대를 클러스터 안쪽에 따로 세웠다. 그 관측대도 up 하나로는 같은 실패를 되풀이하므로 기능 지표를 함께 본다.
|
||||
|
||||
근거를 늘리는 데는 비용이 든다. 명령이 늘고 손으로 치는 선을 넘으면 파서를 짜게 되는데, §192 는 그 선을 grep -o 와 tr 로 쉼표마다 줄을 나누는 데까지로 그었다. 그 이상 가공해야 하면 파서를 짜지 않고 화면에 나온 JSON 을 그대로 읽는다.
|
||||
|
||||
「없다」와 「못 봤다」를 가르는 일도 도구가 대신해 주지 않는다. 스크레이프 대상 목록에서 없는 이름을 알아보려면 사람이 그 이름을 미리 알고 있어야 한다. Redis 와 BFF 와 PostgreSQL 이 빠진 것을 그 목록만 보고 알아낼 방법은 없다.
|
||||
|
||||
이 기준의 근거는 대부분 이 실험대 한 대에서 한 번씩 본 것이다. 다른 판 번호나 다른 배포판에서 같은 명령이 같은 것을 세는지는 재지 않았다.
|
||||
|
||||
가이드에 실린 출력이 이 호스트의 것인지도 한 군데에서 어긋난다. §186 의 실측 줄은 이 호스트가 16 코어 전부에서 지원한다고 적었는데 §178 의 대상 환경은 논리 코어 8(i5-1135G7)이다. 어느 쪽이 이 호스트의 값인지는 재지 않았다.
|
||||
|
||||
## 예시
|
||||
|
||||
- virsh list 가 비었다 : VM 이 없는 것인가 qemu:///session 에 붙은 것인가. virsh uri 로 가른다
|
||||
- net-list 에 그 네트워크가 없다 : --all 을 줬는가. 없음과 꺼짐이 구분되나
|
||||
- kubectl get all 이 다 나왔다 : Secret · ConfigMap · PVC · Ingress 는 거기 없다. 따로 한 번 더 친다
|
||||
- Prometheus 질의가 빈 배열 : 0 이 아니라 그런 지표가 없다
|
||||
- 스크레이프 대상에 Redis · BFF · PostgreSQL 이 없다 : 측정 실패가 아니라 측정된 공백이다
|
||||
- ip-dhcp-host 로 grep 해서 아무것도 안 나온다 : 섹션 이름이라 XML 에 그 문자열이 없다. host mac 으로 찾는다
|
||||
- 이벤트가 하나도 없다 : 기본 한 시간만 남는다. 무사하다는 뜻이 아니다
|
||||
- 로그는 (1) 인데 jgroups_ping 에는 둘 다 있다 : 서로를 찾았고 7800 으로 메시지가 안 간다
|
||||
- describe 가 19 bytes 인데 파드 안 길이가 0 : 저장은 됐고 주입이 안 됐다
|
||||
- 두 노드가 Ready 인데 INTERNAL-IP 가 --node-ip 와 다르다 : 지금 증상 없음. 03 과 노드 상실 실험에서 터진다
|
||||
- nginx -t 통과 : site-available 로 잘못 쳐도 통과한다. 설정을 썼는데 변화가 없으면 경로 오타다
|
||||
- nft 가 433 을 받았다 : 80 은 되고 04 에서 HTTPS 만 안 된다
|
||||
- 파드 둘이 Running 인데 Endpoints 가 하나 : 이미 한쪽으로만 가고 있다
|
||||
- node-exporter 줄이 하나 : 그 노드의 지표가 통째로 없다
|
||||
- up 이 1 : 503 중에도 1 이었다. 기능 지표를 함께 본다
|
||||
- rollout status 가 아무것도 안 찍는다 : 정상이다. 타임아웃으로 끝나는 것도 답이다
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"record": "docs/virtualization/tech-log-studio/build-completion-judgment/reference/reference-tool-output-is-not-the-subject-state.md",
|
||||
"sourceSha256": "0284b7560849381fd172f35593f3f30445f5aa7d2b97875757c2270d7e32bac2",
|
||||
"verdict": "PASS",
|
||||
"liveSourceReconciliation": "UNVERIFIABLE",
|
||||
"liveSourceReason": "source repositories unavailable on current machine",
|
||||
"acceptedByProjectReview": true,
|
||||
"checks": [
|
||||
"local SSOT/Record evidence mismatch 0",
|
||||
"S3/S5/S6 check_evidence --repo invoked",
|
||||
"source checkout absence preserved as exit 3"
|
||||
],
|
||||
"note": "현재 project evidence gate는 불일치 0건·대조 불가 2건이다. source checkout 부재를 PASS로 위장하지 않고 UNVERIFIABLE로 보존했다."
|
||||
}
|
||||
+413
@@ -0,0 +1,413 @@
|
||||
{
|
||||
"schemaVersion": 5,
|
||||
"runId": "2026-09-21-0218-remediation-07-concept-two-l7-hops-and-the-entry-point-recursion",
|
||||
"project": "virtualization",
|
||||
"record": "docs/virtualization/tech-log-studio/lab-entry-path-and-measurement-integrity/concept/concept-two-l7-hops-and-the-entry-point-recursion.md",
|
||||
"startedAt": "2026-09-21T02:24:42+00:00",
|
||||
"finishedAt": "2026-09-21T02:24:45+00:00",
|
||||
"stages": [
|
||||
{
|
||||
"id": "S1",
|
||||
"name": "코드베이스 → SSOT",
|
||||
"skill": "analyzing-codebase-for-tech-log",
|
||||
"runBy": "ssot-analyst",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "이번 remediation은 새 evidence와 현재 SSOT/Record의 정합성·provenance·command/S4를 재검토한다. 코드베이스→SSOT 전면 분석은 다시 하지 않는다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": "edd45dfec66d155a621cd41186b83b5582f2244c",
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"gates": [],
|
||||
"notes": "",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-21T02:24:42+00:00",
|
||||
"elapsedSeconds": 0,
|
||||
"finishedBy": "chatgpt-virtualization-20260921-remediation-v5"
|
||||
},
|
||||
{
|
||||
"id": "S2",
|
||||
"name": "SSOT → 분해 계약",
|
||||
"skill": "deriving-tech-log-root-tree",
|
||||
"runBy": "tree-deriver",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "기존 91 Record 분해 계약은 유지한다. certbot Question의 RESOLVED 상태와 sourcePolicy는 project tree rebuild에서 별도로 동기화했다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": "ab59130196d79e947b32e3b5e6b75335a9e5c1eb",
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"gates": [],
|
||||
"notes": "",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-21T02:24:42+00:00",
|
||||
"elapsedSeconds": 0,
|
||||
"finishedBy": "chatgpt-virtualization-20260921-remediation-v5"
|
||||
},
|
||||
{
|
||||
"id": "S3",
|
||||
"name": "글감 → 기록",
|
||||
"skill": "writing-tech-log-records",
|
||||
"runBy": "record-writer",
|
||||
"status": "DONE",
|
||||
"skipReason": "",
|
||||
"skillEcho": "**인용한 줄은 SSOT 에서 찾아 대조한다.**",
|
||||
"skillRevision": null,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
"docs/virtualization/tech-log-studio/lab-entry-path-and-measurement-integrity/concept/concept-two-l7-hops-and-the-entry-point-recursion.md"
|
||||
],
|
||||
"gates": [
|
||||
{
|
||||
"cmd": "python3 scripts/studio-body.py docs/virtualization/tech-log-studio/lab-entry-path-and-measurement-integrity/concept/concept-two-l7-hops-and-the-entry-point-recursion.md -o runs/virtualization/2026-09-21-0218-remediation-07-concept-two-l7-hops-and-the-entry-point-recursion/stage/S3/studio-body.md",
|
||||
"exit": 0,
|
||||
"status": "PASS",
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"generation": 1,
|
||||
"at": "2026-09-21T02:24:42+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node --experimental-transform-types .agents/skills/writing-tech-log-records/scripts/check_body.mjs runs/virtualization/2026-09-21-0218-remediation-07-concept-two-l7-hops-and-the-entry-point-recursion/stage/S3/studio-body.md --frontend /shared/codebase/tech-log-frontend",
|
||||
"exit": 0,
|
||||
"status": "PASS",
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"generation": 1,
|
||||
"at": "2026-09-21T02:24:42+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node .agents/skills/rewriting-technical-prose-naturally/scripts/check_prose.mjs --warn docs/virtualization/tech-log-studio/lab-entry-path-and-measurement-integrity/concept/concept-two-l7-hops-and-the-entry-point-recursion.md",
|
||||
"exit": 0,
|
||||
"status": "PASS",
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"generation": 1,
|
||||
"at": "2026-09-21T02:24:42+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node .agents/skills/writing-tech-log-records/scripts/check_evidence.mjs virtualization --repo",
|
||||
"exit": 3,
|
||||
"status": "UNVERIFIABLE",
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"generation": 1,
|
||||
"at": "2026-09-21T02:24:42+00:00",
|
||||
"semanticId": "evidence-repo",
|
||||
"reason": "source repositories unavailable on current machine",
|
||||
"acceptedByProjectReview": true
|
||||
}
|
||||
],
|
||||
"notes": "record-writer/fact 계약으로 새 evidence와 SSOT의 current truth를 대조했다. --repo 대조는 실제 실행했고 source checkout 부재만 UNVERIFIABLE로 남겼다.",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-21T02:24:42+00:00",
|
||||
"elapsedSeconds": 0,
|
||||
"generation": 1,
|
||||
"owner": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"finishedBy": "chatgpt-virtualization-20260921-remediation-v5"
|
||||
},
|
||||
{
|
||||
"id": "S4",
|
||||
"name": "기록 → 그림",
|
||||
"skill": "technical-visualizer",
|
||||
"runBy": "diagram-maker",
|
||||
"status": "DONE",
|
||||
"skipReason": "",
|
||||
"skillEcho": "SVG is the publication artifact. VizSpec JSON is the canonical semantic source. Preserve at least one editable source.",
|
||||
"skillRevision": "edd45dfec66d155a621cd41186b83b5582f2244c",
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
"docs/virtualization/final/assets/diagrams/two-l7-hops-entry-recursion/two-l7-hops-entry-recursion.svg"
|
||||
],
|
||||
"gates": [
|
||||
{
|
||||
"cmd": "scripts/techviz lint docs/virtualization/final/.techviz/two-l7-hops-entry-recursion/spec.json --context docs/virtualization/final/.techviz/two-l7-hops-entry-recursion/context.json --json",
|
||||
"exit": 0,
|
||||
"status": "PASS",
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"generation": 1,
|
||||
"at": "2026-09-21T02:24:43+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "python3 scripts/check-figure-text.py virtualization",
|
||||
"exit": 0,
|
||||
"status": "PASS",
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"generation": 1,
|
||||
"at": "2026-09-21T02:24:43+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "python3 scripts/check-figure-overlap.py virtualization",
|
||||
"exit": 0,
|
||||
"status": "PASS",
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"generation": 1,
|
||||
"at": "2026-09-21T02:24:43+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "python3 scripts/preview-figure.py --file docs/virtualization/final/assets/diagrams/two-l7-hops-entry-recursion/two-l7-hops-entry-recursion.svg -o runs/virtualization/2026-09-21-0218-remediation-07-concept-two-l7-hops-and-the-entry-point-recursion/stage/S4/preview",
|
||||
"exit": 0,
|
||||
"status": "PASS",
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"generation": 1,
|
||||
"at": "2026-09-21T02:24:43+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "python3 scripts/check-figure-provenance.py virtualization",
|
||||
"exit": 0,
|
||||
"status": "PASS",
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"generation": 1,
|
||||
"at": "2026-09-21T02:24:44+00:00"
|
||||
}
|
||||
],
|
||||
"notes": "현재 SSOT context로 TechViz를 재검토하고 canonical render 결과의 text/overlap/provenance/preview를 확인했다.",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-21T02:24:44+00:00",
|
||||
"elapsedSeconds": 1,
|
||||
"generation": 1,
|
||||
"owner": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"finishedBy": "chatgpt-virtualization-20260921-remediation-v5"
|
||||
},
|
||||
{
|
||||
"id": "S5",
|
||||
"name": "AI 티 제거",
|
||||
"skill": "rewriting-technical-prose-naturally",
|
||||
"runBy": "prose-rewriter",
|
||||
"status": "DONE",
|
||||
"skipReason": "",
|
||||
"skillEcho": "This is an **editorial** pass. The source's facts, evidence, causal chain, uncertainty, decision status, and technical depth are the contract.",
|
||||
"skillRevision": null,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
"docs/virtualization/tech-log-studio/lab-entry-path-and-measurement-integrity/concept/concept-two-l7-hops-and-the-entry-point-recursion.md"
|
||||
],
|
||||
"gates": [
|
||||
{
|
||||
"cmd": "node .agents/skills/rewriting-technical-prose-naturally/scripts/check_prose.mjs --warn docs/virtualization/tech-log-studio/lab-entry-path-and-measurement-integrity/concept/concept-two-l7-hops-and-the-entry-point-recursion.md",
|
||||
"exit": 0,
|
||||
"status": "PASS",
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"generation": 1,
|
||||
"at": "2026-09-21T02:24:44+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node .agents/skills/rewriting-technical-prose-naturally/scripts/style_profile.mjs docs/virtualization/tech-log-studio/lab-entry-path-and-measurement-integrity/concept/concept-two-l7-hops-and-the-entry-point-recursion.md",
|
||||
"exit": 0,
|
||||
"status": "PASS",
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"generation": 1,
|
||||
"at": "2026-09-21T02:24:44+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node --experimental-transform-types .agents/skills/writing-tech-log-records/scripts/check_body.mjs runs/virtualization/2026-09-21-0218-remediation-07-concept-two-l7-hops-and-the-entry-point-recursion/stage/S5/studio-body.md --frontend /shared/codebase/tech-log-frontend",
|
||||
"exit": 0,
|
||||
"status": "PASS",
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"generation": 1,
|
||||
"at": "2026-09-21T02:24:44+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node .agents/skills/writing-tech-log-records/scripts/check_evidence.mjs virtualization --repo",
|
||||
"exit": 3,
|
||||
"status": "UNVERIFIABLE",
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"generation": 1,
|
||||
"at": "2026-09-21T02:24:44+00:00",
|
||||
"semanticId": "evidence-repo",
|
||||
"reason": "source repositories unavailable on current machine",
|
||||
"acceptedByProjectReview": true
|
||||
}
|
||||
],
|
||||
"notes": "대규모 문체 재작성 없이 stale fact/relation만 현재 evidence에 맞췄다. style_profile은 측정값으로만 기록했다.",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-21T02:24:44+00:00",
|
||||
"elapsedSeconds": 0,
|
||||
"generation": 1,
|
||||
"owner": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"finishedBy": "chatgpt-virtualization-20260921-remediation-v5"
|
||||
},
|
||||
{
|
||||
"id": "S6",
|
||||
"name": "일한 사람의 목소리",
|
||||
"skill": "writing-as-the-person-who-did-it",
|
||||
"runBy": "voice-writer",
|
||||
"status": "DONE",
|
||||
"skipReason": "",
|
||||
"skillEcho": "찾은 것이 없으면 **이 스킬은 여기서 끝난다.** 없는 목소리를 채우지 않는다.",
|
||||
"skillRevision": "edd45dfec66d155a621cd41186b83b5582f2244c",
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
"docs/virtualization/tech-log-studio/lab-entry-path-and-measurement-integrity/concept/concept-two-l7-hops-and-the-entry-point-recursion.md"
|
||||
],
|
||||
"gates": [
|
||||
{
|
||||
"cmd": "node .agents/skills/writing-as-the-person-who-did-it/scripts/check_voice.mjs docs/virtualization/tech-log-studio/lab-entry-path-and-measurement-integrity/concept/concept-two-l7-hops-and-the-entry-point-recursion.md",
|
||||
"exit": 0,
|
||||
"status": "PASS",
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"generation": 1,
|
||||
"at": "2026-09-21T02:24:44+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node .agents/skills/rewriting-technical-prose-naturally/scripts/check_prose.mjs --warn docs/virtualization/tech-log-studio/lab-entry-path-and-measurement-integrity/concept/concept-two-l7-hops-and-the-entry-point-recursion.md",
|
||||
"exit": 0,
|
||||
"status": "PASS",
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"generation": 1,
|
||||
"at": "2026-09-21T02:24:44+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node --experimental-transform-types .agents/skills/writing-tech-log-records/scripts/check_body.mjs runs/virtualization/2026-09-21-0218-remediation-07-concept-two-l7-hops-and-the-entry-point-recursion/stage/S6/studio-body.md --frontend /shared/codebase/tech-log-frontend",
|
||||
"exit": 0,
|
||||
"status": "PASS",
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"generation": 1,
|
||||
"at": "2026-09-21T02:24:45+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node .agents/skills/writing-tech-log-records/scripts/check_evidence.mjs virtualization --repo",
|
||||
"exit": 3,
|
||||
"status": "UNVERIFIABLE",
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"generation": 1,
|
||||
"at": "2026-09-21T02:24:45+00:00",
|
||||
"semanticId": "evidence-repo",
|
||||
"reason": "source repositories unavailable on current machine",
|
||||
"acceptedByProjectReview": true
|
||||
}
|
||||
],
|
||||
"notes": "상류 SSOT/evidence를 다시 보며 실제 작업 흔적이 없는 서사는 추가하지 않았다.",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-21T02:24:45+00:00",
|
||||
"elapsedSeconds": 1,
|
||||
"generation": 1,
|
||||
"owner": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"finishedBy": "chatgpt-virtualization-20260921-remediation-v5"
|
||||
},
|
||||
{
|
||||
"id": "S7",
|
||||
"name": "Studio 저장",
|
||||
"skill": "publishing-tech-log-to-studio",
|
||||
"runBy": "studio-validator",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "이번 remediation에서 Studio import/save는 요청되지 않았다. 저장소 산출물과 검증 영수증만 갱신한다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": "862e502af3e956b49ccd2ae0a8de3fd32f90df9c",
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"gates": [],
|
||||
"notes": "",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-21T02:24:45+00:00",
|
||||
"elapsedSeconds": 0,
|
||||
"finishedBy": "chatgpt-virtualization-20260921-remediation-v5"
|
||||
}
|
||||
],
|
||||
"qualityReviews": {
|
||||
"commandPedagogy": {
|
||||
"initialAnalysis": {
|
||||
"cmd": "python3 scripts/check-command-pedagogy.py --mode reference docs/virtualization/tech-log-studio/lab-entry-path-and-measurement-integrity/concept/concept-two-l7-hops-and-the-entry-point-recursion.md -o runs/virtualization/2026-09-21-0218-remediation-07-concept-two-l7-hops-and-the-entry-point-recursion/stage/S3/command-initial.json",
|
||||
"exit": 0,
|
||||
"shellBlocks": 0,
|
||||
"findings": 0,
|
||||
"majorFindings": 0,
|
||||
"artifact": {
|
||||
"path": "runs/virtualization/2026-09-21-0218-remediation-07-concept-two-l7-hops-and-the-entry-point-recursion/stage/S3/command-initial.json",
|
||||
"sha256": "11896a707a06a6b774f3b9c7d3c7878794450402d31b85be372669b4e2fb114e"
|
||||
}
|
||||
},
|
||||
"finalAnalysis": {
|
||||
"cmd": "python3 scripts/check-command-pedagogy.py --mode reference docs/virtualization/tech-log-studio/lab-entry-path-and-measurement-integrity/concept/concept-two-l7-hops-and-the-entry-point-recursion.md -o runs/virtualization/2026-09-21-0218-remediation-07-concept-two-l7-hops-and-the-entry-point-recursion/stage/S6/command-final.json",
|
||||
"exit": 0,
|
||||
"shellBlocks": 0,
|
||||
"findings": 0,
|
||||
"majorFindings": 0,
|
||||
"artifact": {
|
||||
"path": "runs/virtualization/2026-09-21-0218-remediation-07-concept-two-l7-hops-and-the-entry-point-recursion/stage/S6/command-final.json",
|
||||
"sha256": "11896a707a06a6b774f3b9c7d3c7878794450402d31b85be372669b4e2fb114e"
|
||||
}
|
||||
},
|
||||
"planner": {
|
||||
"runBy": "command-pedagogy-planner",
|
||||
"skill": "writing-practitioner-guides",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "initial deterministic analysis finding이 0이다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": null,
|
||||
"notes": "현재 reference-mode 분석을 확인했다.",
|
||||
"artifact": null
|
||||
},
|
||||
"editor": {
|
||||
"runBy": "command-pedagogy-editor",
|
||||
"skill": "writing-practitioner-guides",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "initial deterministic analysis finding이 0이다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": null,
|
||||
"notes": "현재 reference-mode 분석을 확인했다.",
|
||||
"artifact": null
|
||||
},
|
||||
"reviewer": {
|
||||
"runBy": "command-pedagogy-reviewer",
|
||||
"skill": "writing-practitioner-guides",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "final publication에 shell/CLI block이 없다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": null,
|
||||
"verdict": null,
|
||||
"notes": "final deterministic analysis에서 shellBlocks=0을 확인했다.",
|
||||
"artifact": null,
|
||||
"sourceSha256": null
|
||||
}
|
||||
},
|
||||
"technicalEvidence": {
|
||||
"runBy": "fact-reviewer",
|
||||
"status": "DONE",
|
||||
"skipReason": "",
|
||||
"verdict": "PASS",
|
||||
"notes": "current truth/evidence/provenance를 SSOT와 역대조했다. live source는 실제 --repo 결과 exit 3이며 UNVERIFIABLE로 보존했다.",
|
||||
"sourceSha256": "5b86a26c5b4f7398772cf7a0e5f9bae083c3bc98cc86b087b23ec322ad0e46b6",
|
||||
"liveSourceReconciliation": "UNVERIFIABLE",
|
||||
"liveSourceReason": "source repositories unavailable on current machine",
|
||||
"acceptedByProjectReview": true,
|
||||
"artifact": {
|
||||
"path": "runs/virtualization/2026-09-21-0218-remediation-07-concept-two-l7-hops-and-the-entry-point-recursion/stage/quality/technical-evidence-review.json",
|
||||
"sha256": "332db3a76ea9fd4215655144c85b45ffb242293dd03771e816074ad6c50218e9"
|
||||
}
|
||||
}
|
||||
},
|
||||
"riders": [],
|
||||
"sessions": [
|
||||
{
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"openedAt": "2026-09-21T02:24:42+00:00"
|
||||
},
|
||||
{
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"stage": "S3",
|
||||
"generation": 1,
|
||||
"beganAt": "2026-09-21T02:24:42+00:00"
|
||||
},
|
||||
{
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"stage": "S4",
|
||||
"generation": 1,
|
||||
"beganAt": "2026-09-21T02:24:43+00:00"
|
||||
},
|
||||
{
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"stage": "S5",
|
||||
"generation": 1,
|
||||
"beganAt": "2026-09-21T02:24:44+00:00"
|
||||
},
|
||||
{
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"stage": "S6",
|
||||
"generation": 1,
|
||||
"beganAt": "2026-09-21T02:24:44+00:00"
|
||||
}
|
||||
],
|
||||
"revision": 29,
|
||||
"updatedAt": "2026-09-21T02:24:45+00:00",
|
||||
"executionEnvironment": {
|
||||
"mode": "current-remediation-contract-replay",
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"agentToolAvailable": false,
|
||||
"note": "별도 subagent 실행 도구가 현재 환경에 없어 현재 세션이 각 .claude/agents 역할 정의와 연결된 SKILL/contract를 직접 읽고 역할을 분리해 수행했다. runBy는 계약 역할명이며 별도 프로세스 실행을 주장하지 않는다."
|
||||
}
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"authority": "deterministic",
|
||||
"gate": "command-pedagogy-signals",
|
||||
"section_id": "docs/virtualization/tech-log-studio/lab-entry-path-and-measurement-integrity/concept/concept-two-l7-hops-and-the-entry-point-recursion.md",
|
||||
"mode": "reference",
|
||||
"source_sha256": "5b86a26c5b4f7398772cf7a0e5f9bae083c3bc98cc86b087b23ec322ad0e46b6",
|
||||
"result": "PASS",
|
||||
"requires_editor": false,
|
||||
"blocks": [],
|
||||
"findings": [],
|
||||
"extensions": {
|
||||
"command_like_text_blocks": []
|
||||
}
|
||||
}
|
||||
+140
@@ -0,0 +1,140 @@
|
||||
---
|
||||
kind: CONCEPT
|
||||
slug: two-l7-hops-and-the-entry-point-recursion
|
||||
title: L7 이 두 겹인 이유와, 진입점 하나를 이중화하려 할 때 끝나지 않는 재귀
|
||||
topic: lab-entry-path-and-measurement-integrity
|
||||
topicName: 실험대의 진입 경로
|
||||
project: virtualization
|
||||
status: 게시 전
|
||||
basisVersion: 이 실험대의 2026-09-03 배치 · 호스트 nginx 1.30.4 (Arch) · k3s v1.36.4 의 기본 ingress 인 Traefik
|
||||
assets:
|
||||
- key: two-l7-hops-entry-recursion
|
||||
file: ../../../final/assets/diagrams/two-l7-hops-entry-recursion/two-l7-hops-entry-recursion.svg
|
||||
sourceRevision: import-head 9465582b5d1630eb4ae7c4e078021486919bf6b6 · uncommitted-working-tree snapshot
|
||||
source:
|
||||
- final/document.md#261-진입점-자체가-죽으면-로드밸런서의-재귀-문제
|
||||
- final/document.md#275-호스트-nginx와-traefik은-무엇이-다른가-둘-다-필요한-이유
|
||||
- final/document.md#257-리버스-프록시와-upstream
|
||||
- final/document.md#258-왜-tls를-끊어서-내용을-보는가
|
||||
---
|
||||
|
||||
# L7 이 두 겹인 이유와, 진입점 하나를 이중화하려 할 때 끝나지 않는 재귀
|
||||
|
||||
이 실험대의 요청은 HTTP 를 읽는 서버를 두 번 지난다. 바깥의 nginx 가 TLS 를 끊고 어느 노드로 보낼지 정하고, 그 노드의 Traefik 이 어느 파드로 보낼지 정한다. 한쪽만으로는 안 되는 이유가 서로 다르고, 진입점을 이중화하려 하면 「어느 진입점으로 갈지 누가 정하는가」가 새로 생겨 재귀가 끝나지 않는다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **엣지 nginx 를 호스트에서 게스트 VM 으로 옮긴다 — 성능이 아니라 더러워지는 층의 격리**
|
||||
그 결정이 든 근거가 「L7 홉 수는 전후 모두 2홉」인데 왜 2홉인지는 그 기록에 없다. 이 글이 그 앞을 맡는다.
|
||||
- **신뢰 경계에서는 forwarded 헤더를 덧붙이지 않고 덮어쓴다**
|
||||
그 기준이 말하는 경계가 두 홉 가운데 어느 쪽인지를 이 글이 먼저 정한다.
|
||||
- **공개 터널을 쓰지 않고 tailnet 직결로 둔다 — 홉이 하나 늘면 재려던 계약이 오염된다**
|
||||
여기 적은 두 겹에 세 번째를 붙이지 않기로 한 결정이다.
|
||||
- **엣지 게스트에 nginx 를 세우고 호스트 DNAT 으로 밖에서 들어오는 길을 연다**
|
||||
바깥 홉을 실제로 세우는 절차가 거기 있다.
|
||||
- **Guest 의 packet 이 Host Physical NIC 에 닿기까지 — virtio-net · virtqueue · vhost-net · TAP · Bridge**
|
||||
이 두 겹 아래에서 패킷이 게스트까지 가는 길을 그 글이 설명한다.
|
||||
- **가까운 층부터 한 층씩 건너뛰며 확인한다 — 층마다 성공 신호가 다르다**
|
||||
진입 경로가 층으로 나뉘어 있어서 한 칸씩 건너뛰며 칠 수 있다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
:::evidence key="two-l7-hops-entry-recursion" alt="브라우저에서 edge nginx와 Traefik을 거쳐 Pod로 가는 두 L7 홉과, edge nginx를 두 대로 늘릴 때 앞단 selector가 새 단일 장애점이 되는 재귀를 함께 보여 주는 흐름도." caption=" " zoom="true"
|
||||
:::
|
||||
|
||||
## 요청 하나가 L7 을 두 번 지난다
|
||||
|
||||
브라우저가 보낸 요청은 파드에 닿기까지 HTTP 를 읽는 서버를 두 번 지난다.
|
||||
|
||||
```text
|
||||
브라우저 ─▶ nginx (L7 · TLS 종료) ─▶ Traefik (L7 · Ingress 라우팅) ─▶ Pod
|
||||
```
|
||||
|
||||
리버스 프록시는 클라이언트 요청을 대신 받아 뒤쪽 서버로 전달하는 서버를 말한다. nginx 의 `upstream` 블록은 뒤쪽 서버 여러 대를 하나의 논리 이름으로 묶고, `proxy_pass http://이름;` 으로 그 그룹을 가리키면 nginx 가 요청을 분배한다. 저장소의 단일 호스트용 설정은 `proxy_pass http://keycloak:8080` 으로 대상 하나를 가리키는데, 멀티노드로 재려면 `upstream` 형태로 바꿔야 한다. 바깥 홉이 이 형태이고 묶인 것은 게스트 두 대의 `80` 포트다.
|
||||
|
||||
엣지 nginx 는 물리 호스트에서 게스트 한 대로 옮겨졌는데 그때도 HTTP 를 읽는 홉의 수는 2 그대로였다. 늘어난 것은 호스트 커널이 하는 L4 전달 한 번이고, 커널은 HTTP 를 읽지 않는다.
|
||||
|
||||
## 바깥 홉이 TLS 를 끊는 이유
|
||||
|
||||
TLS 종료는 프록시가 암호를 풀어 평문 HTTP 를 읽는 것을 말한다. 굳이 푸는 첫째 이유는 내용을 안 보면 어디로 보낼지 정할 수 없기 때문이다. 여러 도메인이 하나의 IP 와 443 포트를 공유하고, 어느 서비스로 보낼지는 HTTP `Host` 헤더에 적혀 있는데 그 헤더가 TLS 안에 암호화돼 있다. TLS 핸드셰이크의 평문 부분에 도메인이 들어 있어서 도메인 단위 분기는 풀지 않고도 된다. 다만 경로 단위 분기는 그렇게 할 수 없고, 인증서도 백엔드마다 따로 관리해야 한다.
|
||||
|
||||
이 실험대에는 이유가 하나 더 있다. `X-Forwarded-Proto: https` 나 `X-Forwarded-Host` 같은 헤더는 평문 HTTP 를 편집할 수 있어야 넣을 수 있고, Keycloak 이 `iss` 클레임과 redirect URL 을 외부 주소로 만들려면 그 헤더가 필요하다. 그래서 이 구조에서 TLS 종료는 고를 수 있는 것이 아니라 전제다.
|
||||
|
||||
대가는 셋이다. 프록시 뒤 구간이 평문이 된다 — 운영은 `127.0.0.1`, lab 은 `virbr0` 로 둘 다 머신 밖으로 안 나간다. 신뢰 경계가 프록시까지 넓어져서 프록시 보안이 곧 전체 보안이 된다. 그리고 클라이언트 인증서가 사라지는데, 백엔드가 그것을 직접 검증해야 하면 TLS 를 끊으면 안 되고 그때는 TCP 를 그대로 흘리는 L4 통과 구성을 쓴다.
|
||||
|
||||
## 바깥과 안이 아는 것이 다르다
|
||||
|
||||
| 무엇을 견주나 | 바깥의 nginx | Traefik (k3s ingress) |
|
||||
|---|---|---|
|
||||
| 사는 곳 | 클러스터 밖의 프로세스 | 클러스터 안, 파드 |
|
||||
| 아는 대상 | IP 와 포트 (고정) | 쿠버네티스 Service 와 Ingress (동적) |
|
||||
| 설정하는 방법 | 파일 편집 뒤 `nginx -s reload` | `kubectl apply` 로 Ingress 리소스 |
|
||||
| 대상이 바뀌면 | 사람이 고쳐야 한다 | 자동 반영 |
|
||||
| 무엇을 정하나 | 어느 노드로 보낼까 | 어느 파드로 보낼까 |
|
||||
| TLS | 여기서 종료 | 평문으로 받음 |
|
||||
|
||||
nginx 는 클러스터의 존재를 모르고 파드 IP 가 바뀌는 것도 모른다. Ingress 는 설정을 적어 둔 쿠버네티스 리소스여서 그 자체로는 아무 일도 하지 않고, 그 설정을 실제로 수행하는 프로그램이 Ingress Controller 다. 컨트롤러는 API 서버를 계속 감시하다가 Ingress 리소스가 생기거나 바뀌면 자기 라우팅 설정을 갱신한다.
|
||||
|
||||
Traefik 만 쓰면 어느 노드로 보낼지를 정할 것이 없다. k3s 에 딸린 servicelb 덕분에 Traefik 이 두 노드의 80 과 443 에 모두 바인딩되지만, 브라우저는 어느 노드로 가야 할지 모르고 그 노드가 내려가면 그 IP 로는 아무도 받지 못한다. Traefik 은 노드 안에서 파드로 나눠 주고, 노드들 사이에서 나눠 주는 일은 클러스터 밖의 무언가가 맡아야 한다.
|
||||
|
||||
nginx 만 쓰고 Traefik 을 끄면 Ingress 리소스를 못 쓰게 되고 서비스가 늘거나 파드 IP 가 바뀔 때마다 사람이 파일을 고친다. 그리고 두 경우 모두 운영 구조와 달라진다. 운영이 `host nginx → k3s(Traefik)` 이므로 실험대도 그 2홉을 복제해야 `X-Forwarded-*` 신뢰 경계 결론이 그대로 이전되고, 그것이 둘 다 두는 결정적인 이유다.
|
||||
|
||||
## ALB 와 NLB 는 같은 곳을 놓고 고르는 두 선택지다
|
||||
|
||||
ALB 와 NLB 는 AWS 의 두 제품이고 이 실험대에는 없다. 여기서는 진입점 배치를 견주는 이름으로만 쓴다. 둘 다 클러스터 밖의 로드밸런서여서 Ingress Controller 와 대응되는 짝이 아니다.
|
||||
|
||||
| 무엇이 다른가 | ALB (L7) | NLB (L4) |
|
||||
|---|---|---|
|
||||
| 이해하는 것 | HTTP/HTTPS | TCP/UDP |
|
||||
| 라우팅 기준 | 호스트명·경로 | 포트 |
|
||||
| TLS | 종료함 | 통과 또는 종료 |
|
||||
| `X-Forwarded-*` | 추가함 | 추가 안 함 (PROXY protocol 사용) |
|
||||
|
||||
진입점은 한 곳이고 HTTP 를 읽는 처리는 어딘가에서 반드시 한 번 일어난다. 배치의 차이는 진입점과 L7 처리기가 같은 장비인가 다른 장비인가에 있다.
|
||||
|
||||
```text
|
||||
[ALB 패턴]
|
||||
브라우저 ─▶ ALB (L7 · TLS 종료 · 경로 라우팅) ─▶ Pod
|
||||
└ 진입점이자 L7 처리기. 하나가 두 역할.
|
||||
|
||||
[NLB 패턴]
|
||||
브라우저 ─▶ NLB (L4 · 통과) ─▶ ingress controller (L7 · TLS 종료) ─▶ Pod
|
||||
└ 진입점만 └ L7 처리기. 역할이 둘로 나뉜다.
|
||||
```
|
||||
|
||||
| 배치 | 진입점 | L7 처리 위치 |
|
||||
|---|---|---|
|
||||
| ALB 단독 | ALB (L7) | 진입점 한 곳 |
|
||||
| NLB + ingress | NLB (L4) | 클러스터 안 한 곳 |
|
||||
| L7 + ingress | nginx (L7) | 두 곳 모두 |
|
||||
|
||||
이 실험대는 두 패턴 중 어느 쪽도 아니다. 바깥의 nginx 가 TLS 를 끊고 `X-Forwarded-*` 를 넣으므로 ALB 에 가까운데, 그 뒤의 Traefik 이 또 HTTP 를 읽는다.
|
||||
|
||||
## 진입점을 이중화하려 하면 재귀가 끝나지 않는다
|
||||
|
||||
한 머신 안에서 nginx 를 여러 개 띄우는 것은 의미가 없다. nginx 는 이미 master 프로세스 1개와 worker N개 구조이고, worker 들이 리스닝 소켓을 공유하며 CPU 코어 수만큼 병렬로 처리한다. 같은 머신에 인스턴스를 늘려도 그 머신이 내려가면 전부 함께 내려가므로 가용성은 늘지 않는다.
|
||||
|
||||
진짜 이중화는 머신을 늘리는 것이고, 그러면 「어느 nginx 로 갈지는 누가 정하는가」가 새로 생긴다. 앞에 로드밸런서를 또 두면 이번에는 그것이 단일 장애점이 되어 같은 물음이 한 칸 앞으로 옮겨 갈 뿐이다. 실무는 이 재귀를 소프트웨어가 아니라 네트워크 계층의 장치로 끊는다.
|
||||
|
||||
| 방법 | 재귀를 끊는 원리 | 전환 시간 |
|
||||
|---|---|---|
|
||||
| VIP + VRRP (keepalived) | 선택자가 없다. IP 자체가 이동한다 | 1~3초 |
|
||||
| DNS 다중 A 레코드 | 클라이언트가 고른다. 실패 시 다음 IP로 재시도 | TTL 의존, 느림 |
|
||||
| 애니캐스트 + BGP/ECMP | 라우터가 가장 가까운 경로로 보낸다 | 즉시, 대규모 전용 |
|
||||
| 클라우드 LB에 위임 | AWS가 내부적으로 다중 AZ로 이중화. 사용자는 DNS 이름만 받음 | 관리 불필요 |
|
||||
|
||||
VRRP 는 가상 IP 하나를 여러 장비가 번갈아 갖게 하는 방식으로 재귀를 끊는다. 가상 IP 는 한 번에 한 대만 갖고, MASTER 가 내려가면 BACKUP 이 그 IP 를 가져간 뒤 gratuitous ARP 를 브로드캐스트해 스위치의 MAC 테이블을 갱신한다. 클라이언트는 계속 같은 IP 로 접속하는데 그 IP 가 어느 장비에 붙어 있는지만 바뀐다. 고르는 주체가 없어서 「누가 정하는가」라는 물음 자체가 생기지 않는다.
|
||||
|
||||
클라우드 로드밸런서를 쓰면 AWS 가 여러 가용 영역에 걸쳐 이 재귀를 대신 풀어 주고 사용자는 DNS 이름 하나만 받는다.
|
||||
|
||||
## 이 실험대가 관측한 범위
|
||||
|
||||
바깥의 nginx 는 이 실험대의 단일 장애점이고 물리 머신도 한 대라 그것도 단일 장애점이다. 숨길 이유가 없으므로 알려진 한계로 적어 둔다.
|
||||
|
||||
이 실험대는 진입점을 이중화하지 않는다. 물리 머신이 한 대라 keepalived 를 구성해도 그 머신이 내려가면 끝이고, 검증 대상은 Keycloak 의 세션과 토큰이지 로드밸런서 가용성이 아니다. 다만 Traefik 은 이미 두 노드에 떠 있으므로, 노드 하나를 내리고 바깥 nginx 의 `upstream` 이 어떻게 반응하는지는 그대로 관찰할 수 있다.
|
||||
|
||||
ALB 와 NLB 의 대조는 AWS 의 두 제품을 기준으로 적은 것이고 이 실험대에서 관측한 것이 아니다. VRRP 도 keepalived 의 일반 동작이고 이 실험대에 구성하지 않았다. NLB 에 해당하는 것 역시 이 실험대에 아직 없고, 필요해지면 nginx 의 `stream {}` 블록이 그 일을 맡는다. k3s API 서버를 밖에서 접근하거나 PostgreSQL 과 Redis 를 게스트 밖에서 직접 관찰하거나 Keycloak 의 mTLS 를 실험할 때가 그런 경우다. nginx 는 한 프로세스에서 L7 과 L4 를 함께 수행할 수 있어서, AWS 에서 두 제품으로 나뉘어 있는 것과 다르다.
|
||||
|
||||
<!-- body:end -->
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 30 KiB |
+140
@@ -0,0 +1,140 @@
|
||||
---
|
||||
kind: CONCEPT
|
||||
slug: two-l7-hops-and-the-entry-point-recursion
|
||||
title: L7 이 두 겹인 이유와, 진입점 하나를 이중화하려 할 때 끝나지 않는 재귀
|
||||
topic: lab-entry-path-and-measurement-integrity
|
||||
topicName: 실험대의 진입 경로
|
||||
project: virtualization
|
||||
status: 게시 전
|
||||
basisVersion: 이 실험대의 2026-09-03 배치 · 호스트 nginx 1.30.4 (Arch) · k3s v1.36.4 의 기본 ingress 인 Traefik
|
||||
assets:
|
||||
- key: two-l7-hops-entry-recursion
|
||||
file: ../../../final/assets/diagrams/two-l7-hops-entry-recursion/two-l7-hops-entry-recursion.svg
|
||||
sourceRevision: import-head 9465582b5d1630eb4ae7c4e078021486919bf6b6 · uncommitted-working-tree snapshot
|
||||
source:
|
||||
- final/document.md#261-진입점-자체가-죽으면-로드밸런서의-재귀-문제
|
||||
- final/document.md#275-호스트-nginx와-traefik은-무엇이-다른가-둘-다-필요한-이유
|
||||
- final/document.md#257-리버스-프록시와-upstream
|
||||
- final/document.md#258-왜-tls를-끊어서-내용을-보는가
|
||||
---
|
||||
|
||||
# L7 이 두 겹인 이유와, 진입점 하나를 이중화하려 할 때 끝나지 않는 재귀
|
||||
|
||||
이 실험대의 요청은 HTTP 를 읽는 서버를 두 번 지난다. 바깥의 nginx 가 TLS 를 끊고 어느 노드로 보낼지 정하고, 그 노드의 Traefik 이 어느 파드로 보낼지 정한다. 한쪽만으로는 안 되는 이유가 서로 다르고, 진입점을 이중화하려 하면 「어느 진입점으로 갈지 누가 정하는가」가 새로 생겨 재귀가 끝나지 않는다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **엣지 nginx 를 호스트에서 게스트 VM 으로 옮긴다 — 성능이 아니라 더러워지는 층의 격리**
|
||||
그 결정이 든 근거가 「L7 홉 수는 전후 모두 2홉」인데 왜 2홉인지는 그 기록에 없다. 이 글이 그 앞을 맡는다.
|
||||
- **신뢰 경계에서는 forwarded 헤더를 덧붙이지 않고 덮어쓴다**
|
||||
그 기준이 말하는 경계가 두 홉 가운데 어느 쪽인지를 이 글이 먼저 정한다.
|
||||
- **공개 터널을 쓰지 않고 tailnet 직결로 둔다 — 홉이 하나 늘면 재려던 계약이 오염된다**
|
||||
여기 적은 두 겹에 세 번째를 붙이지 않기로 한 결정이다.
|
||||
- **엣지 게스트에 nginx 를 세우고 호스트 DNAT 으로 밖에서 들어오는 길을 연다**
|
||||
바깥 홉을 실제로 세우는 절차가 거기 있다.
|
||||
- **Guest 의 packet 이 Host Physical NIC 에 닿기까지 — virtio-net · virtqueue · vhost-net · TAP · Bridge**
|
||||
이 두 겹 아래에서 패킷이 게스트까지 가는 길을 그 글이 설명한다.
|
||||
- **가까운 층부터 한 층씩 건너뛰며 확인한다 — 층마다 성공 신호가 다르다**
|
||||
진입 경로가 층으로 나뉘어 있어서 한 칸씩 건너뛰며 칠 수 있다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
:::evidence key="two-l7-hops-entry-recursion" alt="브라우저에서 edge nginx와 Traefik을 거쳐 Pod로 가는 두 L7 홉과, edge nginx를 두 대로 늘릴 때 앞단 selector가 새 단일 장애점이 되는 재귀를 함께 보여 주는 흐름도." caption=" " zoom="true"
|
||||
:::
|
||||
|
||||
## 요청 하나가 L7 을 두 번 지난다
|
||||
|
||||
브라우저가 보낸 요청은 파드에 닿기까지 HTTP 를 읽는 서버를 두 번 지난다.
|
||||
|
||||
```text
|
||||
브라우저 ─▶ nginx (L7 · TLS 종료) ─▶ Traefik (L7 · Ingress 라우팅) ─▶ Pod
|
||||
```
|
||||
|
||||
리버스 프록시는 클라이언트 요청을 대신 받아 뒤쪽 서버로 전달하는 서버를 말한다. nginx 의 `upstream` 블록은 뒤쪽 서버 여러 대를 하나의 논리 이름으로 묶고, `proxy_pass http://이름;` 으로 그 그룹을 가리키면 nginx 가 요청을 분배한다. 저장소의 단일 호스트용 설정은 `proxy_pass http://keycloak:8080` 으로 대상 하나를 가리키는데, 멀티노드로 재려면 `upstream` 형태로 바꿔야 한다. 바깥 홉이 이 형태이고 묶인 것은 게스트 두 대의 `80` 포트다.
|
||||
|
||||
엣지 nginx 는 물리 호스트에서 게스트 한 대로 옮겨졌는데 그때도 HTTP 를 읽는 홉의 수는 2 그대로였다. 늘어난 것은 호스트 커널이 하는 L4 전달 한 번이고, 커널은 HTTP 를 읽지 않는다.
|
||||
|
||||
## 바깥 홉이 TLS 를 끊는 이유
|
||||
|
||||
TLS 종료는 프록시가 암호를 풀어 평문 HTTP 를 읽는 것을 말한다. 굳이 푸는 첫째 이유는 내용을 안 보면 어디로 보낼지 정할 수 없기 때문이다. 여러 도메인이 하나의 IP 와 443 포트를 공유하고, 어느 서비스로 보낼지는 HTTP `Host` 헤더에 적혀 있는데 그 헤더가 TLS 안에 암호화돼 있다. TLS 핸드셰이크의 평문 부분에 도메인이 들어 있어서 도메인 단위 분기는 풀지 않고도 된다. 다만 경로 단위 분기는 그렇게 할 수 없고, 인증서도 백엔드마다 따로 관리해야 한다.
|
||||
|
||||
이 실험대에는 이유가 하나 더 있다. `X-Forwarded-Proto: https` 나 `X-Forwarded-Host` 같은 헤더는 평문 HTTP 를 편집할 수 있어야 넣을 수 있고, Keycloak 이 `iss` 클레임과 redirect URL 을 외부 주소로 만들려면 그 헤더가 필요하다. 그래서 이 구조에서 TLS 종료는 고를 수 있는 것이 아니라 전제다.
|
||||
|
||||
대가는 셋이다. 프록시 뒤 구간이 평문이 된다 — 운영은 `127.0.0.1`, lab 은 `virbr0` 로 둘 다 머신 밖으로 안 나간다. 신뢰 경계가 프록시까지 넓어져서 프록시 보안이 곧 전체 보안이 된다. 그리고 클라이언트 인증서가 사라지는데, 백엔드가 그것을 직접 검증해야 하면 TLS 를 끊으면 안 되고 그때는 TCP 를 그대로 흘리는 L4 통과 구성을 쓴다.
|
||||
|
||||
## 바깥과 안이 아는 것이 다르다
|
||||
|
||||
| 무엇을 견주나 | 바깥의 nginx | Traefik (k3s ingress) |
|
||||
|---|---|---|
|
||||
| 사는 곳 | 클러스터 밖의 프로세스 | 클러스터 안, 파드 |
|
||||
| 아는 대상 | IP 와 포트 (고정) | 쿠버네티스 Service 와 Ingress (동적) |
|
||||
| 설정하는 방법 | 파일 편집 뒤 `nginx -s reload` | `kubectl apply` 로 Ingress 리소스 |
|
||||
| 대상이 바뀌면 | 사람이 고쳐야 한다 | 자동 반영 |
|
||||
| 무엇을 정하나 | 어느 노드로 보낼까 | 어느 파드로 보낼까 |
|
||||
| TLS | 여기서 종료 | 평문으로 받음 |
|
||||
|
||||
nginx 는 클러스터의 존재를 모르고 파드 IP 가 바뀌는 것도 모른다. Ingress 는 설정을 적어 둔 쿠버네티스 리소스여서 그 자체로는 아무 일도 하지 않고, 그 설정을 실제로 수행하는 프로그램이 Ingress Controller 다. 컨트롤러는 API 서버를 계속 감시하다가 Ingress 리소스가 생기거나 바뀌면 자기 라우팅 설정을 갱신한다.
|
||||
|
||||
Traefik 만 쓰면 어느 노드로 보낼지를 정할 것이 없다. k3s 에 딸린 servicelb 덕분에 Traefik 이 두 노드의 80 과 443 에 모두 바인딩되지만, 브라우저는 어느 노드로 가야 할지 모르고 그 노드가 내려가면 그 IP 로는 아무도 받지 못한다. Traefik 은 노드 안에서 파드로 나눠 주고, 노드들 사이에서 나눠 주는 일은 클러스터 밖의 무언가가 맡아야 한다.
|
||||
|
||||
nginx 만 쓰고 Traefik 을 끄면 Ingress 리소스를 못 쓰게 되고 서비스가 늘거나 파드 IP 가 바뀔 때마다 사람이 파일을 고친다. 그리고 두 경우 모두 운영 구조와 달라진다. 운영이 `host nginx → k3s(Traefik)` 이므로 실험대도 그 2홉을 복제해야 `X-Forwarded-*` 신뢰 경계 결론이 그대로 이전되고, 그것이 둘 다 두는 결정적인 이유다.
|
||||
|
||||
## ALB 와 NLB 는 같은 곳을 놓고 고르는 두 선택지다
|
||||
|
||||
ALB 와 NLB 는 AWS 의 두 제품이고 이 실험대에는 없다. 여기서는 진입점 배치를 견주는 이름으로만 쓴다. 둘 다 클러스터 밖의 로드밸런서여서 Ingress Controller 와 대응되는 짝이 아니다.
|
||||
|
||||
| 무엇이 다른가 | ALB (L7) | NLB (L4) |
|
||||
|---|---|---|
|
||||
| 이해하는 것 | HTTP/HTTPS | TCP/UDP |
|
||||
| 라우팅 기준 | 호스트명·경로 | 포트 |
|
||||
| TLS | 종료함 | 통과 또는 종료 |
|
||||
| `X-Forwarded-*` | 추가함 | 추가 안 함 (PROXY protocol 사용) |
|
||||
|
||||
진입점은 한 곳이고 HTTP 를 읽는 처리는 어딘가에서 반드시 한 번 일어난다. 배치의 차이는 진입점과 L7 처리기가 같은 장비인가 다른 장비인가에 있다.
|
||||
|
||||
```text
|
||||
[ALB 패턴]
|
||||
브라우저 ─▶ ALB (L7 · TLS 종료 · 경로 라우팅) ─▶ Pod
|
||||
└ 진입점이자 L7 처리기. 하나가 두 역할.
|
||||
|
||||
[NLB 패턴]
|
||||
브라우저 ─▶ NLB (L4 · 통과) ─▶ ingress controller (L7 · TLS 종료) ─▶ Pod
|
||||
└ 진입점만 └ L7 처리기. 역할이 둘로 나뉜다.
|
||||
```
|
||||
|
||||
| 배치 | 진입점 | L7 처리 위치 |
|
||||
|---|---|---|
|
||||
| ALB 단독 | ALB (L7) | 진입점 한 곳 |
|
||||
| NLB + ingress | NLB (L4) | 클러스터 안 한 곳 |
|
||||
| L7 + ingress | nginx (L7) | 두 곳 모두 |
|
||||
|
||||
이 실험대는 두 패턴 중 어느 쪽도 아니다. 바깥의 nginx 가 TLS 를 끊고 `X-Forwarded-*` 를 넣으므로 ALB 에 가까운데, 그 뒤의 Traefik 이 또 HTTP 를 읽는다.
|
||||
|
||||
## 진입점을 이중화하려 하면 재귀가 끝나지 않는다
|
||||
|
||||
한 머신 안에서 nginx 를 여러 개 띄우는 것은 의미가 없다. nginx 는 이미 master 프로세스 1개와 worker N개 구조이고, worker 들이 리스닝 소켓을 공유하며 CPU 코어 수만큼 병렬로 처리한다. 같은 머신에 인스턴스를 늘려도 그 머신이 내려가면 전부 함께 내려가므로 가용성은 늘지 않는다.
|
||||
|
||||
진짜 이중화는 머신을 늘리는 것이고, 그러면 「어느 nginx 로 갈지는 누가 정하는가」가 새로 생긴다. 앞에 로드밸런서를 또 두면 이번에는 그것이 단일 장애점이 되어 같은 물음이 한 칸 앞으로 옮겨 갈 뿐이다. 실무는 이 재귀를 소프트웨어가 아니라 네트워크 계층의 장치로 끊는다.
|
||||
|
||||
| 방법 | 재귀를 끊는 원리 | 전환 시간 |
|
||||
|---|---|---|
|
||||
| VIP + VRRP (keepalived) | 선택자가 없다. IP 자체가 이동한다 | 1~3초 |
|
||||
| DNS 다중 A 레코드 | 클라이언트가 고른다. 실패 시 다음 IP로 재시도 | TTL 의존, 느림 |
|
||||
| 애니캐스트 + BGP/ECMP | 라우터가 가장 가까운 경로로 보낸다 | 즉시, 대규모 전용 |
|
||||
| 클라우드 LB에 위임 | AWS가 내부적으로 다중 AZ로 이중화. 사용자는 DNS 이름만 받음 | 관리 불필요 |
|
||||
|
||||
VRRP 는 가상 IP 하나를 여러 장비가 번갈아 갖게 하는 방식으로 재귀를 끊는다. 가상 IP 는 한 번에 한 대만 갖고, MASTER 가 내려가면 BACKUP 이 그 IP 를 가져간 뒤 gratuitous ARP 를 브로드캐스트해 스위치의 MAC 테이블을 갱신한다. 클라이언트는 계속 같은 IP 로 접속하는데 그 IP 가 어느 장비에 붙어 있는지만 바뀐다. 고르는 주체가 없어서 「누가 정하는가」라는 물음 자체가 생기지 않는다.
|
||||
|
||||
클라우드 로드밸런서를 쓰면 AWS 가 여러 가용 영역에 걸쳐 이 재귀를 대신 풀어 주고 사용자는 DNS 이름 하나만 받는다.
|
||||
|
||||
## 이 실험대가 관측한 범위
|
||||
|
||||
바깥의 nginx 는 이 실험대의 단일 장애점이고 물리 머신도 한 대라 그것도 단일 장애점이다. 숨길 이유가 없으므로 알려진 한계로 적어 둔다.
|
||||
|
||||
이 실험대는 진입점을 이중화하지 않는다. 물리 머신이 한 대라 keepalived 를 구성해도 그 머신이 내려가면 끝이고, 검증 대상은 Keycloak 의 세션과 토큰이지 로드밸런서 가용성이 아니다. 다만 Traefik 은 이미 두 노드에 떠 있으므로, 노드 하나를 내리고 바깥 nginx 의 `upstream` 이 어떻게 반응하는지는 그대로 관찰할 수 있다.
|
||||
|
||||
ALB 와 NLB 의 대조는 AWS 의 두 제품을 기준으로 적은 것이고 이 실험대에서 관측한 것이 아니다. VRRP 도 keepalived 의 일반 동작이고 이 실험대에 구성하지 않았다. NLB 에 해당하는 것 역시 이 실험대에 아직 없고, 필요해지면 nginx 의 `stream {}` 블록이 그 일을 맡는다. k3s API 서버를 밖에서 접근하거나 PostgreSQL 과 Redis 를 게스트 밖에서 직접 관찰하거나 Keycloak 의 mTLS 를 실험할 때가 그런 경우다. nginx 는 한 프로세스에서 L7 과 L4 를 함께 수행할 수 있어서, AWS 에서 두 제품으로 나뉘어 있는 것과 다르다.
|
||||
|
||||
<!-- body:end -->
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"authority": "deterministic",
|
||||
"gate": "command-pedagogy-signals",
|
||||
"section_id": "docs/virtualization/tech-log-studio/lab-entry-path-and-measurement-integrity/concept/concept-two-l7-hops-and-the-entry-point-recursion.md",
|
||||
"mode": "reference",
|
||||
"source_sha256": "5b86a26c5b4f7398772cf7a0e5f9bae083c3bc98cc86b087b23ec322ad0e46b6",
|
||||
"result": "PASS",
|
||||
"requires_editor": false,
|
||||
"blocks": [],
|
||||
"findings": [],
|
||||
"extensions": {
|
||||
"command_like_text_blocks": []
|
||||
}
|
||||
}
|
||||
+140
@@ -0,0 +1,140 @@
|
||||
---
|
||||
kind: CONCEPT
|
||||
slug: two-l7-hops-and-the-entry-point-recursion
|
||||
title: L7 이 두 겹인 이유와, 진입점 하나를 이중화하려 할 때 끝나지 않는 재귀
|
||||
topic: lab-entry-path-and-measurement-integrity
|
||||
topicName: 실험대의 진입 경로
|
||||
project: virtualization
|
||||
status: 게시 전
|
||||
basisVersion: 이 실험대의 2026-09-03 배치 · 호스트 nginx 1.30.4 (Arch) · k3s v1.36.4 의 기본 ingress 인 Traefik
|
||||
assets:
|
||||
- key: two-l7-hops-entry-recursion
|
||||
file: ../../../final/assets/diagrams/two-l7-hops-entry-recursion/two-l7-hops-entry-recursion.svg
|
||||
sourceRevision: import-head 9465582b5d1630eb4ae7c4e078021486919bf6b6 · uncommitted-working-tree snapshot
|
||||
source:
|
||||
- final/document.md#261-진입점-자체가-죽으면-로드밸런서의-재귀-문제
|
||||
- final/document.md#275-호스트-nginx와-traefik은-무엇이-다른가-둘-다-필요한-이유
|
||||
- final/document.md#257-리버스-프록시와-upstream
|
||||
- final/document.md#258-왜-tls를-끊어서-내용을-보는가
|
||||
---
|
||||
|
||||
# L7 이 두 겹인 이유와, 진입점 하나를 이중화하려 할 때 끝나지 않는 재귀
|
||||
|
||||
이 실험대의 요청은 HTTP 를 읽는 서버를 두 번 지난다. 바깥의 nginx 가 TLS 를 끊고 어느 노드로 보낼지 정하고, 그 노드의 Traefik 이 어느 파드로 보낼지 정한다. 한쪽만으로는 안 되는 이유가 서로 다르고, 진입점을 이중화하려 하면 「어느 진입점으로 갈지 누가 정하는가」가 새로 생겨 재귀가 끝나지 않는다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **엣지 nginx 를 호스트에서 게스트 VM 으로 옮긴다 — 성능이 아니라 더러워지는 층의 격리**
|
||||
그 결정이 든 근거가 「L7 홉 수는 전후 모두 2홉」인데 왜 2홉인지는 그 기록에 없다. 이 글이 그 앞을 맡는다.
|
||||
- **신뢰 경계에서는 forwarded 헤더를 덧붙이지 않고 덮어쓴다**
|
||||
그 기준이 말하는 경계가 두 홉 가운데 어느 쪽인지를 이 글이 먼저 정한다.
|
||||
- **공개 터널을 쓰지 않고 tailnet 직결로 둔다 — 홉이 하나 늘면 재려던 계약이 오염된다**
|
||||
여기 적은 두 겹에 세 번째를 붙이지 않기로 한 결정이다.
|
||||
- **엣지 게스트에 nginx 를 세우고 호스트 DNAT 으로 밖에서 들어오는 길을 연다**
|
||||
바깥 홉을 실제로 세우는 절차가 거기 있다.
|
||||
- **Guest 의 packet 이 Host Physical NIC 에 닿기까지 — virtio-net · virtqueue · vhost-net · TAP · Bridge**
|
||||
이 두 겹 아래에서 패킷이 게스트까지 가는 길을 그 글이 설명한다.
|
||||
- **가까운 층부터 한 층씩 건너뛰며 확인한다 — 층마다 성공 신호가 다르다**
|
||||
진입 경로가 층으로 나뉘어 있어서 한 칸씩 건너뛰며 칠 수 있다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
:::evidence key="two-l7-hops-entry-recursion" alt="브라우저에서 edge nginx와 Traefik을 거쳐 Pod로 가는 두 L7 홉과, edge nginx를 두 대로 늘릴 때 앞단 selector가 새 단일 장애점이 되는 재귀를 함께 보여 주는 흐름도." caption=" " zoom="true"
|
||||
:::
|
||||
|
||||
## 요청 하나가 L7 을 두 번 지난다
|
||||
|
||||
브라우저가 보낸 요청은 파드에 닿기까지 HTTP 를 읽는 서버를 두 번 지난다.
|
||||
|
||||
```text
|
||||
브라우저 ─▶ nginx (L7 · TLS 종료) ─▶ Traefik (L7 · Ingress 라우팅) ─▶ Pod
|
||||
```
|
||||
|
||||
리버스 프록시는 클라이언트 요청을 대신 받아 뒤쪽 서버로 전달하는 서버를 말한다. nginx 의 `upstream` 블록은 뒤쪽 서버 여러 대를 하나의 논리 이름으로 묶고, `proxy_pass http://이름;` 으로 그 그룹을 가리키면 nginx 가 요청을 분배한다. 저장소의 단일 호스트용 설정은 `proxy_pass http://keycloak:8080` 으로 대상 하나를 가리키는데, 멀티노드로 재려면 `upstream` 형태로 바꿔야 한다. 바깥 홉이 이 형태이고 묶인 것은 게스트 두 대의 `80` 포트다.
|
||||
|
||||
엣지 nginx 는 물리 호스트에서 게스트 한 대로 옮겨졌는데 그때도 HTTP 를 읽는 홉의 수는 2 그대로였다. 늘어난 것은 호스트 커널이 하는 L4 전달 한 번이고, 커널은 HTTP 를 읽지 않는다.
|
||||
|
||||
## 바깥 홉이 TLS 를 끊는 이유
|
||||
|
||||
TLS 종료는 프록시가 암호를 풀어 평문 HTTP 를 읽는 것을 말한다. 굳이 푸는 첫째 이유는 내용을 안 보면 어디로 보낼지 정할 수 없기 때문이다. 여러 도메인이 하나의 IP 와 443 포트를 공유하고, 어느 서비스로 보낼지는 HTTP `Host` 헤더에 적혀 있는데 그 헤더가 TLS 안에 암호화돼 있다. TLS 핸드셰이크의 평문 부분에 도메인이 들어 있어서 도메인 단위 분기는 풀지 않고도 된다. 다만 경로 단위 분기는 그렇게 할 수 없고, 인증서도 백엔드마다 따로 관리해야 한다.
|
||||
|
||||
이 실험대에는 이유가 하나 더 있다. `X-Forwarded-Proto: https` 나 `X-Forwarded-Host` 같은 헤더는 평문 HTTP 를 편집할 수 있어야 넣을 수 있고, Keycloak 이 `iss` 클레임과 redirect URL 을 외부 주소로 만들려면 그 헤더가 필요하다. 그래서 이 구조에서 TLS 종료는 고를 수 있는 것이 아니라 전제다.
|
||||
|
||||
대가는 셋이다. 프록시 뒤 구간이 평문이 된다 — 운영은 `127.0.0.1`, lab 은 `virbr0` 로 둘 다 머신 밖으로 안 나간다. 신뢰 경계가 프록시까지 넓어져서 프록시 보안이 곧 전체 보안이 된다. 그리고 클라이언트 인증서가 사라지는데, 백엔드가 그것을 직접 검증해야 하면 TLS 를 끊으면 안 되고 그때는 TCP 를 그대로 흘리는 L4 통과 구성을 쓴다.
|
||||
|
||||
## 바깥과 안이 아는 것이 다르다
|
||||
|
||||
| 무엇을 견주나 | 바깥의 nginx | Traefik (k3s ingress) |
|
||||
|---|---|---|
|
||||
| 사는 곳 | 클러스터 밖의 프로세스 | 클러스터 안, 파드 |
|
||||
| 아는 대상 | IP 와 포트 (고정) | 쿠버네티스 Service 와 Ingress (동적) |
|
||||
| 설정하는 방법 | 파일 편집 뒤 `nginx -s reload` | `kubectl apply` 로 Ingress 리소스 |
|
||||
| 대상이 바뀌면 | 사람이 고쳐야 한다 | 자동 반영 |
|
||||
| 무엇을 정하나 | 어느 노드로 보낼까 | 어느 파드로 보낼까 |
|
||||
| TLS | 여기서 종료 | 평문으로 받음 |
|
||||
|
||||
nginx 는 클러스터의 존재를 모르고 파드 IP 가 바뀌는 것도 모른다. Ingress 는 설정을 적어 둔 쿠버네티스 리소스여서 그 자체로는 아무 일도 하지 않고, 그 설정을 실제로 수행하는 프로그램이 Ingress Controller 다. 컨트롤러는 API 서버를 계속 감시하다가 Ingress 리소스가 생기거나 바뀌면 자기 라우팅 설정을 갱신한다.
|
||||
|
||||
Traefik 만 쓰면 어느 노드로 보낼지를 정할 것이 없다. k3s 에 딸린 servicelb 덕분에 Traefik 이 두 노드의 80 과 443 에 모두 바인딩되지만, 브라우저는 어느 노드로 가야 할지 모르고 그 노드가 내려가면 그 IP 로는 아무도 받지 못한다. Traefik 은 노드 안에서 파드로 나눠 주고, 노드들 사이에서 나눠 주는 일은 클러스터 밖의 무언가가 맡아야 한다.
|
||||
|
||||
nginx 만 쓰고 Traefik 을 끄면 Ingress 리소스를 못 쓰게 되고 서비스가 늘거나 파드 IP 가 바뀔 때마다 사람이 파일을 고친다. 그리고 두 경우 모두 운영 구조와 달라진다. 운영이 `host nginx → k3s(Traefik)` 이므로 실험대도 그 2홉을 복제해야 `X-Forwarded-*` 신뢰 경계 결론이 그대로 이전되고, 그것이 둘 다 두는 결정적인 이유다.
|
||||
|
||||
## ALB 와 NLB 는 같은 곳을 놓고 고르는 두 선택지다
|
||||
|
||||
ALB 와 NLB 는 AWS 의 두 제품이고 이 실험대에는 없다. 여기서는 진입점 배치를 견주는 이름으로만 쓴다. 둘 다 클러스터 밖의 로드밸런서여서 Ingress Controller 와 대응되는 짝이 아니다.
|
||||
|
||||
| 무엇이 다른가 | ALB (L7) | NLB (L4) |
|
||||
|---|---|---|
|
||||
| 이해하는 것 | HTTP/HTTPS | TCP/UDP |
|
||||
| 라우팅 기준 | 호스트명·경로 | 포트 |
|
||||
| TLS | 종료함 | 통과 또는 종료 |
|
||||
| `X-Forwarded-*` | 추가함 | 추가 안 함 (PROXY protocol 사용) |
|
||||
|
||||
진입점은 한 곳이고 HTTP 를 읽는 처리는 어딘가에서 반드시 한 번 일어난다. 배치의 차이는 진입점과 L7 처리기가 같은 장비인가 다른 장비인가에 있다.
|
||||
|
||||
```text
|
||||
[ALB 패턴]
|
||||
브라우저 ─▶ ALB (L7 · TLS 종료 · 경로 라우팅) ─▶ Pod
|
||||
└ 진입점이자 L7 처리기. 하나가 두 역할.
|
||||
|
||||
[NLB 패턴]
|
||||
브라우저 ─▶ NLB (L4 · 통과) ─▶ ingress controller (L7 · TLS 종료) ─▶ Pod
|
||||
└ 진입점만 └ L7 처리기. 역할이 둘로 나뉜다.
|
||||
```
|
||||
|
||||
| 배치 | 진입점 | L7 처리 위치 |
|
||||
|---|---|---|
|
||||
| ALB 단독 | ALB (L7) | 진입점 한 곳 |
|
||||
| NLB + ingress | NLB (L4) | 클러스터 안 한 곳 |
|
||||
| L7 + ingress | nginx (L7) | 두 곳 모두 |
|
||||
|
||||
이 실험대는 두 패턴 중 어느 쪽도 아니다. 바깥의 nginx 가 TLS 를 끊고 `X-Forwarded-*` 를 넣으므로 ALB 에 가까운데, 그 뒤의 Traefik 이 또 HTTP 를 읽는다.
|
||||
|
||||
## 진입점을 이중화하려 하면 재귀가 끝나지 않는다
|
||||
|
||||
한 머신 안에서 nginx 를 여러 개 띄우는 것은 의미가 없다. nginx 는 이미 master 프로세스 1개와 worker N개 구조이고, worker 들이 리스닝 소켓을 공유하며 CPU 코어 수만큼 병렬로 처리한다. 같은 머신에 인스턴스를 늘려도 그 머신이 내려가면 전부 함께 내려가므로 가용성은 늘지 않는다.
|
||||
|
||||
진짜 이중화는 머신을 늘리는 것이고, 그러면 「어느 nginx 로 갈지는 누가 정하는가」가 새로 생긴다. 앞에 로드밸런서를 또 두면 이번에는 그것이 단일 장애점이 되어 같은 물음이 한 칸 앞으로 옮겨 갈 뿐이다. 실무는 이 재귀를 소프트웨어가 아니라 네트워크 계층의 장치로 끊는다.
|
||||
|
||||
| 방법 | 재귀를 끊는 원리 | 전환 시간 |
|
||||
|---|---|---|
|
||||
| VIP + VRRP (keepalived) | 선택자가 없다. IP 자체가 이동한다 | 1~3초 |
|
||||
| DNS 다중 A 레코드 | 클라이언트가 고른다. 실패 시 다음 IP로 재시도 | TTL 의존, 느림 |
|
||||
| 애니캐스트 + BGP/ECMP | 라우터가 가장 가까운 경로로 보낸다 | 즉시, 대규모 전용 |
|
||||
| 클라우드 LB에 위임 | AWS가 내부적으로 다중 AZ로 이중화. 사용자는 DNS 이름만 받음 | 관리 불필요 |
|
||||
|
||||
VRRP 는 가상 IP 하나를 여러 장비가 번갈아 갖게 하는 방식으로 재귀를 끊는다. 가상 IP 는 한 번에 한 대만 갖고, MASTER 가 내려가면 BACKUP 이 그 IP 를 가져간 뒤 gratuitous ARP 를 브로드캐스트해 스위치의 MAC 테이블을 갱신한다. 클라이언트는 계속 같은 IP 로 접속하는데 그 IP 가 어느 장비에 붙어 있는지만 바뀐다. 고르는 주체가 없어서 「누가 정하는가」라는 물음 자체가 생기지 않는다.
|
||||
|
||||
클라우드 로드밸런서를 쓰면 AWS 가 여러 가용 영역에 걸쳐 이 재귀를 대신 풀어 주고 사용자는 DNS 이름 하나만 받는다.
|
||||
|
||||
## 이 실험대가 관측한 범위
|
||||
|
||||
바깥의 nginx 는 이 실험대의 단일 장애점이고 물리 머신도 한 대라 그것도 단일 장애점이다. 숨길 이유가 없으므로 알려진 한계로 적어 둔다.
|
||||
|
||||
이 실험대는 진입점을 이중화하지 않는다. 물리 머신이 한 대라 keepalived 를 구성해도 그 머신이 내려가면 끝이고, 검증 대상은 Keycloak 의 세션과 토큰이지 로드밸런서 가용성이 아니다. 다만 Traefik 은 이미 두 노드에 떠 있으므로, 노드 하나를 내리고 바깥 nginx 의 `upstream` 이 어떻게 반응하는지는 그대로 관찰할 수 있다.
|
||||
|
||||
ALB 와 NLB 의 대조는 AWS 의 두 제품을 기준으로 적은 것이고 이 실험대에서 관측한 것이 아니다. VRRP 도 keepalived 의 일반 동작이고 이 실험대에 구성하지 않았다. NLB 에 해당하는 것 역시 이 실험대에 아직 없고, 필요해지면 nginx 의 `stream {}` 블록이 그 일을 맡는다. k3s API 서버를 밖에서 접근하거나 PostgreSQL 과 Redis 를 게스트 밖에서 직접 관찰하거나 Keycloak 의 mTLS 를 실험할 때가 그런 경우다. nginx 는 한 프로세스에서 L7 과 L4 를 함께 수행할 수 있어서, AWS 에서 두 제품으로 나뉘어 있는 것과 다르다.
|
||||
|
||||
<!-- body:end -->
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"record": "docs/virtualization/tech-log-studio/lab-entry-path-and-measurement-integrity/concept/concept-two-l7-hops-and-the-entry-point-recursion.md",
|
||||
"sourceSha256": "5b86a26c5b4f7398772cf7a0e5f9bae083c3bc98cc86b087b23ec322ad0e46b6",
|
||||
"verdict": "PASS",
|
||||
"liveSourceReconciliation": "UNVERIFIABLE",
|
||||
"liveSourceReason": "source repositories unavailable on current machine",
|
||||
"acceptedByProjectReview": true,
|
||||
"checks": [
|
||||
"local SSOT/Record evidence mismatch 0",
|
||||
"S3/S5/S6 check_evidence --repo invoked",
|
||||
"source checkout absence preserved as exit 3"
|
||||
],
|
||||
"note": "현재 project evidence gate는 불일치 0건·대조 불가 2건이다. source checkout 부재를 PASS로 위장하지 않고 UNVERIFIABLE로 보존했다."
|
||||
}
|
||||
+362
@@ -0,0 +1,362 @@
|
||||
{
|
||||
"schemaVersion": 5,
|
||||
"runId": "2026-09-21-0218-remediation-08-decision-no-public-tunnel-because-a-third-hop-pollutes-the-measurement",
|
||||
"project": "virtualization",
|
||||
"record": "docs/virtualization/tech-log-studio/lab-entry-path-and-measurement-integrity/decision/decision-no-public-tunnel-because-a-third-hop-pollutes-the-measurement.md",
|
||||
"startedAt": "2026-09-21T02:24:45+00:00",
|
||||
"finishedAt": "2026-09-21T02:24:47+00:00",
|
||||
"stages": [
|
||||
{
|
||||
"id": "S1",
|
||||
"name": "코드베이스 → SSOT",
|
||||
"skill": "analyzing-codebase-for-tech-log",
|
||||
"runBy": "ssot-analyst",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "이번 remediation은 새 evidence와 현재 SSOT/Record의 정합성·provenance·command/S4를 재검토한다. 코드베이스→SSOT 전면 분석은 다시 하지 않는다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": "edd45dfec66d155a621cd41186b83b5582f2244c",
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"gates": [],
|
||||
"notes": "",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-21T02:24:45+00:00",
|
||||
"elapsedSeconds": 0,
|
||||
"finishedBy": "chatgpt-virtualization-20260921-remediation-v5"
|
||||
},
|
||||
{
|
||||
"id": "S2",
|
||||
"name": "SSOT → 분해 계약",
|
||||
"skill": "deriving-tech-log-root-tree",
|
||||
"runBy": "tree-deriver",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "기존 91 Record 분해 계약은 유지한다. certbot Question의 RESOLVED 상태와 sourcePolicy는 project tree rebuild에서 별도로 동기화했다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": "ab59130196d79e947b32e3b5e6b75335a9e5c1eb",
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"gates": [],
|
||||
"notes": "",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-21T02:24:45+00:00",
|
||||
"elapsedSeconds": 0,
|
||||
"finishedBy": "chatgpt-virtualization-20260921-remediation-v5"
|
||||
},
|
||||
{
|
||||
"id": "S3",
|
||||
"name": "글감 → 기록",
|
||||
"skill": "writing-tech-log-records",
|
||||
"runBy": "record-writer",
|
||||
"status": "DONE",
|
||||
"skipReason": "",
|
||||
"skillEcho": "**인용한 줄은 SSOT 에서 찾아 대조한다.**",
|
||||
"skillRevision": null,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
"docs/virtualization/tech-log-studio/lab-entry-path-and-measurement-integrity/decision/decision-no-public-tunnel-because-a-third-hop-pollutes-the-measurement.md"
|
||||
],
|
||||
"gates": [
|
||||
{
|
||||
"cmd": "python3 scripts/studio-body.py docs/virtualization/tech-log-studio/lab-entry-path-and-measurement-integrity/decision/decision-no-public-tunnel-because-a-third-hop-pollutes-the-measurement.md -o runs/virtualization/2026-09-21-0218-remediation-08-decision-no-public-tunnel-because-a-third-hop-pollutes-the-measurement/stage/S3/studio-body.md",
|
||||
"exit": 0,
|
||||
"status": "PASS",
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"generation": 1,
|
||||
"at": "2026-09-21T02:24:45+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node --experimental-transform-types .agents/skills/writing-tech-log-records/scripts/check_body.mjs runs/virtualization/2026-09-21-0218-remediation-08-decision-no-public-tunnel-because-a-third-hop-pollutes-the-measurement/stage/S3/studio-body.md --frontend /shared/codebase/tech-log-frontend",
|
||||
"exit": 0,
|
||||
"status": "PASS",
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"generation": 1,
|
||||
"at": "2026-09-21T02:24:45+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node .agents/skills/rewriting-technical-prose-naturally/scripts/check_prose.mjs --warn docs/virtualization/tech-log-studio/lab-entry-path-and-measurement-integrity/decision/decision-no-public-tunnel-because-a-third-hop-pollutes-the-measurement.md",
|
||||
"exit": 0,
|
||||
"status": "PASS",
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"generation": 1,
|
||||
"at": "2026-09-21T02:24:46+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node .agents/skills/writing-tech-log-records/scripts/check_evidence.mjs virtualization --repo",
|
||||
"exit": 3,
|
||||
"status": "UNVERIFIABLE",
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"generation": 1,
|
||||
"at": "2026-09-21T02:24:46+00:00",
|
||||
"semanticId": "evidence-repo",
|
||||
"reason": "source repositories unavailable on current machine",
|
||||
"acceptedByProjectReview": true
|
||||
}
|
||||
],
|
||||
"notes": "record-writer/fact 계약으로 새 evidence와 SSOT의 current truth를 대조했다. --repo 대조는 실제 실행했고 source checkout 부재만 UNVERIFIABLE로 남겼다.",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-21T02:24:46+00:00",
|
||||
"elapsedSeconds": 1,
|
||||
"generation": 1,
|
||||
"owner": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"finishedBy": "chatgpt-virtualization-20260921-remediation-v5"
|
||||
},
|
||||
{
|
||||
"id": "S4",
|
||||
"name": "기록 → 그림",
|
||||
"skill": "technical-visualizer",
|
||||
"runBy": "diagram-maker",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "이 Record 변경은 current truth/evidence/version/provenance 정리이며 새 관계 그림을 요구하지 않는다. 기존 그림은 project-wide 14장 검증에서 별도로 확인했다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": "edd45dfec66d155a621cd41186b83b5582f2244c",
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"gates": [],
|
||||
"notes": "",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-21T02:24:46+00:00",
|
||||
"elapsedSeconds": 0,
|
||||
"finishedBy": "chatgpt-virtualization-20260921-remediation-v5"
|
||||
},
|
||||
{
|
||||
"id": "S5",
|
||||
"name": "AI 티 제거",
|
||||
"skill": "rewriting-technical-prose-naturally",
|
||||
"runBy": "prose-rewriter",
|
||||
"status": "DONE",
|
||||
"skipReason": "",
|
||||
"skillEcho": "This is an **editorial** pass. The source's facts, evidence, causal chain, uncertainty, decision status, and technical depth are the contract.",
|
||||
"skillRevision": null,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
"docs/virtualization/tech-log-studio/lab-entry-path-and-measurement-integrity/decision/decision-no-public-tunnel-because-a-third-hop-pollutes-the-measurement.md"
|
||||
],
|
||||
"gates": [
|
||||
{
|
||||
"cmd": "node .agents/skills/rewriting-technical-prose-naturally/scripts/check_prose.mjs --warn docs/virtualization/tech-log-studio/lab-entry-path-and-measurement-integrity/decision/decision-no-public-tunnel-because-a-third-hop-pollutes-the-measurement.md",
|
||||
"exit": 0,
|
||||
"status": "PASS",
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"generation": 1,
|
||||
"at": "2026-09-21T02:24:46+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node .agents/skills/rewriting-technical-prose-naturally/scripts/style_profile.mjs docs/virtualization/tech-log-studio/lab-entry-path-and-measurement-integrity/decision/decision-no-public-tunnel-because-a-third-hop-pollutes-the-measurement.md",
|
||||
"exit": 1,
|
||||
"status": "FAIL",
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"generation": 1,
|
||||
"at": "2026-09-21T02:24:46+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node --experimental-transform-types .agents/skills/writing-tech-log-records/scripts/check_body.mjs runs/virtualization/2026-09-21-0218-remediation-08-decision-no-public-tunnel-because-a-third-hop-pollutes-the-measurement/stage/S5/studio-body.md --frontend /shared/codebase/tech-log-frontend",
|
||||
"exit": 0,
|
||||
"status": "PASS",
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"generation": 1,
|
||||
"at": "2026-09-21T02:24:46+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node .agents/skills/writing-tech-log-records/scripts/check_evidence.mjs virtualization --repo",
|
||||
"exit": 3,
|
||||
"status": "UNVERIFIABLE",
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"generation": 1,
|
||||
"at": "2026-09-21T02:24:46+00:00",
|
||||
"semanticId": "evidence-repo",
|
||||
"reason": "source repositories unavailable on current machine",
|
||||
"acceptedByProjectReview": true
|
||||
}
|
||||
],
|
||||
"notes": "대규모 문체 재작성 없이 stale fact/relation만 현재 evidence에 맞췄다. style_profile은 측정값으로만 기록했다.",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-21T02:24:46+00:00",
|
||||
"elapsedSeconds": 0,
|
||||
"generation": 1,
|
||||
"owner": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"finishedBy": "chatgpt-virtualization-20260921-remediation-v5"
|
||||
},
|
||||
{
|
||||
"id": "S6",
|
||||
"name": "일한 사람의 목소리",
|
||||
"skill": "writing-as-the-person-who-did-it",
|
||||
"runBy": "voice-writer",
|
||||
"status": "DONE",
|
||||
"skipReason": "",
|
||||
"skillEcho": "찾은 것이 없으면 **이 스킬은 여기서 끝난다.** 없는 목소리를 채우지 않는다.",
|
||||
"skillRevision": "edd45dfec66d155a621cd41186b83b5582f2244c",
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
"docs/virtualization/tech-log-studio/lab-entry-path-and-measurement-integrity/decision/decision-no-public-tunnel-because-a-third-hop-pollutes-the-measurement.md"
|
||||
],
|
||||
"gates": [
|
||||
{
|
||||
"cmd": "node .agents/skills/writing-as-the-person-who-did-it/scripts/check_voice.mjs docs/virtualization/tech-log-studio/lab-entry-path-and-measurement-integrity/decision/decision-no-public-tunnel-because-a-third-hop-pollutes-the-measurement.md",
|
||||
"exit": 0,
|
||||
"status": "PASS",
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"generation": 1,
|
||||
"at": "2026-09-21T02:24:47+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node .agents/skills/rewriting-technical-prose-naturally/scripts/check_prose.mjs --warn docs/virtualization/tech-log-studio/lab-entry-path-and-measurement-integrity/decision/decision-no-public-tunnel-because-a-third-hop-pollutes-the-measurement.md",
|
||||
"exit": 0,
|
||||
"status": "PASS",
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"generation": 1,
|
||||
"at": "2026-09-21T02:24:47+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node --experimental-transform-types .agents/skills/writing-tech-log-records/scripts/check_body.mjs runs/virtualization/2026-09-21-0218-remediation-08-decision-no-public-tunnel-because-a-third-hop-pollutes-the-measurement/stage/S6/studio-body.md --frontend /shared/codebase/tech-log-frontend",
|
||||
"exit": 0,
|
||||
"status": "PASS",
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"generation": 1,
|
||||
"at": "2026-09-21T02:24:47+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node .agents/skills/writing-tech-log-records/scripts/check_evidence.mjs virtualization --repo",
|
||||
"exit": 3,
|
||||
"status": "UNVERIFIABLE",
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"generation": 1,
|
||||
"at": "2026-09-21T02:24:47+00:00",
|
||||
"semanticId": "evidence-repo",
|
||||
"reason": "source repositories unavailable on current machine",
|
||||
"acceptedByProjectReview": true
|
||||
}
|
||||
],
|
||||
"notes": "상류 SSOT/evidence를 다시 보며 실제 작업 흔적이 없는 서사는 추가하지 않았다.",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-21T02:24:47+00:00",
|
||||
"elapsedSeconds": 1,
|
||||
"generation": 1,
|
||||
"owner": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"finishedBy": "chatgpt-virtualization-20260921-remediation-v5"
|
||||
},
|
||||
{
|
||||
"id": "S7",
|
||||
"name": "Studio 저장",
|
||||
"skill": "publishing-tech-log-to-studio",
|
||||
"runBy": "studio-validator",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "이번 remediation에서 Studio import/save는 요청되지 않았다. 저장소 산출물과 검증 영수증만 갱신한다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": "862e502af3e956b49ccd2ae0a8de3fd32f90df9c",
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"gates": [],
|
||||
"notes": "",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-21T02:24:47+00:00",
|
||||
"elapsedSeconds": 0,
|
||||
"finishedBy": "chatgpt-virtualization-20260921-remediation-v5"
|
||||
}
|
||||
],
|
||||
"qualityReviews": {
|
||||
"commandPedagogy": {
|
||||
"initialAnalysis": {
|
||||
"cmd": "python3 scripts/check-command-pedagogy.py --mode reference docs/virtualization/tech-log-studio/lab-entry-path-and-measurement-integrity/decision/decision-no-public-tunnel-because-a-third-hop-pollutes-the-measurement.md -o runs/virtualization/2026-09-21-0218-remediation-08-decision-no-public-tunnel-because-a-third-hop-pollutes-the-measurement/stage/S3/command-initial.json",
|
||||
"exit": 0,
|
||||
"shellBlocks": 0,
|
||||
"findings": 0,
|
||||
"majorFindings": 0,
|
||||
"artifact": {
|
||||
"path": "runs/virtualization/2026-09-21-0218-remediation-08-decision-no-public-tunnel-because-a-third-hop-pollutes-the-measurement/stage/S3/command-initial.json",
|
||||
"sha256": "6178254795eb6b385e3729baa155887bf7ab1ea3a7e79a15567f6bb53d3a6206"
|
||||
}
|
||||
},
|
||||
"finalAnalysis": {
|
||||
"cmd": "python3 scripts/check-command-pedagogy.py --mode reference docs/virtualization/tech-log-studio/lab-entry-path-and-measurement-integrity/decision/decision-no-public-tunnel-because-a-third-hop-pollutes-the-measurement.md -o runs/virtualization/2026-09-21-0218-remediation-08-decision-no-public-tunnel-because-a-third-hop-pollutes-the-measurement/stage/S6/command-final.json",
|
||||
"exit": 0,
|
||||
"shellBlocks": 0,
|
||||
"findings": 0,
|
||||
"majorFindings": 0,
|
||||
"artifact": {
|
||||
"path": "runs/virtualization/2026-09-21-0218-remediation-08-decision-no-public-tunnel-because-a-third-hop-pollutes-the-measurement/stage/S6/command-final.json",
|
||||
"sha256": "6178254795eb6b385e3729baa155887bf7ab1ea3a7e79a15567f6bb53d3a6206"
|
||||
}
|
||||
},
|
||||
"planner": {
|
||||
"runBy": "command-pedagogy-planner",
|
||||
"skill": "writing-practitioner-guides",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "initial deterministic analysis finding이 0이다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": null,
|
||||
"notes": "현재 reference-mode 분석을 확인했다.",
|
||||
"artifact": null
|
||||
},
|
||||
"editor": {
|
||||
"runBy": "command-pedagogy-editor",
|
||||
"skill": "writing-practitioner-guides",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "initial deterministic analysis finding이 0이다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": null,
|
||||
"notes": "현재 reference-mode 분석을 확인했다.",
|
||||
"artifact": null
|
||||
},
|
||||
"reviewer": {
|
||||
"runBy": "command-pedagogy-reviewer",
|
||||
"skill": "writing-practitioner-guides",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "final publication에 shell/CLI block이 없다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": null,
|
||||
"verdict": null,
|
||||
"notes": "final deterministic analysis에서 shellBlocks=0을 확인했다.",
|
||||
"artifact": null,
|
||||
"sourceSha256": null
|
||||
}
|
||||
},
|
||||
"technicalEvidence": {
|
||||
"runBy": "fact-reviewer",
|
||||
"status": "DONE",
|
||||
"skipReason": "",
|
||||
"verdict": "PASS",
|
||||
"notes": "current truth/evidence/provenance를 SSOT와 역대조했다. live source는 실제 --repo 결과 exit 3이며 UNVERIFIABLE로 보존했다.",
|
||||
"sourceSha256": "d0ef95de23013598087ef70718123b8e5c4e5876493c274efb4a1c5721b5c30a",
|
||||
"liveSourceReconciliation": "UNVERIFIABLE",
|
||||
"liveSourceReason": "source repositories unavailable on current machine",
|
||||
"acceptedByProjectReview": true,
|
||||
"artifact": {
|
||||
"path": "runs/virtualization/2026-09-21-0218-remediation-08-decision-no-public-tunnel-because-a-third-hop-pollutes-the-measurement/stage/quality/technical-evidence-review.json",
|
||||
"sha256": "c8382f0b8619968d921ed164ffa73f3239be148fe12d33f1af2a9d3e3bad1612"
|
||||
}
|
||||
}
|
||||
},
|
||||
"riders": [],
|
||||
"sessions": [
|
||||
{
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"openedAt": "2026-09-21T02:24:45+00:00"
|
||||
},
|
||||
{
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"stage": "S3",
|
||||
"generation": 1,
|
||||
"beganAt": "2026-09-21T02:24:45+00:00"
|
||||
},
|
||||
{
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"stage": "S5",
|
||||
"generation": 1,
|
||||
"beganAt": "2026-09-21T02:24:46+00:00"
|
||||
},
|
||||
{
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"stage": "S6",
|
||||
"generation": 1,
|
||||
"beganAt": "2026-09-21T02:24:46+00:00"
|
||||
}
|
||||
],
|
||||
"revision": 23,
|
||||
"updatedAt": "2026-09-21T02:24:47+00:00",
|
||||
"executionEnvironment": {
|
||||
"mode": "current-remediation-contract-replay",
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"agentToolAvailable": false,
|
||||
"note": "별도 subagent 실행 도구가 현재 환경에 없어 현재 세션이 각 .claude/agents 역할 정의와 연결된 SKILL/contract를 직접 읽고 역할을 분리해 수행했다. runBy는 계약 역할명이며 별도 프로세스 실행을 주장하지 않는다."
|
||||
}
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"authority": "deterministic",
|
||||
"gate": "command-pedagogy-signals",
|
||||
"section_id": "docs/virtualization/tech-log-studio/lab-entry-path-and-measurement-integrity/decision/decision-no-public-tunnel-because-a-third-hop-pollutes-the-measurement.md",
|
||||
"mode": "reference",
|
||||
"source_sha256": "d0ef95de23013598087ef70718123b8e5c4e5876493c274efb4a1c5721b5c30a",
|
||||
"result": "PASS",
|
||||
"requires_editor": false,
|
||||
"blocks": [],
|
||||
"findings": [],
|
||||
"extensions": {
|
||||
"command_like_text_blocks": []
|
||||
}
|
||||
}
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
---
|
||||
kind: PROJECT_DECISION
|
||||
slug: no-public-tunnel-because-a-third-hop-pollutes-the-measurement
|
||||
title: 공개 터널을 쓰지 않고 tailnet 직결로 둔다 — 홉이 하나 늘면 재려던 계약이 오염된다
|
||||
topic: lab-entry-path-and-measurement-integrity
|
||||
topicName: 실험대의 진입 경로
|
||||
project: virtualization
|
||||
status: 게시 전
|
||||
decisionStatus: ADOPTED
|
||||
sourceRevision: import-head 9465582b5d1630eb4ae7c4e078021486919bf6b6 · uncommitted-working-tree snapshot
|
||||
source:
|
||||
- final/document.md#305-tunnel-채택하지-않은-이유를-남긴-자산
|
||||
- final/document.md#302-왜-적용하지-않는-것을-남겨두는가
|
||||
- final/document.md#300-9층-deploy-무엇이-살아-있고-무엇이-참조인가
|
||||
- final/document.md#301-전체-지도
|
||||
---
|
||||
|
||||
# 공개 터널을 쓰지 않고 tailnet 직결로 둔다 — 홉이 하나 늘면 재려던 계약이 오염된다
|
||||
|
||||
Cloudflare named tunnel 을 앞에 붙이면 브라우저에서 파드까지가 3홉이 되고, 이 실험대가 재려는 것이 정확히 2홉의 forwarded 헤더 계약이라 측정이 오염된다. 그래서 tailnet 직결로 두고 터널 설정 파일은 채택하지 않은 선택지로 남긴다.
|
||||
|
||||
## 근거
|
||||
|
||||
§305 가 `deploy/` 아래의 `tunnel/cloudflared-config.yml` 을 두고 채택하지 않았다고 적으면서 기각 이유를 전후 홉 그림으로 남겼다. 기각한 파일을 지우지 않는 방침은 §300 부터 §302 에 따로 있다. 저장소에 있으나 적용되지 않는 설정이 여럿인데, 그것들이 죽은 코드가 아니라 의도적으로 남겨 둔 참조 자산이라고 전체 지도와 함께 적는다.
|
||||
|
||||
- **L7 이 두 겹인 이유와, 진입점 하나를 이중화하려 할 때 끝나지 않는 재귀**
|
||||
이 결정이 지키려는 2홉이 무엇이고 그 둘의 역할이 왜 다른지를 그 글이 설명한다.
|
||||
- **신뢰 경계에서는 forwarded 헤더를 덧붙이지 않고 덮어쓴다**
|
||||
앞에 한 겹이 더 붙으면 경계가 Cloudflare 엣지로 옮겨 가 그 기준을 거기서 다시 세워야 한다.
|
||||
- **인증서는 DNS-01 로 받는다 — 실험대 주소가 공개 인터넷에 없어 HTTP-01 이 성립하지 않는다**
|
||||
이 결정이 청구한 비용 하나를 그 결정이 받는다.
|
||||
- **엣지 nginx 를 호스트에서 게스트 VM 으로 옮긴다 — 성능이 아니라 더러워지는 층의 격리**
|
||||
같은 2홉을 지킨 채 엣지의 위치만 바꾼 결정이라 홉 수를 건드리지 않는다.
|
||||
- **이 실험대의 certbot 은 HTTP-01 로 받고 있는가 DNS-01 로 받고 있는가**
|
||||
이 물음은 2026-09-17 `dns-cloudflare` 관측으로 닫혔다. 남은 것은 현재 Cloudflare 자격증명이 실제 재발급에 쓸 수 있는지다.
|
||||
|
||||
## 결정문
|
||||
|
||||
공개 터널을 쓰지 않고 tailnet 직결로 둔다. 기각한 설정 파일 `tunnel/cloudflared-config.yml` 은 지우지 않고 채택하지 않은 선택지로 남긴다.
|
||||
|
||||
조건이 바뀌어 공개 접근이 필요해지면(예를 들어 다른 회선으로 이전) 그 파일을 그대로 쓴다.
|
||||
|
||||
## 판단 이유
|
||||
|
||||
Cloudflare named tunnel 은 아웃바운드 연결만 쓰므로 포트포워딩 없이 공개 HTTPS 이름을 얻는다. 공유기를 건드릴 수 없는 환경에서는 매력적인 선택지이고, §305 는 그 설정의 함정 둘까지 적어 두었다. `service:` 에 `127.0.0.1` 을 쓰면 cloudflared 컨테이너 자신을 가리키므로 Compose 서비스 DNS 이름을 써야 하고, 마지막 catch-all 이 없으면 오류가 난다.
|
||||
|
||||
기각 근거는 홉 수다. 터널을 쓰면 브라우저가 Cloudflare 엣지와 nginx 와 Traefik 을 지나 파드에 닿아 3홉이 된다. 지금은 브라우저가 nginx 와 Traefik 만 지나 파드에 닿는 2홉이다. Cloudflare 엣지가 TLS 를 끊고 다시 맺으면서 HTTP 를 읽는 홉이 하나 늘고 `CF-Connecting-IP` 같은 자체 헤더가 섞인다. 이 실험대가 측정하려는 것이 정확히 `nginx → Traefik` 2홉의 forwarded 헤더 계약이므로, 앞에 한 겹이 더 붙으면 측정이 오염된다.
|
||||
|
||||
터널이 무엇을 해 주는지가 아니라 그것이 이 실험대의 측정 대상에 무엇을 하는지를 보고 정했다.
|
||||
|
||||
## 영향
|
||||
|
||||
진입 경로가 2홉으로 고정된다. 밖에서 들어오는 요청을 처음 받는 프록시가 하나뿐이라 신뢰 경계가 한 곳이고, forwarded 헤더를 누가 쓰는지가 갈리지 않는다.
|
||||
|
||||
감수한 비용은 진입 주소다. `dig` 가 내놓는 `100.83.212.4` 는 `100.64.0.0/10` 안에 있고 그 대역이 CGNAT 용으로 예약돼 있어 공개 인터넷에서 라우팅되지 않는다.
|
||||
|
||||
그 대가가 두 곳에서 청구됐다.
|
||||
인증서 발급 : HTTP-01 로 받을 수 없어 DNS-01 로 갔다
|
||||
재구축 : DNS-01 등록은 확인됐지만 Cloudflare 자격증명 유효성과 dry-run 성공을 확인하기 전에는 `/etc/letsencrypt/` 를 보존한다
|
||||
|
||||
기각한 파일을 지우지 않는 방침에도 근거가 셋 있다.
|
||||
① 저장소의 목적이 비교다 : 선택지를 나란히 두고 트레이드오프를 적는 것 자체가 산출물이라, 하나만 남기면 왜 이것을 골랐는지를 뒷받침할 근거가 없어진다
|
||||
② 죽은 코드가 아니라 테스트되는 코드다 : `scripts/verify-public-tunnel-config.sh` 가 붙어 있어 실행되지 않을 뿐 깨지면 드러난다
|
||||
③ 실험대 전용 설정은 `lab/` 아래로 분리했다 : 일반 배포 설정과 섞이지 않는다
|
||||
|
||||
재지 않은 것도 있다. 터널을 붙인 상태와 지금을 같은 방법으로 잰 비교는 이 저장소에 없다. 기각의 근거는 홉 수와 섞이는 헤더이지 두 구성을 재서 견준 값이 아니다. 인증 방식은 2026-09-17 `dns-cloudflare` 로 확인됐다. 다만 같은 후속 원문에서 Cloudflare 토큰 길이는 0자로 측정돼 재발급 가능성까지 확인된 것은 아니다.
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
---
|
||||
kind: PROJECT_DECISION
|
||||
slug: no-public-tunnel-because-a-third-hop-pollutes-the-measurement
|
||||
title: 공개 터널을 쓰지 않고 tailnet 직결로 둔다 — 홉이 하나 늘면 재려던 계약이 오염된다
|
||||
topic: lab-entry-path-and-measurement-integrity
|
||||
topicName: 실험대의 진입 경로
|
||||
project: virtualization
|
||||
status: 게시 전
|
||||
decisionStatus: ADOPTED
|
||||
sourceRevision: import-head 9465582b5d1630eb4ae7c4e078021486919bf6b6 · uncommitted-working-tree snapshot
|
||||
source:
|
||||
- final/document.md#305-tunnel-채택하지-않은-이유를-남긴-자산
|
||||
- final/document.md#302-왜-적용하지-않는-것을-남겨두는가
|
||||
- final/document.md#300-9층-deploy-무엇이-살아-있고-무엇이-참조인가
|
||||
- final/document.md#301-전체-지도
|
||||
---
|
||||
|
||||
# 공개 터널을 쓰지 않고 tailnet 직결로 둔다 — 홉이 하나 늘면 재려던 계약이 오염된다
|
||||
|
||||
Cloudflare named tunnel 을 앞에 붙이면 브라우저에서 파드까지가 3홉이 되고, 이 실험대가 재려는 것이 정확히 2홉의 forwarded 헤더 계약이라 측정이 오염된다. 그래서 tailnet 직결로 두고 터널 설정 파일은 채택하지 않은 선택지로 남긴다.
|
||||
|
||||
## 근거
|
||||
|
||||
§305 가 `deploy/` 아래의 `tunnel/cloudflared-config.yml` 을 두고 채택하지 않았다고 적으면서 기각 이유를 전후 홉 그림으로 남겼다. 기각한 파일을 지우지 않는 방침은 §300 부터 §302 에 따로 있다. 저장소에 있으나 적용되지 않는 설정이 여럿인데, 그것들이 죽은 코드가 아니라 의도적으로 남겨 둔 참조 자산이라고 전체 지도와 함께 적는다.
|
||||
|
||||
- **L7 이 두 겹인 이유와, 진입점 하나를 이중화하려 할 때 끝나지 않는 재귀**
|
||||
이 결정이 지키려는 2홉이 무엇이고 그 둘의 역할이 왜 다른지를 그 글이 설명한다.
|
||||
- **신뢰 경계에서는 forwarded 헤더를 덧붙이지 않고 덮어쓴다**
|
||||
앞에 한 겹이 더 붙으면 경계가 Cloudflare 엣지로 옮겨 가 그 기준을 거기서 다시 세워야 한다.
|
||||
- **인증서는 DNS-01 로 받는다 — 실험대 주소가 공개 인터넷에 없어 HTTP-01 이 성립하지 않는다**
|
||||
이 결정이 청구한 비용 하나를 그 결정이 받는다.
|
||||
- **엣지 nginx 를 호스트에서 게스트 VM 으로 옮긴다 — 성능이 아니라 더러워지는 층의 격리**
|
||||
같은 2홉을 지킨 채 엣지의 위치만 바꾼 결정이라 홉 수를 건드리지 않는다.
|
||||
- **이 실험대의 certbot 은 HTTP-01 로 받고 있는가 DNS-01 로 받고 있는가**
|
||||
이 물음은 2026-09-17 `dns-cloudflare` 관측으로 닫혔다. 남은 것은 현재 Cloudflare 자격증명이 실제 재발급에 쓸 수 있는지다.
|
||||
|
||||
## 결정문
|
||||
|
||||
공개 터널을 쓰지 않고 tailnet 직결로 둔다. 기각한 설정 파일 `tunnel/cloudflared-config.yml` 은 지우지 않고 채택하지 않은 선택지로 남긴다.
|
||||
|
||||
조건이 바뀌어 공개 접근이 필요해지면(예를 들어 다른 회선으로 이전) 그 파일을 그대로 쓴다.
|
||||
|
||||
## 판단 이유
|
||||
|
||||
Cloudflare named tunnel 은 아웃바운드 연결만 쓰므로 포트포워딩 없이 공개 HTTPS 이름을 얻는다. 공유기를 건드릴 수 없는 환경에서는 매력적인 선택지이고, §305 는 그 설정의 함정 둘까지 적어 두었다. `service:` 에 `127.0.0.1` 을 쓰면 cloudflared 컨테이너 자신을 가리키므로 Compose 서비스 DNS 이름을 써야 하고, 마지막 catch-all 이 없으면 오류가 난다.
|
||||
|
||||
기각 근거는 홉 수다. 터널을 쓰면 브라우저가 Cloudflare 엣지와 nginx 와 Traefik 을 지나 파드에 닿아 3홉이 된다. 지금은 브라우저가 nginx 와 Traefik 만 지나 파드에 닿는 2홉이다. Cloudflare 엣지가 TLS 를 끊고 다시 맺으면서 HTTP 를 읽는 홉이 하나 늘고 `CF-Connecting-IP` 같은 자체 헤더가 섞인다. 이 실험대가 측정하려는 것이 정확히 `nginx → Traefik` 2홉의 forwarded 헤더 계약이므로, 앞에 한 겹이 더 붙으면 측정이 오염된다.
|
||||
|
||||
터널이 무엇을 해 주는지가 아니라 그것이 이 실험대의 측정 대상에 무엇을 하는지를 보고 정했다.
|
||||
|
||||
## 영향
|
||||
|
||||
진입 경로가 2홉으로 고정된다. 밖에서 들어오는 요청을 처음 받는 프록시가 하나뿐이라 신뢰 경계가 한 곳이고, forwarded 헤더를 누가 쓰는지가 갈리지 않는다.
|
||||
|
||||
감수한 비용은 진입 주소다. `dig` 가 내놓는 `100.83.212.4` 는 `100.64.0.0/10` 안에 있고 그 대역이 CGNAT 용으로 예약돼 있어 공개 인터넷에서 라우팅되지 않는다.
|
||||
|
||||
그 대가가 두 곳에서 청구됐다.
|
||||
인증서 발급 : HTTP-01 로 받을 수 없어 DNS-01 로 갔다
|
||||
재구축 : DNS-01 등록은 확인됐지만 Cloudflare 자격증명 유효성과 dry-run 성공을 확인하기 전에는 `/etc/letsencrypt/` 를 보존한다
|
||||
|
||||
기각한 파일을 지우지 않는 방침에도 근거가 셋 있다.
|
||||
① 저장소의 목적이 비교다 : 선택지를 나란히 두고 트레이드오프를 적는 것 자체가 산출물이라, 하나만 남기면 왜 이것을 골랐는지를 뒷받침할 근거가 없어진다
|
||||
② 죽은 코드가 아니라 테스트되는 코드다 : `scripts/verify-public-tunnel-config.sh` 가 붙어 있어 실행되지 않을 뿐 깨지면 드러난다
|
||||
③ 실험대 전용 설정은 `lab/` 아래로 분리했다 : 일반 배포 설정과 섞이지 않는다
|
||||
|
||||
재지 않은 것도 있다. 터널을 붙인 상태와 지금을 같은 방법으로 잰 비교는 이 저장소에 없다. 기각의 근거는 홉 수와 섞이는 헤더이지 두 구성을 재서 견준 값이 아니다. 인증 방식은 2026-09-17 `dns-cloudflare` 로 확인됐다. 다만 같은 후속 원문에서 Cloudflare 토큰 길이는 0자로 측정돼 재발급 가능성까지 확인된 것은 아니다.
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"authority": "deterministic",
|
||||
"gate": "command-pedagogy-signals",
|
||||
"section_id": "docs/virtualization/tech-log-studio/lab-entry-path-and-measurement-integrity/decision/decision-no-public-tunnel-because-a-third-hop-pollutes-the-measurement.md",
|
||||
"mode": "reference",
|
||||
"source_sha256": "d0ef95de23013598087ef70718123b8e5c4e5876493c274efb4a1c5721b5c30a",
|
||||
"result": "PASS",
|
||||
"requires_editor": false,
|
||||
"blocks": [],
|
||||
"findings": [],
|
||||
"extensions": {
|
||||
"command_like_text_blocks": []
|
||||
}
|
||||
}
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
---
|
||||
kind: PROJECT_DECISION
|
||||
slug: no-public-tunnel-because-a-third-hop-pollutes-the-measurement
|
||||
title: 공개 터널을 쓰지 않고 tailnet 직결로 둔다 — 홉이 하나 늘면 재려던 계약이 오염된다
|
||||
topic: lab-entry-path-and-measurement-integrity
|
||||
topicName: 실험대의 진입 경로
|
||||
project: virtualization
|
||||
status: 게시 전
|
||||
decisionStatus: ADOPTED
|
||||
sourceRevision: import-head 9465582b5d1630eb4ae7c4e078021486919bf6b6 · uncommitted-working-tree snapshot
|
||||
source:
|
||||
- final/document.md#305-tunnel-채택하지-않은-이유를-남긴-자산
|
||||
- final/document.md#302-왜-적용하지-않는-것을-남겨두는가
|
||||
- final/document.md#300-9층-deploy-무엇이-살아-있고-무엇이-참조인가
|
||||
- final/document.md#301-전체-지도
|
||||
---
|
||||
|
||||
# 공개 터널을 쓰지 않고 tailnet 직결로 둔다 — 홉이 하나 늘면 재려던 계약이 오염된다
|
||||
|
||||
Cloudflare named tunnel 을 앞에 붙이면 브라우저에서 파드까지가 3홉이 되고, 이 실험대가 재려는 것이 정확히 2홉의 forwarded 헤더 계약이라 측정이 오염된다. 그래서 tailnet 직결로 두고 터널 설정 파일은 채택하지 않은 선택지로 남긴다.
|
||||
|
||||
## 근거
|
||||
|
||||
§305 가 `deploy/` 아래의 `tunnel/cloudflared-config.yml` 을 두고 채택하지 않았다고 적으면서 기각 이유를 전후 홉 그림으로 남겼다. 기각한 파일을 지우지 않는 방침은 §300 부터 §302 에 따로 있다. 저장소에 있으나 적용되지 않는 설정이 여럿인데, 그것들이 죽은 코드가 아니라 의도적으로 남겨 둔 참조 자산이라고 전체 지도와 함께 적는다.
|
||||
|
||||
- **L7 이 두 겹인 이유와, 진입점 하나를 이중화하려 할 때 끝나지 않는 재귀**
|
||||
이 결정이 지키려는 2홉이 무엇이고 그 둘의 역할이 왜 다른지를 그 글이 설명한다.
|
||||
- **신뢰 경계에서는 forwarded 헤더를 덧붙이지 않고 덮어쓴다**
|
||||
앞에 한 겹이 더 붙으면 경계가 Cloudflare 엣지로 옮겨 가 그 기준을 거기서 다시 세워야 한다.
|
||||
- **인증서는 DNS-01 로 받는다 — 실험대 주소가 공개 인터넷에 없어 HTTP-01 이 성립하지 않는다**
|
||||
이 결정이 청구한 비용 하나를 그 결정이 받는다.
|
||||
- **엣지 nginx 를 호스트에서 게스트 VM 으로 옮긴다 — 성능이 아니라 더러워지는 층의 격리**
|
||||
같은 2홉을 지킨 채 엣지의 위치만 바꾼 결정이라 홉 수를 건드리지 않는다.
|
||||
- **이 실험대의 certbot 은 HTTP-01 로 받고 있는가 DNS-01 로 받고 있는가**
|
||||
이 물음은 2026-09-17 `dns-cloudflare` 관측으로 닫혔다. 남은 것은 현재 Cloudflare 자격증명이 실제 재발급에 쓸 수 있는지다.
|
||||
|
||||
## 결정문
|
||||
|
||||
공개 터널을 쓰지 않고 tailnet 직결로 둔다. 기각한 설정 파일 `tunnel/cloudflared-config.yml` 은 지우지 않고 채택하지 않은 선택지로 남긴다.
|
||||
|
||||
조건이 바뀌어 공개 접근이 필요해지면(예를 들어 다른 회선으로 이전) 그 파일을 그대로 쓴다.
|
||||
|
||||
## 판단 이유
|
||||
|
||||
Cloudflare named tunnel 은 아웃바운드 연결만 쓰므로 포트포워딩 없이 공개 HTTPS 이름을 얻는다. 공유기를 건드릴 수 없는 환경에서는 매력적인 선택지이고, §305 는 그 설정의 함정 둘까지 적어 두었다. `service:` 에 `127.0.0.1` 을 쓰면 cloudflared 컨테이너 자신을 가리키므로 Compose 서비스 DNS 이름을 써야 하고, 마지막 catch-all 이 없으면 오류가 난다.
|
||||
|
||||
기각 근거는 홉 수다. 터널을 쓰면 브라우저가 Cloudflare 엣지와 nginx 와 Traefik 을 지나 파드에 닿아 3홉이 된다. 지금은 브라우저가 nginx 와 Traefik 만 지나 파드에 닿는 2홉이다. Cloudflare 엣지가 TLS 를 끊고 다시 맺으면서 HTTP 를 읽는 홉이 하나 늘고 `CF-Connecting-IP` 같은 자체 헤더가 섞인다. 이 실험대가 측정하려는 것이 정확히 `nginx → Traefik` 2홉의 forwarded 헤더 계약이므로, 앞에 한 겹이 더 붙으면 측정이 오염된다.
|
||||
|
||||
터널이 무엇을 해 주는지가 아니라 그것이 이 실험대의 측정 대상에 무엇을 하는지를 보고 정했다.
|
||||
|
||||
## 영향
|
||||
|
||||
진입 경로가 2홉으로 고정된다. 밖에서 들어오는 요청을 처음 받는 프록시가 하나뿐이라 신뢰 경계가 한 곳이고, forwarded 헤더를 누가 쓰는지가 갈리지 않는다.
|
||||
|
||||
감수한 비용은 진입 주소다. `dig` 가 내놓는 `100.83.212.4` 는 `100.64.0.0/10` 안에 있고 그 대역이 CGNAT 용으로 예약돼 있어 공개 인터넷에서 라우팅되지 않는다.
|
||||
|
||||
그 대가가 두 곳에서 청구됐다.
|
||||
인증서 발급 : HTTP-01 로 받을 수 없어 DNS-01 로 갔다
|
||||
재구축 : DNS-01 등록은 확인됐지만 Cloudflare 자격증명 유효성과 dry-run 성공을 확인하기 전에는 `/etc/letsencrypt/` 를 보존한다
|
||||
|
||||
기각한 파일을 지우지 않는 방침에도 근거가 셋 있다.
|
||||
① 저장소의 목적이 비교다 : 선택지를 나란히 두고 트레이드오프를 적는 것 자체가 산출물이라, 하나만 남기면 왜 이것을 골랐는지를 뒷받침할 근거가 없어진다
|
||||
② 죽은 코드가 아니라 테스트되는 코드다 : `scripts/verify-public-tunnel-config.sh` 가 붙어 있어 실행되지 않을 뿐 깨지면 드러난다
|
||||
③ 실험대 전용 설정은 `lab/` 아래로 분리했다 : 일반 배포 설정과 섞이지 않는다
|
||||
|
||||
재지 않은 것도 있다. 터널을 붙인 상태와 지금을 같은 방법으로 잰 비교는 이 저장소에 없다. 기각의 근거는 홉 수와 섞이는 헤더이지 두 구성을 재서 견준 값이 아니다. 인증 방식은 2026-09-17 `dns-cloudflare` 로 확인됐다. 다만 같은 후속 원문에서 Cloudflare 토큰 길이는 0자로 측정돼 재발급 가능성까지 확인된 것은 아니다.
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"record": "docs/virtualization/tech-log-studio/lab-entry-path-and-measurement-integrity/decision/decision-no-public-tunnel-because-a-third-hop-pollutes-the-measurement.md",
|
||||
"sourceSha256": "d0ef95de23013598087ef70718123b8e5c4e5876493c274efb4a1c5721b5c30a",
|
||||
"verdict": "PASS",
|
||||
"liveSourceReconciliation": "UNVERIFIABLE",
|
||||
"liveSourceReason": "source repositories unavailable on current machine",
|
||||
"acceptedByProjectReview": true,
|
||||
"checks": [
|
||||
"local SSOT/Record evidence mismatch 0",
|
||||
"S3/S5/S6 check_evidence --repo invoked",
|
||||
"source checkout absence preserved as exit 3"
|
||||
],
|
||||
"note": "현재 project evidence gate는 불일치 0건·대조 불가 2건이다. source checkout 부재를 PASS로 위장하지 않고 UNVERIFIABLE로 보존했다."
|
||||
}
|
||||
+362
@@ -0,0 +1,362 @@
|
||||
{
|
||||
"schemaVersion": 5,
|
||||
"runId": "2026-09-21-0218-remediation-09-reference-overwrite-a-forwarded-header-at-the-trust-boundary-do-not-extend-it",
|
||||
"project": "virtualization",
|
||||
"record": "docs/virtualization/tech-log-studio/lab-entry-path-and-measurement-integrity/reference/reference-overwrite-a-forwarded-header-at-the-trust-boundary-do-not-extend-it.md",
|
||||
"startedAt": "2026-09-21T02:24:47+00:00",
|
||||
"finishedAt": "2026-09-21T02:24:49+00:00",
|
||||
"stages": [
|
||||
{
|
||||
"id": "S1",
|
||||
"name": "코드베이스 → SSOT",
|
||||
"skill": "analyzing-codebase-for-tech-log",
|
||||
"runBy": "ssot-analyst",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "이번 remediation은 새 evidence와 현재 SSOT/Record의 정합성·provenance·command/S4를 재검토한다. 코드베이스→SSOT 전면 분석은 다시 하지 않는다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": "edd45dfec66d155a621cd41186b83b5582f2244c",
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"gates": [],
|
||||
"notes": "",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-21T02:24:47+00:00",
|
||||
"elapsedSeconds": 0,
|
||||
"finishedBy": "chatgpt-virtualization-20260921-remediation-v5"
|
||||
},
|
||||
{
|
||||
"id": "S2",
|
||||
"name": "SSOT → 분해 계약",
|
||||
"skill": "deriving-tech-log-root-tree",
|
||||
"runBy": "tree-deriver",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "기존 91 Record 분해 계약은 유지한다. certbot Question의 RESOLVED 상태와 sourcePolicy는 project tree rebuild에서 별도로 동기화했다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": "ab59130196d79e947b32e3b5e6b75335a9e5c1eb",
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"gates": [],
|
||||
"notes": "",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-21T02:24:47+00:00",
|
||||
"elapsedSeconds": 0,
|
||||
"finishedBy": "chatgpt-virtualization-20260921-remediation-v5"
|
||||
},
|
||||
{
|
||||
"id": "S3",
|
||||
"name": "글감 → 기록",
|
||||
"skill": "writing-tech-log-records",
|
||||
"runBy": "record-writer",
|
||||
"status": "DONE",
|
||||
"skipReason": "",
|
||||
"skillEcho": "**인용한 줄은 SSOT 에서 찾아 대조한다.**",
|
||||
"skillRevision": null,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
"docs/virtualization/tech-log-studio/lab-entry-path-and-measurement-integrity/reference/reference-overwrite-a-forwarded-header-at-the-trust-boundary-do-not-extend-it.md"
|
||||
],
|
||||
"gates": [
|
||||
{
|
||||
"cmd": "python3 scripts/studio-body.py docs/virtualization/tech-log-studio/lab-entry-path-and-measurement-integrity/reference/reference-overwrite-a-forwarded-header-at-the-trust-boundary-do-not-extend-it.md -o runs/virtualization/2026-09-21-0218-remediation-09-reference-overwrite-a-forwarded-header-at-the-trust-boundary-do-not-extend-it/stage/S3/studio-body.md",
|
||||
"exit": 0,
|
||||
"status": "PASS",
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"generation": 1,
|
||||
"at": "2026-09-21T02:24:47+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node --experimental-transform-types .agents/skills/writing-tech-log-records/scripts/check_body.mjs runs/virtualization/2026-09-21-0218-remediation-09-reference-overwrite-a-forwarded-header-at-the-trust-boundary-do-not-extend-it/stage/S3/studio-body.md --frontend /shared/codebase/tech-log-frontend",
|
||||
"exit": 0,
|
||||
"status": "PASS",
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"generation": 1,
|
||||
"at": "2026-09-21T02:24:48+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node .agents/skills/rewriting-technical-prose-naturally/scripts/check_prose.mjs --warn docs/virtualization/tech-log-studio/lab-entry-path-and-measurement-integrity/reference/reference-overwrite-a-forwarded-header-at-the-trust-boundary-do-not-extend-it.md",
|
||||
"exit": 0,
|
||||
"status": "PASS",
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"generation": 1,
|
||||
"at": "2026-09-21T02:24:48+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node .agents/skills/writing-tech-log-records/scripts/check_evidence.mjs virtualization --repo",
|
||||
"exit": 3,
|
||||
"status": "UNVERIFIABLE",
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"generation": 1,
|
||||
"at": "2026-09-21T02:24:48+00:00",
|
||||
"semanticId": "evidence-repo",
|
||||
"reason": "source repositories unavailable on current machine",
|
||||
"acceptedByProjectReview": true
|
||||
}
|
||||
],
|
||||
"notes": "record-writer/fact 계약으로 새 evidence와 SSOT의 current truth를 대조했다. --repo 대조는 실제 실행했고 source checkout 부재만 UNVERIFIABLE로 남겼다.",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-21T02:24:48+00:00",
|
||||
"elapsedSeconds": 1,
|
||||
"generation": 1,
|
||||
"owner": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"finishedBy": "chatgpt-virtualization-20260921-remediation-v5"
|
||||
},
|
||||
{
|
||||
"id": "S4",
|
||||
"name": "기록 → 그림",
|
||||
"skill": "technical-visualizer",
|
||||
"runBy": "diagram-maker",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "이 Record 변경은 current truth/evidence/version/provenance 정리이며 새 관계 그림을 요구하지 않는다. 기존 그림은 project-wide 14장 검증에서 별도로 확인했다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": "edd45dfec66d155a621cd41186b83b5582f2244c",
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"gates": [],
|
||||
"notes": "",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-21T02:24:48+00:00",
|
||||
"elapsedSeconds": 0,
|
||||
"finishedBy": "chatgpt-virtualization-20260921-remediation-v5"
|
||||
},
|
||||
{
|
||||
"id": "S5",
|
||||
"name": "AI 티 제거",
|
||||
"skill": "rewriting-technical-prose-naturally",
|
||||
"runBy": "prose-rewriter",
|
||||
"status": "DONE",
|
||||
"skipReason": "",
|
||||
"skillEcho": "This is an **editorial** pass. The source's facts, evidence, causal chain, uncertainty, decision status, and technical depth are the contract.",
|
||||
"skillRevision": null,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
"docs/virtualization/tech-log-studio/lab-entry-path-and-measurement-integrity/reference/reference-overwrite-a-forwarded-header-at-the-trust-boundary-do-not-extend-it.md"
|
||||
],
|
||||
"gates": [
|
||||
{
|
||||
"cmd": "node .agents/skills/rewriting-technical-prose-naturally/scripts/check_prose.mjs --warn docs/virtualization/tech-log-studio/lab-entry-path-and-measurement-integrity/reference/reference-overwrite-a-forwarded-header-at-the-trust-boundary-do-not-extend-it.md",
|
||||
"exit": 0,
|
||||
"status": "PASS",
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"generation": 1,
|
||||
"at": "2026-09-21T02:24:48+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node .agents/skills/rewriting-technical-prose-naturally/scripts/style_profile.mjs docs/virtualization/tech-log-studio/lab-entry-path-and-measurement-integrity/reference/reference-overwrite-a-forwarded-header-at-the-trust-boundary-do-not-extend-it.md",
|
||||
"exit": 1,
|
||||
"status": "FAIL",
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"generation": 1,
|
||||
"at": "2026-09-21T02:24:48+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node --experimental-transform-types .agents/skills/writing-tech-log-records/scripts/check_body.mjs runs/virtualization/2026-09-21-0218-remediation-09-reference-overwrite-a-forwarded-header-at-the-trust-boundary-do-not-extend-it/stage/S5/studio-body.md --frontend /shared/codebase/tech-log-frontend",
|
||||
"exit": 0,
|
||||
"status": "PASS",
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"generation": 1,
|
||||
"at": "2026-09-21T02:24:48+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node .agents/skills/writing-tech-log-records/scripts/check_evidence.mjs virtualization --repo",
|
||||
"exit": 3,
|
||||
"status": "UNVERIFIABLE",
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"generation": 1,
|
||||
"at": "2026-09-21T02:24:49+00:00",
|
||||
"semanticId": "evidence-repo",
|
||||
"reason": "source repositories unavailable on current machine",
|
||||
"acceptedByProjectReview": true
|
||||
}
|
||||
],
|
||||
"notes": "대규모 문체 재작성 없이 stale fact/relation만 현재 evidence에 맞췄다. style_profile은 측정값으로만 기록했다.",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-21T02:24:49+00:00",
|
||||
"elapsedSeconds": 1,
|
||||
"generation": 1,
|
||||
"owner": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"finishedBy": "chatgpt-virtualization-20260921-remediation-v5"
|
||||
},
|
||||
{
|
||||
"id": "S6",
|
||||
"name": "일한 사람의 목소리",
|
||||
"skill": "writing-as-the-person-who-did-it",
|
||||
"runBy": "voice-writer",
|
||||
"status": "DONE",
|
||||
"skipReason": "",
|
||||
"skillEcho": "찾은 것이 없으면 **이 스킬은 여기서 끝난다.** 없는 목소리를 채우지 않는다.",
|
||||
"skillRevision": "edd45dfec66d155a621cd41186b83b5582f2244c",
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
"docs/virtualization/tech-log-studio/lab-entry-path-and-measurement-integrity/reference/reference-overwrite-a-forwarded-header-at-the-trust-boundary-do-not-extend-it.md"
|
||||
],
|
||||
"gates": [
|
||||
{
|
||||
"cmd": "node .agents/skills/writing-as-the-person-who-did-it/scripts/check_voice.mjs docs/virtualization/tech-log-studio/lab-entry-path-and-measurement-integrity/reference/reference-overwrite-a-forwarded-header-at-the-trust-boundary-do-not-extend-it.md",
|
||||
"exit": 0,
|
||||
"status": "PASS",
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"generation": 1,
|
||||
"at": "2026-09-21T02:24:49+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node .agents/skills/rewriting-technical-prose-naturally/scripts/check_prose.mjs --warn docs/virtualization/tech-log-studio/lab-entry-path-and-measurement-integrity/reference/reference-overwrite-a-forwarded-header-at-the-trust-boundary-do-not-extend-it.md",
|
||||
"exit": 0,
|
||||
"status": "PASS",
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"generation": 1,
|
||||
"at": "2026-09-21T02:24:49+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node --experimental-transform-types .agents/skills/writing-tech-log-records/scripts/check_body.mjs runs/virtualization/2026-09-21-0218-remediation-09-reference-overwrite-a-forwarded-header-at-the-trust-boundary-do-not-extend-it/stage/S6/studio-body.md --frontend /shared/codebase/tech-log-frontend",
|
||||
"exit": 0,
|
||||
"status": "PASS",
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"generation": 1,
|
||||
"at": "2026-09-21T02:24:49+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node .agents/skills/writing-tech-log-records/scripts/check_evidence.mjs virtualization --repo",
|
||||
"exit": 3,
|
||||
"status": "UNVERIFIABLE",
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"generation": 1,
|
||||
"at": "2026-09-21T02:24:49+00:00",
|
||||
"semanticId": "evidence-repo",
|
||||
"reason": "source repositories unavailable on current machine",
|
||||
"acceptedByProjectReview": true
|
||||
}
|
||||
],
|
||||
"notes": "상류 SSOT/evidence를 다시 보며 실제 작업 흔적이 없는 서사는 추가하지 않았다.",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-21T02:24:49+00:00",
|
||||
"elapsedSeconds": 0,
|
||||
"generation": 1,
|
||||
"owner": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"finishedBy": "chatgpt-virtualization-20260921-remediation-v5"
|
||||
},
|
||||
{
|
||||
"id": "S7",
|
||||
"name": "Studio 저장",
|
||||
"skill": "publishing-tech-log-to-studio",
|
||||
"runBy": "studio-validator",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "이번 remediation에서 Studio import/save는 요청되지 않았다. 저장소 산출물과 검증 영수증만 갱신한다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": "862e502af3e956b49ccd2ae0a8de3fd32f90df9c",
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"gates": [],
|
||||
"notes": "",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-21T02:24:49+00:00",
|
||||
"elapsedSeconds": 0,
|
||||
"finishedBy": "chatgpt-virtualization-20260921-remediation-v5"
|
||||
}
|
||||
],
|
||||
"qualityReviews": {
|
||||
"commandPedagogy": {
|
||||
"initialAnalysis": {
|
||||
"cmd": "python3 scripts/check-command-pedagogy.py --mode reference docs/virtualization/tech-log-studio/lab-entry-path-and-measurement-integrity/reference/reference-overwrite-a-forwarded-header-at-the-trust-boundary-do-not-extend-it.md -o runs/virtualization/2026-09-21-0218-remediation-09-reference-overwrite-a-forwarded-header-at-the-trust-boundary-do-not-extend-it/stage/S3/command-initial.json",
|
||||
"exit": 0,
|
||||
"shellBlocks": 0,
|
||||
"findings": 0,
|
||||
"majorFindings": 0,
|
||||
"artifact": {
|
||||
"path": "runs/virtualization/2026-09-21-0218-remediation-09-reference-overwrite-a-forwarded-header-at-the-trust-boundary-do-not-extend-it/stage/S3/command-initial.json",
|
||||
"sha256": "9edf2459b8c3cb9ff53056c64808d71e55a1b96ef41a5a6dbc3dc606ee2b3082"
|
||||
}
|
||||
},
|
||||
"finalAnalysis": {
|
||||
"cmd": "python3 scripts/check-command-pedagogy.py --mode reference docs/virtualization/tech-log-studio/lab-entry-path-and-measurement-integrity/reference/reference-overwrite-a-forwarded-header-at-the-trust-boundary-do-not-extend-it.md -o runs/virtualization/2026-09-21-0218-remediation-09-reference-overwrite-a-forwarded-header-at-the-trust-boundary-do-not-extend-it/stage/S6/command-final.json",
|
||||
"exit": 0,
|
||||
"shellBlocks": 0,
|
||||
"findings": 0,
|
||||
"majorFindings": 0,
|
||||
"artifact": {
|
||||
"path": "runs/virtualization/2026-09-21-0218-remediation-09-reference-overwrite-a-forwarded-header-at-the-trust-boundary-do-not-extend-it/stage/S6/command-final.json",
|
||||
"sha256": "9edf2459b8c3cb9ff53056c64808d71e55a1b96ef41a5a6dbc3dc606ee2b3082"
|
||||
}
|
||||
},
|
||||
"planner": {
|
||||
"runBy": "command-pedagogy-planner",
|
||||
"skill": "writing-practitioner-guides",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "initial deterministic analysis finding이 0이다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": null,
|
||||
"notes": "현재 reference-mode 분석을 확인했다.",
|
||||
"artifact": null
|
||||
},
|
||||
"editor": {
|
||||
"runBy": "command-pedagogy-editor",
|
||||
"skill": "writing-practitioner-guides",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "initial deterministic analysis finding이 0이다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": null,
|
||||
"notes": "현재 reference-mode 분석을 확인했다.",
|
||||
"artifact": null
|
||||
},
|
||||
"reviewer": {
|
||||
"runBy": "command-pedagogy-reviewer",
|
||||
"skill": "writing-practitioner-guides",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "final publication에 shell/CLI block이 없다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": null,
|
||||
"verdict": null,
|
||||
"notes": "final deterministic analysis에서 shellBlocks=0을 확인했다.",
|
||||
"artifact": null,
|
||||
"sourceSha256": null
|
||||
}
|
||||
},
|
||||
"technicalEvidence": {
|
||||
"runBy": "fact-reviewer",
|
||||
"status": "DONE",
|
||||
"skipReason": "",
|
||||
"verdict": "PASS",
|
||||
"notes": "current truth/evidence/provenance를 SSOT와 역대조했다. live source는 실제 --repo 결과 exit 3이며 UNVERIFIABLE로 보존했다.",
|
||||
"sourceSha256": "3ac4c9254e1984f5966ee0de4417440e7a46e0867a1d3b4cd4960ff2b9019da5",
|
||||
"liveSourceReconciliation": "UNVERIFIABLE",
|
||||
"liveSourceReason": "source repositories unavailable on current machine",
|
||||
"acceptedByProjectReview": true,
|
||||
"artifact": {
|
||||
"path": "runs/virtualization/2026-09-21-0218-remediation-09-reference-overwrite-a-forwarded-header-at-the-trust-boundary-do-not-extend-it/stage/quality/technical-evidence-review.json",
|
||||
"sha256": "f8ce4d6bf8cfbbd355cb9f28ab8649860c213b95e574630d7d4849e830297c73"
|
||||
}
|
||||
}
|
||||
},
|
||||
"riders": [],
|
||||
"sessions": [
|
||||
{
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"openedAt": "2026-09-21T02:24:47+00:00"
|
||||
},
|
||||
{
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"stage": "S3",
|
||||
"generation": 1,
|
||||
"beganAt": "2026-09-21T02:24:47+00:00"
|
||||
},
|
||||
{
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"stage": "S5",
|
||||
"generation": 1,
|
||||
"beganAt": "2026-09-21T02:24:48+00:00"
|
||||
},
|
||||
{
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"stage": "S6",
|
||||
"generation": 1,
|
||||
"beganAt": "2026-09-21T02:24:49+00:00"
|
||||
}
|
||||
],
|
||||
"revision": 23,
|
||||
"updatedAt": "2026-09-21T02:24:49+00:00",
|
||||
"executionEnvironment": {
|
||||
"mode": "current-remediation-contract-replay",
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"agentToolAvailable": false,
|
||||
"note": "별도 subagent 실행 도구가 현재 환경에 없어 현재 세션이 각 .claude/agents 역할 정의와 연결된 SKILL/contract를 직접 읽고 역할을 분리해 수행했다. runBy는 계약 역할명이며 별도 프로세스 실행을 주장하지 않는다."
|
||||
}
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"authority": "deterministic",
|
||||
"gate": "command-pedagogy-signals",
|
||||
"section_id": "docs/virtualization/tech-log-studio/lab-entry-path-and-measurement-integrity/reference/reference-overwrite-a-forwarded-header-at-the-trust-boundary-do-not-extend-it.md",
|
||||
"mode": "reference",
|
||||
"source_sha256": "3ac4c9254e1984f5966ee0de4417440e7a46e0867a1d3b4cd4960ff2b9019da5",
|
||||
"result": "PASS",
|
||||
"requires_editor": false,
|
||||
"blocks": [],
|
||||
"findings": [],
|
||||
"extensions": {
|
||||
"command_like_text_blocks": []
|
||||
}
|
||||
}
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
---
|
||||
kind: REFERENCE
|
||||
slug: overwrite-a-forwarded-header-at-the-trust-boundary-do-not-extend-it
|
||||
title: 신뢰 경계에서는 forwarded 헤더를 덧붙이지 않고 덮어쓴다
|
||||
topic: lab-entry-path-and-measurement-integrity
|
||||
topicName: 실험대의 진입 경로
|
||||
project: virtualization
|
||||
status: 게시 전
|
||||
sourceRevision: import-head 9465582b5d1630eb4ae7c4e078021486919bf6b6 · uncommitted-working-tree snapshot
|
||||
source:
|
||||
- final/document.md#209-nginx-keycloak-lab-conf-스티키-스위치와-신뢰-경계
|
||||
- final/document.md#259-x-forwarded--와-신뢰-경계
|
||||
- final/document.md#207-lab-edge-dnat-nft-dnat-파일이-자기-안에-적어-둔-네-가지
|
||||
- final/document.md#206-이-부의-출처와-범위
|
||||
---
|
||||
|
||||
# 신뢰 경계에서는 forwarded 헤더를 덧붙이지 않고 덮어쓴다
|
||||
|
||||
맨 바깥 프록시는 클라이언트가 보낸 `X-Forwarded-For` 를 버리고 자기가 본 주소로 덮어쓴다. nginx 에서는 `$proxy_add_x_forwarded_for` 가 아니라 `$remote_addr` 를 쓴다는 뜻이다. 커널 쪽에도 같은 계약의 절반이 있어서, 경계 앞에서는 출발지 주소를 바꾸지 않는다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **L7 이 두 겹인 이유와, 진입점 하나를 이중화하려 할 때 끝나지 않는 재귀**
|
||||
이 기준이 말하는 경계가 두 홉 가운데 어느 쪽인지를 그 글이 먼저 정한다.
|
||||
- **엣지 게스트에 nginx 를 세우고 호스트 DNAT 으로 밖에서 들어오는 길을 연다**
|
||||
이 기준이 적용되는 설정을 실제로 쓰는 절차다. 헤더 네 줄과 DNAT 규칙이 거기 있다.
|
||||
- **엣지 nginx 를 호스트에서 게스트 VM 으로 옮긴다 — 성능이 아니라 더러워지는 층의 격리**
|
||||
그 이동으로 경계가 호스트에서 엣지 게스트로 옮겨 갔다.
|
||||
- **공개 터널을 쓰지 않고 tailnet 직결로 둔다 — 홉이 하나 늘면 재려던 계약이 오염된다**
|
||||
경계 앞에 한 겹을 더 세우지 않기로 한 결정이다. 그 결정의 이유가 이 실험대가 재려는 계약이다.
|
||||
- **호스트 안에서는 404, 밖에서는 connection refused — 앞 체인의 accept 가 libvirt 의 reject 를 막지 못했다**
|
||||
경계 프록시가 아닌 경로를 막는 일이 방화벽의 몫이라는 것을 그 기록이 보여 준다.
|
||||
|
||||
## 목적
|
||||
|
||||
`X-Forwarded-For` 는 누구나 보낼 수 있는 평범한 HTTP 헤더다. 값을 믿을 만하게 만드는 것은 헤더 이름이 아니라 그 값을 쓴 주체이고, 쓰는 주체를 하나로 좁히는 것이 이 기준이다. 맨 바깥 프록시가 클라이언트의 값을 이어 붙이면 위조된 값이 사슬 앞에 남고, 그러면 뒤쪽의 어느 것도 그 헤더를 근거로 쓸 수 없다.
|
||||
|
||||
설정 정본이 그 줄 위에 이유를 적어 두었다. 원문은 영어이고 그대로 옮긴다.
|
||||
|
||||
$remote_addr, not $proxy_add_x_forwarded_for. This is the trust boundary: a client-supplied X-Forwarded-For must be discarded, not extended, or nothing downstream can rely on the value.
|
||||
|
||||
§206 은 설정 원본의 주석 59줄을 대조하면서 이 저장소 어디에도 없는 것 둘을 셌고, 이 설명이 그중 하나다. §189 과 §190 은 그 줄을 옮겨 적기만 하고 왜 그 형태여야 하는지는 적지 않았다.
|
||||
|
||||
## 규칙
|
||||
|
||||
### 1. 맨 바깥 프록시는 클라이언트가 보낸 `X-Forwarded-For` 를 버리고 자기가 본 주소로 덮어쓴다
|
||||
|
||||
nginx 에서는 `proxy_set_header X-Forwarded-For $remote_addr;` 이고 `$proxy_add_x_forwarded_for` 가 아니다. 둘의 차이는 클라이언트가 보낸 값을 사슬 앞에 남기느냐 버리느냐에 있다. `proxy_set_header` 가 값을 이어 붙이지 않고 덮어쓰는 지시어인 이유도 여기에 있다.
|
||||
|
||||
### 2. `X-Forwarded-Proto` 와 `X-Forwarded-Host` 도 같은 규칙으로 다룬다
|
||||
|
||||
셋 다 프록시가 뒤쪽 서버에게 원래 클라이언트가 어땠는지 알려 주는 관례적 헤더이고, 클라이언트가 임의로 보낼 수 있다. 하나만 덮어쓰고 나머지를 이어 붙이면, 뒤쪽 서버는 클라이언트가 보낸 값을 그대로 근거로 삼는다. 공격자가 `X-Forwarded-Host` 를 조작해 인증 흐름을 자기 도메인으로 돌릴 수 있는 것도 이 경로다.
|
||||
|
||||
### 3. 경계 앞에서는 출발지 주소를 바꾸지 않는다
|
||||
|
||||
호스트의 DNAT 파일은 DNAT 만 걸고 SNAT 는 걸지 않는다. 파일 자신이 「DNAT only, never SNAT」이라고 적고 그 이유를 바로 뒤에 붙였다. masquerade 를 걸면 출발지가 다시 쓰여 엣지가 모든 클라이언트를 `192.168.122.1` 로 보게 되고, 그러면 이 실험대가 재는 `X-Forwarded-For` 계약이 조용히 무효가 된다. SNAT 없이도 응답이 돌아오는 것은 게스트의 기본 경로가 호스트이기 때문이다. 응답이 호스트를 다시 지나고, conntrack 이 변환을 알아서 되돌린다. 경계 앞에서는 출발지를 바꾸지 않고 경계에서는 클라이언트가 준 값을 버린다는 두 문장이 한 계약이다.
|
||||
|
||||
### 4. 경계가 어디인지는 「그 앞에 우리가 통제하지 않는 것이 있는가」로 가른다
|
||||
|
||||
이름에 엣지가 붙었다고 해서 그 프록시가 경계가 되는 것은 아니다. 우리가 통제하지 않는 쪽에서 요청을 직접 받는 첫 프록시가 경계이고, 그 한 대만 이 기준을 따른다. 이 실험대에서는 엣지 게스트의 nginx 가 그렇고, 클라우드라면 ALB 가 같은 곳에 선다.
|
||||
|
||||
### 5. 프록시를 한 겹 더 넣거나 옮길 때 이 기준을 다시 적용한다
|
||||
|
||||
설정을 처음 쓸 때보다 구성을 바꿀 때 이 기준이 실제로 쓰인다. 이 실험대가 엣지를 호스트에서 게스트로 옮겼을 때 경계도 함께 옮겨 갔다. 공개 터널을 앞에 붙였다면 경계가 Cloudflare 엣지로 한 번 더 옮겨 갔을 것이다. 옮겨 간 뒤에도 옛 경계가 클라이언트의 값을 이어 붙이고 있으면 사슬은 다시 믿을 수 없게 된다.
|
||||
|
||||
## 적용 조건
|
||||
|
||||
- 신뢰 경계에 선 맨 바깥 프록시 한 대. 이 실험대에서는 엣지 게스트의 nginx 이고, 클라우드라면 ALB 가 같은 곳에 선다
|
||||
- 대상 헤더 : `X-Forwarded-For` · `X-Forwarded-Proto` · `X-Forwarded-Host` 셋. 셋 다 관례적 헤더이고 클라이언트가 임의로 보낼 수 있다
|
||||
- 적용 시점 : 설정을 처음 쓸 때가 아니라 프록시를 한 겹 더 넣거나 옮길 때
|
||||
- 경계 판정 : 그 프록시 앞에 우리가 통제하지 않는 것이 있을 때
|
||||
|
||||
## 예외
|
||||
|
||||
경계 안쪽의 두 번째 홉은 반대다. 이 실험대의 Traefik 처럼 신뢰하는 프록시 뒤에 서는 것은 앞이 쓴 값을 이어받아야 하고, 거기서 덮어쓰면 원래 클라이언트 주소가 없어진다. `$proxy_add_x_forwarded_for` 자체가 틀린 값은 아니고, 쓰는 곳이 따로 정해져 있다. 경계에서 쓰면 클라이언트가 위조한 값을 그대로 통과시킨다.
|
||||
|
||||
이 실험대는 그 두 번째 홉을 아직 재지 않았다. 저장소에 적힌 forwarded 헤더 계약이 1홉을 가정한 것이라 `nginx → Traefik` 2홉과 어긋나고, Traefik 이 앞이 쓴 값을 덮어쓰는지 신뢰하는지 이어 붙이는지에 따라 결과가 갈린다. 이 실험대가 가장 먼저 실측할 항목이 그것이다.
|
||||
|
||||
L4 통과 구성에는 적용되지 않는다. NLB 처럼 TCP 를 그대로 흘리면 원본 IP 가 보존되어 헤더가 아예 필요 없다. 그때 원본 주소를 알리는 데 쓰는 것은 PROXY protocol 이라 이 기준의 대상이 아니다.
|
||||
|
||||
경계 앞에 CDN 이나 터널이 있으면 그 공급자의 헤더가 정본이 된다. Cloudflare 라면 `CF-Connecting-IP` 이고, 그때는 그 헤더를 놓고 이 기준을 다시 세운다.
|
||||
|
||||
이 기준만으로 신뢰가 완성되지 않는다. 경계 프록시를 거치지 않는 경로로 뒤쪽에 직접 닿을 수 있으면 헤더를 어떻게 쓰든 소용이 없다. 그 경로를 막는 것은 방화벽과 네트워크 배치의 일이고, 이 실험대에서는 게스트가 libvirt NAT 뒤에 있는 것이 그 몫을 한다.
|
||||
|
||||
## 예시
|
||||
|
||||
- 경계의 nginx : `proxy_set_header X-Forwarded-For $remote_addr;`
|
||||
- 경계 안쪽 두 번째 홉 : `$proxy_add_x_forwarded_for` 로 앞이 쓴 값을 이어받는다
|
||||
- 호스트 커널 : DNAT 만 걸고 masquerade 는 걸지 않는다
|
||||
- masquerade 를 걸었을 때 엣지가 보는 클라이언트 주소 : `192.168.122.1` 하나
|
||||
- 경계 앞에 Cloudflare 가 있을 때의 정본 헤더 : `CF-Connecting-IP`
|
||||
- 클라이언트가 보낸 값을 이어 붙인 사슬 : 위조된 값이 앞에 남아 뒤쪽에서 근거로 못 쓴다
|
||||
- L4 통과 구성 : 헤더가 필요 없고 PROXY protocol 을 쓴다
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
---
|
||||
kind: REFERENCE
|
||||
slug: overwrite-a-forwarded-header-at-the-trust-boundary-do-not-extend-it
|
||||
title: 신뢰 경계에서는 forwarded 헤더를 덧붙이지 않고 덮어쓴다
|
||||
topic: lab-entry-path-and-measurement-integrity
|
||||
topicName: 실험대의 진입 경로
|
||||
project: virtualization
|
||||
status: 게시 전
|
||||
sourceRevision: import-head 9465582b5d1630eb4ae7c4e078021486919bf6b6 · uncommitted-working-tree snapshot
|
||||
source:
|
||||
- final/document.md#209-nginx-keycloak-lab-conf-스티키-스위치와-신뢰-경계
|
||||
- final/document.md#259-x-forwarded--와-신뢰-경계
|
||||
- final/document.md#207-lab-edge-dnat-nft-dnat-파일이-자기-안에-적어-둔-네-가지
|
||||
- final/document.md#206-이-부의-출처와-범위
|
||||
---
|
||||
|
||||
# 신뢰 경계에서는 forwarded 헤더를 덧붙이지 않고 덮어쓴다
|
||||
|
||||
맨 바깥 프록시는 클라이언트가 보낸 `X-Forwarded-For` 를 버리고 자기가 본 주소로 덮어쓴다. nginx 에서는 `$proxy_add_x_forwarded_for` 가 아니라 `$remote_addr` 를 쓴다는 뜻이다. 커널 쪽에도 같은 계약의 절반이 있어서, 경계 앞에서는 출발지 주소를 바꾸지 않는다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **L7 이 두 겹인 이유와, 진입점 하나를 이중화하려 할 때 끝나지 않는 재귀**
|
||||
이 기준이 말하는 경계가 두 홉 가운데 어느 쪽인지를 그 글이 먼저 정한다.
|
||||
- **엣지 게스트에 nginx 를 세우고 호스트 DNAT 으로 밖에서 들어오는 길을 연다**
|
||||
이 기준이 적용되는 설정을 실제로 쓰는 절차다. 헤더 네 줄과 DNAT 규칙이 거기 있다.
|
||||
- **엣지 nginx 를 호스트에서 게스트 VM 으로 옮긴다 — 성능이 아니라 더러워지는 층의 격리**
|
||||
그 이동으로 경계가 호스트에서 엣지 게스트로 옮겨 갔다.
|
||||
- **공개 터널을 쓰지 않고 tailnet 직결로 둔다 — 홉이 하나 늘면 재려던 계약이 오염된다**
|
||||
경계 앞에 한 겹을 더 세우지 않기로 한 결정이다. 그 결정의 이유가 이 실험대가 재려는 계약이다.
|
||||
- **호스트 안에서는 404, 밖에서는 connection refused — 앞 체인의 accept 가 libvirt 의 reject 를 막지 못했다**
|
||||
경계 프록시가 아닌 경로를 막는 일이 방화벽의 몫이라는 것을 그 기록이 보여 준다.
|
||||
|
||||
## 목적
|
||||
|
||||
`X-Forwarded-For` 는 누구나 보낼 수 있는 평범한 HTTP 헤더다. 값을 믿을 만하게 만드는 것은 헤더 이름이 아니라 그 값을 쓴 주체이고, 쓰는 주체를 하나로 좁히는 것이 이 기준이다. 맨 바깥 프록시가 클라이언트의 값을 이어 붙이면 위조된 값이 사슬 앞에 남고, 그러면 뒤쪽의 어느 것도 그 헤더를 근거로 쓸 수 없다.
|
||||
|
||||
설정 정본이 그 줄 위에 이유를 적어 두었다. 원문은 영어이고 그대로 옮긴다.
|
||||
|
||||
$remote_addr, not $proxy_add_x_forwarded_for. This is the trust boundary: a client-supplied X-Forwarded-For must be discarded, not extended, or nothing downstream can rely on the value.
|
||||
|
||||
§206 은 설정 원본의 주석 59줄을 대조하면서 이 저장소 어디에도 없는 것 둘을 셌고, 이 설명이 그중 하나다. §189 과 §190 은 그 줄을 옮겨 적기만 하고 왜 그 형태여야 하는지는 적지 않았다.
|
||||
|
||||
## 규칙
|
||||
|
||||
### 1. 맨 바깥 프록시는 클라이언트가 보낸 `X-Forwarded-For` 를 버리고 자기가 본 주소로 덮어쓴다
|
||||
|
||||
nginx 에서는 `proxy_set_header X-Forwarded-For $remote_addr;` 이고 `$proxy_add_x_forwarded_for` 가 아니다. 둘의 차이는 클라이언트가 보낸 값을 사슬 앞에 남기느냐 버리느냐에 있다. `proxy_set_header` 가 값을 이어 붙이지 않고 덮어쓰는 지시어인 이유도 여기에 있다.
|
||||
|
||||
### 2. `X-Forwarded-Proto` 와 `X-Forwarded-Host` 도 같은 규칙으로 다룬다
|
||||
|
||||
셋 다 프록시가 뒤쪽 서버에게 원래 클라이언트가 어땠는지 알려 주는 관례적 헤더이고, 클라이언트가 임의로 보낼 수 있다. 하나만 덮어쓰고 나머지를 이어 붙이면, 뒤쪽 서버는 클라이언트가 보낸 값을 그대로 근거로 삼는다. 공격자가 `X-Forwarded-Host` 를 조작해 인증 흐름을 자기 도메인으로 돌릴 수 있는 것도 이 경로다.
|
||||
|
||||
### 3. 경계 앞에서는 출발지 주소를 바꾸지 않는다
|
||||
|
||||
호스트의 DNAT 파일은 DNAT 만 걸고 SNAT 는 걸지 않는다. 파일 자신이 「DNAT only, never SNAT」이라고 적고 그 이유를 바로 뒤에 붙였다. masquerade 를 걸면 출발지가 다시 쓰여 엣지가 모든 클라이언트를 `192.168.122.1` 로 보게 되고, 그러면 이 실험대가 재는 `X-Forwarded-For` 계약이 조용히 무효가 된다. SNAT 없이도 응답이 돌아오는 것은 게스트의 기본 경로가 호스트이기 때문이다. 응답이 호스트를 다시 지나고, conntrack 이 변환을 알아서 되돌린다. 경계 앞에서는 출발지를 바꾸지 않고 경계에서는 클라이언트가 준 값을 버린다는 두 문장이 한 계약이다.
|
||||
|
||||
### 4. 경계가 어디인지는 「그 앞에 우리가 통제하지 않는 것이 있는가」로 가른다
|
||||
|
||||
이름에 엣지가 붙었다고 해서 그 프록시가 경계가 되는 것은 아니다. 우리가 통제하지 않는 쪽에서 요청을 직접 받는 첫 프록시가 경계이고, 그 한 대만 이 기준을 따른다. 이 실험대에서는 엣지 게스트의 nginx 가 그렇고, 클라우드라면 ALB 가 같은 곳에 선다.
|
||||
|
||||
### 5. 프록시를 한 겹 더 넣거나 옮길 때 이 기준을 다시 적용한다
|
||||
|
||||
설정을 처음 쓸 때보다 구성을 바꿀 때 이 기준이 실제로 쓰인다. 이 실험대가 엣지를 호스트에서 게스트로 옮겼을 때 경계도 함께 옮겨 갔다. 공개 터널을 앞에 붙였다면 경계가 Cloudflare 엣지로 한 번 더 옮겨 갔을 것이다. 옮겨 간 뒤에도 옛 경계가 클라이언트의 값을 이어 붙이고 있으면 사슬은 다시 믿을 수 없게 된다.
|
||||
|
||||
## 적용 조건
|
||||
|
||||
- 신뢰 경계에 선 맨 바깥 프록시 한 대. 이 실험대에서는 엣지 게스트의 nginx 이고, 클라우드라면 ALB 가 같은 곳에 선다
|
||||
- 대상 헤더 : `X-Forwarded-For` · `X-Forwarded-Proto` · `X-Forwarded-Host` 셋. 셋 다 관례적 헤더이고 클라이언트가 임의로 보낼 수 있다
|
||||
- 적용 시점 : 설정을 처음 쓸 때가 아니라 프록시를 한 겹 더 넣거나 옮길 때
|
||||
- 경계 판정 : 그 프록시 앞에 우리가 통제하지 않는 것이 있을 때
|
||||
|
||||
## 예외
|
||||
|
||||
경계 안쪽의 두 번째 홉은 반대다. 이 실험대의 Traefik 처럼 신뢰하는 프록시 뒤에 서는 것은 앞이 쓴 값을 이어받아야 하고, 거기서 덮어쓰면 원래 클라이언트 주소가 없어진다. `$proxy_add_x_forwarded_for` 자체가 틀린 값은 아니고, 쓰는 곳이 따로 정해져 있다. 경계에서 쓰면 클라이언트가 위조한 값을 그대로 통과시킨다.
|
||||
|
||||
이 실험대는 그 두 번째 홉을 아직 재지 않았다. 저장소에 적힌 forwarded 헤더 계약이 1홉을 가정한 것이라 `nginx → Traefik` 2홉과 어긋나고, Traefik 이 앞이 쓴 값을 덮어쓰는지 신뢰하는지 이어 붙이는지에 따라 결과가 갈린다. 이 실험대가 가장 먼저 실측할 항목이 그것이다.
|
||||
|
||||
L4 통과 구성에는 적용되지 않는다. NLB 처럼 TCP 를 그대로 흘리면 원본 IP 가 보존되어 헤더가 아예 필요 없다. 그때 원본 주소를 알리는 데 쓰는 것은 PROXY protocol 이라 이 기준의 대상이 아니다.
|
||||
|
||||
경계 앞에 CDN 이나 터널이 있으면 그 공급자의 헤더가 정본이 된다. Cloudflare 라면 `CF-Connecting-IP` 이고, 그때는 그 헤더를 놓고 이 기준을 다시 세운다.
|
||||
|
||||
이 기준만으로 신뢰가 완성되지 않는다. 경계 프록시를 거치지 않는 경로로 뒤쪽에 직접 닿을 수 있으면 헤더를 어떻게 쓰든 소용이 없다. 그 경로를 막는 것은 방화벽과 네트워크 배치의 일이고, 이 실험대에서는 게스트가 libvirt NAT 뒤에 있는 것이 그 몫을 한다.
|
||||
|
||||
## 예시
|
||||
|
||||
- 경계의 nginx : `proxy_set_header X-Forwarded-For $remote_addr;`
|
||||
- 경계 안쪽 두 번째 홉 : `$proxy_add_x_forwarded_for` 로 앞이 쓴 값을 이어받는다
|
||||
- 호스트 커널 : DNAT 만 걸고 masquerade 는 걸지 않는다
|
||||
- masquerade 를 걸었을 때 엣지가 보는 클라이언트 주소 : `192.168.122.1` 하나
|
||||
- 경계 앞에 Cloudflare 가 있을 때의 정본 헤더 : `CF-Connecting-IP`
|
||||
- 클라이언트가 보낸 값을 이어 붙인 사슬 : 위조된 값이 앞에 남아 뒤쪽에서 근거로 못 쓴다
|
||||
- L4 통과 구성 : 헤더가 필요 없고 PROXY protocol 을 쓴다
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"authority": "deterministic",
|
||||
"gate": "command-pedagogy-signals",
|
||||
"section_id": "docs/virtualization/tech-log-studio/lab-entry-path-and-measurement-integrity/reference/reference-overwrite-a-forwarded-header-at-the-trust-boundary-do-not-extend-it.md",
|
||||
"mode": "reference",
|
||||
"source_sha256": "3ac4c9254e1984f5966ee0de4417440e7a46e0867a1d3b4cd4960ff2b9019da5",
|
||||
"result": "PASS",
|
||||
"requires_editor": false,
|
||||
"blocks": [],
|
||||
"findings": [],
|
||||
"extensions": {
|
||||
"command_like_text_blocks": []
|
||||
}
|
||||
}
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
---
|
||||
kind: REFERENCE
|
||||
slug: overwrite-a-forwarded-header-at-the-trust-boundary-do-not-extend-it
|
||||
title: 신뢰 경계에서는 forwarded 헤더를 덧붙이지 않고 덮어쓴다
|
||||
topic: lab-entry-path-and-measurement-integrity
|
||||
topicName: 실험대의 진입 경로
|
||||
project: virtualization
|
||||
status: 게시 전
|
||||
sourceRevision: import-head 9465582b5d1630eb4ae7c4e078021486919bf6b6 · uncommitted-working-tree snapshot
|
||||
source:
|
||||
- final/document.md#209-nginx-keycloak-lab-conf-스티키-스위치와-신뢰-경계
|
||||
- final/document.md#259-x-forwarded--와-신뢰-경계
|
||||
- final/document.md#207-lab-edge-dnat-nft-dnat-파일이-자기-안에-적어-둔-네-가지
|
||||
- final/document.md#206-이-부의-출처와-범위
|
||||
---
|
||||
|
||||
# 신뢰 경계에서는 forwarded 헤더를 덧붙이지 않고 덮어쓴다
|
||||
|
||||
맨 바깥 프록시는 클라이언트가 보낸 `X-Forwarded-For` 를 버리고 자기가 본 주소로 덮어쓴다. nginx 에서는 `$proxy_add_x_forwarded_for` 가 아니라 `$remote_addr` 를 쓴다는 뜻이다. 커널 쪽에도 같은 계약의 절반이 있어서, 경계 앞에서는 출발지 주소를 바꾸지 않는다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **L7 이 두 겹인 이유와, 진입점 하나를 이중화하려 할 때 끝나지 않는 재귀**
|
||||
이 기준이 말하는 경계가 두 홉 가운데 어느 쪽인지를 그 글이 먼저 정한다.
|
||||
- **엣지 게스트에 nginx 를 세우고 호스트 DNAT 으로 밖에서 들어오는 길을 연다**
|
||||
이 기준이 적용되는 설정을 실제로 쓰는 절차다. 헤더 네 줄과 DNAT 규칙이 거기 있다.
|
||||
- **엣지 nginx 를 호스트에서 게스트 VM 으로 옮긴다 — 성능이 아니라 더러워지는 층의 격리**
|
||||
그 이동으로 경계가 호스트에서 엣지 게스트로 옮겨 갔다.
|
||||
- **공개 터널을 쓰지 않고 tailnet 직결로 둔다 — 홉이 하나 늘면 재려던 계약이 오염된다**
|
||||
경계 앞에 한 겹을 더 세우지 않기로 한 결정이다. 그 결정의 이유가 이 실험대가 재려는 계약이다.
|
||||
- **호스트 안에서는 404, 밖에서는 connection refused — 앞 체인의 accept 가 libvirt 의 reject 를 막지 못했다**
|
||||
경계 프록시가 아닌 경로를 막는 일이 방화벽의 몫이라는 것을 그 기록이 보여 준다.
|
||||
|
||||
## 목적
|
||||
|
||||
`X-Forwarded-For` 는 누구나 보낼 수 있는 평범한 HTTP 헤더다. 값을 믿을 만하게 만드는 것은 헤더 이름이 아니라 그 값을 쓴 주체이고, 쓰는 주체를 하나로 좁히는 것이 이 기준이다. 맨 바깥 프록시가 클라이언트의 값을 이어 붙이면 위조된 값이 사슬 앞에 남고, 그러면 뒤쪽의 어느 것도 그 헤더를 근거로 쓸 수 없다.
|
||||
|
||||
설정 정본이 그 줄 위에 이유를 적어 두었다. 원문은 영어이고 그대로 옮긴다.
|
||||
|
||||
$remote_addr, not $proxy_add_x_forwarded_for. This is the trust boundary: a client-supplied X-Forwarded-For must be discarded, not extended, or nothing downstream can rely on the value.
|
||||
|
||||
§206 은 설정 원본의 주석 59줄을 대조하면서 이 저장소 어디에도 없는 것 둘을 셌고, 이 설명이 그중 하나다. §189 과 §190 은 그 줄을 옮겨 적기만 하고 왜 그 형태여야 하는지는 적지 않았다.
|
||||
|
||||
## 규칙
|
||||
|
||||
### 1. 맨 바깥 프록시는 클라이언트가 보낸 `X-Forwarded-For` 를 버리고 자기가 본 주소로 덮어쓴다
|
||||
|
||||
nginx 에서는 `proxy_set_header X-Forwarded-For $remote_addr;` 이고 `$proxy_add_x_forwarded_for` 가 아니다. 둘의 차이는 클라이언트가 보낸 값을 사슬 앞에 남기느냐 버리느냐에 있다. `proxy_set_header` 가 값을 이어 붙이지 않고 덮어쓰는 지시어인 이유도 여기에 있다.
|
||||
|
||||
### 2. `X-Forwarded-Proto` 와 `X-Forwarded-Host` 도 같은 규칙으로 다룬다
|
||||
|
||||
셋 다 프록시가 뒤쪽 서버에게 원래 클라이언트가 어땠는지 알려 주는 관례적 헤더이고, 클라이언트가 임의로 보낼 수 있다. 하나만 덮어쓰고 나머지를 이어 붙이면, 뒤쪽 서버는 클라이언트가 보낸 값을 그대로 근거로 삼는다. 공격자가 `X-Forwarded-Host` 를 조작해 인증 흐름을 자기 도메인으로 돌릴 수 있는 것도 이 경로다.
|
||||
|
||||
### 3. 경계 앞에서는 출발지 주소를 바꾸지 않는다
|
||||
|
||||
호스트의 DNAT 파일은 DNAT 만 걸고 SNAT 는 걸지 않는다. 파일 자신이 「DNAT only, never SNAT」이라고 적고 그 이유를 바로 뒤에 붙였다. masquerade 를 걸면 출발지가 다시 쓰여 엣지가 모든 클라이언트를 `192.168.122.1` 로 보게 되고, 그러면 이 실험대가 재는 `X-Forwarded-For` 계약이 조용히 무효가 된다. SNAT 없이도 응답이 돌아오는 것은 게스트의 기본 경로가 호스트이기 때문이다. 응답이 호스트를 다시 지나고, conntrack 이 변환을 알아서 되돌린다. 경계 앞에서는 출발지를 바꾸지 않고 경계에서는 클라이언트가 준 값을 버린다는 두 문장이 한 계약이다.
|
||||
|
||||
### 4. 경계가 어디인지는 「그 앞에 우리가 통제하지 않는 것이 있는가」로 가른다
|
||||
|
||||
이름에 엣지가 붙었다고 해서 그 프록시가 경계가 되는 것은 아니다. 우리가 통제하지 않는 쪽에서 요청을 직접 받는 첫 프록시가 경계이고, 그 한 대만 이 기준을 따른다. 이 실험대에서는 엣지 게스트의 nginx 가 그렇고, 클라우드라면 ALB 가 같은 곳에 선다.
|
||||
|
||||
### 5. 프록시를 한 겹 더 넣거나 옮길 때 이 기준을 다시 적용한다
|
||||
|
||||
설정을 처음 쓸 때보다 구성을 바꿀 때 이 기준이 실제로 쓰인다. 이 실험대가 엣지를 호스트에서 게스트로 옮겼을 때 경계도 함께 옮겨 갔다. 공개 터널을 앞에 붙였다면 경계가 Cloudflare 엣지로 한 번 더 옮겨 갔을 것이다. 옮겨 간 뒤에도 옛 경계가 클라이언트의 값을 이어 붙이고 있으면 사슬은 다시 믿을 수 없게 된다.
|
||||
|
||||
## 적용 조건
|
||||
|
||||
- 신뢰 경계에 선 맨 바깥 프록시 한 대. 이 실험대에서는 엣지 게스트의 nginx 이고, 클라우드라면 ALB 가 같은 곳에 선다
|
||||
- 대상 헤더 : `X-Forwarded-For` · `X-Forwarded-Proto` · `X-Forwarded-Host` 셋. 셋 다 관례적 헤더이고 클라이언트가 임의로 보낼 수 있다
|
||||
- 적용 시점 : 설정을 처음 쓸 때가 아니라 프록시를 한 겹 더 넣거나 옮길 때
|
||||
- 경계 판정 : 그 프록시 앞에 우리가 통제하지 않는 것이 있을 때
|
||||
|
||||
## 예외
|
||||
|
||||
경계 안쪽의 두 번째 홉은 반대다. 이 실험대의 Traefik 처럼 신뢰하는 프록시 뒤에 서는 것은 앞이 쓴 값을 이어받아야 하고, 거기서 덮어쓰면 원래 클라이언트 주소가 없어진다. `$proxy_add_x_forwarded_for` 자체가 틀린 값은 아니고, 쓰는 곳이 따로 정해져 있다. 경계에서 쓰면 클라이언트가 위조한 값을 그대로 통과시킨다.
|
||||
|
||||
이 실험대는 그 두 번째 홉을 아직 재지 않았다. 저장소에 적힌 forwarded 헤더 계약이 1홉을 가정한 것이라 `nginx → Traefik` 2홉과 어긋나고, Traefik 이 앞이 쓴 값을 덮어쓰는지 신뢰하는지 이어 붙이는지에 따라 결과가 갈린다. 이 실험대가 가장 먼저 실측할 항목이 그것이다.
|
||||
|
||||
L4 통과 구성에는 적용되지 않는다. NLB 처럼 TCP 를 그대로 흘리면 원본 IP 가 보존되어 헤더가 아예 필요 없다. 그때 원본 주소를 알리는 데 쓰는 것은 PROXY protocol 이라 이 기준의 대상이 아니다.
|
||||
|
||||
경계 앞에 CDN 이나 터널이 있으면 그 공급자의 헤더가 정본이 된다. Cloudflare 라면 `CF-Connecting-IP` 이고, 그때는 그 헤더를 놓고 이 기준을 다시 세운다.
|
||||
|
||||
이 기준만으로 신뢰가 완성되지 않는다. 경계 프록시를 거치지 않는 경로로 뒤쪽에 직접 닿을 수 있으면 헤더를 어떻게 쓰든 소용이 없다. 그 경로를 막는 것은 방화벽과 네트워크 배치의 일이고, 이 실험대에서는 게스트가 libvirt NAT 뒤에 있는 것이 그 몫을 한다.
|
||||
|
||||
## 예시
|
||||
|
||||
- 경계의 nginx : `proxy_set_header X-Forwarded-For $remote_addr;`
|
||||
- 경계 안쪽 두 번째 홉 : `$proxy_add_x_forwarded_for` 로 앞이 쓴 값을 이어받는다
|
||||
- 호스트 커널 : DNAT 만 걸고 masquerade 는 걸지 않는다
|
||||
- masquerade 를 걸었을 때 엣지가 보는 클라이언트 주소 : `192.168.122.1` 하나
|
||||
- 경계 앞에 Cloudflare 가 있을 때의 정본 헤더 : `CF-Connecting-IP`
|
||||
- 클라이언트가 보낸 값을 이어 붙인 사슬 : 위조된 값이 앞에 남아 뒤쪽에서 근거로 못 쓴다
|
||||
- L4 통과 구성 : 헤더가 필요 없고 PROXY protocol 을 쓴다
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"record": "docs/virtualization/tech-log-studio/lab-entry-path-and-measurement-integrity/reference/reference-overwrite-a-forwarded-header-at-the-trust-boundary-do-not-extend-it.md",
|
||||
"sourceSha256": "3ac4c9254e1984f5966ee0de4417440e7a46e0867a1d3b4cd4960ff2b9019da5",
|
||||
"verdict": "PASS",
|
||||
"liveSourceReconciliation": "UNVERIFIABLE",
|
||||
"liveSourceReason": "source repositories unavailable on current machine",
|
||||
"acceptedByProjectReview": true,
|
||||
"checks": [
|
||||
"local SSOT/Record evidence mismatch 0",
|
||||
"S3/S5/S6 check_evidence --repo invoked",
|
||||
"source checkout absence preserved as exit 3"
|
||||
],
|
||||
"note": "현재 project evidence gate는 불일치 0건·대조 불가 2건이다. source checkout 부재를 PASS로 위장하지 않고 UNVERIFIABLE로 보존했다."
|
||||
}
|
||||
+362
@@ -0,0 +1,362 @@
|
||||
{
|
||||
"schemaVersion": 5,
|
||||
"runId": "2026-09-21-0218-remediation-10-case-declared-memory-and-disk-are-ceilings-not-occupancy",
|
||||
"project": "virtualization",
|
||||
"record": "docs/virtualization/tech-log-studio/lab-environment-build/case/case-declared-memory-and-disk-are-ceilings-not-occupancy.md",
|
||||
"startedAt": "2026-09-21T02:24:49+00:00",
|
||||
"finishedAt": "2026-09-21T02:24:52+00:00",
|
||||
"stages": [
|
||||
{
|
||||
"id": "S1",
|
||||
"name": "코드베이스 → SSOT",
|
||||
"skill": "analyzing-codebase-for-tech-log",
|
||||
"runBy": "ssot-analyst",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "이번 remediation은 새 evidence와 현재 SSOT/Record의 정합성·provenance·command/S4를 재검토한다. 코드베이스→SSOT 전면 분석은 다시 하지 않는다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": "edd45dfec66d155a621cd41186b83b5582f2244c",
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"gates": [],
|
||||
"notes": "",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-21T02:24:50+00:00",
|
||||
"elapsedSeconds": 0,
|
||||
"finishedBy": "chatgpt-virtualization-20260921-remediation-v5"
|
||||
},
|
||||
{
|
||||
"id": "S2",
|
||||
"name": "SSOT → 분해 계약",
|
||||
"skill": "deriving-tech-log-root-tree",
|
||||
"runBy": "tree-deriver",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "기존 91 Record 분해 계약은 유지한다. certbot Question의 RESOLVED 상태와 sourcePolicy는 project tree rebuild에서 별도로 동기화했다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": "ab59130196d79e947b32e3b5e6b75335a9e5c1eb",
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"gates": [],
|
||||
"notes": "",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-21T02:24:50+00:00",
|
||||
"elapsedSeconds": 0,
|
||||
"finishedBy": "chatgpt-virtualization-20260921-remediation-v5"
|
||||
},
|
||||
{
|
||||
"id": "S3",
|
||||
"name": "글감 → 기록",
|
||||
"skill": "writing-tech-log-records",
|
||||
"runBy": "record-writer",
|
||||
"status": "DONE",
|
||||
"skipReason": "",
|
||||
"skillEcho": "**인용한 줄은 SSOT 에서 찾아 대조한다.**",
|
||||
"skillRevision": null,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
"docs/virtualization/tech-log-studio/lab-environment-build/case/case-declared-memory-and-disk-are-ceilings-not-occupancy.md"
|
||||
],
|
||||
"gates": [
|
||||
{
|
||||
"cmd": "python3 scripts/studio-body.py docs/virtualization/tech-log-studio/lab-environment-build/case/case-declared-memory-and-disk-are-ceilings-not-occupancy.md -o runs/virtualization/2026-09-21-0218-remediation-10-case-declared-memory-and-disk-are-ceilings-not-occupancy/stage/S3/studio-body.md",
|
||||
"exit": 0,
|
||||
"status": "PASS",
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"generation": 1,
|
||||
"at": "2026-09-21T02:24:50+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node --experimental-transform-types .agents/skills/writing-tech-log-records/scripts/check_body.mjs runs/virtualization/2026-09-21-0218-remediation-10-case-declared-memory-and-disk-are-ceilings-not-occupancy/stage/S3/studio-body.md --frontend /shared/codebase/tech-log-frontend",
|
||||
"exit": 0,
|
||||
"status": "PASS",
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"generation": 1,
|
||||
"at": "2026-09-21T02:24:50+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node .agents/skills/rewriting-technical-prose-naturally/scripts/check_prose.mjs --warn docs/virtualization/tech-log-studio/lab-environment-build/case/case-declared-memory-and-disk-are-ceilings-not-occupancy.md",
|
||||
"exit": 0,
|
||||
"status": "PASS",
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"generation": 1,
|
||||
"at": "2026-09-21T02:24:50+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node .agents/skills/writing-tech-log-records/scripts/check_evidence.mjs virtualization --repo",
|
||||
"exit": 3,
|
||||
"status": "UNVERIFIABLE",
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"generation": 1,
|
||||
"at": "2026-09-21T02:24:50+00:00",
|
||||
"semanticId": "evidence-repo",
|
||||
"reason": "source repositories unavailable on current machine",
|
||||
"acceptedByProjectReview": true
|
||||
}
|
||||
],
|
||||
"notes": "record-writer/fact 계약으로 새 evidence와 SSOT의 current truth를 대조했다. --repo 대조는 실제 실행했고 source checkout 부재만 UNVERIFIABLE로 남겼다.",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-21T02:24:50+00:00",
|
||||
"elapsedSeconds": 0,
|
||||
"generation": 1,
|
||||
"owner": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"finishedBy": "chatgpt-virtualization-20260921-remediation-v5"
|
||||
},
|
||||
{
|
||||
"id": "S4",
|
||||
"name": "기록 → 그림",
|
||||
"skill": "technical-visualizer",
|
||||
"runBy": "diagram-maker",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "이 Record 변경은 current truth/evidence/version/provenance 정리이며 새 관계 그림을 요구하지 않는다. 기존 그림은 project-wide 14장 검증에서 별도로 확인했다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": "edd45dfec66d155a621cd41186b83b5582f2244c",
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"gates": [],
|
||||
"notes": "",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-21T02:24:50+00:00",
|
||||
"elapsedSeconds": 0,
|
||||
"finishedBy": "chatgpt-virtualization-20260921-remediation-v5"
|
||||
},
|
||||
{
|
||||
"id": "S5",
|
||||
"name": "AI 티 제거",
|
||||
"skill": "rewriting-technical-prose-naturally",
|
||||
"runBy": "prose-rewriter",
|
||||
"status": "DONE",
|
||||
"skipReason": "",
|
||||
"skillEcho": "This is an **editorial** pass. The source's facts, evidence, causal chain, uncertainty, decision status, and technical depth are the contract.",
|
||||
"skillRevision": null,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
"docs/virtualization/tech-log-studio/lab-environment-build/case/case-declared-memory-and-disk-are-ceilings-not-occupancy.md"
|
||||
],
|
||||
"gates": [
|
||||
{
|
||||
"cmd": "node .agents/skills/rewriting-technical-prose-naturally/scripts/check_prose.mjs --warn docs/virtualization/tech-log-studio/lab-environment-build/case/case-declared-memory-and-disk-are-ceilings-not-occupancy.md",
|
||||
"exit": 0,
|
||||
"status": "PASS",
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"generation": 1,
|
||||
"at": "2026-09-21T02:24:50+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node .agents/skills/rewriting-technical-prose-naturally/scripts/style_profile.mjs docs/virtualization/tech-log-studio/lab-environment-build/case/case-declared-memory-and-disk-are-ceilings-not-occupancy.md",
|
||||
"exit": 1,
|
||||
"status": "FAIL",
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"generation": 1,
|
||||
"at": "2026-09-21T02:24:50+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node --experimental-transform-types .agents/skills/writing-tech-log-records/scripts/check_body.mjs runs/virtualization/2026-09-21-0218-remediation-10-case-declared-memory-and-disk-are-ceilings-not-occupancy/stage/S5/studio-body.md --frontend /shared/codebase/tech-log-frontend",
|
||||
"exit": 0,
|
||||
"status": "PASS",
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"generation": 1,
|
||||
"at": "2026-09-21T02:24:51+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node .agents/skills/writing-tech-log-records/scripts/check_evidence.mjs virtualization --repo",
|
||||
"exit": 3,
|
||||
"status": "UNVERIFIABLE",
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"generation": 1,
|
||||
"at": "2026-09-21T02:24:51+00:00",
|
||||
"semanticId": "evidence-repo",
|
||||
"reason": "source repositories unavailable on current machine",
|
||||
"acceptedByProjectReview": true
|
||||
}
|
||||
],
|
||||
"notes": "대규모 문체 재작성 없이 stale fact/relation만 현재 evidence에 맞췄다. style_profile은 측정값으로만 기록했다.",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-21T02:24:51+00:00",
|
||||
"elapsedSeconds": 1,
|
||||
"generation": 1,
|
||||
"owner": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"finishedBy": "chatgpt-virtualization-20260921-remediation-v5"
|
||||
},
|
||||
{
|
||||
"id": "S6",
|
||||
"name": "일한 사람의 목소리",
|
||||
"skill": "writing-as-the-person-who-did-it",
|
||||
"runBy": "voice-writer",
|
||||
"status": "DONE",
|
||||
"skipReason": "",
|
||||
"skillEcho": "찾은 것이 없으면 **이 스킬은 여기서 끝난다.** 없는 목소리를 채우지 않는다.",
|
||||
"skillRevision": "edd45dfec66d155a621cd41186b83b5582f2244c",
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
"docs/virtualization/tech-log-studio/lab-environment-build/case/case-declared-memory-and-disk-are-ceilings-not-occupancy.md"
|
||||
],
|
||||
"gates": [
|
||||
{
|
||||
"cmd": "node .agents/skills/writing-as-the-person-who-did-it/scripts/check_voice.mjs docs/virtualization/tech-log-studio/lab-environment-build/case/case-declared-memory-and-disk-are-ceilings-not-occupancy.md",
|
||||
"exit": 0,
|
||||
"status": "PASS",
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"generation": 1,
|
||||
"at": "2026-09-21T02:24:51+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node .agents/skills/rewriting-technical-prose-naturally/scripts/check_prose.mjs --warn docs/virtualization/tech-log-studio/lab-environment-build/case/case-declared-memory-and-disk-are-ceilings-not-occupancy.md",
|
||||
"exit": 0,
|
||||
"status": "PASS",
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"generation": 1,
|
||||
"at": "2026-09-21T02:24:51+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node --experimental-transform-types .agents/skills/writing-tech-log-records/scripts/check_body.mjs runs/virtualization/2026-09-21-0218-remediation-10-case-declared-memory-and-disk-are-ceilings-not-occupancy/stage/S6/studio-body.md --frontend /shared/codebase/tech-log-frontend",
|
||||
"exit": 0,
|
||||
"status": "PASS",
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"generation": 1,
|
||||
"at": "2026-09-21T02:24:51+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node .agents/skills/writing-tech-log-records/scripts/check_evidence.mjs virtualization --repo",
|
||||
"exit": 3,
|
||||
"status": "UNVERIFIABLE",
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"generation": 1,
|
||||
"at": "2026-09-21T02:24:51+00:00",
|
||||
"semanticId": "evidence-repo",
|
||||
"reason": "source repositories unavailable on current machine",
|
||||
"acceptedByProjectReview": true
|
||||
}
|
||||
],
|
||||
"notes": "상류 SSOT/evidence를 다시 보며 실제 작업 흔적이 없는 서사는 추가하지 않았다.",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-21T02:24:51+00:00",
|
||||
"elapsedSeconds": 0,
|
||||
"generation": 1,
|
||||
"owner": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"finishedBy": "chatgpt-virtualization-20260921-remediation-v5"
|
||||
},
|
||||
{
|
||||
"id": "S7",
|
||||
"name": "Studio 저장",
|
||||
"skill": "publishing-tech-log-to-studio",
|
||||
"runBy": "studio-validator",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "이번 remediation에서 Studio import/save는 요청되지 않았다. 저장소 산출물과 검증 영수증만 갱신한다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": "862e502af3e956b49ccd2ae0a8de3fd32f90df9c",
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"gates": [],
|
||||
"notes": "",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-21T02:24:52+00:00",
|
||||
"elapsedSeconds": 0,
|
||||
"finishedBy": "chatgpt-virtualization-20260921-remediation-v5"
|
||||
}
|
||||
],
|
||||
"qualityReviews": {
|
||||
"commandPedagogy": {
|
||||
"initialAnalysis": {
|
||||
"cmd": "python3 scripts/check-command-pedagogy.py --mode reference docs/virtualization/tech-log-studio/lab-environment-build/case/case-declared-memory-and-disk-are-ceilings-not-occupancy.md -o runs/virtualization/2026-09-21-0218-remediation-10-case-declared-memory-and-disk-are-ceilings-not-occupancy/stage/S3/command-initial.json",
|
||||
"exit": 0,
|
||||
"shellBlocks": 0,
|
||||
"findings": 0,
|
||||
"majorFindings": 0,
|
||||
"artifact": {
|
||||
"path": "runs/virtualization/2026-09-21-0218-remediation-10-case-declared-memory-and-disk-are-ceilings-not-occupancy/stage/S3/command-initial.json",
|
||||
"sha256": "a12d198514de2512a42f4113a30914775ec8c141f5ca5595cea7822767ac4b09"
|
||||
}
|
||||
},
|
||||
"finalAnalysis": {
|
||||
"cmd": "python3 scripts/check-command-pedagogy.py --mode reference docs/virtualization/tech-log-studio/lab-environment-build/case/case-declared-memory-and-disk-are-ceilings-not-occupancy.md -o runs/virtualization/2026-09-21-0218-remediation-10-case-declared-memory-and-disk-are-ceilings-not-occupancy/stage/S6/command-final.json",
|
||||
"exit": 0,
|
||||
"shellBlocks": 0,
|
||||
"findings": 0,
|
||||
"majorFindings": 0,
|
||||
"artifact": {
|
||||
"path": "runs/virtualization/2026-09-21-0218-remediation-10-case-declared-memory-and-disk-are-ceilings-not-occupancy/stage/S6/command-final.json",
|
||||
"sha256": "a12d198514de2512a42f4113a30914775ec8c141f5ca5595cea7822767ac4b09"
|
||||
}
|
||||
},
|
||||
"planner": {
|
||||
"runBy": "command-pedagogy-planner",
|
||||
"skill": "writing-practitioner-guides",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "initial deterministic analysis finding이 0이다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": null,
|
||||
"notes": "현재 reference-mode 분석을 확인했다.",
|
||||
"artifact": null
|
||||
},
|
||||
"editor": {
|
||||
"runBy": "command-pedagogy-editor",
|
||||
"skill": "writing-practitioner-guides",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "initial deterministic analysis finding이 0이다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": null,
|
||||
"notes": "현재 reference-mode 분석을 확인했다.",
|
||||
"artifact": null
|
||||
},
|
||||
"reviewer": {
|
||||
"runBy": "command-pedagogy-reviewer",
|
||||
"skill": "writing-practitioner-guides",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "final publication에 shell/CLI block이 없다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": null,
|
||||
"verdict": null,
|
||||
"notes": "final deterministic analysis에서 shellBlocks=0을 확인했다.",
|
||||
"artifact": null,
|
||||
"sourceSha256": null
|
||||
}
|
||||
},
|
||||
"technicalEvidence": {
|
||||
"runBy": "fact-reviewer",
|
||||
"status": "DONE",
|
||||
"skipReason": "",
|
||||
"verdict": "PASS",
|
||||
"notes": "current truth/evidence/provenance를 SSOT와 역대조했다. live source는 실제 --repo 결과 exit 3이며 UNVERIFIABLE로 보존했다.",
|
||||
"sourceSha256": "5d8d230d67e11f05f3146bed379e78940f7b84ddb066439d751ca2dbafc9a080",
|
||||
"liveSourceReconciliation": "UNVERIFIABLE",
|
||||
"liveSourceReason": "source repositories unavailable on current machine",
|
||||
"acceptedByProjectReview": true,
|
||||
"artifact": {
|
||||
"path": "runs/virtualization/2026-09-21-0218-remediation-10-case-declared-memory-and-disk-are-ceilings-not-occupancy/stage/quality/technical-evidence-review.json",
|
||||
"sha256": "be4c17ac77570ea2bf9c89c6757217528cc9c30f380ce9cc22387e8e244d7131"
|
||||
}
|
||||
}
|
||||
},
|
||||
"riders": [],
|
||||
"sessions": [
|
||||
{
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"openedAt": "2026-09-21T02:24:49+00:00"
|
||||
},
|
||||
{
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"stage": "S3",
|
||||
"generation": 1,
|
||||
"beganAt": "2026-09-21T02:24:50+00:00"
|
||||
},
|
||||
{
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"stage": "S5",
|
||||
"generation": 1,
|
||||
"beganAt": "2026-09-21T02:24:50+00:00"
|
||||
},
|
||||
{
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"stage": "S6",
|
||||
"generation": 1,
|
||||
"beganAt": "2026-09-21T02:24:51+00:00"
|
||||
}
|
||||
],
|
||||
"revision": 23,
|
||||
"updatedAt": "2026-09-21T02:24:52+00:00",
|
||||
"executionEnvironment": {
|
||||
"mode": "current-remediation-contract-replay",
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"agentToolAvailable": false,
|
||||
"note": "별도 subagent 실행 도구가 현재 환경에 없어 현재 세션이 각 .claude/agents 역할 정의와 연결된 SKILL/contract를 직접 읽고 역할을 분리해 수행했다. runBy는 계약 역할명이며 별도 프로세스 실행을 주장하지 않는다."
|
||||
}
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"authority": "deterministic",
|
||||
"gate": "command-pedagogy-signals",
|
||||
"section_id": "docs/virtualization/tech-log-studio/lab-environment-build/case/case-declared-memory-and-disk-are-ceilings-not-occupancy.md",
|
||||
"mode": "reference",
|
||||
"source_sha256": "5d8d230d67e11f05f3146bed379e78940f7b84ddb066439d751ca2dbafc9a080",
|
||||
"result": "PASS",
|
||||
"requires_editor": false,
|
||||
"blocks": [],
|
||||
"findings": [],
|
||||
"extensions": {
|
||||
"command_like_text_blocks": []
|
||||
}
|
||||
}
|
||||
+203
@@ -0,0 +1,203 @@
|
||||
---
|
||||
kind: CASE
|
||||
slug: declared-memory-and-disk-are-ceilings-not-occupancy
|
||||
title: 5120MB 를 줬는데 353MB 를 쓰고 있었다 — 선언한 양과 실제로 드는 양
|
||||
topic: lab-environment-build
|
||||
topicName: 실험대 환경 구성
|
||||
project: virtualization
|
||||
status: 게시 전
|
||||
lastVerifiedOn: 2026-09-10
|
||||
sourceRevision: import-head 9465582b5d1630eb4ae7c4e078021486919bf6b6 · uncommitted-working-tree snapshot
|
||||
source:
|
||||
- final/document.md#198-자원-할당과-실사용은-다르다
|
||||
- final/document.md#199-디스크-오버레이는-얼마나-쓰나
|
||||
- final/document.md#202-철거-실제-출력-전문
|
||||
- final/document.md#197-측정-환경
|
||||
- final/document.md#195-이-부의-출처와-범위
|
||||
- final/document.md#211-이-부의-출처와-범위
|
||||
---
|
||||
|
||||
# 5120MB 를 줬는데 353MB 를 쓰고 있었다 — 선언한 양과 실제로 드는 양
|
||||
|
||||
k3s 만 올린 상태에서 kc-lab-1 은 5120MB 를 할당받고 353MB 를 쓰고 있었다. k3s 두 노드에 8240MB 를 선언해 실제 점유는 654MB 였고, 디스크는 40GB 를 선언해 2.1GB 를 썼다. 2026-09-10 에 test-server 에서 쟀다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **이 호스트의 가상 머신들은 설정한 메모리와 지금 잡고 있는 메모리가 얼마나 다른가**
|
||||
그 물음이 묻는 세 값 가운데 configured 와 게스트 사용량이 여기서 나왔다. 호스트 resident 는 비어 있어서 그 물음은 닫히지 않았다.
|
||||
- **이 disk image 는 qcow2 인가 RAW 인가, 그리고 Guest 가 보는 크기와 Host 가 실제로 쓰는 크기는 얼마나 다른가**
|
||||
그 물음이 요구하는 세 값 가운데 `qemu-img info` 와 `ls` 가 여기서 나왔고 `du` 는 돌리지 않았다.
|
||||
- **QEMU 프로세스가 실제로 붙잡고 있는 호스트 메모리는 얼마이고 어떻게 나뉘어 있는가**
|
||||
게스트 안에서 본 실사용과 호스트가 실제로 잡아 둔 양은 다른 수다. 후자를 그 물음이 받는다.
|
||||
- **qcow2 파일 안 — 매핑표와 클러스터, 그리고 항목이 0 이면 바닥에 다시 묻는다**
|
||||
20GB 를 선언한 파일이 1.4GiB 인 까닭을 그 글이 매핑표와 오버레이로 설명한다.
|
||||
- **실험대를 철거하고 무엇이 남는지 확인한다**
|
||||
회수량 3.1GB 를 낸 철거 절차가 그 기록에 있다.
|
||||
- **실험대를 껐다 켜고 게스트 메모리를 다시 나눈다**
|
||||
`kc-lab-1` 이 3584M 에서 5120MB 가 된 재배분 절차가 그 기록에 있다.
|
||||
- **도구가 낸 출력은 대상의 상태가 아니다 — 그 명령이 무엇을 세는지부터 가른다**
|
||||
`dommemstat` 의 `actual`, `free` 의 `available`, `pool-info` 의 `Allocation` 이 셋 다 이름과 다른 것을 센다.
|
||||
|
||||
## 문제
|
||||
|
||||
제1~4부에는 이 호스트에서 잰 값이 하나도 없다. 제5·6부는 버전과 주소와 명령까지만 적었다.
|
||||
|
||||
게스트 세 대에 메모리 8240MB 와 디스크 40GB 를 선언해 두었는데, 그 선언이 11,648MiB 와 226G 짜리 호스트 한 대에서 실제로 얼마를 먹는지는 어디에도 없었다. 「5GB 를 줬으니 5GB 를 쓴다」와 「20GB 두 장이면 40GB 를 쓴다」가 맞는지 모르는 채로 게스트를 더 띄울지 정해야 했다.
|
||||
|
||||
## 결론
|
||||
|
||||
선언한 양은 상한이고 점유가 아니다. k3s 만 떠 있고 Keycloak 은 아직 안 올린 상태에서 넷을 쟀다.
|
||||
|
||||
메모리 : `kc-lab-1` 은 할당 5120MB 에 실사용 353MB, `kc-lab-2` 는 할당 3120MB 에 실사용 301MB
|
||||
메모리 합계 : 8240MB 를 할당했고 실제 점유는 654MB
|
||||
디스크 : `kc-lab-1.qcow2` 1.4GiB, `kc-lab-2.qcow2` 665MiB, 바닥 `base.qcow2` 는 `virtual size` 3 GiB 에 `disk size` 335MiB
|
||||
디스크 합계 : 20GB 를 두 장 선언했고 실제로 쓴 것은 2.1GB
|
||||
철거 : 게스트 셋을 지우자 `df -h /` 가 11G 에서 7.9G 로 내려 3.1GB 가 회수됐다
|
||||
|
||||
`virt-install --memory 4096` 으로 만든 `kc-lab-2` 의 할당이 3120 으로 보인다. `dommemstat` 의 `actual` 은 현재 할당이지 선언한 상한이 아니고, 상한은 `virsh dominfo` 의 `Max memory` 에 있다. 줄어든 까닭은 virtio-balloon 회수로 보이는데, 두 값을 나란히 찍어 보지는 않았다.
|
||||
|
||||
## 검증 환경
|
||||
|
||||
측정일 : 2026-09-10
|
||||
호스트 : `test-server`, Arch Linux
|
||||
CPU : `11th Gen Intel(R) Core(TM) i5-1135G7 @ 2.40GHz`, 논리 코어 8
|
||||
RAM : 11,648MiB
|
||||
루트 파일시스템 : 226G 가운데 9.9G 사용
|
||||
QEMU : 11.1.1
|
||||
libvirt : 12.7.0
|
||||
커널 : `7.2.2-arch1-1`
|
||||
중첩 가상화 : `nested` 가 `Y` 지만 이 실험대는 쓰지 않는다
|
||||
게스트 : Debian 12 genericcloud 3대 — 엣지 1대, k3s 2노드
|
||||
워크로드 : k3s 만 떠 있고 Keycloak · PostgreSQL · Redis · Prometheus 는 올리기 전
|
||||
|
||||
## 재현 조건
|
||||
|
||||
1. k3s 두 노드만 띄우고 Keycloak · PostgreSQL · Redis · Prometheus 는 올리지 않는다.
|
||||
2. 게스트마다 `virsh dommemstat` 을 돌려 `actual` 과 `unused` 를 읽고, 그 차이를 실사용으로 잡는다.
|
||||
3. `qemu-img info` 로 `base.qcow2` 의 `virtual size` 와 `disk size` 를 읽고, `ls -l /var/lib/libvirt/images/` 로 오버레이와 시드 파일의 바이트 수를 읽는다.
|
||||
4. 철거하기 전에 `df -h /` 를 읽어 둔다.
|
||||
5. `virsh destroy` 와 `virsh undefine --remove-all-storage` 로 게스트 셋을 지우고 `df -h /` 를 다시 읽는다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
## 이 호스트에서 처음으로 양을 쟀다
|
||||
|
||||
이 실험대의 문서는 제5·6부까지 버전과 주소와 명령을 적었고 자원의 양은 적지 않았다. 제7부가 그 양과 시간을 처음 쟀고, 원본이 표기 규약을 스스로 밝혀 두었다.
|
||||
|
||||
> 여기 적힌 숫자는 전부 2026-09-10 에 `test-server` 에서 실제로 돌려 받은 출력이다. 추정값·예상값은 없다. 없는 값은 「미측정」이라고 쓴다.
|
||||
|
||||
측정 환경부터 읽어 둔다. 논리 코어가 8 이고 게스트 셋에 vCPU 를 2 + 2 + 1 로 잡아 여유를 뒀다.
|
||||
|
||||
```text label="측정 환경 — free -m 의 앞 두 줄"
|
||||
total used free shared buff/cache available
|
||||
Mem: 11648 5642 2599 4 3776 6005
|
||||
```
|
||||
|
||||
`available` 이 6005 로 `free` 2599 보다 훨씬 큰데, `buff/cache` 3776 이 필요해지면 회수되기 때문이다. 게스트를 몇 대 더 띄울 수 있는지는 `free` 가 아니라 `available` 로 읽는다.
|
||||
|
||||
## 메모리 — 할당 5120MB 에 실사용 353MB
|
||||
|
||||
게스트마다 `dommemstat` 의 `actual` 에서 `unused` 를 뺀 값이 실사용이다.
|
||||
|
||||
```text label="k3s 만 떠 있고 Keycloak 은 아직 안 올린 상태"
|
||||
kc-lab-1 할당 5120MB 실사용 353MB
|
||||
kc-lab-2 할당 3120MB 실사용 301MB
|
||||
```
|
||||
|
||||
k3s server 한 대가 353MB 를 쓰니 할당의 7% 다. 둘을 합치면 8240MB 를 할당했고 실제 점유는 654MB 여서, 11,648MiB 짜리 호스트 한 대에서 게스트 세 대가 무리 없이 돈다.
|
||||
|
||||
## `kc-lab-2` 의 할당이 4096 이 아니라 3120 이다
|
||||
|
||||
`kc-lab-2` 는 `virt-install --memory 4096` 으로 만들었는데 `dommemstat` 이 3120 을 낸다.
|
||||
|
||||
`dommemstat` 의 `actual` 은 현재 할당이지 선언한 상한이 아니라서, 상한을 보려면 `virsh dominfo` 의 `Max memory` 를 읽어야 한다. 둘을 같은 값으로 읽으면 「메모리가 왜 줄었지」가 된다.
|
||||
|
||||
줄어든 까닭은 virtio-balloon 회수로 보인다. 게스트가 안 쓰는 만큼 balloon 드라이버가 호스트에 돌려주고, 돌려준 만큼 현재 할당이 내려간다. 다만 이 실험대에서 `dommemstat` 의 `actual` 과 `dominfo` 의 `Max memory` 를 나란히 찍어 대조한 기록이 없어서, 3120 이 balloon 회수의 결과라는 것은 관측이 아니라 추론이다.
|
||||
|
||||
## 디스크 — 40GB 를 선언해 2.1GB
|
||||
|
||||
게스트 디스크는 `base.qcow2` 위의 오버레이다. 20GB 짜리를 두 장 만들어도 바닥은 한 벌이고 변경분만 쌓인다.
|
||||
|
||||
```text label="qemu-img info 가 읽은 바닥 이미지"
|
||||
image: /var/lib/libvirt/images/base.qcow2
|
||||
file format: qcow2
|
||||
virtual size: 3 GiB (3221225472 bytes)
|
||||
disk size: 335 MiB
|
||||
```
|
||||
|
||||
```text label="엣지를 만들기 전, k3s 2 노드만 있던 시점의 ls -l"
|
||||
-rw-r--r-- base.qcow2 351404032 (335 MiB)
|
||||
-rw------- kc-lab-1.qcow2 1521025024 (1.4 GiB) ← 선언 20GB
|
||||
-rw------- kc-lab-2.qcow2 697499648 (665 MiB) ← 선언 20GB
|
||||
-rw------- seed-kc-lab-1.iso 378880 (370 KiB)
|
||||
-rw------- seed-kc-lab-2.iso 378880 (370 KiB)
|
||||
```
|
||||
|
||||
바닥은 게스트가 3 GiB 로 보는데 파일은 335MiB 이고, 20GB 로 선언한 오버레이 둘도 실제로는 1.4GiB 와 665MiB 다. 합쳐 40GB 를 선언하고 2.1GB 를 썼다. `kc-lab-1` 이 `kc-lab-2` 의 두 배 이상을 쓰는 것은 k3s server 가 컨트롤 플레인 바이너리와 SQLite 를 들고 있기 때문이다.
|
||||
|
||||
## `virsh pool-info` 의 `Allocation` 은 VM 사용량이 아니다
|
||||
|
||||
같은 날 `virsh pool-info default` 도 읽었다.
|
||||
|
||||
```text label="스토리지 풀 default"
|
||||
Name: default
|
||||
State: running
|
||||
Persistent: yes Autostart: yes
|
||||
Capacity: 225.31 GiB
|
||||
Allocation: 7.84 GiB
|
||||
Available: 217.46 GiB
|
||||
```
|
||||
|
||||
`Allocation` 7.84 GiB 는 풀이 얹힌 호스트 루트 파일시스템 전체의 사용량이다. VM 이 얼마를 쓰는지는 위의 `ls -l` 이 말한다. 두 수를 같은 것으로 읽으면 게스트 둘이 7.84 GiB 를 먹은 것이 된다.
|
||||
|
||||
## 철거 — `df -h /` 가 11G 에서 7.9G 로
|
||||
|
||||
게스트 셋을 `virsh destroy` 로 내리고 `virsh undefine --remove-all-storage` 로 지웠다. 한 대분 출력은 이렇다.
|
||||
|
||||
```text label="kc-lab-edge 한 대를 철거한 출력"
|
||||
Domain 'kc-lab-edge' destroyed
|
||||
Domain 'kc-lab-edge' has been undefined
|
||||
Volume 'vda'(/var/lib/libvirt/images/kc-lab-edge.qcow2) removed.
|
||||
Volume 'vdb'(/var/lib/libvirt/images/seed-kc-lab-edge.iso) removed.
|
||||
```
|
||||
|
||||
`Volume` 줄이 두 개 나오는데, 오버레이 디스크 `vda` 와 시드 ISO `vdb` 다.
|
||||
|
||||
| 무엇을 읽었나 | 철거 전 | 철거 후 |
|
||||
|---|---|---|
|
||||
| `virsh list --all` | 3 대 running | (없음) |
|
||||
| `virsh vol-list default` | 7 개 | `base.qcow2` 1 개 |
|
||||
| DHCP 예약 | 3 줄 | 0 줄 |
|
||||
| `df -h /` | 11G | 7.9G |
|
||||
| `virbr0` | UP | DOWN |
|
||||
|
||||
3.1GB 가 회수됐고 내역은 `kc-lab-1` 1.4GB 와 `kc-lab-2` 665MB 와 시드 ISO 3개(각 370KB)다. `base.qcow2` 335MB 는 다음 재구축의 바닥이라 남긴다. 다시 받아도 몇 분이면 된다.
|
||||
|
||||
## 같은 대상의 숫자가 두 벌이다
|
||||
|
||||
이 SSOT 안에는 같은 실험대의 스냅샷이 두 벌 있다. §218 은 2026-09-03 값이고 이 기록은 2026-09-10 값이다.
|
||||
|
||||
| 무엇 | 2026-09-03 | 2026-09-10 |
|
||||
|---|---|---|
|
||||
| 호스트 RAM | `RAM 7.4Gi` | `Mem: 11648` |
|
||||
| `kc-lab-1` | `RAM 3584M · vCPU 2` | 할당 5120MB |
|
||||
| `kc-lab-2` | `RAM 2560M · vCPU 2` | 할당 3120MB (선언 4096) |
|
||||
| 게스트 수 | 2 (엣지 없음) | 3 (엣지 추가) |
|
||||
|
||||
그사이에 호스트 RAM 이 8GB 에서 12GB 로 물리 증설됐고 `setmaxmem` 과 `setmem` 으로 게스트 메모리가 재배분됐다. 두 값이 어긋나 보이면 틀린 것이 아니라 다른 날이다.
|
||||
|
||||
날짜가 아니라 단위로 갈리는 것도 하나 있다. §198 은 같은 호스트의 RAM 을 `11.6GB` 로도 적는데, 그 표기가 원 가이드에서 온 것이라 고쳐 쓰지 않고 어긋남을 적어 둔다고 스스로 밝힌다. `free -m` 의 `Mem: 11648` 은 MiB 단위이므로 11,648MiB, 약 11.4GiB 다.
|
||||
|
||||
## 확인하지 못한 것
|
||||
|
||||
이 값은 호스트 한 대의 한 시점이다. Keycloak 2 파드와 PostgreSQL 과 Redis 와 Prometheus 가 올라간 뒤의 메모리는 재지 않았다. 원본도 §198 에서 그 시점의 값을 미측정으로 밝힌다.
|
||||
|
||||
디스크 값과 철거 값은 시점이 서로 다르다. `ls -l` 은 엣지를 만들기 전 k3s 2노드만 있던 때의 것이고, 3.1GB 회수는 엣지까지 세 대가 있던 때의 것이다. `kc-lab-edge` 의 디스크 크기는 따로 재 두지 않았고, 회수 합계에서 역산하면 1GB 안팎이다.
|
||||
|
||||
게스트 안에서 본 실사용과 QEMU 프로세스가 호스트에서 붙잡고 있는 양은 다른 수인데, 뒤엣것은 이 기록이 재지 않았다.
|
||||
|
||||
여기 옮긴 출력은 전부 SSOT 본문의 코드 블록에서 왔고, 이 저장소의 `final/evidence/` 에는 그 명령들의 출력 원문이 파일로 없다. 같은 측정을 다시 돌려 `final/evidence/raw/` 에 남기면 그때 원문을 댈 수 있다.
|
||||
|
||||
<!-- body:end -->
|
||||
+203
@@ -0,0 +1,203 @@
|
||||
---
|
||||
kind: CASE
|
||||
slug: declared-memory-and-disk-are-ceilings-not-occupancy
|
||||
title: 5120MB 를 줬는데 353MB 를 쓰고 있었다 — 선언한 양과 실제로 드는 양
|
||||
topic: lab-environment-build
|
||||
topicName: 실험대 환경 구성
|
||||
project: virtualization
|
||||
status: 게시 전
|
||||
lastVerifiedOn: 2026-09-10
|
||||
sourceRevision: import-head 9465582b5d1630eb4ae7c4e078021486919bf6b6 · uncommitted-working-tree snapshot
|
||||
source:
|
||||
- final/document.md#198-자원-할당과-실사용은-다르다
|
||||
- final/document.md#199-디스크-오버레이는-얼마나-쓰나
|
||||
- final/document.md#202-철거-실제-출력-전문
|
||||
- final/document.md#197-측정-환경
|
||||
- final/document.md#195-이-부의-출처와-범위
|
||||
- final/document.md#211-이-부의-출처와-범위
|
||||
---
|
||||
|
||||
# 5120MB 를 줬는데 353MB 를 쓰고 있었다 — 선언한 양과 실제로 드는 양
|
||||
|
||||
k3s 만 올린 상태에서 kc-lab-1 은 5120MB 를 할당받고 353MB 를 쓰고 있었다. k3s 두 노드에 8240MB 를 선언해 실제 점유는 654MB 였고, 디스크는 40GB 를 선언해 2.1GB 를 썼다. 2026-09-10 에 test-server 에서 쟀다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **이 호스트의 가상 머신들은 설정한 메모리와 지금 잡고 있는 메모리가 얼마나 다른가**
|
||||
그 물음이 묻는 세 값 가운데 configured 와 게스트 사용량이 여기서 나왔다. 호스트 resident 는 비어 있어서 그 물음은 닫히지 않았다.
|
||||
- **이 disk image 는 qcow2 인가 RAW 인가, 그리고 Guest 가 보는 크기와 Host 가 실제로 쓰는 크기는 얼마나 다른가**
|
||||
그 물음이 요구하는 세 값 가운데 `qemu-img info` 와 `ls` 가 여기서 나왔고 `du` 는 돌리지 않았다.
|
||||
- **QEMU 프로세스가 실제로 붙잡고 있는 호스트 메모리는 얼마이고 어떻게 나뉘어 있는가**
|
||||
게스트 안에서 본 실사용과 호스트가 실제로 잡아 둔 양은 다른 수다. 후자를 그 물음이 받는다.
|
||||
- **qcow2 파일 안 — 매핑표와 클러스터, 그리고 항목이 0 이면 바닥에 다시 묻는다**
|
||||
20GB 를 선언한 파일이 1.4GiB 인 까닭을 그 글이 매핑표와 오버레이로 설명한다.
|
||||
- **실험대를 철거하고 무엇이 남는지 확인한다**
|
||||
회수량 3.1GB 를 낸 철거 절차가 그 기록에 있다.
|
||||
- **실험대를 껐다 켜고 게스트 메모리를 다시 나눈다**
|
||||
`kc-lab-1` 이 3584M 에서 5120MB 가 된 재배분 절차가 그 기록에 있다.
|
||||
- **도구가 낸 출력은 대상의 상태가 아니다 — 그 명령이 무엇을 세는지부터 가른다**
|
||||
`dommemstat` 의 `actual`, `free` 의 `available`, `pool-info` 의 `Allocation` 이 셋 다 이름과 다른 것을 센다.
|
||||
|
||||
## 문제
|
||||
|
||||
제1~4부에는 이 호스트에서 잰 값이 하나도 없다. 제5·6부는 버전과 주소와 명령까지만 적었다.
|
||||
|
||||
게스트 세 대에 메모리 8240MB 와 디스크 40GB 를 선언해 두었는데, 그 선언이 11,648MiB 와 226G 짜리 호스트 한 대에서 실제로 얼마를 먹는지는 어디에도 없었다. 「5GB 를 줬으니 5GB 를 쓴다」와 「20GB 두 장이면 40GB 를 쓴다」가 맞는지 모르는 채로 게스트를 더 띄울지 정해야 했다.
|
||||
|
||||
## 결론
|
||||
|
||||
선언한 양은 상한이고 점유가 아니다. k3s 만 떠 있고 Keycloak 은 아직 안 올린 상태에서 넷을 쟀다.
|
||||
|
||||
메모리 : `kc-lab-1` 은 할당 5120MB 에 실사용 353MB, `kc-lab-2` 는 할당 3120MB 에 실사용 301MB
|
||||
메모리 합계 : 8240MB 를 할당했고 실제 점유는 654MB
|
||||
디스크 : `kc-lab-1.qcow2` 1.4GiB, `kc-lab-2.qcow2` 665MiB, 바닥 `base.qcow2` 는 `virtual size` 3 GiB 에 `disk size` 335MiB
|
||||
디스크 합계 : 20GB 를 두 장 선언했고 실제로 쓴 것은 2.1GB
|
||||
철거 : 게스트 셋을 지우자 `df -h /` 가 11G 에서 7.9G 로 내려 3.1GB 가 회수됐다
|
||||
|
||||
`virt-install --memory 4096` 으로 만든 `kc-lab-2` 의 할당이 3120 으로 보인다. `dommemstat` 의 `actual` 은 현재 할당이지 선언한 상한이 아니고, 상한은 `virsh dominfo` 의 `Max memory` 에 있다. 줄어든 까닭은 virtio-balloon 회수로 보이는데, 두 값을 나란히 찍어 보지는 않았다.
|
||||
|
||||
## 검증 환경
|
||||
|
||||
측정일 : 2026-09-10
|
||||
호스트 : `test-server`, Arch Linux
|
||||
CPU : `11th Gen Intel(R) Core(TM) i5-1135G7 @ 2.40GHz`, 논리 코어 8
|
||||
RAM : 11,648MiB
|
||||
루트 파일시스템 : 226G 가운데 9.9G 사용
|
||||
QEMU : 11.1.1
|
||||
libvirt : 12.7.0
|
||||
커널 : `7.2.2-arch1-1`
|
||||
중첩 가상화 : `nested` 가 `Y` 지만 이 실험대는 쓰지 않는다
|
||||
게스트 : Debian 12 genericcloud 3대 — 엣지 1대, k3s 2노드
|
||||
워크로드 : k3s 만 떠 있고 Keycloak · PostgreSQL · Redis · Prometheus 는 올리기 전
|
||||
|
||||
## 재현 조건
|
||||
|
||||
1. k3s 두 노드만 띄우고 Keycloak · PostgreSQL · Redis · Prometheus 는 올리지 않는다.
|
||||
2. 게스트마다 `virsh dommemstat` 을 돌려 `actual` 과 `unused` 를 읽고, 그 차이를 실사용으로 잡는다.
|
||||
3. `qemu-img info` 로 `base.qcow2` 의 `virtual size` 와 `disk size` 를 읽고, `ls -l /var/lib/libvirt/images/` 로 오버레이와 시드 파일의 바이트 수를 읽는다.
|
||||
4. 철거하기 전에 `df -h /` 를 읽어 둔다.
|
||||
5. `virsh destroy` 와 `virsh undefine --remove-all-storage` 로 게스트 셋을 지우고 `df -h /` 를 다시 읽는다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
## 이 호스트에서 처음으로 양을 쟀다
|
||||
|
||||
이 실험대의 문서는 제5·6부까지 버전과 주소와 명령을 적었고 자원의 양은 적지 않았다. 제7부가 그 양과 시간을 처음 쟀고, 원본이 표기 규약을 스스로 밝혀 두었다.
|
||||
|
||||
> 여기 적힌 숫자는 전부 2026-09-10 에 `test-server` 에서 실제로 돌려 받은 출력이다. 추정값·예상값은 없다. 없는 값은 「미측정」이라고 쓴다.
|
||||
|
||||
측정 환경부터 읽어 둔다. 논리 코어가 8 이고 게스트 셋에 vCPU 를 2 + 2 + 1 로 잡아 여유를 뒀다.
|
||||
|
||||
```text label="측정 환경 — free -m 의 앞 두 줄"
|
||||
total used free shared buff/cache available
|
||||
Mem: 11648 5642 2599 4 3776 6005
|
||||
```
|
||||
|
||||
`available` 이 6005 로 `free` 2599 보다 훨씬 큰데, `buff/cache` 3776 이 필요해지면 회수되기 때문이다. 게스트를 몇 대 더 띄울 수 있는지는 `free` 가 아니라 `available` 로 읽는다.
|
||||
|
||||
## 메모리 — 할당 5120MB 에 실사용 353MB
|
||||
|
||||
게스트마다 `dommemstat` 의 `actual` 에서 `unused` 를 뺀 값이 실사용이다.
|
||||
|
||||
```text label="k3s 만 떠 있고 Keycloak 은 아직 안 올린 상태"
|
||||
kc-lab-1 할당 5120MB 실사용 353MB
|
||||
kc-lab-2 할당 3120MB 실사용 301MB
|
||||
```
|
||||
|
||||
k3s server 한 대가 353MB 를 쓰니 할당의 7% 다. 둘을 합치면 8240MB 를 할당했고 실제 점유는 654MB 여서, 11,648MiB 짜리 호스트 한 대에서 게스트 세 대가 무리 없이 돈다.
|
||||
|
||||
## `kc-lab-2` 의 할당이 4096 이 아니라 3120 이다
|
||||
|
||||
`kc-lab-2` 는 `virt-install --memory 4096` 으로 만들었는데 `dommemstat` 이 3120 을 낸다.
|
||||
|
||||
`dommemstat` 의 `actual` 은 현재 할당이지 선언한 상한이 아니라서, 상한을 보려면 `virsh dominfo` 의 `Max memory` 를 읽어야 한다. 둘을 같은 값으로 읽으면 「메모리가 왜 줄었지」가 된다.
|
||||
|
||||
줄어든 까닭은 virtio-balloon 회수로 보인다. 게스트가 안 쓰는 만큼 balloon 드라이버가 호스트에 돌려주고, 돌려준 만큼 현재 할당이 내려간다. 다만 이 실험대에서 `dommemstat` 의 `actual` 과 `dominfo` 의 `Max memory` 를 나란히 찍어 대조한 기록이 없어서, 3120 이 balloon 회수의 결과라는 것은 관측이 아니라 추론이다.
|
||||
|
||||
## 디스크 — 40GB 를 선언해 2.1GB
|
||||
|
||||
게스트 디스크는 `base.qcow2` 위의 오버레이다. 20GB 짜리를 두 장 만들어도 바닥은 한 벌이고 변경분만 쌓인다.
|
||||
|
||||
```text label="qemu-img info 가 읽은 바닥 이미지"
|
||||
image: /var/lib/libvirt/images/base.qcow2
|
||||
file format: qcow2
|
||||
virtual size: 3 GiB (3221225472 bytes)
|
||||
disk size: 335 MiB
|
||||
```
|
||||
|
||||
```text label="엣지를 만들기 전, k3s 2 노드만 있던 시점의 ls -l"
|
||||
-rw-r--r-- base.qcow2 351404032 (335 MiB)
|
||||
-rw------- kc-lab-1.qcow2 1521025024 (1.4 GiB) ← 선언 20GB
|
||||
-rw------- kc-lab-2.qcow2 697499648 (665 MiB) ← 선언 20GB
|
||||
-rw------- seed-kc-lab-1.iso 378880 (370 KiB)
|
||||
-rw------- seed-kc-lab-2.iso 378880 (370 KiB)
|
||||
```
|
||||
|
||||
바닥은 게스트가 3 GiB 로 보는데 파일은 335MiB 이고, 20GB 로 선언한 오버레이 둘도 실제로는 1.4GiB 와 665MiB 다. 합쳐 40GB 를 선언하고 2.1GB 를 썼다. `kc-lab-1` 이 `kc-lab-2` 의 두 배 이상을 쓰는 것은 k3s server 가 컨트롤 플레인 바이너리와 SQLite 를 들고 있기 때문이다.
|
||||
|
||||
## `virsh pool-info` 의 `Allocation` 은 VM 사용량이 아니다
|
||||
|
||||
같은 날 `virsh pool-info default` 도 읽었다.
|
||||
|
||||
```text label="스토리지 풀 default"
|
||||
Name: default
|
||||
State: running
|
||||
Persistent: yes Autostart: yes
|
||||
Capacity: 225.31 GiB
|
||||
Allocation: 7.84 GiB
|
||||
Available: 217.46 GiB
|
||||
```
|
||||
|
||||
`Allocation` 7.84 GiB 는 풀이 얹힌 호스트 루트 파일시스템 전체의 사용량이다. VM 이 얼마를 쓰는지는 위의 `ls -l` 이 말한다. 두 수를 같은 것으로 읽으면 게스트 둘이 7.84 GiB 를 먹은 것이 된다.
|
||||
|
||||
## 철거 — `df -h /` 가 11G 에서 7.9G 로
|
||||
|
||||
게스트 셋을 `virsh destroy` 로 내리고 `virsh undefine --remove-all-storage` 로 지웠다. 한 대분 출력은 이렇다.
|
||||
|
||||
```text label="kc-lab-edge 한 대를 철거한 출력"
|
||||
Domain 'kc-lab-edge' destroyed
|
||||
Domain 'kc-lab-edge' has been undefined
|
||||
Volume 'vda'(/var/lib/libvirt/images/kc-lab-edge.qcow2) removed.
|
||||
Volume 'vdb'(/var/lib/libvirt/images/seed-kc-lab-edge.iso) removed.
|
||||
```
|
||||
|
||||
`Volume` 줄이 두 개 나오는데, 오버레이 디스크 `vda` 와 시드 ISO `vdb` 다.
|
||||
|
||||
| 무엇을 읽었나 | 철거 전 | 철거 후 |
|
||||
|---|---|---|
|
||||
| `virsh list --all` | 3 대 running | (없음) |
|
||||
| `virsh vol-list default` | 7 개 | `base.qcow2` 1 개 |
|
||||
| DHCP 예약 | 3 줄 | 0 줄 |
|
||||
| `df -h /` | 11G | 7.9G |
|
||||
| `virbr0` | UP | DOWN |
|
||||
|
||||
3.1GB 가 회수됐고 내역은 `kc-lab-1` 1.4GB 와 `kc-lab-2` 665MB 와 시드 ISO 3개(각 370KB)다. `base.qcow2` 335MB 는 다음 재구축의 바닥이라 남긴다. 다시 받아도 몇 분이면 된다.
|
||||
|
||||
## 같은 대상의 숫자가 두 벌이다
|
||||
|
||||
이 SSOT 안에는 같은 실험대의 스냅샷이 두 벌 있다. §218 은 2026-09-03 값이고 이 기록은 2026-09-10 값이다.
|
||||
|
||||
| 무엇 | 2026-09-03 | 2026-09-10 |
|
||||
|---|---|---|
|
||||
| 호스트 RAM | `RAM 7.4Gi` | `Mem: 11648` |
|
||||
| `kc-lab-1` | `RAM 3584M · vCPU 2` | 할당 5120MB |
|
||||
| `kc-lab-2` | `RAM 2560M · vCPU 2` | 할당 3120MB (선언 4096) |
|
||||
| 게스트 수 | 2 (엣지 없음) | 3 (엣지 추가) |
|
||||
|
||||
그사이에 호스트 RAM 이 8GB 에서 12GB 로 물리 증설됐고 `setmaxmem` 과 `setmem` 으로 게스트 메모리가 재배분됐다. 두 값이 어긋나 보이면 틀린 것이 아니라 다른 날이다.
|
||||
|
||||
날짜가 아니라 단위로 갈리는 것도 하나 있다. §198 은 같은 호스트의 RAM 을 `11.6GB` 로도 적는데, 그 표기가 원 가이드에서 온 것이라 고쳐 쓰지 않고 어긋남을 적어 둔다고 스스로 밝힌다. `free -m` 의 `Mem: 11648` 은 MiB 단위이므로 11,648MiB, 약 11.4GiB 다.
|
||||
|
||||
## 확인하지 못한 것
|
||||
|
||||
이 값은 호스트 한 대의 한 시점이다. Keycloak 2 파드와 PostgreSQL 과 Redis 와 Prometheus 가 올라간 뒤의 메모리는 재지 않았다. 원본도 §198 에서 그 시점의 값을 미측정으로 밝힌다.
|
||||
|
||||
디스크 값과 철거 값은 시점이 서로 다르다. `ls -l` 은 엣지를 만들기 전 k3s 2노드만 있던 때의 것이고, 3.1GB 회수는 엣지까지 세 대가 있던 때의 것이다. `kc-lab-edge` 의 디스크 크기는 따로 재 두지 않았고, 회수 합계에서 역산하면 1GB 안팎이다.
|
||||
|
||||
게스트 안에서 본 실사용과 QEMU 프로세스가 호스트에서 붙잡고 있는 양은 다른 수인데, 뒤엣것은 이 기록이 재지 않았다.
|
||||
|
||||
여기 옮긴 출력은 전부 SSOT 본문의 코드 블록에서 왔고, 이 저장소의 `final/evidence/` 에는 그 명령들의 출력 원문이 파일로 없다. 같은 측정을 다시 돌려 `final/evidence/raw/` 에 남기면 그때 원문을 댈 수 있다.
|
||||
|
||||
<!-- body:end -->
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"authority": "deterministic",
|
||||
"gate": "command-pedagogy-signals",
|
||||
"section_id": "docs/virtualization/tech-log-studio/lab-environment-build/case/case-declared-memory-and-disk-are-ceilings-not-occupancy.md",
|
||||
"mode": "reference",
|
||||
"source_sha256": "5d8d230d67e11f05f3146bed379e78940f7b84ddb066439d751ca2dbafc9a080",
|
||||
"result": "PASS",
|
||||
"requires_editor": false,
|
||||
"blocks": [],
|
||||
"findings": [],
|
||||
"extensions": {
|
||||
"command_like_text_blocks": []
|
||||
}
|
||||
}
|
||||
+203
@@ -0,0 +1,203 @@
|
||||
---
|
||||
kind: CASE
|
||||
slug: declared-memory-and-disk-are-ceilings-not-occupancy
|
||||
title: 5120MB 를 줬는데 353MB 를 쓰고 있었다 — 선언한 양과 실제로 드는 양
|
||||
topic: lab-environment-build
|
||||
topicName: 실험대 환경 구성
|
||||
project: virtualization
|
||||
status: 게시 전
|
||||
lastVerifiedOn: 2026-09-10
|
||||
sourceRevision: import-head 9465582b5d1630eb4ae7c4e078021486919bf6b6 · uncommitted-working-tree snapshot
|
||||
source:
|
||||
- final/document.md#198-자원-할당과-실사용은-다르다
|
||||
- final/document.md#199-디스크-오버레이는-얼마나-쓰나
|
||||
- final/document.md#202-철거-실제-출력-전문
|
||||
- final/document.md#197-측정-환경
|
||||
- final/document.md#195-이-부의-출처와-범위
|
||||
- final/document.md#211-이-부의-출처와-범위
|
||||
---
|
||||
|
||||
# 5120MB 를 줬는데 353MB 를 쓰고 있었다 — 선언한 양과 실제로 드는 양
|
||||
|
||||
k3s 만 올린 상태에서 kc-lab-1 은 5120MB 를 할당받고 353MB 를 쓰고 있었다. k3s 두 노드에 8240MB 를 선언해 실제 점유는 654MB 였고, 디스크는 40GB 를 선언해 2.1GB 를 썼다. 2026-09-10 에 test-server 에서 쟀다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **이 호스트의 가상 머신들은 설정한 메모리와 지금 잡고 있는 메모리가 얼마나 다른가**
|
||||
그 물음이 묻는 세 값 가운데 configured 와 게스트 사용량이 여기서 나왔다. 호스트 resident 는 비어 있어서 그 물음은 닫히지 않았다.
|
||||
- **이 disk image 는 qcow2 인가 RAW 인가, 그리고 Guest 가 보는 크기와 Host 가 실제로 쓰는 크기는 얼마나 다른가**
|
||||
그 물음이 요구하는 세 값 가운데 `qemu-img info` 와 `ls` 가 여기서 나왔고 `du` 는 돌리지 않았다.
|
||||
- **QEMU 프로세스가 실제로 붙잡고 있는 호스트 메모리는 얼마이고 어떻게 나뉘어 있는가**
|
||||
게스트 안에서 본 실사용과 호스트가 실제로 잡아 둔 양은 다른 수다. 후자를 그 물음이 받는다.
|
||||
- **qcow2 파일 안 — 매핑표와 클러스터, 그리고 항목이 0 이면 바닥에 다시 묻는다**
|
||||
20GB 를 선언한 파일이 1.4GiB 인 까닭을 그 글이 매핑표와 오버레이로 설명한다.
|
||||
- **실험대를 철거하고 무엇이 남는지 확인한다**
|
||||
회수량 3.1GB 를 낸 철거 절차가 그 기록에 있다.
|
||||
- **실험대를 껐다 켜고 게스트 메모리를 다시 나눈다**
|
||||
`kc-lab-1` 이 3584M 에서 5120MB 가 된 재배분 절차가 그 기록에 있다.
|
||||
- **도구가 낸 출력은 대상의 상태가 아니다 — 그 명령이 무엇을 세는지부터 가른다**
|
||||
`dommemstat` 의 `actual`, `free` 의 `available`, `pool-info` 의 `Allocation` 이 셋 다 이름과 다른 것을 센다.
|
||||
|
||||
## 문제
|
||||
|
||||
제1~4부에는 이 호스트에서 잰 값이 하나도 없다. 제5·6부는 버전과 주소와 명령까지만 적었다.
|
||||
|
||||
게스트 세 대에 메모리 8240MB 와 디스크 40GB 를 선언해 두었는데, 그 선언이 11,648MiB 와 226G 짜리 호스트 한 대에서 실제로 얼마를 먹는지는 어디에도 없었다. 「5GB 를 줬으니 5GB 를 쓴다」와 「20GB 두 장이면 40GB 를 쓴다」가 맞는지 모르는 채로 게스트를 더 띄울지 정해야 했다.
|
||||
|
||||
## 결론
|
||||
|
||||
선언한 양은 상한이고 점유가 아니다. k3s 만 떠 있고 Keycloak 은 아직 안 올린 상태에서 넷을 쟀다.
|
||||
|
||||
메모리 : `kc-lab-1` 은 할당 5120MB 에 실사용 353MB, `kc-lab-2` 는 할당 3120MB 에 실사용 301MB
|
||||
메모리 합계 : 8240MB 를 할당했고 실제 점유는 654MB
|
||||
디스크 : `kc-lab-1.qcow2` 1.4GiB, `kc-lab-2.qcow2` 665MiB, 바닥 `base.qcow2` 는 `virtual size` 3 GiB 에 `disk size` 335MiB
|
||||
디스크 합계 : 20GB 를 두 장 선언했고 실제로 쓴 것은 2.1GB
|
||||
철거 : 게스트 셋을 지우자 `df -h /` 가 11G 에서 7.9G 로 내려 3.1GB 가 회수됐다
|
||||
|
||||
`virt-install --memory 4096` 으로 만든 `kc-lab-2` 의 할당이 3120 으로 보인다. `dommemstat` 의 `actual` 은 현재 할당이지 선언한 상한이 아니고, 상한은 `virsh dominfo` 의 `Max memory` 에 있다. 줄어든 까닭은 virtio-balloon 회수로 보이는데, 두 값을 나란히 찍어 보지는 않았다.
|
||||
|
||||
## 검증 환경
|
||||
|
||||
측정일 : 2026-09-10
|
||||
호스트 : `test-server`, Arch Linux
|
||||
CPU : `11th Gen Intel(R) Core(TM) i5-1135G7 @ 2.40GHz`, 논리 코어 8
|
||||
RAM : 11,648MiB
|
||||
루트 파일시스템 : 226G 가운데 9.9G 사용
|
||||
QEMU : 11.1.1
|
||||
libvirt : 12.7.0
|
||||
커널 : `7.2.2-arch1-1`
|
||||
중첩 가상화 : `nested` 가 `Y` 지만 이 실험대는 쓰지 않는다
|
||||
게스트 : Debian 12 genericcloud 3대 — 엣지 1대, k3s 2노드
|
||||
워크로드 : k3s 만 떠 있고 Keycloak · PostgreSQL · Redis · Prometheus 는 올리기 전
|
||||
|
||||
## 재현 조건
|
||||
|
||||
1. k3s 두 노드만 띄우고 Keycloak · PostgreSQL · Redis · Prometheus 는 올리지 않는다.
|
||||
2. 게스트마다 `virsh dommemstat` 을 돌려 `actual` 과 `unused` 를 읽고, 그 차이를 실사용으로 잡는다.
|
||||
3. `qemu-img info` 로 `base.qcow2` 의 `virtual size` 와 `disk size` 를 읽고, `ls -l /var/lib/libvirt/images/` 로 오버레이와 시드 파일의 바이트 수를 읽는다.
|
||||
4. 철거하기 전에 `df -h /` 를 읽어 둔다.
|
||||
5. `virsh destroy` 와 `virsh undefine --remove-all-storage` 로 게스트 셋을 지우고 `df -h /` 를 다시 읽는다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
## 이 호스트에서 처음으로 양을 쟀다
|
||||
|
||||
이 실험대의 문서는 제5·6부까지 버전과 주소와 명령을 적었고 자원의 양은 적지 않았다. 제7부가 그 양과 시간을 처음 쟀고, 원본이 표기 규약을 스스로 밝혀 두었다.
|
||||
|
||||
> 여기 적힌 숫자는 전부 2026-09-10 에 `test-server` 에서 실제로 돌려 받은 출력이다. 추정값·예상값은 없다. 없는 값은 「미측정」이라고 쓴다.
|
||||
|
||||
측정 환경부터 읽어 둔다. 논리 코어가 8 이고 게스트 셋에 vCPU 를 2 + 2 + 1 로 잡아 여유를 뒀다.
|
||||
|
||||
```text label="측정 환경 — free -m 의 앞 두 줄"
|
||||
total used free shared buff/cache available
|
||||
Mem: 11648 5642 2599 4 3776 6005
|
||||
```
|
||||
|
||||
`available` 이 6005 로 `free` 2599 보다 훨씬 큰데, `buff/cache` 3776 이 필요해지면 회수되기 때문이다. 게스트를 몇 대 더 띄울 수 있는지는 `free` 가 아니라 `available` 로 읽는다.
|
||||
|
||||
## 메모리 — 할당 5120MB 에 실사용 353MB
|
||||
|
||||
게스트마다 `dommemstat` 의 `actual` 에서 `unused` 를 뺀 값이 실사용이다.
|
||||
|
||||
```text label="k3s 만 떠 있고 Keycloak 은 아직 안 올린 상태"
|
||||
kc-lab-1 할당 5120MB 실사용 353MB
|
||||
kc-lab-2 할당 3120MB 실사용 301MB
|
||||
```
|
||||
|
||||
k3s server 한 대가 353MB 를 쓰니 할당의 7% 다. 둘을 합치면 8240MB 를 할당했고 실제 점유는 654MB 여서, 11,648MiB 짜리 호스트 한 대에서 게스트 세 대가 무리 없이 돈다.
|
||||
|
||||
## `kc-lab-2` 의 할당이 4096 이 아니라 3120 이다
|
||||
|
||||
`kc-lab-2` 는 `virt-install --memory 4096` 으로 만들었는데 `dommemstat` 이 3120 을 낸다.
|
||||
|
||||
`dommemstat` 의 `actual` 은 현재 할당이지 선언한 상한이 아니라서, 상한을 보려면 `virsh dominfo` 의 `Max memory` 를 읽어야 한다. 둘을 같은 값으로 읽으면 「메모리가 왜 줄었지」가 된다.
|
||||
|
||||
줄어든 까닭은 virtio-balloon 회수로 보인다. 게스트가 안 쓰는 만큼 balloon 드라이버가 호스트에 돌려주고, 돌려준 만큼 현재 할당이 내려간다. 다만 이 실험대에서 `dommemstat` 의 `actual` 과 `dominfo` 의 `Max memory` 를 나란히 찍어 대조한 기록이 없어서, 3120 이 balloon 회수의 결과라는 것은 관측이 아니라 추론이다.
|
||||
|
||||
## 디스크 — 40GB 를 선언해 2.1GB
|
||||
|
||||
게스트 디스크는 `base.qcow2` 위의 오버레이다. 20GB 짜리를 두 장 만들어도 바닥은 한 벌이고 변경분만 쌓인다.
|
||||
|
||||
```text label="qemu-img info 가 읽은 바닥 이미지"
|
||||
image: /var/lib/libvirt/images/base.qcow2
|
||||
file format: qcow2
|
||||
virtual size: 3 GiB (3221225472 bytes)
|
||||
disk size: 335 MiB
|
||||
```
|
||||
|
||||
```text label="엣지를 만들기 전, k3s 2 노드만 있던 시점의 ls -l"
|
||||
-rw-r--r-- base.qcow2 351404032 (335 MiB)
|
||||
-rw------- kc-lab-1.qcow2 1521025024 (1.4 GiB) ← 선언 20GB
|
||||
-rw------- kc-lab-2.qcow2 697499648 (665 MiB) ← 선언 20GB
|
||||
-rw------- seed-kc-lab-1.iso 378880 (370 KiB)
|
||||
-rw------- seed-kc-lab-2.iso 378880 (370 KiB)
|
||||
```
|
||||
|
||||
바닥은 게스트가 3 GiB 로 보는데 파일은 335MiB 이고, 20GB 로 선언한 오버레이 둘도 실제로는 1.4GiB 와 665MiB 다. 합쳐 40GB 를 선언하고 2.1GB 를 썼다. `kc-lab-1` 이 `kc-lab-2` 의 두 배 이상을 쓰는 것은 k3s server 가 컨트롤 플레인 바이너리와 SQLite 를 들고 있기 때문이다.
|
||||
|
||||
## `virsh pool-info` 의 `Allocation` 은 VM 사용량이 아니다
|
||||
|
||||
같은 날 `virsh pool-info default` 도 읽었다.
|
||||
|
||||
```text label="스토리지 풀 default"
|
||||
Name: default
|
||||
State: running
|
||||
Persistent: yes Autostart: yes
|
||||
Capacity: 225.31 GiB
|
||||
Allocation: 7.84 GiB
|
||||
Available: 217.46 GiB
|
||||
```
|
||||
|
||||
`Allocation` 7.84 GiB 는 풀이 얹힌 호스트 루트 파일시스템 전체의 사용량이다. VM 이 얼마를 쓰는지는 위의 `ls -l` 이 말한다. 두 수를 같은 것으로 읽으면 게스트 둘이 7.84 GiB 를 먹은 것이 된다.
|
||||
|
||||
## 철거 — `df -h /` 가 11G 에서 7.9G 로
|
||||
|
||||
게스트 셋을 `virsh destroy` 로 내리고 `virsh undefine --remove-all-storage` 로 지웠다. 한 대분 출력은 이렇다.
|
||||
|
||||
```text label="kc-lab-edge 한 대를 철거한 출력"
|
||||
Domain 'kc-lab-edge' destroyed
|
||||
Domain 'kc-lab-edge' has been undefined
|
||||
Volume 'vda'(/var/lib/libvirt/images/kc-lab-edge.qcow2) removed.
|
||||
Volume 'vdb'(/var/lib/libvirt/images/seed-kc-lab-edge.iso) removed.
|
||||
```
|
||||
|
||||
`Volume` 줄이 두 개 나오는데, 오버레이 디스크 `vda` 와 시드 ISO `vdb` 다.
|
||||
|
||||
| 무엇을 읽었나 | 철거 전 | 철거 후 |
|
||||
|---|---|---|
|
||||
| `virsh list --all` | 3 대 running | (없음) |
|
||||
| `virsh vol-list default` | 7 개 | `base.qcow2` 1 개 |
|
||||
| DHCP 예약 | 3 줄 | 0 줄 |
|
||||
| `df -h /` | 11G | 7.9G |
|
||||
| `virbr0` | UP | DOWN |
|
||||
|
||||
3.1GB 가 회수됐고 내역은 `kc-lab-1` 1.4GB 와 `kc-lab-2` 665MB 와 시드 ISO 3개(각 370KB)다. `base.qcow2` 335MB 는 다음 재구축의 바닥이라 남긴다. 다시 받아도 몇 분이면 된다.
|
||||
|
||||
## 같은 대상의 숫자가 두 벌이다
|
||||
|
||||
이 SSOT 안에는 같은 실험대의 스냅샷이 두 벌 있다. §218 은 2026-09-03 값이고 이 기록은 2026-09-10 값이다.
|
||||
|
||||
| 무엇 | 2026-09-03 | 2026-09-10 |
|
||||
|---|---|---|
|
||||
| 호스트 RAM | `RAM 7.4Gi` | `Mem: 11648` |
|
||||
| `kc-lab-1` | `RAM 3584M · vCPU 2` | 할당 5120MB |
|
||||
| `kc-lab-2` | `RAM 2560M · vCPU 2` | 할당 3120MB (선언 4096) |
|
||||
| 게스트 수 | 2 (엣지 없음) | 3 (엣지 추가) |
|
||||
|
||||
그사이에 호스트 RAM 이 8GB 에서 12GB 로 물리 증설됐고 `setmaxmem` 과 `setmem` 으로 게스트 메모리가 재배분됐다. 두 값이 어긋나 보이면 틀린 것이 아니라 다른 날이다.
|
||||
|
||||
날짜가 아니라 단위로 갈리는 것도 하나 있다. §198 은 같은 호스트의 RAM 을 `11.6GB` 로도 적는데, 그 표기가 원 가이드에서 온 것이라 고쳐 쓰지 않고 어긋남을 적어 둔다고 스스로 밝힌다. `free -m` 의 `Mem: 11648` 은 MiB 단위이므로 11,648MiB, 약 11.4GiB 다.
|
||||
|
||||
## 확인하지 못한 것
|
||||
|
||||
이 값은 호스트 한 대의 한 시점이다. Keycloak 2 파드와 PostgreSQL 과 Redis 와 Prometheus 가 올라간 뒤의 메모리는 재지 않았다. 원본도 §198 에서 그 시점의 값을 미측정으로 밝힌다.
|
||||
|
||||
디스크 값과 철거 값은 시점이 서로 다르다. `ls -l` 은 엣지를 만들기 전 k3s 2노드만 있던 때의 것이고, 3.1GB 회수는 엣지까지 세 대가 있던 때의 것이다. `kc-lab-edge` 의 디스크 크기는 따로 재 두지 않았고, 회수 합계에서 역산하면 1GB 안팎이다.
|
||||
|
||||
게스트 안에서 본 실사용과 QEMU 프로세스가 호스트에서 붙잡고 있는 양은 다른 수인데, 뒤엣것은 이 기록이 재지 않았다.
|
||||
|
||||
여기 옮긴 출력은 전부 SSOT 본문의 코드 블록에서 왔고, 이 저장소의 `final/evidence/` 에는 그 명령들의 출력 원문이 파일로 없다. 같은 측정을 다시 돌려 `final/evidence/raw/` 에 남기면 그때 원문을 댈 수 있다.
|
||||
|
||||
<!-- body:end -->
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"record": "docs/virtualization/tech-log-studio/lab-environment-build/case/case-declared-memory-and-disk-are-ceilings-not-occupancy.md",
|
||||
"sourceSha256": "5d8d230d67e11f05f3146bed379e78940f7b84ddb066439d751ca2dbafc9a080",
|
||||
"verdict": "PASS",
|
||||
"liveSourceReconciliation": "UNVERIFIABLE",
|
||||
"liveSourceReason": "source repositories unavailable on current machine",
|
||||
"acceptedByProjectReview": true,
|
||||
"checks": [
|
||||
"local SSOT/Record evidence mismatch 0",
|
||||
"S3/S5/S6 check_evidence --repo invoked",
|
||||
"source checkout absence preserved as exit 3"
|
||||
],
|
||||
"note": "현재 project evidence gate는 불일치 0건·대조 불가 2건이다. source checkout 부재를 PASS로 위장하지 않고 UNVERIFIABLE로 보존했다."
|
||||
}
|
||||
+365
@@ -0,0 +1,365 @@
|
||||
{
|
||||
"schemaVersion": 5,
|
||||
"runId": "2026-09-21-0218-remediation-11-case-nftables-accept-did-not-stop-the-libvirt-reject",
|
||||
"project": "virtualization",
|
||||
"record": "docs/virtualization/tech-log-studio/lab-environment-build/case/case-nftables-accept-did-not-stop-the-libvirt-reject.md",
|
||||
"startedAt": "2026-09-21T02:24:52+00:00",
|
||||
"finishedAt": "2026-09-21T02:24:54+00:00",
|
||||
"stages": [
|
||||
{
|
||||
"id": "S1",
|
||||
"name": "코드베이스 → SSOT",
|
||||
"skill": "analyzing-codebase-for-tech-log",
|
||||
"runBy": "ssot-analyst",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "이번 remediation은 새 evidence와 현재 SSOT/Record의 정합성·provenance·command/S4를 재검토한다. 코드베이스→SSOT 전면 분석은 다시 하지 않는다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": "edd45dfec66d155a621cd41186b83b5582f2244c",
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"gates": [],
|
||||
"notes": "",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-21T02:24:52+00:00",
|
||||
"elapsedSeconds": 0,
|
||||
"finishedBy": "chatgpt-virtualization-20260921-remediation-v5"
|
||||
},
|
||||
{
|
||||
"id": "S2",
|
||||
"name": "SSOT → 분해 계약",
|
||||
"skill": "deriving-tech-log-root-tree",
|
||||
"runBy": "tree-deriver",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "기존 91 Record 분해 계약은 유지한다. certbot Question의 RESOLVED 상태와 sourcePolicy는 project tree rebuild에서 별도로 동기화했다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": "ab59130196d79e947b32e3b5e6b75335a9e5c1eb",
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"gates": [],
|
||||
"notes": "",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-21T02:24:52+00:00",
|
||||
"elapsedSeconds": 0,
|
||||
"finishedBy": "chatgpt-virtualization-20260921-remediation-v5"
|
||||
},
|
||||
{
|
||||
"id": "S3",
|
||||
"name": "글감 → 기록",
|
||||
"skill": "writing-tech-log-records",
|
||||
"runBy": "record-writer",
|
||||
"status": "DONE",
|
||||
"skipReason": "",
|
||||
"skillEcho": "**인용한 줄은 SSOT 에서 찾아 대조한다.**",
|
||||
"skillRevision": null,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
"docs/virtualization/tech-log-studio/lab-environment-build/case/case-nftables-accept-did-not-stop-the-libvirt-reject.md"
|
||||
],
|
||||
"gates": [
|
||||
{
|
||||
"cmd": "python3 scripts/studio-body.py docs/virtualization/tech-log-studio/lab-environment-build/case/case-nftables-accept-did-not-stop-the-libvirt-reject.md -o runs/virtualization/2026-09-21-0218-remediation-11-case-nftables-accept-did-not-stop-the-libvirt-reject/stage/S3/studio-body.md",
|
||||
"exit": 0,
|
||||
"status": "PASS",
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"generation": 1,
|
||||
"at": "2026-09-21T02:24:52+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node --experimental-transform-types .agents/skills/writing-tech-log-records/scripts/check_body.mjs runs/virtualization/2026-09-21-0218-remediation-11-case-nftables-accept-did-not-stop-the-libvirt-reject/stage/S3/studio-body.md --frontend /shared/codebase/tech-log-frontend",
|
||||
"exit": 0,
|
||||
"status": "PASS",
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"generation": 1,
|
||||
"at": "2026-09-21T02:24:52+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node .agents/skills/rewriting-technical-prose-naturally/scripts/check_prose.mjs --warn docs/virtualization/tech-log-studio/lab-environment-build/case/case-nftables-accept-did-not-stop-the-libvirt-reject.md",
|
||||
"exit": 0,
|
||||
"status": "PASS",
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"generation": 1,
|
||||
"at": "2026-09-21T02:24:52+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node .agents/skills/writing-tech-log-records/scripts/check_evidence.mjs virtualization --repo",
|
||||
"exit": 3,
|
||||
"status": "UNVERIFIABLE",
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"generation": 1,
|
||||
"at": "2026-09-21T02:24:52+00:00",
|
||||
"semanticId": "evidence-repo",
|
||||
"reason": "source repositories unavailable on current machine",
|
||||
"acceptedByProjectReview": true
|
||||
}
|
||||
],
|
||||
"notes": "record-writer/fact 계약으로 새 evidence와 SSOT의 current truth를 대조했다. --repo 대조는 실제 실행했고 source checkout 부재만 UNVERIFIABLE로 남겼다.",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-21T02:24:52+00:00",
|
||||
"elapsedSeconds": 0,
|
||||
"generation": 1,
|
||||
"owner": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"finishedBy": "chatgpt-virtualization-20260921-remediation-v5"
|
||||
},
|
||||
{
|
||||
"id": "S4",
|
||||
"name": "기록 → 그림",
|
||||
"skill": "technical-visualizer",
|
||||
"runBy": "diagram-maker",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "이 Record 변경은 current truth/evidence/version/provenance 정리이며 새 관계 그림을 요구하지 않는다. 기존 그림은 project-wide 14장 검증에서 별도로 확인했다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": "edd45dfec66d155a621cd41186b83b5582f2244c",
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"gates": [],
|
||||
"notes": "",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-21T02:24:52+00:00",
|
||||
"elapsedSeconds": 0,
|
||||
"finishedBy": "chatgpt-virtualization-20260921-remediation-v5"
|
||||
},
|
||||
{
|
||||
"id": "S5",
|
||||
"name": "AI 티 제거",
|
||||
"skill": "rewriting-technical-prose-naturally",
|
||||
"runBy": "prose-rewriter",
|
||||
"status": "DONE",
|
||||
"skipReason": "",
|
||||
"skillEcho": "This is an **editorial** pass. The source's facts, evidence, causal chain, uncertainty, decision status, and technical depth are the contract.",
|
||||
"skillRevision": null,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
"docs/virtualization/tech-log-studio/lab-environment-build/case/case-nftables-accept-did-not-stop-the-libvirt-reject.md"
|
||||
],
|
||||
"gates": [
|
||||
{
|
||||
"cmd": "node .agents/skills/rewriting-technical-prose-naturally/scripts/check_prose.mjs --warn docs/virtualization/tech-log-studio/lab-environment-build/case/case-nftables-accept-did-not-stop-the-libvirt-reject.md",
|
||||
"exit": 0,
|
||||
"status": "PASS",
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"generation": 1,
|
||||
"at": "2026-09-21T02:24:53+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node .agents/skills/rewriting-technical-prose-naturally/scripts/style_profile.mjs docs/virtualization/tech-log-studio/lab-environment-build/case/case-nftables-accept-did-not-stop-the-libvirt-reject.md",
|
||||
"exit": 0,
|
||||
"status": "PASS",
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"generation": 1,
|
||||
"at": "2026-09-21T02:24:53+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node --experimental-transform-types .agents/skills/writing-tech-log-records/scripts/check_body.mjs runs/virtualization/2026-09-21-0218-remediation-11-case-nftables-accept-did-not-stop-the-libvirt-reject/stage/S5/studio-body.md --frontend /shared/codebase/tech-log-frontend",
|
||||
"exit": 0,
|
||||
"status": "PASS",
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"generation": 1,
|
||||
"at": "2026-09-21T02:24:53+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node .agents/skills/writing-tech-log-records/scripts/check_evidence.mjs virtualization --repo",
|
||||
"exit": 3,
|
||||
"status": "UNVERIFIABLE",
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"generation": 1,
|
||||
"at": "2026-09-21T02:24:53+00:00",
|
||||
"semanticId": "evidence-repo",
|
||||
"reason": "source repositories unavailable on current machine",
|
||||
"acceptedByProjectReview": true
|
||||
}
|
||||
],
|
||||
"notes": "대규모 문체 재작성 없이 stale fact/relation만 현재 evidence에 맞췄다. style_profile은 측정값으로만 기록했다.",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-21T02:24:53+00:00",
|
||||
"elapsedSeconds": 1,
|
||||
"generation": 1,
|
||||
"owner": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"finishedBy": "chatgpt-virtualization-20260921-remediation-v5"
|
||||
},
|
||||
{
|
||||
"id": "S6",
|
||||
"name": "일한 사람의 목소리",
|
||||
"skill": "writing-as-the-person-who-did-it",
|
||||
"runBy": "voice-writer",
|
||||
"status": "DONE",
|
||||
"skipReason": "",
|
||||
"skillEcho": "찾은 것이 없으면 **이 스킬은 여기서 끝난다.** 없는 목소리를 채우지 않는다.",
|
||||
"skillRevision": "edd45dfec66d155a621cd41186b83b5582f2244c",
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
"docs/virtualization/tech-log-studio/lab-environment-build/case/case-nftables-accept-did-not-stop-the-libvirt-reject.md"
|
||||
],
|
||||
"gates": [
|
||||
{
|
||||
"cmd": "node .agents/skills/writing-as-the-person-who-did-it/scripts/check_voice.mjs docs/virtualization/tech-log-studio/lab-environment-build/case/case-nftables-accept-did-not-stop-the-libvirt-reject.md",
|
||||
"exit": 0,
|
||||
"status": "PASS",
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"generation": 1,
|
||||
"at": "2026-09-21T02:24:53+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node .agents/skills/rewriting-technical-prose-naturally/scripts/check_prose.mjs --warn docs/virtualization/tech-log-studio/lab-environment-build/case/case-nftables-accept-did-not-stop-the-libvirt-reject.md",
|
||||
"exit": 0,
|
||||
"status": "PASS",
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"generation": 1,
|
||||
"at": "2026-09-21T02:24:53+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node --experimental-transform-types .agents/skills/writing-tech-log-records/scripts/check_body.mjs runs/virtualization/2026-09-21-0218-remediation-11-case-nftables-accept-did-not-stop-the-libvirt-reject/stage/S6/studio-body.md --frontend /shared/codebase/tech-log-frontend",
|
||||
"exit": 0,
|
||||
"status": "PASS",
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"generation": 1,
|
||||
"at": "2026-09-21T02:24:54+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node .agents/skills/writing-tech-log-records/scripts/check_evidence.mjs virtualization --repo",
|
||||
"exit": 3,
|
||||
"status": "UNVERIFIABLE",
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"generation": 1,
|
||||
"at": "2026-09-21T02:24:54+00:00",
|
||||
"semanticId": "evidence-repo",
|
||||
"reason": "source repositories unavailable on current machine",
|
||||
"acceptedByProjectReview": true
|
||||
}
|
||||
],
|
||||
"notes": "상류 SSOT/evidence를 다시 보며 실제 작업 흔적이 없는 서사는 추가하지 않았다.",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-21T02:24:54+00:00",
|
||||
"elapsedSeconds": 1,
|
||||
"generation": 1,
|
||||
"owner": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"finishedBy": "chatgpt-virtualization-20260921-remediation-v5"
|
||||
},
|
||||
{
|
||||
"id": "S7",
|
||||
"name": "Studio 저장",
|
||||
"skill": "publishing-tech-log-to-studio",
|
||||
"runBy": "studio-validator",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "이번 remediation에서 Studio import/save는 요청되지 않았다. 저장소 산출물과 검증 영수증만 갱신한다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": "862e502af3e956b49ccd2ae0a8de3fd32f90df9c",
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"gates": [],
|
||||
"notes": "",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-21T02:24:54+00:00",
|
||||
"elapsedSeconds": 0,
|
||||
"finishedBy": "chatgpt-virtualization-20260921-remediation-v5"
|
||||
}
|
||||
],
|
||||
"qualityReviews": {
|
||||
"commandPedagogy": {
|
||||
"initialAnalysis": {
|
||||
"cmd": "python3 scripts/check-command-pedagogy.py --mode reference docs/virtualization/tech-log-studio/lab-environment-build/case/case-nftables-accept-did-not-stop-the-libvirt-reject.md -o runs/virtualization/2026-09-21-0218-remediation-11-case-nftables-accept-did-not-stop-the-libvirt-reject/stage/S3/command-initial.json",
|
||||
"exit": 0,
|
||||
"shellBlocks": 1,
|
||||
"findings": 0,
|
||||
"majorFindings": 0,
|
||||
"artifact": {
|
||||
"path": "runs/virtualization/2026-09-21-0218-remediation-11-case-nftables-accept-did-not-stop-the-libvirt-reject/stage/S3/command-initial.json",
|
||||
"sha256": "97d4c484b480649f85d4937bba44ceaa766d815022081403ee5499cf022d0d2f"
|
||||
}
|
||||
},
|
||||
"finalAnalysis": {
|
||||
"cmd": "python3 scripts/check-command-pedagogy.py --mode reference docs/virtualization/tech-log-studio/lab-environment-build/case/case-nftables-accept-did-not-stop-the-libvirt-reject.md -o runs/virtualization/2026-09-21-0218-remediation-11-case-nftables-accept-did-not-stop-the-libvirt-reject/stage/S6/command-final.json",
|
||||
"exit": 0,
|
||||
"shellBlocks": 1,
|
||||
"findings": 0,
|
||||
"majorFindings": 0,
|
||||
"artifact": {
|
||||
"path": "runs/virtualization/2026-09-21-0218-remediation-11-case-nftables-accept-did-not-stop-the-libvirt-reject/stage/S6/command-final.json",
|
||||
"sha256": "97d4c484b480649f85d4937bba44ceaa766d815022081403ee5499cf022d0d2f"
|
||||
}
|
||||
},
|
||||
"planner": {
|
||||
"runBy": "command-pedagogy-planner",
|
||||
"skill": "writing-practitioner-guides",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "initial deterministic analysis finding이 0이다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": null,
|
||||
"notes": "현재 reference-mode 분석을 확인했다.",
|
||||
"artifact": null
|
||||
},
|
||||
"editor": {
|
||||
"runBy": "command-pedagogy-editor",
|
||||
"skill": "writing-practitioner-guides",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "initial deterministic analysis finding이 0이다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": null,
|
||||
"notes": "현재 reference-mode 분석을 확인했다.",
|
||||
"artifact": null
|
||||
},
|
||||
"reviewer": {
|
||||
"runBy": "command-pedagogy-reviewer",
|
||||
"skill": "writing-practitioner-guides",
|
||||
"status": "DONE",
|
||||
"skipReason": "",
|
||||
"skillEcho": "**Optimize for human operation, not command compactness.**",
|
||||
"skillRevision": null,
|
||||
"verdict": "PASS",
|
||||
"notes": "project-level command audit와 현재 final analysis를 대조했다. final major finding은 0이다.",
|
||||
"artifact": {
|
||||
"path": "runs/virtualization/2026-09-21-0218-remediation-11-case-nftables-accept-did-not-stop-the-libvirt-reject/stage/quality/command-review.json",
|
||||
"sha256": "2a6582bbf86ed7f2c15650fb4e9d72638f58960158aa8a0068d2c05bd8ab898e"
|
||||
},
|
||||
"sourceSha256": "781755144a688998e099f7462d228b3df044260b7e6b3417858a4358fcfb4d5a"
|
||||
}
|
||||
},
|
||||
"technicalEvidence": {
|
||||
"runBy": "fact-reviewer",
|
||||
"status": "DONE",
|
||||
"skipReason": "",
|
||||
"verdict": "PASS",
|
||||
"notes": "current truth/evidence/provenance를 SSOT와 역대조했다. live source는 실제 --repo 결과 exit 3이며 UNVERIFIABLE로 보존했다.",
|
||||
"sourceSha256": "781755144a688998e099f7462d228b3df044260b7e6b3417858a4358fcfb4d5a",
|
||||
"liveSourceReconciliation": "UNVERIFIABLE",
|
||||
"liveSourceReason": "source repositories unavailable on current machine",
|
||||
"acceptedByProjectReview": true,
|
||||
"artifact": {
|
||||
"path": "runs/virtualization/2026-09-21-0218-remediation-11-case-nftables-accept-did-not-stop-the-libvirt-reject/stage/quality/technical-evidence-review.json",
|
||||
"sha256": "e608f33a0c732ece6da480e3ae8b9d990053392fd6586873dcb26e78a007c832"
|
||||
}
|
||||
}
|
||||
},
|
||||
"riders": [],
|
||||
"sessions": [
|
||||
{
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"openedAt": "2026-09-21T02:24:52+00:00"
|
||||
},
|
||||
{
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"stage": "S3",
|
||||
"generation": 1,
|
||||
"beganAt": "2026-09-21T02:24:52+00:00"
|
||||
},
|
||||
{
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"stage": "S5",
|
||||
"generation": 1,
|
||||
"beganAt": "2026-09-21T02:24:52+00:00"
|
||||
},
|
||||
{
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"stage": "S6",
|
||||
"generation": 1,
|
||||
"beganAt": "2026-09-21T02:24:53+00:00"
|
||||
}
|
||||
],
|
||||
"revision": 23,
|
||||
"updatedAt": "2026-09-21T02:24:54+00:00",
|
||||
"executionEnvironment": {
|
||||
"mode": "current-remediation-contract-replay",
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"agentToolAvailable": false,
|
||||
"note": "별도 subagent 실행 도구가 현재 환경에 없어 현재 세션이 각 .claude/agents 역할 정의와 연결된 SKILL/contract를 직접 읽고 역할을 분리해 수행했다. runBy는 계약 역할명이며 별도 프로세스 실행을 주장하지 않는다."
|
||||
}
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"authority": "deterministic",
|
||||
"gate": "command-pedagogy-signals",
|
||||
"section_id": "docs/virtualization/tech-log-studio/lab-environment-build/case/case-nftables-accept-did-not-stop-the-libvirt-reject.md",
|
||||
"mode": "reference",
|
||||
"source_sha256": "781755144a688998e099f7462d228b3df044260b7e6b3417858a4358fcfb4d5a",
|
||||
"result": "PASS",
|
||||
"requires_editor": false,
|
||||
"blocks": [
|
||||
{
|
||||
"id": "command-001",
|
||||
"ordinal": 1,
|
||||
"language": "bash",
|
||||
"info_string": "label=\"guest_input 맨 앞에 구멍을 넣는다\"",
|
||||
"label": "guest_input 맨 앞에 구멍을 넣는다",
|
||||
"mode": "reference",
|
||||
"mode_explicit": false,
|
||||
"start": 6342,
|
||||
"end": 6521,
|
||||
"source_sha256": "63b9616453d559228110849f0cf504b52fe55e304583a50d5816dbf1a852be87",
|
||||
"body_sha256": "88245c8ac4a4525e40d863f2a1aa3fc7b42e314163cbaed638341cc1119c428c",
|
||||
"source": "```bash label=\"guest_input 맨 앞에 구멍을 넣는다\"\nnft insert rule ip libvirt_network guest_input \\\n oif virbr0 ip daddr 192.168.122.10 tcp dport '{80,443}' ct state new counter accept\n```",
|
||||
"body": "nft insert rule ip libvirt_network guest_input \\\n oif virbr0 ip daddr 192.168.122.10 tcp dport '{80,443}' ct state new counter accept\n"
|
||||
}
|
||||
],
|
||||
"findings": [],
|
||||
"extensions": {
|
||||
"command_like_text_blocks": []
|
||||
}
|
||||
}
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
---
|
||||
kind: CASE
|
||||
slug: nftables-accept-did-not-stop-the-libvirt-reject
|
||||
title: 호스트 안에서는 404, 밖에서는 connection refused — 앞 체인의 accept 가 libvirt 의 reject 를 막지 못했다
|
||||
topic: lab-environment-build
|
||||
topicName: 실험대 환경 구성
|
||||
project: virtualization
|
||||
status: 게시 전
|
||||
assets:
|
||||
- key: nftables-forward-hook-chain-order
|
||||
file: ../../../final/assets/diagrams/nftables-forward-hook-chain-order/nftables-forward-hook-chain-order.svg
|
||||
sourceRevision: import-head 9465582b5d1630eb4ae7c4e078021486919bf6b6 · uncommitted-working-tree snapshot
|
||||
source:
|
||||
- final/document.md#180-nftables-는-앞-체인의-accept-로-뒤-체인의-reject-를-막지-못한다
|
||||
- final/document.md#179-엣지를-물리-호스트에서-vm-으로-옮기면-무엇이-새로-필요해지나
|
||||
- final/document.md#178-이-부의-출처와-범위
|
||||
---
|
||||
|
||||
# 호스트 안에서는 404, 밖에서는 connection refused — 앞 체인의 accept 가 libvirt 의 reject 를 막지 못했다
|
||||
|
||||
밖에서 온 요청은 먼저 돌게 해 둔 forward 체인의 accept 를 지나고도 libvirt 의 guest_input 체인 끝 reject 에서 끊겼다. nftables 가 같은 훅에 붙은 base 체인을 우선순위 순으로 전부 평가하기 때문이다. 그래서 구멍을 libvirt 체인 맨 앞에 넣었다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **엣지 nginx 를 호스트에서 게스트 VM 으로 옮긴다 — 성능이 아니라 더러워지는 층의 격리**
|
||||
그 결정으로 새로 필요해진 libvirt 방화벽 구멍을 이 사건에서 실제로 뚫었다.
|
||||
- **libvirt 의 firewall_backend 가 iptables 일 때도 guest_input 에 구멍이 필요한가**
|
||||
여기서 재지 않고 넘긴 미확인 항목을 그 질문이 받는다.
|
||||
- **단계별 구축 문서는 작성 시점이 아니라 실행 순서로 검증한다**
|
||||
같은 구축에서 나온 문서 결함들을 하나의 규칙으로 정리한 글이다.
|
||||
- **Guest 의 packet 이 Host Physical NIC 에 닿기까지 — virtio-net · virtqueue · vhost-net · TAP · Bridge**
|
||||
밖에서 온 패킷이 게스트에 닿기까지의 경로를 그 글이 세우고, 이 사건은 그 경로의 한 구간에서 막혔다.
|
||||
- **packet 이 어디서 끊겼는지는 계층마다 capture 해서 가른다**
|
||||
어느 계층까지 패킷이 보이는지로 의심 구간을 좁히는 절차이고, 여기서는 규칙 카운터가 같은 일을 했다.
|
||||
|
||||
## 문제
|
||||
|
||||
엣지 nginx 를 호스트에서 게스트 VM 으로 옮긴 뒤 밖에서 들어오는 요청만 엣지에 닿지 않았다.
|
||||
|
||||
호스트에서 친 요청 : curl http://192.168.122.10 → 404, 엣지 nginx 가 응답한다
|
||||
밖에서 친 요청 : curl http://100.83.212.4 → connection refused
|
||||
|
||||
호스트에서 친 요청에는 404 를 돌려줬으니 엣지 nginx 는 게스트 안에서 돌고 있었다. 밖에서 친 요청만 끊겼고, 타임아웃이 아니라 즉시 거절이었다.
|
||||
|
||||
## 결론
|
||||
|
||||
밖에서 온 패킷을 거절한 것은 libvirt 가 만든 규칙이다. libvirt 는 자기 테이블 ip libvirt_network 의 guest_input 체인을 ct state established,related accept 다음의 reject 로 끝낸다. 그 reject 규칙의 카운터가 4 패킷 240 바이트로 밖에서 친 curl 횟수와 정확히 일치해서 범인을 확정했다.
|
||||
|
||||
DNAT 를 정의한 파일에는 priority filter - 10 을 줘서 먼저 돌게 한 forward 체인이 있는데, 거기 넣은 ct state new accept 가 그 reject 를 막지 못한다. nftables 가 같은 훅에 붙은 base 체인을 우선순위 순으로 전부 평가하기 때문이다. 앞 체인의 accept 는 그 체인을 통과했다는 뜻이지 평가가 끝났다는 뜻이 아니고, 즉시 종결하는 것은 drop 뿐이다.
|
||||
|
||||
해결 : 구멍을 libvirt 체인 맨 앞에 넣는다. insert 가 맨 앞이고 add 가 맨 뒤다.
|
||||
수명 : libvirt 가 네트워크를 다시 세우면 guest_input 을 새로 쓰면서 그 규칙이 날아간다. 그래서 DNAT 유닛의 ExecStartPost 에 넣는다.
|
||||
|
||||
## 검증 환경
|
||||
|
||||
호스트 : test-server, Arch Linux
|
||||
CPU : i5-1135G7, 논리 코어 8
|
||||
RAM : 11,648MiB
|
||||
QEMU : 11.1.1
|
||||
libvirt : 12.7.0
|
||||
libvirt firewall_backend : nftables
|
||||
호스트 이더넷 : 없다 — WiFi 만 있다
|
||||
게스트 네트워크 : libvirt NAT, virbr0
|
||||
게스트 : Debian 12 genericcloud 3대 — 엣지 1대(nginx·certbot), k3s 2노드
|
||||
엣지 게스트 주소 : 192.168.122.10
|
||||
|
||||
## 재현 조건
|
||||
|
||||
1. 엣지 nginx 를 게스트 192.168.122.10 에 두고, 호스트 커널의 DNAT 로 밖에서 들어온 요청을 그 게스트로 넘긴다.
|
||||
2. 호스트에서 curl http://192.168.122.10 을 친다. 엣지 nginx 가 404 로 응답한다.
|
||||
3. 밖에서 curl http://100.83.212.4 를 친다. connection refused 가 온다.
|
||||
4. libvirt 테이블 ip libvirt_network 의 guest_input 체인을 규칙과 카운터까지 덤프한다. 마지막 reject 규칙의 패킷 수가 3번을 친 횟수와 맞으면 그 규칙이 그 패킷을 끝냈다.
|
||||
5. guest_input 맨 앞에 구멍을 넣고 3번을 다시 친다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
## 엣지 nginx 를 게스트로 옮기고 새로 필요해진 것
|
||||
|
||||
이 호스트에는 이더넷이 없고 WiFi 만 있어서 브리지를 못 쓰고, libvirt NAT(`virbr0`)에 호스트로 들어온 요청을 넘기는 구조를 택했다. 그 위에 Debian 12 게스트 세 대가 있고, 그중 한 대가 nginx 와 certbot 을 돌리는 엣지다. 엣지 nginx 는 원래 호스트에 있었고 사는 곳만 게스트로 바꿨다.
|
||||
|
||||
```text label="엣지 이동 전과 후"
|
||||
전: tailnet:443 ─▶ [호스트 nginx] ─────────────▶ Traefik(게스트 .11/.12)
|
||||
후: tailnet:443 ─▶ [호스트 커널 DNAT] ─▶ [엣지 nginx(.10)] ─▶ Traefik(.11/.12)
|
||||
```
|
||||
|
||||
L7 홉 수는 전후 모두 2홉이고 늘어난 것은 커널이 하는 L4 전달 한 번뿐이라 `X-Forwarded-*` 계약은 그대로 성립한다. 옮긴 이유도 성능이 아니라 더러워지는 층의 격리였다. nginx 설정과 인증서, certbot, deploy 훅은 자주 갈아엎는 것들인데 호스트에 있으면 초기화가 불가능하고, 엣지 장애 실험이 SSH 까지 위험하게 만든다.
|
||||
|
||||
그 대가로 일곱 가지가 새로 필요해졌는데 그중 둘은 배포판 차이가 아니라 패킷이 지나는 길이 달라져서 생겼다. 하나는 DNAT(Destination NAT) 다. 들어온 패킷의 도착지 주소를 바꿔 다른 기계로 넘기는 것을 말한다. 전에는 호스트가 직접 `:443` 을 들었으니 넘길 일이 없었는데 지금은 호스트에 리스너가 아예 없다. 다른 하나는 libvirt 방화벽에 구멍을 내는 일이다. 호스트가 게스트에 접속할 때는 OUTPUT 경로라 필터를 안 탔지만, 밖에서 게스트로 들어오는 것은 FORWARD 다. 「호스트가 게스트에 접속한다」와 「밖에서 게스트로 들어온다」는 커널이 보기에 완전히 다른 일이다. 그 구멍을 뚫는 데서 이 구축이 가장 오래 막혔다.
|
||||
|
||||
## 호스트 안에서는 되는데 밖에서만 안 된다
|
||||
|
||||
엣지 nginx 는 호스트에서 친 요청에 404 를 돌려줬으니 게스트 안에서 살아 있었는데, 밖에서 친 요청만 끊겼다.
|
||||
|
||||
| 어디서 쳤나 | 결과 |
|
||||
|---|---|
|
||||
| 호스트에서 `curl http://192.168.122.10` | 404, 엣지 nginx 가 응답 |
|
||||
| 밖에서 `curl http://100.83.212.4` | connection refused |
|
||||
|
||||
패킷을 조용히 버리는 `drop` 이면 클라이언트가 응답을 기다리다 죽으므로, 타임아웃이 아니라 즉시 거절이 돌아왔다는 것이 단서였다.
|
||||
|
||||
## guest_input 체인 끝의 reject 와 카운터 4 패킷
|
||||
|
||||
libvirt 는 자기 테이블 `ip libvirt_network` 안에 `guest_input` 체인을 만들고, 이미 맺어진 연결과 그에 딸린 연결만 통과시킨 다음 나머지를 거절하는 규칙으로 그 체인을 끝낸다.
|
||||
|
||||
```text label="libvirt 가 만든 guest_input 체인의 끝"
|
||||
oif "virbr0" ip daddr 192.168.122.0/24 ct state established,related accept
|
||||
oif "virbr0" counter packets 4 bytes 240 reject ← 여기서 죽는다
|
||||
```
|
||||
|
||||
규칙 하나를 지목해 놓고 시작한 것이 아니다. `nft list ruleset` 에서 `reject` 와 `drop` 이 든 줄만 뽑아 놓고, 밖에서 친 횟수와 카운터가 맞아떨어지는 줄을 찾았다. 이 `reject` 규칙의 카운터가 4 패킷 240 바이트였고, 밖에서 친 `curl` 횟수와 정확히 일치했다. 범인 확정에 쓴 것이 이 숫자다.
|
||||
|
||||
## 왜 앞 체인의 accept 가 안 먹혔나
|
||||
|
||||
DNAT 를 정의한 파일에는 libvirt 체인보다 먼저 돌도록 `priority filter - 10` 을 준 `forward` 체인을 두고 거기에 `ct state new accept` 를 넣어 두었다. 밖에서 온 패킷은 그 `accept` 를 지나고도 거절됐는데, nftables 가 같은 훅에 붙은 base 체인을 우선순위 순으로 전부 평가하기 때문이다. 앞 체인의 `accept` 는 「이 체인은 통과」라는 뜻이지 「평가 끝」이 아니고, 즉시 종결하는 것은 `drop` 뿐이다. iptables 감각으로 쓰면 정확히 여기서 틀린다.
|
||||
|
||||
그 `forward` 체인은 지금 DNAT 파일에 없다. 남은 체인은 `prerouting` 하나이고, 체인을 그냥 지우는 대신 주석 하나를 남겨 두었다 — 여기에 `forward` 체인을 두지 않은 것이 의도이며 구멍은 유닛의 `ExecStartPost` 가 libvirt 자기 체인 안에 넣는다는 내용이다.
|
||||
|
||||
:::evidence key="nftables-forward-hook-chain-order" alt="밖에서 온 패킷 하나가 forward 훅에 붙은 base 체인 둘을 우선순위 순으로 지나는 경로도. 왼쪽 점선 상자가 priority filter - 10 인 forward 체인이고 그 안에 ct state new accept 가 있다. 오른쪽 점선 상자가 ip libvirt_network 의 guest_input 체인이고 그 안에 맨 앞의 inserted accept 와 맨 끝의 reject 두 상자가 있다. 점선 화살표는 구멍을 넣기 전의 경로로 체인 끝 reject 를 지나 connection refused 로 끝나고, 실선 화살표는 넣은 뒤의 경로로 맨 앞의 구멍을 지나 엣지 nginx 로 간다." caption=" " zoom="true"
|
||||
:::
|
||||
|
||||
점선 상자 두 개가 같은 훅에 붙은 base 체인 둘이고, 왼쪽이 먼저 돈다. 점선 화살표는 구멍을 넣기 전의 경로다 — 앞 체인의 `accept` 를 지난 패킷이 `guest_input` 으로 이어지고, 위 덤프에 적힌 `ct state established,related accept` 에 걸리지 못한 채 체인 끝 `reject` 에 닿아 connection refused 로 끝난다.
|
||||
|
||||
실선 화살표는 다음 절에서 뚫을 구멍을 지나는 경로다. `guest_input` 상자 안에 놓인 두 규칙의 위아래가 체인 안의 순서이고, 구멍이 위에 있어서 같은 패킷이 아래의 `reject` 를 보기 전에 그 규칙에서 `accept` 된다.
|
||||
|
||||
## 구멍은 맨 앞에 넣고, 네트워크를 다시 세울 때 다시 넣는다
|
||||
|
||||
그래서 구멍을 libvirt 체인 맨 앞에 뚫었다. `insert` 가 맨 앞이고 `add` 가 맨 뒤다.
|
||||
|
||||
```bash label="guest_input 맨 앞에 구멍을 넣는다"
|
||||
nft insert rule ip libvirt_network guest_input \
|
||||
oif virbr0 ip daddr 192.168.122.10 tcp dport '{80,443}' ct state new counter accept
|
||||
```
|
||||
|
||||
이 규칙은 휘발성이다. libvirt 가 네트워크를 다시 세우면 `guest_input` 을 새로 쓰면서 규칙이 날아가므로, DNAT 유닛의 `ExecStartPost` 에 넣어 네트워크가 다시 설 때마다 같은 규칙이 다시 들어가게 했다.
|
||||
|
||||
그 `ExecStartPost` 줄 앞에는 `-` 를 붙였다. `libvirt_network` 테이블은 가상 네트워크가 올라온 뒤에야 생기므로, 그 전에 유닛이 뜨면 이 줄이 실패한다. `-` 를 붙여 두면 그때도 DNAT 은 그대로 올라가고 구멍만 빠지며, 빠진 구멍은 유닛을 다시 시작해서 넣는다. 그리고 그렇게 걸어 둔 뒤 libvirt 네트워크를 실제로 다시 세워 규칙이 되돌아오는지는 확인하지 않았다.
|
||||
|
||||
## 확인하지 못한 것
|
||||
|
||||
이 호스트 한 대에서만 봤다. libvirt 의 `firewall_backend` 가 iptables 인 호스트에서도 같은 구멍이 필요한지는 재지 않았고, 이 호스트는 nftables 백엔드다.
|
||||
|
||||
카운터 4 패킷 240 바이트는 위에 옮긴 규칙 덤프에 찍힌 값이다. 그 덤프와 `curl` 출력의 원문은 `final/evidence/` 에 파일로 남기지 않았다. 같은 재현을 다시 돌려 출력을 파일로 남기면 그때 원문을 댈 수 있다.
|
||||
|
||||
<!-- body:end -->
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
---
|
||||
kind: CASE
|
||||
slug: nftables-accept-did-not-stop-the-libvirt-reject
|
||||
title: 호스트 안에서는 404, 밖에서는 connection refused — 앞 체인의 accept 가 libvirt 의 reject 를 막지 못했다
|
||||
topic: lab-environment-build
|
||||
topicName: 실험대 환경 구성
|
||||
project: virtualization
|
||||
status: 게시 전
|
||||
assets:
|
||||
- key: nftables-forward-hook-chain-order
|
||||
file: ../../../final/assets/diagrams/nftables-forward-hook-chain-order/nftables-forward-hook-chain-order.svg
|
||||
sourceRevision: import-head 9465582b5d1630eb4ae7c4e078021486919bf6b6 · uncommitted-working-tree snapshot
|
||||
source:
|
||||
- final/document.md#180-nftables-는-앞-체인의-accept-로-뒤-체인의-reject-를-막지-못한다
|
||||
- final/document.md#179-엣지를-물리-호스트에서-vm-으로-옮기면-무엇이-새로-필요해지나
|
||||
- final/document.md#178-이-부의-출처와-범위
|
||||
---
|
||||
|
||||
# 호스트 안에서는 404, 밖에서는 connection refused — 앞 체인의 accept 가 libvirt 의 reject 를 막지 못했다
|
||||
|
||||
밖에서 온 요청은 먼저 돌게 해 둔 forward 체인의 accept 를 지나고도 libvirt 의 guest_input 체인 끝 reject 에서 끊겼다. nftables 가 같은 훅에 붙은 base 체인을 우선순위 순으로 전부 평가하기 때문이다. 그래서 구멍을 libvirt 체인 맨 앞에 넣었다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **엣지 nginx 를 호스트에서 게스트 VM 으로 옮긴다 — 성능이 아니라 더러워지는 층의 격리**
|
||||
그 결정으로 새로 필요해진 libvirt 방화벽 구멍을 이 사건에서 실제로 뚫었다.
|
||||
- **libvirt 의 firewall_backend 가 iptables 일 때도 guest_input 에 구멍이 필요한가**
|
||||
여기서 재지 않고 넘긴 미확인 항목을 그 질문이 받는다.
|
||||
- **단계별 구축 문서는 작성 시점이 아니라 실행 순서로 검증한다**
|
||||
같은 구축에서 나온 문서 결함들을 하나의 규칙으로 정리한 글이다.
|
||||
- **Guest 의 packet 이 Host Physical NIC 에 닿기까지 — virtio-net · virtqueue · vhost-net · TAP · Bridge**
|
||||
밖에서 온 패킷이 게스트에 닿기까지의 경로를 그 글이 세우고, 이 사건은 그 경로의 한 구간에서 막혔다.
|
||||
- **packet 이 어디서 끊겼는지는 계층마다 capture 해서 가른다**
|
||||
어느 계층까지 패킷이 보이는지로 의심 구간을 좁히는 절차이고, 여기서는 규칙 카운터가 같은 일을 했다.
|
||||
|
||||
## 문제
|
||||
|
||||
엣지 nginx 를 호스트에서 게스트 VM 으로 옮긴 뒤 밖에서 들어오는 요청만 엣지에 닿지 않았다.
|
||||
|
||||
호스트에서 친 요청 : curl http://192.168.122.10 → 404, 엣지 nginx 가 응답한다
|
||||
밖에서 친 요청 : curl http://100.83.212.4 → connection refused
|
||||
|
||||
호스트에서 친 요청에는 404 를 돌려줬으니 엣지 nginx 는 게스트 안에서 돌고 있었다. 밖에서 친 요청만 끊겼고, 타임아웃이 아니라 즉시 거절이었다.
|
||||
|
||||
## 결론
|
||||
|
||||
밖에서 온 패킷을 거절한 것은 libvirt 가 만든 규칙이다. libvirt 는 자기 테이블 ip libvirt_network 의 guest_input 체인을 ct state established,related accept 다음의 reject 로 끝낸다. 그 reject 규칙의 카운터가 4 패킷 240 바이트로 밖에서 친 curl 횟수와 정확히 일치해서 범인을 확정했다.
|
||||
|
||||
DNAT 를 정의한 파일에는 priority filter - 10 을 줘서 먼저 돌게 한 forward 체인이 있는데, 거기 넣은 ct state new accept 가 그 reject 를 막지 못한다. nftables 가 같은 훅에 붙은 base 체인을 우선순위 순으로 전부 평가하기 때문이다. 앞 체인의 accept 는 그 체인을 통과했다는 뜻이지 평가가 끝났다는 뜻이 아니고, 즉시 종결하는 것은 drop 뿐이다.
|
||||
|
||||
해결 : 구멍을 libvirt 체인 맨 앞에 넣는다. insert 가 맨 앞이고 add 가 맨 뒤다.
|
||||
수명 : libvirt 가 네트워크를 다시 세우면 guest_input 을 새로 쓰면서 그 규칙이 날아간다. 그래서 DNAT 유닛의 ExecStartPost 에 넣는다.
|
||||
|
||||
## 검증 환경
|
||||
|
||||
호스트 : test-server, Arch Linux
|
||||
CPU : i5-1135G7, 논리 코어 8
|
||||
RAM : 11,648MiB
|
||||
QEMU : 11.1.1
|
||||
libvirt : 12.7.0
|
||||
libvirt firewall_backend : nftables
|
||||
호스트 이더넷 : 없다 — WiFi 만 있다
|
||||
게스트 네트워크 : libvirt NAT, virbr0
|
||||
게스트 : Debian 12 genericcloud 3대 — 엣지 1대(nginx·certbot), k3s 2노드
|
||||
엣지 게스트 주소 : 192.168.122.10
|
||||
|
||||
## 재현 조건
|
||||
|
||||
1. 엣지 nginx 를 게스트 192.168.122.10 에 두고, 호스트 커널의 DNAT 로 밖에서 들어온 요청을 그 게스트로 넘긴다.
|
||||
2. 호스트에서 curl http://192.168.122.10 을 친다. 엣지 nginx 가 404 로 응답한다.
|
||||
3. 밖에서 curl http://100.83.212.4 를 친다. connection refused 가 온다.
|
||||
4. libvirt 테이블 ip libvirt_network 의 guest_input 체인을 규칙과 카운터까지 덤프한다. 마지막 reject 규칙의 패킷 수가 3번을 친 횟수와 맞으면 그 규칙이 그 패킷을 끝냈다.
|
||||
5. guest_input 맨 앞에 구멍을 넣고 3번을 다시 친다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
## 엣지 nginx 를 게스트로 옮기고 새로 필요해진 것
|
||||
|
||||
이 호스트에는 이더넷이 없고 WiFi 만 있어서 브리지를 못 쓰고, libvirt NAT(`virbr0`)에 호스트로 들어온 요청을 넘기는 구조를 택했다. 그 위에 Debian 12 게스트 세 대가 있고, 그중 한 대가 nginx 와 certbot 을 돌리는 엣지다. 엣지 nginx 는 원래 호스트에 있었고 사는 곳만 게스트로 바꿨다.
|
||||
|
||||
```text label="엣지 이동 전과 후"
|
||||
전: tailnet:443 ─▶ [호스트 nginx] ─────────────▶ Traefik(게스트 .11/.12)
|
||||
후: tailnet:443 ─▶ [호스트 커널 DNAT] ─▶ [엣지 nginx(.10)] ─▶ Traefik(.11/.12)
|
||||
```
|
||||
|
||||
L7 홉 수는 전후 모두 2홉이고 늘어난 것은 커널이 하는 L4 전달 한 번뿐이라 `X-Forwarded-*` 계약은 그대로 성립한다. 옮긴 이유도 성능이 아니라 더러워지는 층의 격리였다. nginx 설정과 인증서, certbot, deploy 훅은 자주 갈아엎는 것들인데 호스트에 있으면 초기화가 불가능하고, 엣지 장애 실험이 SSH 까지 위험하게 만든다.
|
||||
|
||||
그 대가로 일곱 가지가 새로 필요해졌는데 그중 둘은 배포판 차이가 아니라 패킷이 지나는 길이 달라져서 생겼다. 하나는 DNAT(Destination NAT) 다. 들어온 패킷의 도착지 주소를 바꿔 다른 기계로 넘기는 것을 말한다. 전에는 호스트가 직접 `:443` 을 들었으니 넘길 일이 없었는데 지금은 호스트에 리스너가 아예 없다. 다른 하나는 libvirt 방화벽에 구멍을 내는 일이다. 호스트가 게스트에 접속할 때는 OUTPUT 경로라 필터를 안 탔지만, 밖에서 게스트로 들어오는 것은 FORWARD 다. 「호스트가 게스트에 접속한다」와 「밖에서 게스트로 들어온다」는 커널이 보기에 완전히 다른 일이다. 그 구멍을 뚫는 데서 이 구축이 가장 오래 막혔다.
|
||||
|
||||
## 호스트 안에서는 되는데 밖에서만 안 된다
|
||||
|
||||
엣지 nginx 는 호스트에서 친 요청에 404 를 돌려줬으니 게스트 안에서 살아 있었는데, 밖에서 친 요청만 끊겼다.
|
||||
|
||||
| 어디서 쳤나 | 결과 |
|
||||
|---|---|
|
||||
| 호스트에서 `curl http://192.168.122.10` | 404, 엣지 nginx 가 응답 |
|
||||
| 밖에서 `curl http://100.83.212.4` | connection refused |
|
||||
|
||||
패킷을 조용히 버리는 `drop` 이면 클라이언트가 응답을 기다리다 죽으므로, 타임아웃이 아니라 즉시 거절이 돌아왔다는 것이 단서였다.
|
||||
|
||||
## guest_input 체인 끝의 reject 와 카운터 4 패킷
|
||||
|
||||
libvirt 는 자기 테이블 `ip libvirt_network` 안에 `guest_input` 체인을 만들고, 이미 맺어진 연결과 그에 딸린 연결만 통과시킨 다음 나머지를 거절하는 규칙으로 그 체인을 끝낸다.
|
||||
|
||||
```text label="libvirt 가 만든 guest_input 체인의 끝"
|
||||
oif "virbr0" ip daddr 192.168.122.0/24 ct state established,related accept
|
||||
oif "virbr0" counter packets 4 bytes 240 reject ← 여기서 죽는다
|
||||
```
|
||||
|
||||
규칙 하나를 지목해 놓고 시작한 것이 아니다. `nft list ruleset` 에서 `reject` 와 `drop` 이 든 줄만 뽑아 놓고, 밖에서 친 횟수와 카운터가 맞아떨어지는 줄을 찾았다. 이 `reject` 규칙의 카운터가 4 패킷 240 바이트였고, 밖에서 친 `curl` 횟수와 정확히 일치했다. 범인 확정에 쓴 것이 이 숫자다.
|
||||
|
||||
## 왜 앞 체인의 accept 가 안 먹혔나
|
||||
|
||||
DNAT 를 정의한 파일에는 libvirt 체인보다 먼저 돌도록 `priority filter - 10` 을 준 `forward` 체인을 두고 거기에 `ct state new accept` 를 넣어 두었다. 밖에서 온 패킷은 그 `accept` 를 지나고도 거절됐는데, nftables 가 같은 훅에 붙은 base 체인을 우선순위 순으로 전부 평가하기 때문이다. 앞 체인의 `accept` 는 「이 체인은 통과」라는 뜻이지 「평가 끝」이 아니고, 즉시 종결하는 것은 `drop` 뿐이다. iptables 감각으로 쓰면 정확히 여기서 틀린다.
|
||||
|
||||
그 `forward` 체인은 지금 DNAT 파일에 없다. 남은 체인은 `prerouting` 하나이고, 체인을 그냥 지우는 대신 주석 하나를 남겨 두었다 — 여기에 `forward` 체인을 두지 않은 것이 의도이며 구멍은 유닛의 `ExecStartPost` 가 libvirt 자기 체인 안에 넣는다는 내용이다.
|
||||
|
||||
:::evidence key="nftables-forward-hook-chain-order" alt="밖에서 온 패킷 하나가 forward 훅에 붙은 base 체인 둘을 우선순위 순으로 지나는 경로도. 왼쪽 점선 상자가 priority filter - 10 인 forward 체인이고 그 안에 ct state new accept 가 있다. 오른쪽 점선 상자가 ip libvirt_network 의 guest_input 체인이고 그 안에 맨 앞의 inserted accept 와 맨 끝의 reject 두 상자가 있다. 점선 화살표는 구멍을 넣기 전의 경로로 체인 끝 reject 를 지나 connection refused 로 끝나고, 실선 화살표는 넣은 뒤의 경로로 맨 앞의 구멍을 지나 엣지 nginx 로 간다." caption=" " zoom="true"
|
||||
:::
|
||||
|
||||
점선 상자 두 개가 같은 훅에 붙은 base 체인 둘이고, 왼쪽이 먼저 돈다. 점선 화살표는 구멍을 넣기 전의 경로다 — 앞 체인의 `accept` 를 지난 패킷이 `guest_input` 으로 이어지고, 위 덤프에 적힌 `ct state established,related accept` 에 걸리지 못한 채 체인 끝 `reject` 에 닿아 connection refused 로 끝난다.
|
||||
|
||||
실선 화살표는 다음 절에서 뚫을 구멍을 지나는 경로다. `guest_input` 상자 안에 놓인 두 규칙의 위아래가 체인 안의 순서이고, 구멍이 위에 있어서 같은 패킷이 아래의 `reject` 를 보기 전에 그 규칙에서 `accept` 된다.
|
||||
|
||||
## 구멍은 맨 앞에 넣고, 네트워크를 다시 세울 때 다시 넣는다
|
||||
|
||||
그래서 구멍을 libvirt 체인 맨 앞에 뚫었다. `insert` 가 맨 앞이고 `add` 가 맨 뒤다.
|
||||
|
||||
```bash label="guest_input 맨 앞에 구멍을 넣는다"
|
||||
nft insert rule ip libvirt_network guest_input \
|
||||
oif virbr0 ip daddr 192.168.122.10 tcp dport '{80,443}' ct state new counter accept
|
||||
```
|
||||
|
||||
이 규칙은 휘발성이다. libvirt 가 네트워크를 다시 세우면 `guest_input` 을 새로 쓰면서 규칙이 날아가므로, DNAT 유닛의 `ExecStartPost` 에 넣어 네트워크가 다시 설 때마다 같은 규칙이 다시 들어가게 했다.
|
||||
|
||||
그 `ExecStartPost` 줄 앞에는 `-` 를 붙였다. `libvirt_network` 테이블은 가상 네트워크가 올라온 뒤에야 생기므로, 그 전에 유닛이 뜨면 이 줄이 실패한다. `-` 를 붙여 두면 그때도 DNAT 은 그대로 올라가고 구멍만 빠지며, 빠진 구멍은 유닛을 다시 시작해서 넣는다. 그리고 그렇게 걸어 둔 뒤 libvirt 네트워크를 실제로 다시 세워 규칙이 되돌아오는지는 확인하지 않았다.
|
||||
|
||||
## 확인하지 못한 것
|
||||
|
||||
이 호스트 한 대에서만 봤다. libvirt 의 `firewall_backend` 가 iptables 인 호스트에서도 같은 구멍이 필요한지는 재지 않았고, 이 호스트는 nftables 백엔드다.
|
||||
|
||||
카운터 4 패킷 240 바이트는 위에 옮긴 규칙 덤프에 찍힌 값이다. 그 덤프와 `curl` 출력의 원문은 `final/evidence/` 에 파일로 남기지 않았다. 같은 재현을 다시 돌려 출력을 파일로 남기면 그때 원문을 댈 수 있다.
|
||||
|
||||
<!-- body:end -->
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"authority": "deterministic",
|
||||
"gate": "command-pedagogy-signals",
|
||||
"section_id": "docs/virtualization/tech-log-studio/lab-environment-build/case/case-nftables-accept-did-not-stop-the-libvirt-reject.md",
|
||||
"mode": "reference",
|
||||
"source_sha256": "781755144a688998e099f7462d228b3df044260b7e6b3417858a4358fcfb4d5a",
|
||||
"result": "PASS",
|
||||
"requires_editor": false,
|
||||
"blocks": [
|
||||
{
|
||||
"id": "command-001",
|
||||
"ordinal": 1,
|
||||
"language": "bash",
|
||||
"info_string": "label=\"guest_input 맨 앞에 구멍을 넣는다\"",
|
||||
"label": "guest_input 맨 앞에 구멍을 넣는다",
|
||||
"mode": "reference",
|
||||
"mode_explicit": false,
|
||||
"start": 6342,
|
||||
"end": 6521,
|
||||
"source_sha256": "63b9616453d559228110849f0cf504b52fe55e304583a50d5816dbf1a852be87",
|
||||
"body_sha256": "88245c8ac4a4525e40d863f2a1aa3fc7b42e314163cbaed638341cc1119c428c",
|
||||
"source": "```bash label=\"guest_input 맨 앞에 구멍을 넣는다\"\nnft insert rule ip libvirt_network guest_input \\\n oif virbr0 ip daddr 192.168.122.10 tcp dport '{80,443}' ct state new counter accept\n```",
|
||||
"body": "nft insert rule ip libvirt_network guest_input \\\n oif virbr0 ip daddr 192.168.122.10 tcp dport '{80,443}' ct state new counter accept\n"
|
||||
}
|
||||
],
|
||||
"findings": [],
|
||||
"extensions": {
|
||||
"command_like_text_blocks": []
|
||||
}
|
||||
}
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
---
|
||||
kind: CASE
|
||||
slug: nftables-accept-did-not-stop-the-libvirt-reject
|
||||
title: 호스트 안에서는 404, 밖에서는 connection refused — 앞 체인의 accept 가 libvirt 의 reject 를 막지 못했다
|
||||
topic: lab-environment-build
|
||||
topicName: 실험대 환경 구성
|
||||
project: virtualization
|
||||
status: 게시 전
|
||||
assets:
|
||||
- key: nftables-forward-hook-chain-order
|
||||
file: ../../../final/assets/diagrams/nftables-forward-hook-chain-order/nftables-forward-hook-chain-order.svg
|
||||
sourceRevision: import-head 9465582b5d1630eb4ae7c4e078021486919bf6b6 · uncommitted-working-tree snapshot
|
||||
source:
|
||||
- final/document.md#180-nftables-는-앞-체인의-accept-로-뒤-체인의-reject-를-막지-못한다
|
||||
- final/document.md#179-엣지를-물리-호스트에서-vm-으로-옮기면-무엇이-새로-필요해지나
|
||||
- final/document.md#178-이-부의-출처와-범위
|
||||
---
|
||||
|
||||
# 호스트 안에서는 404, 밖에서는 connection refused — 앞 체인의 accept 가 libvirt 의 reject 를 막지 못했다
|
||||
|
||||
밖에서 온 요청은 먼저 돌게 해 둔 forward 체인의 accept 를 지나고도 libvirt 의 guest_input 체인 끝 reject 에서 끊겼다. nftables 가 같은 훅에 붙은 base 체인을 우선순위 순으로 전부 평가하기 때문이다. 그래서 구멍을 libvirt 체인 맨 앞에 넣었다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **엣지 nginx 를 호스트에서 게스트 VM 으로 옮긴다 — 성능이 아니라 더러워지는 층의 격리**
|
||||
그 결정으로 새로 필요해진 libvirt 방화벽 구멍을 이 사건에서 실제로 뚫었다.
|
||||
- **libvirt 의 firewall_backend 가 iptables 일 때도 guest_input 에 구멍이 필요한가**
|
||||
여기서 재지 않고 넘긴 미확인 항목을 그 질문이 받는다.
|
||||
- **단계별 구축 문서는 작성 시점이 아니라 실행 순서로 검증한다**
|
||||
같은 구축에서 나온 문서 결함들을 하나의 규칙으로 정리한 글이다.
|
||||
- **Guest 의 packet 이 Host Physical NIC 에 닿기까지 — virtio-net · virtqueue · vhost-net · TAP · Bridge**
|
||||
밖에서 온 패킷이 게스트에 닿기까지의 경로를 그 글이 세우고, 이 사건은 그 경로의 한 구간에서 막혔다.
|
||||
- **packet 이 어디서 끊겼는지는 계층마다 capture 해서 가른다**
|
||||
어느 계층까지 패킷이 보이는지로 의심 구간을 좁히는 절차이고, 여기서는 규칙 카운터가 같은 일을 했다.
|
||||
|
||||
## 문제
|
||||
|
||||
엣지 nginx 를 호스트에서 게스트 VM 으로 옮긴 뒤 밖에서 들어오는 요청만 엣지에 닿지 않았다.
|
||||
|
||||
호스트에서 친 요청 : curl http://192.168.122.10 → 404, 엣지 nginx 가 응답한다
|
||||
밖에서 친 요청 : curl http://100.83.212.4 → connection refused
|
||||
|
||||
호스트에서 친 요청에는 404 를 돌려줬으니 엣지 nginx 는 게스트 안에서 돌고 있었다. 밖에서 친 요청만 끊겼고, 타임아웃이 아니라 즉시 거절이었다.
|
||||
|
||||
## 결론
|
||||
|
||||
밖에서 온 패킷을 거절한 것은 libvirt 가 만든 규칙이다. libvirt 는 자기 테이블 ip libvirt_network 의 guest_input 체인을 ct state established,related accept 다음의 reject 로 끝낸다. 그 reject 규칙의 카운터가 4 패킷 240 바이트로 밖에서 친 curl 횟수와 정확히 일치해서 범인을 확정했다.
|
||||
|
||||
DNAT 를 정의한 파일에는 priority filter - 10 을 줘서 먼저 돌게 한 forward 체인이 있는데, 거기 넣은 ct state new accept 가 그 reject 를 막지 못한다. nftables 가 같은 훅에 붙은 base 체인을 우선순위 순으로 전부 평가하기 때문이다. 앞 체인의 accept 는 그 체인을 통과했다는 뜻이지 평가가 끝났다는 뜻이 아니고, 즉시 종결하는 것은 drop 뿐이다.
|
||||
|
||||
해결 : 구멍을 libvirt 체인 맨 앞에 넣는다. insert 가 맨 앞이고 add 가 맨 뒤다.
|
||||
수명 : libvirt 가 네트워크를 다시 세우면 guest_input 을 새로 쓰면서 그 규칙이 날아간다. 그래서 DNAT 유닛의 ExecStartPost 에 넣는다.
|
||||
|
||||
## 검증 환경
|
||||
|
||||
호스트 : test-server, Arch Linux
|
||||
CPU : i5-1135G7, 논리 코어 8
|
||||
RAM : 11,648MiB
|
||||
QEMU : 11.1.1
|
||||
libvirt : 12.7.0
|
||||
libvirt firewall_backend : nftables
|
||||
호스트 이더넷 : 없다 — WiFi 만 있다
|
||||
게스트 네트워크 : libvirt NAT, virbr0
|
||||
게스트 : Debian 12 genericcloud 3대 — 엣지 1대(nginx·certbot), k3s 2노드
|
||||
엣지 게스트 주소 : 192.168.122.10
|
||||
|
||||
## 재현 조건
|
||||
|
||||
1. 엣지 nginx 를 게스트 192.168.122.10 에 두고, 호스트 커널의 DNAT 로 밖에서 들어온 요청을 그 게스트로 넘긴다.
|
||||
2. 호스트에서 curl http://192.168.122.10 을 친다. 엣지 nginx 가 404 로 응답한다.
|
||||
3. 밖에서 curl http://100.83.212.4 를 친다. connection refused 가 온다.
|
||||
4. libvirt 테이블 ip libvirt_network 의 guest_input 체인을 규칙과 카운터까지 덤프한다. 마지막 reject 규칙의 패킷 수가 3번을 친 횟수와 맞으면 그 규칙이 그 패킷을 끝냈다.
|
||||
5. guest_input 맨 앞에 구멍을 넣고 3번을 다시 친다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
## 엣지 nginx 를 게스트로 옮기고 새로 필요해진 것
|
||||
|
||||
이 호스트에는 이더넷이 없고 WiFi 만 있어서 브리지를 못 쓰고, libvirt NAT(`virbr0`)에 호스트로 들어온 요청을 넘기는 구조를 택했다. 그 위에 Debian 12 게스트 세 대가 있고, 그중 한 대가 nginx 와 certbot 을 돌리는 엣지다. 엣지 nginx 는 원래 호스트에 있었고 사는 곳만 게스트로 바꿨다.
|
||||
|
||||
```text label="엣지 이동 전과 후"
|
||||
전: tailnet:443 ─▶ [호스트 nginx] ─────────────▶ Traefik(게스트 .11/.12)
|
||||
후: tailnet:443 ─▶ [호스트 커널 DNAT] ─▶ [엣지 nginx(.10)] ─▶ Traefik(.11/.12)
|
||||
```
|
||||
|
||||
L7 홉 수는 전후 모두 2홉이고 늘어난 것은 커널이 하는 L4 전달 한 번뿐이라 `X-Forwarded-*` 계약은 그대로 성립한다. 옮긴 이유도 성능이 아니라 더러워지는 층의 격리였다. nginx 설정과 인증서, certbot, deploy 훅은 자주 갈아엎는 것들인데 호스트에 있으면 초기화가 불가능하고, 엣지 장애 실험이 SSH 까지 위험하게 만든다.
|
||||
|
||||
그 대가로 일곱 가지가 새로 필요해졌는데 그중 둘은 배포판 차이가 아니라 패킷이 지나는 길이 달라져서 생겼다. 하나는 DNAT(Destination NAT) 다. 들어온 패킷의 도착지 주소를 바꿔 다른 기계로 넘기는 것을 말한다. 전에는 호스트가 직접 `:443` 을 들었으니 넘길 일이 없었는데 지금은 호스트에 리스너가 아예 없다. 다른 하나는 libvirt 방화벽에 구멍을 내는 일이다. 호스트가 게스트에 접속할 때는 OUTPUT 경로라 필터를 안 탔지만, 밖에서 게스트로 들어오는 것은 FORWARD 다. 「호스트가 게스트에 접속한다」와 「밖에서 게스트로 들어온다」는 커널이 보기에 완전히 다른 일이다. 그 구멍을 뚫는 데서 이 구축이 가장 오래 막혔다.
|
||||
|
||||
## 호스트 안에서는 되는데 밖에서만 안 된다
|
||||
|
||||
엣지 nginx 는 호스트에서 친 요청에 404 를 돌려줬으니 게스트 안에서 살아 있었는데, 밖에서 친 요청만 끊겼다.
|
||||
|
||||
| 어디서 쳤나 | 결과 |
|
||||
|---|---|
|
||||
| 호스트에서 `curl http://192.168.122.10` | 404, 엣지 nginx 가 응답 |
|
||||
| 밖에서 `curl http://100.83.212.4` | connection refused |
|
||||
|
||||
패킷을 조용히 버리는 `drop` 이면 클라이언트가 응답을 기다리다 죽으므로, 타임아웃이 아니라 즉시 거절이 돌아왔다는 것이 단서였다.
|
||||
|
||||
## guest_input 체인 끝의 reject 와 카운터 4 패킷
|
||||
|
||||
libvirt 는 자기 테이블 `ip libvirt_network` 안에 `guest_input` 체인을 만들고, 이미 맺어진 연결과 그에 딸린 연결만 통과시킨 다음 나머지를 거절하는 규칙으로 그 체인을 끝낸다.
|
||||
|
||||
```text label="libvirt 가 만든 guest_input 체인의 끝"
|
||||
oif "virbr0" ip daddr 192.168.122.0/24 ct state established,related accept
|
||||
oif "virbr0" counter packets 4 bytes 240 reject ← 여기서 죽는다
|
||||
```
|
||||
|
||||
규칙 하나를 지목해 놓고 시작한 것이 아니다. `nft list ruleset` 에서 `reject` 와 `drop` 이 든 줄만 뽑아 놓고, 밖에서 친 횟수와 카운터가 맞아떨어지는 줄을 찾았다. 이 `reject` 규칙의 카운터가 4 패킷 240 바이트였고, 밖에서 친 `curl` 횟수와 정확히 일치했다. 범인 확정에 쓴 것이 이 숫자다.
|
||||
|
||||
## 왜 앞 체인의 accept 가 안 먹혔나
|
||||
|
||||
DNAT 를 정의한 파일에는 libvirt 체인보다 먼저 돌도록 `priority filter - 10` 을 준 `forward` 체인을 두고 거기에 `ct state new accept` 를 넣어 두었다. 밖에서 온 패킷은 그 `accept` 를 지나고도 거절됐는데, nftables 가 같은 훅에 붙은 base 체인을 우선순위 순으로 전부 평가하기 때문이다. 앞 체인의 `accept` 는 「이 체인은 통과」라는 뜻이지 「평가 끝」이 아니고, 즉시 종결하는 것은 `drop` 뿐이다. iptables 감각으로 쓰면 정확히 여기서 틀린다.
|
||||
|
||||
그 `forward` 체인은 지금 DNAT 파일에 없다. 남은 체인은 `prerouting` 하나이고, 체인을 그냥 지우는 대신 주석 하나를 남겨 두었다 — 여기에 `forward` 체인을 두지 않은 것이 의도이며 구멍은 유닛의 `ExecStartPost` 가 libvirt 자기 체인 안에 넣는다는 내용이다.
|
||||
|
||||
:::evidence key="nftables-forward-hook-chain-order" alt="밖에서 온 패킷 하나가 forward 훅에 붙은 base 체인 둘을 우선순위 순으로 지나는 경로도. 왼쪽 점선 상자가 priority filter - 10 인 forward 체인이고 그 안에 ct state new accept 가 있다. 오른쪽 점선 상자가 ip libvirt_network 의 guest_input 체인이고 그 안에 맨 앞의 inserted accept 와 맨 끝의 reject 두 상자가 있다. 점선 화살표는 구멍을 넣기 전의 경로로 체인 끝 reject 를 지나 connection refused 로 끝나고, 실선 화살표는 넣은 뒤의 경로로 맨 앞의 구멍을 지나 엣지 nginx 로 간다." caption=" " zoom="true"
|
||||
:::
|
||||
|
||||
점선 상자 두 개가 같은 훅에 붙은 base 체인 둘이고, 왼쪽이 먼저 돈다. 점선 화살표는 구멍을 넣기 전의 경로다 — 앞 체인의 `accept` 를 지난 패킷이 `guest_input` 으로 이어지고, 위 덤프에 적힌 `ct state established,related accept` 에 걸리지 못한 채 체인 끝 `reject` 에 닿아 connection refused 로 끝난다.
|
||||
|
||||
실선 화살표는 다음 절에서 뚫을 구멍을 지나는 경로다. `guest_input` 상자 안에 놓인 두 규칙의 위아래가 체인 안의 순서이고, 구멍이 위에 있어서 같은 패킷이 아래의 `reject` 를 보기 전에 그 규칙에서 `accept` 된다.
|
||||
|
||||
## 구멍은 맨 앞에 넣고, 네트워크를 다시 세울 때 다시 넣는다
|
||||
|
||||
그래서 구멍을 libvirt 체인 맨 앞에 뚫었다. `insert` 가 맨 앞이고 `add` 가 맨 뒤다.
|
||||
|
||||
```bash label="guest_input 맨 앞에 구멍을 넣는다"
|
||||
nft insert rule ip libvirt_network guest_input \
|
||||
oif virbr0 ip daddr 192.168.122.10 tcp dport '{80,443}' ct state new counter accept
|
||||
```
|
||||
|
||||
이 규칙은 휘발성이다. libvirt 가 네트워크를 다시 세우면 `guest_input` 을 새로 쓰면서 규칙이 날아가므로, DNAT 유닛의 `ExecStartPost` 에 넣어 네트워크가 다시 설 때마다 같은 규칙이 다시 들어가게 했다.
|
||||
|
||||
그 `ExecStartPost` 줄 앞에는 `-` 를 붙였다. `libvirt_network` 테이블은 가상 네트워크가 올라온 뒤에야 생기므로, 그 전에 유닛이 뜨면 이 줄이 실패한다. `-` 를 붙여 두면 그때도 DNAT 은 그대로 올라가고 구멍만 빠지며, 빠진 구멍은 유닛을 다시 시작해서 넣는다. 그리고 그렇게 걸어 둔 뒤 libvirt 네트워크를 실제로 다시 세워 규칙이 되돌아오는지는 확인하지 않았다.
|
||||
|
||||
## 확인하지 못한 것
|
||||
|
||||
이 호스트 한 대에서만 봤다. libvirt 의 `firewall_backend` 가 iptables 인 호스트에서도 같은 구멍이 필요한지는 재지 않았고, 이 호스트는 nftables 백엔드다.
|
||||
|
||||
카운터 4 패킷 240 바이트는 위에 옮긴 규칙 덤프에 찍힌 값이다. 그 덤프와 `curl` 출력의 원문은 `final/evidence/` 에 파일로 남기지 않았다. 같은 재현을 다시 돌려 출력을 파일로 남기면 그때 원문을 댈 수 있다.
|
||||
|
||||
<!-- body:end -->
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"scope": "command-pedagogy",
|
||||
"reviewer": "command-pedagogy-reviewer",
|
||||
"verdict": "PASS",
|
||||
"source_sha256": "781755144a688998e099f7462d228b3df044260b7e6b3417858a4358fcfb4d5a",
|
||||
"findings": [],
|
||||
"notes": "reference mode final major finding은 0이다. historical compact command는 reference disposition으로 보존한다."
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"record": "docs/virtualization/tech-log-studio/lab-environment-build/case/case-nftables-accept-did-not-stop-the-libvirt-reject.md",
|
||||
"sourceSha256": "781755144a688998e099f7462d228b3df044260b7e6b3417858a4358fcfb4d5a",
|
||||
"verdict": "PASS",
|
||||
"liveSourceReconciliation": "UNVERIFIABLE",
|
||||
"liveSourceReason": "source repositories unavailable on current machine",
|
||||
"acceptedByProjectReview": true,
|
||||
"checks": [
|
||||
"local SSOT/Record evidence mismatch 0",
|
||||
"S3/S5/S6 check_evidence --repo invoked",
|
||||
"source checkout absence preserved as exit 3"
|
||||
],
|
||||
"note": "현재 project evidence gate는 불일치 0건·대조 불가 2건이다. source checkout 부재를 PASS로 위장하지 않고 UNVERIFIABLE로 보존했다."
|
||||
}
|
||||
+362
@@ -0,0 +1,362 @@
|
||||
{
|
||||
"schemaVersion": 5,
|
||||
"runId": "2026-09-21-0218-remediation-12-concept-a-cloud-image-is-an-installed-disk-and-cloud-init-fills-the-blanks",
|
||||
"project": "virtualization",
|
||||
"record": "docs/virtualization/tech-log-studio/lab-environment-build/concept/concept-a-cloud-image-is-an-installed-disk-and-cloud-init-fills-the-blanks.md",
|
||||
"startedAt": "2026-09-21T02:24:54+00:00",
|
||||
"finishedAt": "2026-09-21T02:24:56+00:00",
|
||||
"stages": [
|
||||
{
|
||||
"id": "S1",
|
||||
"name": "코드베이스 → SSOT",
|
||||
"skill": "analyzing-codebase-for-tech-log",
|
||||
"runBy": "ssot-analyst",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "이번 remediation은 새 evidence와 현재 SSOT/Record의 정합성·provenance·command/S4를 재검토한다. 코드베이스→SSOT 전면 분석은 다시 하지 않는다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": "edd45dfec66d155a621cd41186b83b5582f2244c",
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"gates": [],
|
||||
"notes": "",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-21T02:24:54+00:00",
|
||||
"elapsedSeconds": 0,
|
||||
"finishedBy": "chatgpt-virtualization-20260921-remediation-v5"
|
||||
},
|
||||
{
|
||||
"id": "S2",
|
||||
"name": "SSOT → 분해 계약",
|
||||
"skill": "deriving-tech-log-root-tree",
|
||||
"runBy": "tree-deriver",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "기존 91 Record 분해 계약은 유지한다. certbot Question의 RESOLVED 상태와 sourcePolicy는 project tree rebuild에서 별도로 동기화했다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": "ab59130196d79e947b32e3b5e6b75335a9e5c1eb",
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"gates": [],
|
||||
"notes": "",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-21T02:24:54+00:00",
|
||||
"elapsedSeconds": 0,
|
||||
"finishedBy": "chatgpt-virtualization-20260921-remediation-v5"
|
||||
},
|
||||
{
|
||||
"id": "S3",
|
||||
"name": "글감 → 기록",
|
||||
"skill": "writing-tech-log-records",
|
||||
"runBy": "record-writer",
|
||||
"status": "DONE",
|
||||
"skipReason": "",
|
||||
"skillEcho": "**인용한 줄은 SSOT 에서 찾아 대조한다.**",
|
||||
"skillRevision": null,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
"docs/virtualization/tech-log-studio/lab-environment-build/concept/concept-a-cloud-image-is-an-installed-disk-and-cloud-init-fills-the-blanks.md"
|
||||
],
|
||||
"gates": [
|
||||
{
|
||||
"cmd": "python3 scripts/studio-body.py docs/virtualization/tech-log-studio/lab-environment-build/concept/concept-a-cloud-image-is-an-installed-disk-and-cloud-init-fills-the-blanks.md -o runs/virtualization/2026-09-21-0218-remediation-12-concept-a-cloud-image-is-an-installed-disk-and-cloud-init-fills-the-blanks/stage/S3/studio-body.md",
|
||||
"exit": 0,
|
||||
"status": "PASS",
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"generation": 1,
|
||||
"at": "2026-09-21T02:24:54+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node --experimental-transform-types .agents/skills/writing-tech-log-records/scripts/check_body.mjs runs/virtualization/2026-09-21-0218-remediation-12-concept-a-cloud-image-is-an-installed-disk-and-cloud-init-fills-the-blanks/stage/S3/studio-body.md --frontend /shared/codebase/tech-log-frontend",
|
||||
"exit": 0,
|
||||
"status": "PASS",
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"generation": 1,
|
||||
"at": "2026-09-21T02:24:54+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node .agents/skills/rewriting-technical-prose-naturally/scripts/check_prose.mjs --warn docs/virtualization/tech-log-studio/lab-environment-build/concept/concept-a-cloud-image-is-an-installed-disk-and-cloud-init-fills-the-blanks.md",
|
||||
"exit": 0,
|
||||
"status": "PASS",
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"generation": 1,
|
||||
"at": "2026-09-21T02:24:54+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node .agents/skills/writing-tech-log-records/scripts/check_evidence.mjs virtualization --repo",
|
||||
"exit": 3,
|
||||
"status": "UNVERIFIABLE",
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"generation": 1,
|
||||
"at": "2026-09-21T02:24:55+00:00",
|
||||
"semanticId": "evidence-repo",
|
||||
"reason": "source repositories unavailable on current machine",
|
||||
"acceptedByProjectReview": true
|
||||
}
|
||||
],
|
||||
"notes": "record-writer/fact 계약으로 새 evidence와 SSOT의 current truth를 대조했다. --repo 대조는 실제 실행했고 source checkout 부재만 UNVERIFIABLE로 남겼다.",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-21T02:24:55+00:00",
|
||||
"elapsedSeconds": 1,
|
||||
"generation": 1,
|
||||
"owner": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"finishedBy": "chatgpt-virtualization-20260921-remediation-v5"
|
||||
},
|
||||
{
|
||||
"id": "S4",
|
||||
"name": "기록 → 그림",
|
||||
"skill": "technical-visualizer",
|
||||
"runBy": "diagram-maker",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "이 Record 변경은 current truth/evidence/version/provenance 정리이며 새 관계 그림을 요구하지 않는다. 기존 그림은 project-wide 14장 검증에서 별도로 확인했다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": "edd45dfec66d155a621cd41186b83b5582f2244c",
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"gates": [],
|
||||
"notes": "",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-21T02:24:55+00:00",
|
||||
"elapsedSeconds": 0,
|
||||
"finishedBy": "chatgpt-virtualization-20260921-remediation-v5"
|
||||
},
|
||||
{
|
||||
"id": "S5",
|
||||
"name": "AI 티 제거",
|
||||
"skill": "rewriting-technical-prose-naturally",
|
||||
"runBy": "prose-rewriter",
|
||||
"status": "DONE",
|
||||
"skipReason": "",
|
||||
"skillEcho": "This is an **editorial** pass. The source's facts, evidence, causal chain, uncertainty, decision status, and technical depth are the contract.",
|
||||
"skillRevision": null,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
"docs/virtualization/tech-log-studio/lab-environment-build/concept/concept-a-cloud-image-is-an-installed-disk-and-cloud-init-fills-the-blanks.md"
|
||||
],
|
||||
"gates": [
|
||||
{
|
||||
"cmd": "node .agents/skills/rewriting-technical-prose-naturally/scripts/check_prose.mjs --warn docs/virtualization/tech-log-studio/lab-environment-build/concept/concept-a-cloud-image-is-an-installed-disk-and-cloud-init-fills-the-blanks.md",
|
||||
"exit": 0,
|
||||
"status": "PASS",
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"generation": 1,
|
||||
"at": "2026-09-21T02:24:55+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node .agents/skills/rewriting-technical-prose-naturally/scripts/style_profile.mjs docs/virtualization/tech-log-studio/lab-environment-build/concept/concept-a-cloud-image-is-an-installed-disk-and-cloud-init-fills-the-blanks.md",
|
||||
"exit": 1,
|
||||
"status": "FAIL",
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"generation": 1,
|
||||
"at": "2026-09-21T02:24:55+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node --experimental-transform-types .agents/skills/writing-tech-log-records/scripts/check_body.mjs runs/virtualization/2026-09-21-0218-remediation-12-concept-a-cloud-image-is-an-installed-disk-and-cloud-init-fills-the-blanks/stage/S5/studio-body.md --frontend /shared/codebase/tech-log-frontend",
|
||||
"exit": 0,
|
||||
"status": "PASS",
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"generation": 1,
|
||||
"at": "2026-09-21T02:24:55+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node .agents/skills/writing-tech-log-records/scripts/check_evidence.mjs virtualization --repo",
|
||||
"exit": 3,
|
||||
"status": "UNVERIFIABLE",
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"generation": 1,
|
||||
"at": "2026-09-21T02:24:55+00:00",
|
||||
"semanticId": "evidence-repo",
|
||||
"reason": "source repositories unavailable on current machine",
|
||||
"acceptedByProjectReview": true
|
||||
}
|
||||
],
|
||||
"notes": "대규모 문체 재작성 없이 stale fact/relation만 현재 evidence에 맞췄다. style_profile은 측정값으로만 기록했다.",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-21T02:24:55+00:00",
|
||||
"elapsedSeconds": 0,
|
||||
"generation": 1,
|
||||
"owner": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"finishedBy": "chatgpt-virtualization-20260921-remediation-v5"
|
||||
},
|
||||
{
|
||||
"id": "S6",
|
||||
"name": "일한 사람의 목소리",
|
||||
"skill": "writing-as-the-person-who-did-it",
|
||||
"runBy": "voice-writer",
|
||||
"status": "DONE",
|
||||
"skipReason": "",
|
||||
"skillEcho": "찾은 것이 없으면 **이 스킬은 여기서 끝난다.** 없는 목소리를 채우지 않는다.",
|
||||
"skillRevision": "edd45dfec66d155a621cd41186b83b5582f2244c",
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
"docs/virtualization/tech-log-studio/lab-environment-build/concept/concept-a-cloud-image-is-an-installed-disk-and-cloud-init-fills-the-blanks.md"
|
||||
],
|
||||
"gates": [
|
||||
{
|
||||
"cmd": "node .agents/skills/writing-as-the-person-who-did-it/scripts/check_voice.mjs docs/virtualization/tech-log-studio/lab-environment-build/concept/concept-a-cloud-image-is-an-installed-disk-and-cloud-init-fills-the-blanks.md",
|
||||
"exit": 0,
|
||||
"status": "PASS",
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"generation": 1,
|
||||
"at": "2026-09-21T02:24:56+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node .agents/skills/rewriting-technical-prose-naturally/scripts/check_prose.mjs --warn docs/virtualization/tech-log-studio/lab-environment-build/concept/concept-a-cloud-image-is-an-installed-disk-and-cloud-init-fills-the-blanks.md",
|
||||
"exit": 0,
|
||||
"status": "PASS",
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"generation": 1,
|
||||
"at": "2026-09-21T02:24:56+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node --experimental-transform-types .agents/skills/writing-tech-log-records/scripts/check_body.mjs runs/virtualization/2026-09-21-0218-remediation-12-concept-a-cloud-image-is-an-installed-disk-and-cloud-init-fills-the-blanks/stage/S6/studio-body.md --frontend /shared/codebase/tech-log-frontend",
|
||||
"exit": 0,
|
||||
"status": "PASS",
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"generation": 1,
|
||||
"at": "2026-09-21T02:24:56+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node .agents/skills/writing-tech-log-records/scripts/check_evidence.mjs virtualization --repo",
|
||||
"exit": 3,
|
||||
"status": "UNVERIFIABLE",
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"generation": 1,
|
||||
"at": "2026-09-21T02:24:56+00:00",
|
||||
"semanticId": "evidence-repo",
|
||||
"reason": "source repositories unavailable on current machine",
|
||||
"acceptedByProjectReview": true
|
||||
}
|
||||
],
|
||||
"notes": "상류 SSOT/evidence를 다시 보며 실제 작업 흔적이 없는 서사는 추가하지 않았다.",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-21T02:24:56+00:00",
|
||||
"elapsedSeconds": 1,
|
||||
"generation": 1,
|
||||
"owner": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"finishedBy": "chatgpt-virtualization-20260921-remediation-v5"
|
||||
},
|
||||
{
|
||||
"id": "S7",
|
||||
"name": "Studio 저장",
|
||||
"skill": "publishing-tech-log-to-studio",
|
||||
"runBy": "studio-validator",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "이번 remediation에서 Studio import/save는 요청되지 않았다. 저장소 산출물과 검증 영수증만 갱신한다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": "862e502af3e956b49ccd2ae0a8de3fd32f90df9c",
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"gates": [],
|
||||
"notes": "",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-21T02:24:56+00:00",
|
||||
"elapsedSeconds": 0,
|
||||
"finishedBy": "chatgpt-virtualization-20260921-remediation-v5"
|
||||
}
|
||||
],
|
||||
"qualityReviews": {
|
||||
"commandPedagogy": {
|
||||
"initialAnalysis": {
|
||||
"cmd": "python3 scripts/check-command-pedagogy.py --mode reference docs/virtualization/tech-log-studio/lab-environment-build/concept/concept-a-cloud-image-is-an-installed-disk-and-cloud-init-fills-the-blanks.md -o runs/virtualization/2026-09-21-0218-remediation-12-concept-a-cloud-image-is-an-installed-disk-and-cloud-init-fills-the-blanks/stage/S3/command-initial.json",
|
||||
"exit": 0,
|
||||
"shellBlocks": 0,
|
||||
"findings": 0,
|
||||
"majorFindings": 0,
|
||||
"artifact": {
|
||||
"path": "runs/virtualization/2026-09-21-0218-remediation-12-concept-a-cloud-image-is-an-installed-disk-and-cloud-init-fills-the-blanks/stage/S3/command-initial.json",
|
||||
"sha256": "8be5768f1eb3793573ca3f4d505f5719cbf7d7a91e0e961105933851fd980d15"
|
||||
}
|
||||
},
|
||||
"finalAnalysis": {
|
||||
"cmd": "python3 scripts/check-command-pedagogy.py --mode reference docs/virtualization/tech-log-studio/lab-environment-build/concept/concept-a-cloud-image-is-an-installed-disk-and-cloud-init-fills-the-blanks.md -o runs/virtualization/2026-09-21-0218-remediation-12-concept-a-cloud-image-is-an-installed-disk-and-cloud-init-fills-the-blanks/stage/S6/command-final.json",
|
||||
"exit": 0,
|
||||
"shellBlocks": 0,
|
||||
"findings": 0,
|
||||
"majorFindings": 0,
|
||||
"artifact": {
|
||||
"path": "runs/virtualization/2026-09-21-0218-remediation-12-concept-a-cloud-image-is-an-installed-disk-and-cloud-init-fills-the-blanks/stage/S6/command-final.json",
|
||||
"sha256": "8be5768f1eb3793573ca3f4d505f5719cbf7d7a91e0e961105933851fd980d15"
|
||||
}
|
||||
},
|
||||
"planner": {
|
||||
"runBy": "command-pedagogy-planner",
|
||||
"skill": "writing-practitioner-guides",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "initial deterministic analysis finding이 0이다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": null,
|
||||
"notes": "현재 reference-mode 분석을 확인했다.",
|
||||
"artifact": null
|
||||
},
|
||||
"editor": {
|
||||
"runBy": "command-pedagogy-editor",
|
||||
"skill": "writing-practitioner-guides",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "initial deterministic analysis finding이 0이다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": null,
|
||||
"notes": "현재 reference-mode 분석을 확인했다.",
|
||||
"artifact": null
|
||||
},
|
||||
"reviewer": {
|
||||
"runBy": "command-pedagogy-reviewer",
|
||||
"skill": "writing-practitioner-guides",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "final publication에 shell/CLI block이 없다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": null,
|
||||
"verdict": null,
|
||||
"notes": "final deterministic analysis에서 shellBlocks=0을 확인했다.",
|
||||
"artifact": null,
|
||||
"sourceSha256": null
|
||||
}
|
||||
},
|
||||
"technicalEvidence": {
|
||||
"runBy": "fact-reviewer",
|
||||
"status": "DONE",
|
||||
"skipReason": "",
|
||||
"verdict": "PASS",
|
||||
"notes": "current truth/evidence/provenance를 SSOT와 역대조했다. live source는 실제 --repo 결과 exit 3이며 UNVERIFIABLE로 보존했다.",
|
||||
"sourceSha256": "1362f3037e9b0534c02667e88e4792e9a45f8d2c774c67f716d0369dd75ceb76",
|
||||
"liveSourceReconciliation": "UNVERIFIABLE",
|
||||
"liveSourceReason": "source repositories unavailable on current machine",
|
||||
"acceptedByProjectReview": true,
|
||||
"artifact": {
|
||||
"path": "runs/virtualization/2026-09-21-0218-remediation-12-concept-a-cloud-image-is-an-installed-disk-and-cloud-init-fills-the-blanks/stage/quality/technical-evidence-review.json",
|
||||
"sha256": "5057b3edf9e8716fb72df3f8211a13a27bb7e0f63342acf799d0cb167b85066c"
|
||||
}
|
||||
}
|
||||
},
|
||||
"riders": [],
|
||||
"sessions": [
|
||||
{
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"openedAt": "2026-09-21T02:24:54+00:00"
|
||||
},
|
||||
{
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"stage": "S3",
|
||||
"generation": 1,
|
||||
"beganAt": "2026-09-21T02:24:54+00:00"
|
||||
},
|
||||
{
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"stage": "S5",
|
||||
"generation": 1,
|
||||
"beganAt": "2026-09-21T02:24:55+00:00"
|
||||
},
|
||||
{
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"stage": "S6",
|
||||
"generation": 1,
|
||||
"beganAt": "2026-09-21T02:24:55+00:00"
|
||||
}
|
||||
],
|
||||
"revision": 23,
|
||||
"updatedAt": "2026-09-21T02:24:56+00:00",
|
||||
"executionEnvironment": {
|
||||
"mode": "current-remediation-contract-replay",
|
||||
"session": "chatgpt-virtualization-20260921-remediation-v5",
|
||||
"agentToolAvailable": false,
|
||||
"note": "별도 subagent 실행 도구가 현재 환경에 없어 현재 세션이 각 .claude/agents 역할 정의와 연결된 SKILL/contract를 직접 읽고 역할을 분리해 수행했다. runBy는 계약 역할명이며 별도 프로세스 실행을 주장하지 않는다."
|
||||
}
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"authority": "deterministic",
|
||||
"gate": "command-pedagogy-signals",
|
||||
"section_id": "docs/virtualization/tech-log-studio/lab-environment-build/concept/concept-a-cloud-image-is-an-installed-disk-and-cloud-init-fills-the-blanks.md",
|
||||
"mode": "reference",
|
||||
"source_sha256": "1362f3037e9b0534c02667e88e4792e9a45f8d2c774c67f716d0369dd75ceb76",
|
||||
"result": "PASS",
|
||||
"requires_editor": false,
|
||||
"blocks": [],
|
||||
"findings": [],
|
||||
"extensions": {
|
||||
"command_like_text_blocks": []
|
||||
}
|
||||
}
|
||||
+175
@@ -0,0 +1,175 @@
|
||||
---
|
||||
kind: CONCEPT
|
||||
slug: a-cloud-image-is-an-installed-disk-and-cloud-init-fills-the-blanks
|
||||
title: 설치를 안 했는데 왜 뜨는가 — 클라우드 이미지는 설치가 끝난 디스크이고 cloud-init 이 빈칸을 채운다
|
||||
topic: lab-environment-build
|
||||
topicName: 실험대 환경 구성
|
||||
project: virtualization
|
||||
status: 게시 전
|
||||
basisVersion: Debian 12 genericcloud 위의 cloud-init 22.4.2 · NoCloud 데이터소스 · 호스트는 QEMU 11.1.1 · libvirt 12.7.0
|
||||
sourceRevision: import-head 9465582b5d1630eb4ae7c4e078021486919bf6b6 · uncommitted-working-tree snapshot
|
||||
source:
|
||||
- final/document.md#229-왜-os를-설치하지-않아도-vm이-뜨는가
|
||||
- final/document.md#236-클라우드-이미지와-cloud-init
|
||||
- final/document.md#235-multipass-virt-install-virsh-무엇이-다른가
|
||||
- final/document.md#214-전체-구조-한눈에-보기
|
||||
- final/document.md#215-vm-한-대의-디스크-구성
|
||||
- final/document.md#216-설정-파일이-게스트에-도달하는-경로
|
||||
- final/document.md#217-부팅할-때-일어나는-일
|
||||
---
|
||||
|
||||
# 설치를 안 했는데 왜 뜨는가 — 클라우드 이미지는 설치가 끝난 디스크이고 cloud-init 이 빈칸을 채운다
|
||||
|
||||
클라우드 이미지는 배포자가 설치를 한 번 끝내 놓은 디스크 파일이라 게스트를 만들 때 설치 단계가 없다. 대신 hostname 과 SSH 호스트키 같은 고유값을 비워 둔 채 배포하고, cloud-init 이 첫 부팅에 그 빈칸을 채운다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **cloud-init 시드로 게스트 세 대를 만들고 SSH 가 키로 붙게 한다**
|
||||
그 절차가 「base 이미지를 받아 오버레이로 게스트 셋을 만든다」로 시작한다. 왜 받기만 하고 설치하지 않는지를 이 글이 댄다.
|
||||
- **cloud-init 이 안 도는 원인은 넷인데 증상은 「SSH 가 안 붙는다」 하나였다**
|
||||
그 원인 넷이 전부 「시드가 안 읽혔다」로 모인다. 시드가 무엇이고 어느 단계에서 읽히는지가 여기 있다.
|
||||
- **qcow2 파일 안 — 매핑표와 클러스터, 그리고 항목이 0 이면 바닥에 다시 묻는다**
|
||||
받은 파일이 어떻게 생겼길래 3 GiB 짜리가 335MiB 인지를 그 글이 연다.
|
||||
- **qcow2 파일 한 장이 담는 것 — 매핑표와 데이터 클러스터가 같은 파일 안에 있고, 파일 밖을 가리키는 것은 백킹 파일 경로 하나다**
|
||||
받은 바닥 이미지를 다른 호스트로 들고 갈 때 무엇이 따라가는지를 그 글이 가른다.
|
||||
- **가이드대로 다시 쳐서 이 실험대가 같은 상태로 서는가**
|
||||
게스트를 지우고 다시 만드는 비용이 낮다는 것이 이 선택의 근거인데, 그 비용을 실제로 재는 것은 그 물음이다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
## 설치 프로그램이 만드는 것은 결국 파일 하나의 내용이다
|
||||
|
||||
VM 의 디스크는 호스트의 파일 하나다. `kc-lab-1.qcow2` 라는 파일이 게스트에게는 20GB 하드디스크로 보이고, 게스트는 그것이 파일인 줄 모르는데 QEMU 가 디스크인 척해 주기 때문이다.
|
||||
|
||||
그러면 「OS 를 설치한다」가 무슨 작업인지 풀어 본다.
|
||||
|
||||
```text label="설치 프로그램이 빈 디스크에 하는 일"
|
||||
빈 디스크
|
||||
│ 설치 프로그램이 수행하는 일
|
||||
├─ 파티션 테이블 작성
|
||||
├─ 파일시스템 생성 (ext4, vfat …)
|
||||
├─ 패키지 수천 개를 풀어 배치
|
||||
├─ 부트로더 기록
|
||||
└─ 초기 설정 작성
|
||||
▼
|
||||
"부팅 가능한 특정 바이트 배열" 상태의 디스크
|
||||
```
|
||||
|
||||
설치 과정은 수단이고 목적은 마지막 줄의 상태이며, 그 상태는 파일 하나의 내용으로 남는다. Debian 과 Ubuntu 는 자기 빌드 서버에서 이 설치를 한 번 수행하고 완성된 디스크를 qcow2 파일로 떠서 공개하고, 우리는 그 파일을 내려받아 붙인다. 소스를 직접 컴파일하는 대신 이미 빌드된 바이너리를 받아 쓰는 것과 같아서, 결과물은 같고 시간만 아낀다.
|
||||
|
||||
## 그대로 복제하면 식별자가 겹치므로 일부러 비워 둔다
|
||||
|
||||
디스크가 바이트 단위로 같으면 안에 적힌 식별자도 같아진다.
|
||||
|
||||
| 값이 겹치면 | 무엇이 깨지나 |
|
||||
|---|---|
|
||||
| machine-id | systemd/DHCP가 두 기계를 같은 기계로 오인 |
|
||||
| SSH 호스트 키 | 두 서버가 같은 신원을 주장 → MITM 탐지 무력화 |
|
||||
| 파일시스템 UUID | `/etc/fstab`이 엉뚱한 디스크를 마운트 |
|
||||
| hostname | 로그·클러스터에서 노드 구분 불가 |
|
||||
|
||||
그래서 클라우드 이미지는 이 값들을 비워 둔 채 배포된다.
|
||||
|
||||
| 배포본이 비워 두는 것 | 첫 부팅 전 상태 |
|
||||
|---|---|
|
||||
| hostname | 미설정 (`localhost`) |
|
||||
| 사용자 계정 | 없음 |
|
||||
| 비밀번호 | 없음 |
|
||||
| SSH 호스트 키 | 없음 — 첫 부팅에 새로 생성 |
|
||||
| machine-id | 비어 있음 |
|
||||
|
||||
cloud-init 이 이 빈칸을 첫 부팅에 채우는 장치다. `user-data` 라는 YAML 을 읽어 계정을 만들고 SSH 키를 등록하고 패키지를 깔고 임의의 스크립트를 실행한다.
|
||||
|
||||
```text label="설치와 개인화를 누가 언제 하나"
|
||||
전통적 설치 : [설치 + 개인화]를 부팅 전에 대화형으로 수행
|
||||
클라우드 : [설치]는 배포자가 미리 완료
|
||||
[개인화]만 첫 부팅에 cloud-init 이 자동 수행
|
||||
```
|
||||
|
||||
## 격리는 실행 시점에 KVM/QEMU 가 만든다
|
||||
|
||||
「설치를 안 했으니 격리가 약한가」는 오해다. 격리는 실행 시점에 KVM/QEMU 가 만들지 설치 과정이 만들지 않는다. 게스트는 자기 커널로 부팅하고 자기 메모리 공간에서 돌며, 디스크 내용을 어떻게 얻었는지와 무관하다.
|
||||
|
||||
## 이 실험대가 cloud-init 을 고른 까닭은 재생성 비용이다
|
||||
|
||||
게스트에 계정과 키를 심는 방법은 셋이다.
|
||||
|
||||
| 계정과 키를 심는 방법 | 무엇이 드나 | 다시 만들 때 |
|
||||
|---|---|---|
|
||||
| ISO로 정식 설치 | VM마다 대화형 설치 | 매번 처음부터 반복 |
|
||||
| 이미지를 미리 개조 (`virt-customize`, `guestfish`) | libguestfs 설치 + 이미지 마운트 | 개조본을 따로 관리해야 함 |
|
||||
| cloud-init | YAML 한 장 | 명령 한 줄 |
|
||||
|
||||
이 실험대는 `virsh destroy` 와 오버레이 삭제로 게스트를 반복해서 지우고 다시 만드는 것이 실험 그 자체다. 재생성 비용이 낮아야 실험이 굴러간다. 두 노드가 바이트 단위로 같은 초기 상태로 만들어져야 한다는 조건도 붙는다. 손으로 설치하면 미묘하게 달라지고 그 차이가 실험 결과를 오염시킨다.
|
||||
|
||||
이미지 종류도 그 축에서 고른다. Debian 은 같은 판을 여러 변종으로 배포한다.
|
||||
|
||||
| Debian 변종 | 어디에 쓰나 |
|
||||
|---|---|
|
||||
| `genericcloud` | 가상화 환경 전용. virtio 드라이버만 담아 가볍다 → KVM에는 이걸 |
|
||||
| `generic` | 베어메탈 포함. 드라이버가 많아 더 크다 |
|
||||
| `nocloud` | cloud-init 없이 기본 계정이 박혀 있는 변종 |
|
||||
|
||||
`genericcloud` 가 가벼운 까닭은 물리 하드웨어 드라이버를 뺐다는 데 있고, 시드를 SATA CD-ROM 으로 붙이면 게스트가 그 장치를 보지 못하는 함정도 같은 이유로 생긴다. `virt-install --cloud-init` 은 시드를 `<target dev='sda' bus='sata'/>`, 즉 SATA CD-ROM 으로 붙인다. 그래서 이 조합에서는 게스트가 시드를 아예 장치로 보지 못한다. §237 이 성공 판정을 `virsh domblklist` 의 장치 이름으로 잡아 둔 까닭이 여기 있다 — 시드가 `vdb` 로 보여야 하고, `sda` 로 보이면 게스트가 읽지 못한다.
|
||||
|
||||
## 게스트에게 디스크는 두 장이고, 시드는 OS 가 아니다
|
||||
|
||||
가장 자주 하는 오해는 시드 ISO 를 OS 이미지로 아는 것이다. 시드는 설정 데이터만 담은 370KB 짜리 별도 디스크다.
|
||||
|
||||
| 게스트가 보는 디스크 | 크기 | 무엇이 들었나 |
|
||||
|---|---|---|
|
||||
| `vda` | 20G | ext4 루트. 여기서 부팅한다 |
|
||||
| `vdb` | 370K | `LABEL=CIDATA` 인 iso9660. 읽기 전용이고 마운트되지 않는다 |
|
||||
|
||||
`vda` 는 `base.qcow2` 위의 오버레이라 바닥 한 벌을 두 게스트가 공유하고 각자 변경분만 쌓는다. `vdb` 는 원본 YAML 을 구워 만든 ISO 를 풀에 올린 것이다.
|
||||
|
||||
```text label="같은 설정이 존재하는 세 곳"
|
||||
kc-lab-1.yaml ──①──▶ seed-kc-lab-1.iso ──②③──▶ /var/lib/libvirt/images/seed-kc-lab-1.iso
|
||||
```
|
||||
|
||||
①은 `xorrisofs` 가 굽는 단계이고 여기서 파일 이름이 ISO 안의 `/user-data` 와 `/meta-data` 로 바뀌는데, 두 이름이 정확해야 인식된다. ②와 ③은 `virsh vol-create-as` 로 자리를 잡고 `virsh vol-upload` 로 내용을 붓는 단계다. 홈 디렉터리가 `700` 이라 qemu 가 못 읽어서 풀에 둔다.
|
||||
|
||||
같은 내용이 세 곳에 있어서 원본만 고치면 VM 에 반영되지 않는다. 셋을 한 번에 맞추는 것이 `deploy/lab/scripts/rebuild-seed.sh` 다.
|
||||
|
||||
## 부팅 다섯 단계 가운데 3번이 실패하면 조용히 끝난다
|
||||
|
||||
```text label="첫 부팅에 일어나는 다섯 단계"
|
||||
1. QEMU 가 vda 에서 부팅 → Debian 커널 시작
|
||||
2. cloud-init 서비스 기동 → 모든 블록 장치를 스캔
|
||||
3. vdb 에서 LABEL=CIDATA 발견 → 잠깐 마운트
|
||||
4. user-data / meta-data 읽기 → 사용자·hostname·sudo·패키지 적용
|
||||
5. 언마운트 → SSH 로그인 가능
|
||||
```
|
||||
|
||||
cloud-init 은 `cidata` 레이블을 가진 블록 장치를 찾지 못하면 데이터소스 없이 종료하기 때문에, 3번이 실패하면 hostname 이 `localhost` 로 남고 사용자가 생성되지 않는다. 오류 메시지는 어디에도 남지 않는다. `virsh screenshot` 으로 로그인 프롬프트만 봐도 즉시 판정할 수 있다.
|
||||
|
||||
`user-data` 파일은 `#cloud-config` 로 시작해야 한다. 이 첫 줄이 없으면 cloud-init 이 YAML 로 인식하지 못하고 무시하며, 증상은 「부팅은 됐는데 계정이 없다」로 나타난다.
|
||||
|
||||
§236 은 비상 접근 수단을 남기라는 항목 하나를 「실제로 겪은 교훈」이라고 따로 적어 두었다. `ssh_pwauth: false` 에 키 인증만 걸어 둔 상태로 3번이 실패하면 사용자가 생성되지 않아 키도 비밀번호도 없고, 콘솔에 붙어도 로그인할 수 없다. 실패 원인을 적어 둔 `/var/log/cloud-init.log` 를 읽을 방법이 그래서 사라지고, VM 을 지우고 다시 만드는 것 말고 남는 선택지가 없어진다. 콘솔 로그인용 비밀번호를 하나 넣어 두면 그 골목을 피하는데, 콘솔 로그인은 sshd 를 거치지 않으므로 `ssh_pwauth: false` 는 그대로 둔다.
|
||||
|
||||
## multipass 가 감춰 주던 것이 이 단계들이다
|
||||
|
||||
multipass 와 virt-install 과 virsh 는 서로 배포판이 다른 도구가 아니라 계층이 다른 도구다. multipass 도 리눅스에서는 QEMU/KVM 위에서 돌고, `multipass set local.driver=libvirt` 로 libvirt 를 쓰게 할 수도 있다.
|
||||
|
||||
| multipass 가 자동으로 | 이번에 우리가 한 것 |
|
||||
|---|---|
|
||||
| Ubuntu 클라우드 이미지 자동 다운로드·캐시 | `curl`로 base.qcow2 내려받기 |
|
||||
| cloud-init user-data 자동 생성 | `kc-lab-1.yaml` 작성 |
|
||||
| 시드 ISO 생성·연결 | `xorrisofs` + virtio 디스크 연결 |
|
||||
| SSH 키 자동 생성·주입 | `ssh-keygen` + `ssh_authorized_keys` |
|
||||
| 네트워크·DHCP 구성 | `virbr0` + DHCP 예약 |
|
||||
| `multipass shell` 로 즉시 접속 | `~/.ssh/config` 별칭 작성 |
|
||||
|
||||
multipass 를 쓰지 않은 까닭은 배포판이 아니라 범위에 있다. multipass 는 Ubuntu 이미지만 공식 지원해서 Debian 게스트를 띄울 수 없다. 그리고 이 실험대는 `virsh destroy` 로 노드를 죽이고 NetworkPolicy 로 포트를 막고 스냅샷으로 되돌리는 저수준 제어가 실험의 본체라 관리 계층이 필요했다.
|
||||
|
||||
## 이 설명이 걸려 있는 판올림
|
||||
|
||||
게스트에 깔린 cloud-init 은 22.4.2 이고 데이터소스는 NoCloud 다. 판올림이 바뀌면 같은 YAML 에 대한 스키마 검사기의 판정이 달라진다.
|
||||
|
||||
크기 값은 두 날짜에서 왔다. 시드 ISO 370KB 와 게스트가 보는 `vda` 20G · `vdb` 370K 는 2026-09-03 실측이고, 같은 절이 `base.qcow2` 를 333M 로 적는다. 2026-09-10 에 다시 잰 `disk size` 는 335MiB 다.
|
||||
|
||||
파일 안이 어떻게 생겼길래 3 GiB 가 335MiB 로 앉는지는 이 글이 다루지 않는다. 시드를 굽는 세 명령의 옵션별 뜻도 마찬가지로 절차 쪽에 있다.
|
||||
|
||||
<!-- body:end -->
|
||||
+175
@@ -0,0 +1,175 @@
|
||||
---
|
||||
kind: CONCEPT
|
||||
slug: a-cloud-image-is-an-installed-disk-and-cloud-init-fills-the-blanks
|
||||
title: 설치를 안 했는데 왜 뜨는가 — 클라우드 이미지는 설치가 끝난 디스크이고 cloud-init 이 빈칸을 채운다
|
||||
topic: lab-environment-build
|
||||
topicName: 실험대 환경 구성
|
||||
project: virtualization
|
||||
status: 게시 전
|
||||
basisVersion: Debian 12 genericcloud 위의 cloud-init 22.4.2 · NoCloud 데이터소스 · 호스트는 QEMU 11.1.1 · libvirt 12.7.0
|
||||
sourceRevision: import-head 9465582b5d1630eb4ae7c4e078021486919bf6b6 · uncommitted-working-tree snapshot
|
||||
source:
|
||||
- final/document.md#229-왜-os를-설치하지-않아도-vm이-뜨는가
|
||||
- final/document.md#236-클라우드-이미지와-cloud-init
|
||||
- final/document.md#235-multipass-virt-install-virsh-무엇이-다른가
|
||||
- final/document.md#214-전체-구조-한눈에-보기
|
||||
- final/document.md#215-vm-한-대의-디스크-구성
|
||||
- final/document.md#216-설정-파일이-게스트에-도달하는-경로
|
||||
- final/document.md#217-부팅할-때-일어나는-일
|
||||
---
|
||||
|
||||
# 설치를 안 했는데 왜 뜨는가 — 클라우드 이미지는 설치가 끝난 디스크이고 cloud-init 이 빈칸을 채운다
|
||||
|
||||
클라우드 이미지는 배포자가 설치를 한 번 끝내 놓은 디스크 파일이라 게스트를 만들 때 설치 단계가 없다. 대신 hostname 과 SSH 호스트키 같은 고유값을 비워 둔 채 배포하고, cloud-init 이 첫 부팅에 그 빈칸을 채운다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **cloud-init 시드로 게스트 세 대를 만들고 SSH 가 키로 붙게 한다**
|
||||
그 절차가 「base 이미지를 받아 오버레이로 게스트 셋을 만든다」로 시작한다. 왜 받기만 하고 설치하지 않는지를 이 글이 댄다.
|
||||
- **cloud-init 이 안 도는 원인은 넷인데 증상은 「SSH 가 안 붙는다」 하나였다**
|
||||
그 원인 넷이 전부 「시드가 안 읽혔다」로 모인다. 시드가 무엇이고 어느 단계에서 읽히는지가 여기 있다.
|
||||
- **qcow2 파일 안 — 매핑표와 클러스터, 그리고 항목이 0 이면 바닥에 다시 묻는다**
|
||||
받은 파일이 어떻게 생겼길래 3 GiB 짜리가 335MiB 인지를 그 글이 연다.
|
||||
- **qcow2 파일 한 장이 담는 것 — 매핑표와 데이터 클러스터가 같은 파일 안에 있고, 파일 밖을 가리키는 것은 백킹 파일 경로 하나다**
|
||||
받은 바닥 이미지를 다른 호스트로 들고 갈 때 무엇이 따라가는지를 그 글이 가른다.
|
||||
- **가이드대로 다시 쳐서 이 실험대가 같은 상태로 서는가**
|
||||
게스트를 지우고 다시 만드는 비용이 낮다는 것이 이 선택의 근거인데, 그 비용을 실제로 재는 것은 그 물음이다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
## 설치 프로그램이 만드는 것은 결국 파일 하나의 내용이다
|
||||
|
||||
VM 의 디스크는 호스트의 파일 하나다. `kc-lab-1.qcow2` 라는 파일이 게스트에게는 20GB 하드디스크로 보이고, 게스트는 그것이 파일인 줄 모르는데 QEMU 가 디스크인 척해 주기 때문이다.
|
||||
|
||||
그러면 「OS 를 설치한다」가 무슨 작업인지 풀어 본다.
|
||||
|
||||
```text label="설치 프로그램이 빈 디스크에 하는 일"
|
||||
빈 디스크
|
||||
│ 설치 프로그램이 수행하는 일
|
||||
├─ 파티션 테이블 작성
|
||||
├─ 파일시스템 생성 (ext4, vfat …)
|
||||
├─ 패키지 수천 개를 풀어 배치
|
||||
├─ 부트로더 기록
|
||||
└─ 초기 설정 작성
|
||||
▼
|
||||
"부팅 가능한 특정 바이트 배열" 상태의 디스크
|
||||
```
|
||||
|
||||
설치 과정은 수단이고 목적은 마지막 줄의 상태이며, 그 상태는 파일 하나의 내용으로 남는다. Debian 과 Ubuntu 는 자기 빌드 서버에서 이 설치를 한 번 수행하고 완성된 디스크를 qcow2 파일로 떠서 공개하고, 우리는 그 파일을 내려받아 붙인다. 소스를 직접 컴파일하는 대신 이미 빌드된 바이너리를 받아 쓰는 것과 같아서, 결과물은 같고 시간만 아낀다.
|
||||
|
||||
## 그대로 복제하면 식별자가 겹치므로 일부러 비워 둔다
|
||||
|
||||
디스크가 바이트 단위로 같으면 안에 적힌 식별자도 같아진다.
|
||||
|
||||
| 값이 겹치면 | 무엇이 깨지나 |
|
||||
|---|---|
|
||||
| machine-id | systemd/DHCP가 두 기계를 같은 기계로 오인 |
|
||||
| SSH 호스트 키 | 두 서버가 같은 신원을 주장 → MITM 탐지 무력화 |
|
||||
| 파일시스템 UUID | `/etc/fstab`이 엉뚱한 디스크를 마운트 |
|
||||
| hostname | 로그·클러스터에서 노드 구분 불가 |
|
||||
|
||||
그래서 클라우드 이미지는 이 값들을 비워 둔 채 배포된다.
|
||||
|
||||
| 배포본이 비워 두는 것 | 첫 부팅 전 상태 |
|
||||
|---|---|
|
||||
| hostname | 미설정 (`localhost`) |
|
||||
| 사용자 계정 | 없음 |
|
||||
| 비밀번호 | 없음 |
|
||||
| SSH 호스트 키 | 없음 — 첫 부팅에 새로 생성 |
|
||||
| machine-id | 비어 있음 |
|
||||
|
||||
cloud-init 이 이 빈칸을 첫 부팅에 채우는 장치다. `user-data` 라는 YAML 을 읽어 계정을 만들고 SSH 키를 등록하고 패키지를 깔고 임의의 스크립트를 실행한다.
|
||||
|
||||
```text label="설치와 개인화를 누가 언제 하나"
|
||||
전통적 설치 : [설치 + 개인화]를 부팅 전에 대화형으로 수행
|
||||
클라우드 : [설치]는 배포자가 미리 완료
|
||||
[개인화]만 첫 부팅에 cloud-init 이 자동 수행
|
||||
```
|
||||
|
||||
## 격리는 실행 시점에 KVM/QEMU 가 만든다
|
||||
|
||||
「설치를 안 했으니 격리가 약한가」는 오해다. 격리는 실행 시점에 KVM/QEMU 가 만들지 설치 과정이 만들지 않는다. 게스트는 자기 커널로 부팅하고 자기 메모리 공간에서 돌며, 디스크 내용을 어떻게 얻었는지와 무관하다.
|
||||
|
||||
## 이 실험대가 cloud-init 을 고른 까닭은 재생성 비용이다
|
||||
|
||||
게스트에 계정과 키를 심는 방법은 셋이다.
|
||||
|
||||
| 계정과 키를 심는 방법 | 무엇이 드나 | 다시 만들 때 |
|
||||
|---|---|---|
|
||||
| ISO로 정식 설치 | VM마다 대화형 설치 | 매번 처음부터 반복 |
|
||||
| 이미지를 미리 개조 (`virt-customize`, `guestfish`) | libguestfs 설치 + 이미지 마운트 | 개조본을 따로 관리해야 함 |
|
||||
| cloud-init | YAML 한 장 | 명령 한 줄 |
|
||||
|
||||
이 실험대는 `virsh destroy` 와 오버레이 삭제로 게스트를 반복해서 지우고 다시 만드는 것이 실험 그 자체다. 재생성 비용이 낮아야 실험이 굴러간다. 두 노드가 바이트 단위로 같은 초기 상태로 만들어져야 한다는 조건도 붙는다. 손으로 설치하면 미묘하게 달라지고 그 차이가 실험 결과를 오염시킨다.
|
||||
|
||||
이미지 종류도 그 축에서 고른다. Debian 은 같은 판을 여러 변종으로 배포한다.
|
||||
|
||||
| Debian 변종 | 어디에 쓰나 |
|
||||
|---|---|
|
||||
| `genericcloud` | 가상화 환경 전용. virtio 드라이버만 담아 가볍다 → KVM에는 이걸 |
|
||||
| `generic` | 베어메탈 포함. 드라이버가 많아 더 크다 |
|
||||
| `nocloud` | cloud-init 없이 기본 계정이 박혀 있는 변종 |
|
||||
|
||||
`genericcloud` 가 가벼운 까닭은 물리 하드웨어 드라이버를 뺐다는 데 있고, 시드를 SATA CD-ROM 으로 붙이면 게스트가 그 장치를 보지 못하는 함정도 같은 이유로 생긴다. `virt-install --cloud-init` 은 시드를 `<target dev='sda' bus='sata'/>`, 즉 SATA CD-ROM 으로 붙인다. 그래서 이 조합에서는 게스트가 시드를 아예 장치로 보지 못한다. §237 이 성공 판정을 `virsh domblklist` 의 장치 이름으로 잡아 둔 까닭이 여기 있다 — 시드가 `vdb` 로 보여야 하고, `sda` 로 보이면 게스트가 읽지 못한다.
|
||||
|
||||
## 게스트에게 디스크는 두 장이고, 시드는 OS 가 아니다
|
||||
|
||||
가장 자주 하는 오해는 시드 ISO 를 OS 이미지로 아는 것이다. 시드는 설정 데이터만 담은 370KB 짜리 별도 디스크다.
|
||||
|
||||
| 게스트가 보는 디스크 | 크기 | 무엇이 들었나 |
|
||||
|---|---|---|
|
||||
| `vda` | 20G | ext4 루트. 여기서 부팅한다 |
|
||||
| `vdb` | 370K | `LABEL=CIDATA` 인 iso9660. 읽기 전용이고 마운트되지 않는다 |
|
||||
|
||||
`vda` 는 `base.qcow2` 위의 오버레이라 바닥 한 벌을 두 게스트가 공유하고 각자 변경분만 쌓는다. `vdb` 는 원본 YAML 을 구워 만든 ISO 를 풀에 올린 것이다.
|
||||
|
||||
```text label="같은 설정이 존재하는 세 곳"
|
||||
kc-lab-1.yaml ──①──▶ seed-kc-lab-1.iso ──②③──▶ /var/lib/libvirt/images/seed-kc-lab-1.iso
|
||||
```
|
||||
|
||||
①은 `xorrisofs` 가 굽는 단계이고 여기서 파일 이름이 ISO 안의 `/user-data` 와 `/meta-data` 로 바뀌는데, 두 이름이 정확해야 인식된다. ②와 ③은 `virsh vol-create-as` 로 자리를 잡고 `virsh vol-upload` 로 내용을 붓는 단계다. 홈 디렉터리가 `700` 이라 qemu 가 못 읽어서 풀에 둔다.
|
||||
|
||||
같은 내용이 세 곳에 있어서 원본만 고치면 VM 에 반영되지 않는다. 셋을 한 번에 맞추는 것이 `deploy/lab/scripts/rebuild-seed.sh` 다.
|
||||
|
||||
## 부팅 다섯 단계 가운데 3번이 실패하면 조용히 끝난다
|
||||
|
||||
```text label="첫 부팅에 일어나는 다섯 단계"
|
||||
1. QEMU 가 vda 에서 부팅 → Debian 커널 시작
|
||||
2. cloud-init 서비스 기동 → 모든 블록 장치를 스캔
|
||||
3. vdb 에서 LABEL=CIDATA 발견 → 잠깐 마운트
|
||||
4. user-data / meta-data 읽기 → 사용자·hostname·sudo·패키지 적용
|
||||
5. 언마운트 → SSH 로그인 가능
|
||||
```
|
||||
|
||||
cloud-init 은 `cidata` 레이블을 가진 블록 장치를 찾지 못하면 데이터소스 없이 종료하기 때문에, 3번이 실패하면 hostname 이 `localhost` 로 남고 사용자가 생성되지 않는다. 오류 메시지는 어디에도 남지 않는다. `virsh screenshot` 으로 로그인 프롬프트만 봐도 즉시 판정할 수 있다.
|
||||
|
||||
`user-data` 파일은 `#cloud-config` 로 시작해야 한다. 이 첫 줄이 없으면 cloud-init 이 YAML 로 인식하지 못하고 무시하며, 증상은 「부팅은 됐는데 계정이 없다」로 나타난다.
|
||||
|
||||
§236 은 비상 접근 수단을 남기라는 항목 하나를 「실제로 겪은 교훈」이라고 따로 적어 두었다. `ssh_pwauth: false` 에 키 인증만 걸어 둔 상태로 3번이 실패하면 사용자가 생성되지 않아 키도 비밀번호도 없고, 콘솔에 붙어도 로그인할 수 없다. 실패 원인을 적어 둔 `/var/log/cloud-init.log` 를 읽을 방법이 그래서 사라지고, VM 을 지우고 다시 만드는 것 말고 남는 선택지가 없어진다. 콘솔 로그인용 비밀번호를 하나 넣어 두면 그 골목을 피하는데, 콘솔 로그인은 sshd 를 거치지 않으므로 `ssh_pwauth: false` 는 그대로 둔다.
|
||||
|
||||
## multipass 가 감춰 주던 것이 이 단계들이다
|
||||
|
||||
multipass 와 virt-install 과 virsh 는 서로 배포판이 다른 도구가 아니라 계층이 다른 도구다. multipass 도 리눅스에서는 QEMU/KVM 위에서 돌고, `multipass set local.driver=libvirt` 로 libvirt 를 쓰게 할 수도 있다.
|
||||
|
||||
| multipass 가 자동으로 | 이번에 우리가 한 것 |
|
||||
|---|---|
|
||||
| Ubuntu 클라우드 이미지 자동 다운로드·캐시 | `curl`로 base.qcow2 내려받기 |
|
||||
| cloud-init user-data 자동 생성 | `kc-lab-1.yaml` 작성 |
|
||||
| 시드 ISO 생성·연결 | `xorrisofs` + virtio 디스크 연결 |
|
||||
| SSH 키 자동 생성·주입 | `ssh-keygen` + `ssh_authorized_keys` |
|
||||
| 네트워크·DHCP 구성 | `virbr0` + DHCP 예약 |
|
||||
| `multipass shell` 로 즉시 접속 | `~/.ssh/config` 별칭 작성 |
|
||||
|
||||
multipass 를 쓰지 않은 까닭은 배포판이 아니라 범위에 있다. multipass 는 Ubuntu 이미지만 공식 지원해서 Debian 게스트를 띄울 수 없다. 그리고 이 실험대는 `virsh destroy` 로 노드를 죽이고 NetworkPolicy 로 포트를 막고 스냅샷으로 되돌리는 저수준 제어가 실험의 본체라 관리 계층이 필요했다.
|
||||
|
||||
## 이 설명이 걸려 있는 판올림
|
||||
|
||||
게스트에 깔린 cloud-init 은 22.4.2 이고 데이터소스는 NoCloud 다. 판올림이 바뀌면 같은 YAML 에 대한 스키마 검사기의 판정이 달라진다.
|
||||
|
||||
크기 값은 두 날짜에서 왔다. 시드 ISO 370KB 와 게스트가 보는 `vda` 20G · `vdb` 370K 는 2026-09-03 실측이고, 같은 절이 `base.qcow2` 를 333M 로 적는다. 2026-09-10 에 다시 잰 `disk size` 는 335MiB 다.
|
||||
|
||||
파일 안이 어떻게 생겼길래 3 GiB 가 335MiB 로 앉는지는 이 글이 다루지 않는다. 시드를 굽는 세 명령의 옵션별 뜻도 마찬가지로 절차 쪽에 있다.
|
||||
|
||||
<!-- body:end -->
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user