TL;DR — 라이브 마이그레이션 직후 VM 네트워크가 약 20초 끊겼다. 원인은 cutover 이후에 직렬로 실행되던 방화벽 체인 생성(호스트 에이전트로의 원격 호출). idempotent한 그 작업을 마이그레이션 시작 전으로 옮기는 것만으로 다운타임을 1–2초로 줄였다. 알고리즘이 아니라 _실행 순서_가 문제였다.
프라이빗 클라우드 IaaS 제품 **CloudiA**의 VM 라이브 마이그레이션을 0→1로 구현하면서 부딪힌 문제 중, 체감이 가장 컸던 게 이거였다. “라이브”의 핵심은 사용자가 눈치채지 못하게 실행 중인 VM을 다른 호스트로 옮기는 것인데, 초기 구현에서 마이그레이션 직후 VM 내부 네트워크가 약 20초 끊겼다.
20초면 SSH 세션이 끊기고, Kubernetes healthcheck처럼 짧은 타이머에 의존하는 서비스는 연결이 죽었다고 판단해 강제 재시작한다. 라이브 마이그레이션의 존재 의의(“다운타임 없는 이동”)가 사실상 무의미해지는 수준이다.
이 글은 라이브 마이그레이션 시리즈의 deep-dive 편입니다. 전체 구현·하드닝 개요는 라이브 마이그레이션을 0→1로 만들고 단단하게 만든 과정에 있습니다.
라이브 마이그레이션은 어디서 네트워크를 끊는가
libvirt의 P2P 라이브 마이그레이션은 내부적으로 QEMU가 메모리 페이지를 반복 전송(iterative copy)하다가, 남은 dirty page가 충분히 작아지면 source를 멈추고 target에서 VM을 깨우는 cutover로 끝난다. cutover 자체는 보통 수십–수백 ms다.
문제는 cutover 이후다. target 호스트에서 VM의 네트워크를 실제로 살리려면 후처리가 남아 있다:
- VM의 가상 NIC에 대응하는 TAP 인터페이스가 등장했는지 확인
- 방화벽(Security Group) 체인 — iptables chain을 생성하고 점프 규칙을 연결
초기 구현은 이 후처리를 cutover 이후에 직렬로 실행했다. 사실 직관적인 순서다 — “VM이 target에 올라온 다음 네트워크를 붙인다.” 함정은, 이 직관이 후처리 중 일부가 미리 해둬도 되는 작업이라는 걸 놓쳤다는 점이다. 그 시간 동안 target VM의 패킷은 갈 곳이 없어 drop된다. 바로 이 구간이 “네트워크 끊김”이다.
어떻게 측정했고, 시간은 어디서 샜나
추측하지 않으려고 두 가지를 동시에 봤다:
- 다운타임 측정: VM 내부에서 외부로 짧은 간격의 연속 ping을 흘리며, 응답이 끊긴 구간(연속 손실)을 다운타임으로 봤다.
- 단계별 타임스탬프: 서버 측 각 단계 시작/완료에 로그를 박아 어디서 시간이 가는지 분리했다.
결과, 다운타임의 지배적 비중이 “마이그레이션 성공 직후 → target 방화벽 체인 bind 완료”까지의 한 구간에 몰려 있었다. 이 구간이 십수 초에 달했는데, 방화벽 체인 생성이 단순 로컬 iptables가 아니라 호스트 에이전트로의 원격 호출 + 규칙 세트 구성을 cutover 직후 직렬로 수행했기 때문이다. 거기에 TAP 등장 확인 폴링이 1초 간격이라, TAP이 수십 ms 만에 떠도 최대 1초를 더 기다렸다.
솔직히, 20초 안에서 cutover 자체 시간과 방화벽 구간을 ms 단위로 완벽히 분리하진 못했다. 다만 개선 전후 비교로 이 구간이 지배적이었다는 건 분명했다.
핵심 인사이트: idempotent한 작업은 critical path 밖으로 빼라
방화벽 체인 생성에는 중요한 성질이 있었다. 이미 같은 체인이 있으면 그냥 skip하는, 즉 idempotent한 연산이라는 점이다.
그렇다면 굳이 cutover 이후(= 네트워크가 끊긴 상태)에 만들 이유가 없다. 마이그레이션을 시작하기 전에 target 호스트에 미리 만들어두면(pre-warm) 된다. cutover 직후에는 점프 규칙만 연결하면 끝난다. 이건 oVirt나 OpenStack Nova가 네트워크 설정을 미리 준비해두는 패턴과도 일치한다.
# Before — 후처리가 다운타임 안에 들어가 있음
migrate(source → target) # cutover (수십 ms)
create_firewall_chains(target) # 십수 초 ── 이 동안 패킷 drop ❌
wait_for_tap(interval = 1s) # 최대 1s 낭비 ❌
# After — idempotent 작업을 critical path 밖으로
create_firewall_chains(target) # 마이그레이션 전에 미리 (idempotent, 안전)
migrate(source → target) # cutover
wait_for_tap(interval = 200ms) # 첫 체크는 sleep 없이 즉시
pre-warm의 대가: 실패하면 직접 치운다
pre-warm은 공짜가 아니다. 마이그레이션 전에 target에 체인을 만들어뒀는데 그 뒤 마이그레이션이 실패하면? 그 체인은 정작 VM이 올라오지 않은 호스트에 **고아(orphan)**로 남는다.
그래서 “이번 마이그레이션에서 내가 생성한 체인”만 따로 추적해뒀다가, 실패 경로에서 정확히 그것만 제거한다. 이미 존재하던 체인은 건드리지 않는다 — idempotent한 생성과 짝이 되는 idempotent한 정리다. 이 추적이 없으면 “성능은 좋아졌는데 방화벽 규칙이 슬금슬금 새는” 더 나쁜 문제로 바뀐다.
폴링 간격도 UX다
TAP 등장을 확인하는 폴링을 1초 → 200ms로 낮추고, 루프의 첫 번째 확인은 sleep 없이 즉시 수행하도록 바꿨다. 더 줄일 수도 있었지만, 폴링이 잦을수록 호스트 에이전트에 거는 부하가 늘어 200ms로 타협했다. 사실 더 큰 효과는 간격 자체보다 첫 체크를 즉시 하는 것 — TAP이 cutover 직후 바로 등장하는 흔한 경우에 불필요한 최대 1초 대기를 통째로 없앤다.
결과
| 지표 | Before | After |
|---|---|---|
| 네트워크 다운타임 | 약 20s | 1–2s |
| 방화벽 체인 생성 시점 | cutover 이후 (직렬) | 마이그레이션 전 (pre-warm) |
| TAP 폴링 간격 | 1000ms | 200ms (첫 확인 즉시) |
수치는 개발 환경(동일 rack, 10GbE)에서 위 ping 방식으로 측정한 기준이며, 운영 네트워크 조건에 따라 편차가 있을 수 있다.
배운 점
- 순서 변경만으로 10배. 병렬화도, 정교한 알고리즘도 없었다. idempotent한 연산을 critical path 밖으로 끌어낸 것만으로 체감 다운타임이 십수 초에서 1–2초가 됐다.
- 빠른 path의 sleep은 곧 사용자 다운타임이다. “폴링 간격을 얼마로 잡을지”, “첫 체크를 언제 할지”가 그대로 체감으로 반영된다.
- “라이브” 마이그레이션의 진짜 적은 메모리 전송이 아니라 cutover 이후 후처리일 수 있다. 가장 화려한 부분(iterative memory copy)이 아니라, 그 뒤에 조용히 직렬로 붙어 있던 네트워크 셋업이 지배적이었다.
이 글에서 다루지 않은 것
다운타임은 라이브 마이그레이션을 단단하게 만드는 여러 축 중 하나였다. CPU 이기종 호환성(세대·벤더가 다른 호스트로의 이동 실패를 런타임이 아니라 사전 필터링으로), 동시 마이그레이션 제어와 deadlock 방지, 한 건의 마이그레이션을 끝까지 추적하는 관측성 — 이런 것들은 개요 글에 정리해뒀다.
이 기능의 사용자 관점 문서는 CloudiA 매뉴얼의 Instance Migration 항목에서 볼 수 있다.