상태: 초안 (2026-10-16), 우리 코드 + 사고 기록 + 일부 실측. 동작은 TT Lab 백엔드의
reap_once·delete_vm_session과 설계·사고 기록을 따랐습니다. 쿼터와 상한은 같은 날 읽기 전용으로 조회해 저장소 값과 같은지 확인했습니다. 작성 규칙: 실측 데이터가 기본이고, 못 잰 것은 「미실측」, 문서에서 찾지 못한 것은 「미확인」으로 표시합니다.
학생 VM 이 뜨기까지 5: 수명과 회수
목차
TT Lab 의 한 문장 원칙은 「수강생에게 진짜 root 셸을 주되, 밖으로 나가지 못하게 막고, 사용한 자원은 반드시 회수한다」 입니다. VM 에서는 마지막 구절이 특히 무겁습니다. 실습 파드 하나가 남으면 몇백 MB 가 새지만, VM 하나가 남으면 4GiB 가 통째로 묶입니다.
1. 수명: 60분, 연장은 세 배까지
- 세션을 열면 만료 시각이 시작 + 60분으로 정해집니다(기본값, 환경 변수로 바꿀 수 있습니다).
- 학생은 연장을 누를 수 있습니다. 연장하면 만료가 「지금 + 60분」 으로 밀리지만, 시작 + 180분을 넘지 못합니다. 무한 연장을 막는 상한입니다.
- 남은 시간은 화면의 카운트다운과 회수기가 같은 함수로 계산합니다. 둘이 따로 계산하면 화면은 5분 남았다는데 VM 이 사라지는 일이 생깁니다.
- 1인 1세션이므로 한 학생이 VM 을 여러 대 쌓아 둘 수도 없습니다(1편 ②).
2. 회수기: 60초마다 세 방향
백엔드는 60초마다 회수를 한 바퀴 돕니다. 같은 함수를 관리자 화면의 「지금 회수」 버튼도 씁니다. 보는 방향은 셋입니다.
| 방향 | 무엇 | 왜 필요한가 |
|---|---|---|
| 만료 | 시간이 다 된 세션 → 자원을 지우고 기록을 expired 로 마감 |
기본 경로 |
| 고아 | 클러스터에는 있는데 DB 에 살아 있는 세션이 없는 VM → 지움 | 백엔드가 VM 을 만든 직후 죽는 식으로 기록과 자원이 어긋날 때 |
| 유령 | DB 는 실행 중인데 VM 이 없는 세션 → 기록을 failed 로 마감 |
노드 재시작·OOM 으로 VM 이 사라졌을 때. 이게 없으면 1인 1세션에 걸려 학생이 영영 새 실습을 못 연다 |
VM 고아를 따로 훑는 데도 이유가 있습니다. 회수기는 처음에 실습 파드 목록만 봤는데, VM 은 다른 네임스페이스에 있어서 그 목록에 영영 보이지 않았습니다. DB 행이 사라진 VM 이 메모리 4GiB 를 붙잡은 채 아무에게도 안 보이는 상태가 될 수 있었습니다.
고아를 지우기 직전에는 DB 를 한 번 더 봅니다. 회수기가 목록을 읽은 뒤에 막 만들어진 세션의 VM 을 고아로 착각하지 않기 위해서입니다.
사고 1: 다른 환경의 회수기가 학생 VM 을 지웠다
2026-09-12, 운영 환경에서 학생 API 로 연 VM 세션이 준비를 끝내지 못하고 실패했습니다. 기록을 맞춰 보니 VMI 가 시작되고 26초 뒤에 VM 이 지워졌습니다. 시험 도구가 지운 것이 아니었습니다.
원인은 공유였습니다. 개발 환경과 운영 환경의 백엔드는 DB 가 따로인데 VM 네임스페이스는 하나를 같이 씁니다. 당시 회수기는 VM 네임스페이스 전체를 읽고 「내 DB 에 없으면 고아」 로 판단했습니다. 그러니 개발 환경의 회수기 눈에 운영 학생의 VM 은 고아였습니다. 같은 조건을 준 시험에서 운영 VM 을 지우는 호출이 재현됐습니다(쿠버네티스 감사 로그에서 실제 삭제 요청자의 신원까지 뽑지는 않았습니다).
고친 방법은 라벨 하나입니다. 백엔드는 VM 과 Secret 을 만들 때 자기 환경의 이름을 라벨로 붙이고, 고아 회수는 그 라벨이 자기 환경과 같은 VM 만 대상으로 삼습니다. 라벨이 없는 옛 VM 의 주인은 추측하지 않습니다. 목록 자체는 계속 전체를 보므로, DB 에 등록된 옛 VM 을 「없는 자원」 으로 오인하지도 않습니다. 고친 뒤 학생 API 로 다시 연 세션은 90.991초에 준비됐고, 끝낸 뒤 VM·VMI·Secret 이 모두 사라진 것을 확인했습니다(1회).
사고 2: 행 하나의 실패가 회수 전체를 멈췄다
2026-10-14 사이트 보안 점검에서 찾은 문제입니다. 만료 세션을 지우는 고리 안에서 삭제 하나가 예외를 내면 그 바퀴 전체가 끝났습니다. 같은 행이 매분 같은 자리에서 실패하는 동안, 그 뒤의 다른 만료 세션도, 고아도, 유령도 아무도 거두지 않은 채 쌓였습니다.
지금은 행마다 실패를 가둡니다.
for r in rows:
try:
delete_session_resource(r)
except Exception as exc:
failed += 1 # 실패를 세고
print(f"[reaper] 만료 세션 회수 실패 {str(r['id'])[:8]}: {type(exc).__name__}", flush=True)
continue # 기록은 그대로 두어 다음 바퀴에 다시 본다
con.execute("update sessions set status='expired', finished_at=now() where id=%s", (r["id"],))
기록을 expired 로 바꾸는 것은 지우기에 성공한 뒤입니다. 순서가 반대면 지우지 못한 VM 이 「끝난 세션」 으로 기록되어 다시는 회수 대상이 되지 않습니다. 실패 수는 관리자 화면에 나오는 회수 상태에 함께 남습니다.
같은 점검에서 실습 파드에는 마감(activeDeadlineSeconds = 60분 × 3 + 15분)도 넣었습니다. 웹 파드와 DB 가 함께 멈춰 회수기가 아예 돌지 않아도, 만료된 root 셸이 영원히 살지는 않게 하려는 것입니다. 정상 세션은 연장 상한(180분)보다 일찍 끝나므로 이 마감에 닿지 않습니다.
3. 무엇을, 어떤 순서로 지우나
백엔드가 직접 지우는 것은 둘뿐입니다.
VirtualMachine객체- cloud-init Secret(채점 토큰이 들어 있는)
나머지는 쿠버네티스와 KubeVirt 가 이어서 지웁니다. 순서는 이렇습니다.
| 순서 | 무엇이 | 누가 | 근거 |
|---|---|---|---|
| 1 | VM 삭제 | 백엔드 | delete_vm_session |
| 2 | VMI 정지·삭제 | KubeVirt | VM 이 VMI 를 소유한다 |
| 3 | virt-launcher 파드 종료 | KubeVirt·쿠버네티스 | 파드는 게스트에 종료 신호를 넘기고 VM 이 꺼질 때까지 기다린다(유예 30초) |
| 4 | 쓰기 층(emptyDir) 삭제 | 쿠버네티스 | emptyDir 는 파드와 수명이 같다 |
| 5 | DataVolume 과 그 PVC 삭제 | 쿠버네티스 가비지 컬렉터 | VM 이 dataVolumeTemplates 로 만든 DataVolume 을 소유한다(영구 디스크 실습만) |
| — | 골든 PVC | 아무도 지우지 않는다 | 소유자가 없고, 회수기가 고르는 라벨도 없다 |
순서를 백엔드가 직접 맞추지 않아도 되는 데는 쿠버네티스의 사용 중인 스토리지 객체 보호가 한몫합니다. 쿠버네티스 문서에 따르면 파드가 쓰고 있는 PVC 는 지우라고 해도 그 파드가 더 이상 쓰지 않을 때까지 실제 삭제가 미뤄집니다. 그러니 VM → 파드 → 볼륨의 순서가 자연스럽게 지켜지고, 게스트가 아직 쓰고 있는 디스크가 먼저 사라지는 일이 없습니다. 골든을 지울 때도 같은 보호가 작동해, 쓰고 있는 VM 이 다 끝날 때까지 골든은 「지우는 중」 으로 남습니다(4편).
노드 셋짜리 세션은 대표 VM 하나를 지우면 나머지 VM·Secret·세션 전용 망의 정책·서비스가 라벨로 함께 지워집니다. 실측에서 대표 하나를 지우고 15초 뒤 VM 3대·Secret 3개·서비스·정책이 모두 0개가 됐고, 둘째 워커를 만들다 실패하게 했을 때 이미 만든 것도 전부 회수됐습니다(2026-09-24).
Secret 은 왜 따로 한 번 더 지우나
cloud-init Secret 에는 VM 을 소유자로 적어 두므로, VM 이 지워지면 가비지 컬렉터가 함께 지워야 합니다. 그런데 그 소유자 연결이 실패할 수 있습니다. VM 을 만든 뒤 Secret 에 소유자를 다는 것은 별도의 수정 요청인데, 보안 점검에서 백엔드 서비스 계정에 Secret 수정 권한이 없어 이 연결이 늘 실패하는 것으로 보인다는 기록이 나왔습니다. 실제로 VM 없이 32일째 남은 cloud-init Secret 이 하나 발견됐습니다. 그래서 백엔드는 VM 을 지운 뒤 Secret 을 이름으로 한 번 더 지웁니다. 남으면 같은 세션 이름이 다시 나왔을 때 옛 cloud-init 이 되살아나기 때문입니다. 남은 Secret 의 정리와 권한 정리는 사람이 할 일로 기록돼 있습니다.
4. 상한: 피해의 크기를 묶는다
회수가 아무리 튼튼해도 실패할 수 있습니다. 그래서 「최악일 때 얼마나 새는가」 를 상한으로 묶어 둡니다.
| 상한 | 값 | 어디서 |
|---|---|---|
| 동시 실습 VM | 8대 | 백엔드(클러스터에 물어서 센다) |
| VM 네임스페이스 쿼터 — VM 수 | 8 | ResourceQuota |
| 같은 쿼터 — CPU 요청 / 메모리 요청 | 16 / 48Gi | ResourceQuota |
| 같은 쿼터 — PVC 수 | 12 | ResourceQuota(골든과 가져오는 동안의 스크래치만 쓴다) |
두 겹인 이유는 백엔드의 상한이 셈을 못 할 때(KubeVirt 가 흔들려 목록을 못 읽으면 백엔드는 상한을 걸지 않습니다 — 여기서 막으면 모든 VM 실습이 함께 멈추기 때문입니다) 쿼터가 마지막에 막아 주기 때문입니다.
8이라는 숫자를 올리지 않은 이유도 기록돼 있습니다. VM 여러 대짜리 세션을 만들 때(2026-09-24) KVM 을 실제로 내주는 노드의 메모리 요청 여유를 합쳐도 약 17GiB 였고, 측정 중에 노드 하나가 시스템 OOM 으로 약 1분 동안 NotReady 가 되기까지 했습니다. 쿼터를 올리면 거절이 「자리가 없습니다」 에서 스케줄 실패와 노드 압박으로 바뀔 뿐입니다. 노드 메모리가 먼저 모자랍니다. VM 은 메모리 요청에 할인이 없고(2편), 노드가 메모리 압박에 빠지면 먼저 죽는 것은 VM 이 아니라 같은 노드의 실습 파드라는 점도 상한을 보수적으로 둔 이유입니다.
5. 남은 빈틈
- VM 에는 파드의 마감 같은 마지막 안전장치가 없습니다. 회수기가 멈추면 만료된 VM 은 그대로 남습니다. 그때 피해를 묶는 것은 위의 상한(8대, 48Gi)뿐입니다. VM 객체에 마감에 해당하는 설정을 거는 방법은 미확인입니다.
- VM 고아 정리는 한 덩어리로 실패합니다. 만료 고리는 행마다 실패를 가두지만, VM 고아를 훑는 고리는 KubeVirt 가 흔들릴 때 회수 전체가 죽지 않도록 통째로 감싸 두었을 뿐이라, 고아 하나를 못 지우면 그 바퀴의 나머지 VM 고아는 다음 바퀴로 밀립니다. 로그에는 남습니다.
- 학생 세션에서 「만료부터 VM 이 사라질 때까지」 걸리는 시간의 분포는 미실측입니다. 회수 주기가 60초이므로 대략 1분 남짓일 것으로 보지만, 게스트 종료 유예(30초)까지 더한 실제 값은 재지 않았습니다.
티티 한마디. 지우는 코드는 짧을수록 좋아요. 백엔드는 딱 두 개만 지우고, 나머지는 「누가 누구를 소유하나」 와 「쓰는 중이면 안 지운다」 에 맡겨요. 그리고 실패한 행은 건너뛰고 다음 바퀴에 다시! 하나 때문에 전부 멈추면 안 되니까요.
참고
문서는 모두 2026-10-16 에 열람했습니다.
- 쿠버네티스 문서, 퍼시스턴트 볼륨(사용 중인 스토리지 객체 보호): https://kubernetes.io/docs/concepts/storage/persistent-volumes/
- KubeVirt 구성요소 문서(virt-launcher 의 종료 처리): https://github.com/kubevirt/kubevirt/blob/main/docs/components.md
- TT Lab 코드:
backend/app/main.py(reap_once·reaper·delete_vm_session·delete_session_resource·extend_session·count_live_vm_sessions),backend/app/vm_session_scope.py,backend/app/vm_group.py,deploy/vms/rbac.yaml(ResourceQuota) - 사고와 점검 기록:
docs/plans/2026-09-12-vm-reaper-environment.md,docs/plans/2026-09-24-multi-vm-sessions.md,docs/SECURITY-REVIEW-2026-10-14.md