TT Lab
요금제

한국어

시작하기
블로그

블로그

학생 VM 이 뜨기까지 5: 수명과 회수 — 무엇을 어떤 순서로 지우고, 실패해도 멈추지 않게

상태: 초안 (2026-10-16), 우리 코드 + 사고 기록 + 일부 실측. 동작은 TT Lab 백엔드의 reap_once·delete_vm_session 과 설계·사고 기록을 따랐습니다. 쿼터와 상한은 같은 날 읽기 전용으로 조회해 저장소 값과 같은지 확인했습니다. 작성 규칙: 실측 데이터가 기본이고, 못 잰 것은 「미실측」, 문서에서 찾지 못한 것은 「미확인」으로 표시합니다.

학생 VM 이 뜨기까지 5: 수명과 회수

목차

TT Lab 의 한 문장 원칙은 「수강생에게 진짜 root 셸을 주되, 밖으로 나가지 못하게 막고, 사용한 자원은 반드시 회수한다」 입니다. VM 에서는 마지막 구절이 특히 무겁습니다. 실습 파드 하나가 남으면 몇백 MB 가 새지만, VM 하나가 남으면 4GiB 가 통째로 묶입니다.

세션의 수명과 지우는 순서 ① 시간표 (기본값) 시작 60분 만료 180분 연장 상한 연장(한 번에 60분까지) 회수기는 60초마다 돈다: 만료 · 고아(DB 에 없는 VM) · 유령(VM 없는 DB 행) 한 세션을 못 지우면 그 행만 건너뛰고 다음 바퀴에 다시 본다 ② 백엔드가 지우는 것은 둘뿐 VirtualMachine 삭제 cloud-init Secret 삭제 VMI 정지 KubeVirt virt-launcher 파드 종료 게스트 종료 신호 emptyDir 쓰기 층 삭제 쿠버네티스(파드와 함께) DataVolume 의 PVC VM 이 소유 → 가비지 컬렉션 (B 방식만) 골든 PVC 소유자 없음 · 세션 라벨 없음 → 지워지지 않는다 PVC 는 쓰는 파드가 있는 동안 지워지지 않는다(사용 중 보호). 그래서 VM → 파드 → 볼륨 순서가 자연스럽게 지켜진다.
그림: 세션의 수명과 지우는 순서

1. 수명: 60분, 연장은 세 배까지

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. 무엇을, 어떤 순서로 지우나

백엔드가 직접 지우는 것은 둘뿐입니다.

  1. VirtualMachine 객체
  2. 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. 남은 빈틈

티티 한마디. 지우는 코드는 짧을수록 좋아요. 백엔드는 딱 두 개만 지우고, 나머지는 「누가 누구를 소유하나」 와 「쓰는 중이면 안 지운다」 에 맡겨요. 그리고 실패한 행은 건너뛰고 다음 바퀴에 다시! 하나 때문에 전부 멈추면 안 되니까요.

참고

문서는 모두 2026-10-16 에 열람했습니다.

로그인하면 좋아요를 누를 수 있습니다

댓글

아직 댓글이 없습니다.

로그인하면 댓글을 쓸 수 있습니다