feat(pipeline): keycloak-session-store 25편·virtualization 59편을 S3→S5→S6 으로 돌린다
기록 84편을 계약 에이전트로 다시 썼다. 기존 71편(kss 25 · virt 46)과, 계약에만 있고 안 쓰여 있던 새 글감 13편이다. 원장 84개를 열어 단계마다 스킬 영수증과 관문 종료 코드를 적었고 verify-pipeline-run.py 가 error 0 으로 닫는다. SSOT 결함 둘을 고쳤다. - kss 의 `약 58일` 이 반입 중 `약 59일` 로 바뀌어 있었다. 원 증거 파일이 「남은 일수: 88일 … 실제 갱신까지 약 58일」로 산수를 직접 적는다. D-4a 쪽 `약 59일` 은 강제 갱신 뒤(`VALID: 89 days`)라 맞는 값이라 그대로 뒀다. - virt §198 의 `11.6GB` 는 §178 의 원 측정 `Mem: 11648`(MiB)과 어긋나는데 원 가이드의 표기 그대로라 고치지 않고 쓰이는 자리에 대조를 적었다. 기록의 수치 오류 셋을 고쳤다 — CASE 요약의 「게스트 셋에 8240MB」(5120+3120 은 둘이다), k3s 편이 같은 것을 여섯·일곱·여덟로 세던 것, no-docker 편의 「셋을 더 든다」(§281 의 표는 네 행이고 디스크 행이 빠져 있었다). 계약을 셋 고쳤다. - kss 의 sourceRepository 리비전이 cdac9b8 이었는데 그 커밋에는 docs/guides/** 28개가 아예 없다. 9465582b 로 바꾸고, 반입한 바이트가 어느 커밋과도 같지 않다는 것을 측정값과 함께 적었다 — 반입은 커밋이 아니라 그 시점의 작업 트리에서 떠 온 것이다(kss 297/306 · virt 12/14 가 작업 트리와 같고, 200 커밋을 거슬러 전수 대조했을 때 가장 가까운 커밋도 28개가 어긋났다). - virt 계약이 「2026-09-11 재배분」이라고 적는데 SSOT 는 재배분 날짜를 적지 않고 재배분 뒤 값은 이미 2026-09-10 측정에 찍혀 있다. - kss 후보 대장이 지나친 절 아홉에 처분을 적었다(warn 9 → 0). 새 글감은 0건이고 넷은 앵커가 h3 슬러그의 접두가 아니라 중간 토막이라 검사기가 못 본 것이었다. style_profile.mjs 의 결함 둘을 고쳤다 — frontmatter 가 문장으로 세어져 (실측 398자짜리 「문장」 하나) 평균 길이를 기준 안으로 밀어 올리고 있었고, engPerSent 의 분자는 목록을 포함한 글에서, 분모는 목록을 걷어낸 글에서 세고 있었다(Question 기록에서 11.94 → 3.86). verify-pipeline.py 전 항목 PASS · error 0 · unittest 334건 OK. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
d473609e0a
commit
2109f726fe
+165
@@ -0,0 +1,165 @@
|
||||
---
|
||||
kind: CASE
|
||||
slug: an-empty-token-installed-the-agent-anyway
|
||||
title: 빈 토큰이 조용히 흘러갔다 — 설치 출력은 성공이었고 agent 만 5초마다 다시 죽었다
|
||||
topic: build-completion-judgment
|
||||
topicName: 끝났다는 판정
|
||||
project: virtualization
|
||||
status: 게시 전
|
||||
sourceRevision: 9465582b5d1630eb4ae7c4e078021486919bf6b6
|
||||
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"
|
||||
```
|
||||
|
||||
토큰이 셸 히스토리에 남지 않고, 변수를 쓰지 않으니 셸이 달라도 된다.
|
||||
|
||||
## 확인하지 못한 것
|
||||
|
||||
설치 출력도 `journalctl` 출력도 `final/evidence/` 에 남기지 않았다. `level=fatal msg="Error: --token is required"` 와 `Host key verification failed.` 는 SSOT 본문에서 옮긴 것이고 명령 출력 파일이 근거가 아니다. 토큰 108자도 같다.
|
||||
|
||||
이 실패를 일부러 다시 만들어 본 기록도 없어서, 빈 토큰으로 설치하면 설치 출력이 성공으로 끝난다는 것은 한 번의 관측이다. 재현이 쉽지 않은 까닭은 이 부의 검증 방식에 있다 — 만드는 명령은 다시 치면 돌고 있는 실험대가 없어지므로 구축할 때 쓴 것을 옮기고 결과 상태를 확인하는 것으로 대신했다. 재현 없이 남길 수 있는 것은 `journalctl` 쪽이고, 그 출력을 `final/evidence/raw/` 에 남기면 진단 절에 증거가 붙는다.
|
||||
|
||||
가드 한 줄이 실제로 빈 토큰을 잡아 본 기록도 없다.
|
||||
|
||||
<!-- body:end -->
|
||||
+196
@@ -0,0 +1,196 @@
|
||||
---
|
||||
kind: CASE
|
||||
slug: cloud-init-failures-all-look-like-ssh-refused
|
||||
title: cloud-init 이 안 도는 원인은 넷인데 증상은 「SSH 가 안 붙는다」 하나였다
|
||||
topic: build-completion-judgment
|
||||
topicName: 끝났다는 판정
|
||||
project: virtualization
|
||||
status: 게시 전
|
||||
sourceRevision: 9465582b5d1630eb4ae7c4e078021486919bf6b6
|
||||
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
|
||||
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'
|
||||
```
|
||||
|
||||
이 파일에는 콘솔 비밀번호가 평문으로 들어 있어 `/tmp` 가 아니라 자기 홈에 `600` 으로 두고, 검사가 끝나면 바로 지운다.
|
||||
|
||||
## 확인하지 못한 것
|
||||
|
||||
넷 가운데 SSOT 가 관측으로 표시한 것은 시드를 SATA 로 붙였을 때 AHCI 장치가 보이지 않는 것과 스키마 검사기의 거부 문구뿐이다. YAML 파싱 실패와 `vol-upload` 누락과 네트워크 autostart 셋은 가이드가 막히면 표에 적어 둔 항목이라, 이 실험대에서 실제로 그 증상을 본 것인지 예상해 적은 것인지 SSOT 가 가르지 않았다. 이 글에서 그 셋은 그렇게 되는 구조까지이고 그렇게 됐다가 아니다.
|
||||
|
||||
명령 출력은 원문으로 남아 있지 않다. `Permission denied (publickey)` 도 `cloud-init status: done` 도 약 50초도 SSOT 본문이 근거다. `final/evidence/` 에 그 출력을 담은 파일이 없다. `virsh screenshot` 으로 뜬 화면도 저장해 두지 않았다.
|
||||
|
||||
만드는 명령은 재실행으로 검증되지 않았다. 게스트를 다시 만들면 돌고 있는 실험대가 없어지므로 시드와 관련된 셋은 다시 재현하기 어렵다. 네트워크 autostart 만은 호스트를 재부팅해 확인할 수 있는데 그 기록도 없다.
|
||||
|
||||
<!-- 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: 9465582b5d1630eb4ae7c4e078021486919bf6b6
|
||||
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/hyeonworks.com/
|
||||
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 -->
|
||||
Reference in New Issue
Block a user