VM 재시작과 Minecraft readiness 검증

중복 복구 이벤트부터 Minecraft readiness까지, 분리된 단계를 하나의 검증 경로로 묶는 방법.

복구 함수의 HTTP 응답, VM의 RUNNING, systemd의 active와 Minecraft 접속 가능 상태는 서로 다른 완료 조건이다. 운영 코드에는 TERMINATEDRUNNING을 기다리는 polling이 있었지만 Compute operation 오류, Pub/Sub 중복 사건과 Minecraft protocol readiness는 끝까지 연결되지 않았다.

분석 대상도 먼저 고정해야 했다. Terraform은 소스 디렉터리가 아니라 미리 압축된 ZIP을 배포했고, 저장소에 남은 ZIP 내부 main.py와 바깥 Python 소스의 해시와 제어 흐름이 달랐다. 소스가 맞더라도 배포 산출물이 다르면 운영 중인 함수에 대한 설명이 아니다.

소스에서 실행 코드까지의 경로

실제 1세대 함수는 기존 ZIP의 파일 해시를 객체 이름에 넣어 Cloud Storage에 업로드했다.

resource "google_storage_bucket_object" "function_zip" {
  name   = "restart-${filesha256("function.zip")}.zip"
  bucket = google_storage_bucket.function_source.name
  source = "function.zip"
}

resource "google_cloudfunctions_function" "restart_vm" {
  name                  = "restart-minecraft-vm"
  runtime               = "python310"
  source_archive_bucket = google_storage_bucket.function_source.name
  source_archive_object = google_storage_bucket_object.function_zip.name
  entry_point           = "restart_vm"
}

filesha256는 주어진 ZIP이 바뀌었음을 Terraform에 알린다. Python 소스와 ZIP이 일치하는지는 확인하지 않는다. 사람이 ZIP을 갱신하지 않으면 소스만 고친 변경은 배포 계획에 나타나지 않는다.

flowchart LR
  accTitle: 복구 함수 소스와 배포 산출물의 연결
  accDescr: 함수 소스와 잠긴 의존성을 하나의 검증된 ZIP으로 만들고, Terraform이 그 산출물을 함수 revision과 배포 기록으로 연결한다.
  SRC[main.py] --> PACKAGE[clean build<br/>deterministic ZIP<br/>SHA-256 검사]
  REQ[잠긴 requirements] --> PACKAGE
  PACKAGE --> RELEASE[Terraform plan<br/>Cloud Storage object]
  RELEASE --> DEPLOY[function revision<br/>revision·hash 기록]

재현 가능한 패키징에서는 이전 ZIP을 덮어쓰지 않는다. 새 임시 디렉터리에 필요한 소스와 의존성만 복사하고 timestamp·파일 순서를 고정한 뒤 압축한다. ZIP 내부 파일 목록, entry point 존재, 의존성 버전과 SHA-256을 검사하고 그 경로를 Terraform에 전달한다. 배포 뒤에는 함수 revision과 같은 hash를 구조화 로그 또는 release 기록에 남긴다.

Terraform의 archive_file data source를 사용하면 소스 변경을 계획에 연결할 수 있다.

data "archive_file" "restart_function" {
  type        = "zip"
  source_dir  = "${path.module}/function"
  output_path = "${path.module}/build/restart-function.zip"
}

resource "google_storage_bucket_object" "function_zip" {
  name   = "restart-${data.archive_file.restart_function.output_sha256}.zip"
  bucket = google_storage_bucket.function_source.name
  source = data.archive_file.restart_function.output_path
}

의존성을 ZIP에 vendoring한다면 패키지 설치까지 별도 build step으로 고정해야 한다. archive_file만으로 Python dependency가 자동 설치되지는 않는다. 런타임이 requirements.txt를 설치하는 배포 방식이라면 source archive에 lock된 명세를 포함한다.

중복을 전제로 한 메시지 처리

Pub/Sub은 기본적으로 at-least-once 전달을 제공한다. 함수가 결과를 기록하기 전에 죽거나 acknowledgement가 유실되면 같은 메시지가 다시 처리될 수 있다. 서로 다른 Monitoring incident가 같은 VM 종료를 가리킬 수도 있다.

가장 위험한 구간은 check-then-act다.

실행 A: VM 상태 TERMINATED 확인
실행 B: VM 상태 TERMINATED 확인
실행 A: instances.start 호출
실행 B: instances.start 호출

두 번째 start가 단순 오류로 끝날 수 있어도 로그와 알림이 실패처럼 보이고, 다른 상태 변경 API에서는 실제 중복 부작용이 생길 수 있다. 사건 ID와 API 요청 ID를 연결해 같은 의미의 호출을 같은 연산으로 취급한다.

from uuid import UUID

def request_id_for(incident_id: str) -> str:
    # 실제 구현에서는 incident ID를 namespace UUID로 안정적으로 변환한다.
    return str(UUID(incident_id))

operation = compute.instances().start(
    project=project,
    zone=zone,
    instance=instance,
    requestId=request_id_for(incident_id),
).execute()

Monitoring incident ID가 UUID 형식이 아닐 수 있으므로 예시는 변환 지점을 생략한 개념 코드다. 실제 구현은 UUID v5처럼 동일 문자열에서 동일 UUID를 만드는 함수를 사용한다. 무작위 UUID를 매 실행마다 만들면 중복 억제 효과가 없다.

상태별 동작도 멱등적으로 만든다.

현재 상태처리
RUNNING성공 no-op, 기존 operation과 readiness 확인
PROVISIONING·STAGINGstart 재호출 없이 기존 전이 관찰
STOPPING제한 시간 동안 TERMINATED 대기
TERMINATED안정적인 requestId로 start
상태 조회 실패오류 종류 기록, 권한 오류는 즉시 중단

별도 DB나 분산 lock은 실제 중복 부작용과 동시 복구 대상 수가 늘어난 뒤 고려한다. VM 한 대에서는 상태별 no-op, 안정적인 request ID와 구조화 로그가 먼저 줄이는 위험이 더 크다.

Compute operation 완료 대기

실제 함수의 시작 코드는 start 응답을 버리고 VM 상태만 polling했다.

def start_vm_and_wait(compute, project, zone, instance):
    compute.instances().start(
        project=project, zone=zone, instance=instance
    ).execute()

    for _ in range(0, 120, 5):
        vm = compute.instances().get(
            project=project, zone=zone, instance=instance
        ).execute()
        if vm.get("status") == "RUNNING":
            return True
        time.sleep(5)
    return False

Compute Engine의 start 응답은 zone operation이다. operation의 statusDONE이 돼도 error가 있을 수 있다. API 연산 실패와 VM 상태 전이 지연을 분리하려면 operation을 먼저 기다린다.

def wait_for_zone_operation(compute, project, zone, operation_name):
    while True:
        operation = compute.zoneOperations().get(
            project=project,
            zone=zone,
            operation=operation_name,
        ).execute()
        if operation["status"] == "DONE":
            if "error" in operation:
                raise RuntimeError(operation["error"])
            return operation
        time.sleep(2)

operation 완료 뒤 instance status를 확인하는 것은 중복이 아니다. operation은 API 작업의 완료와 오류를 말하고 instance status는 대상 자원의 현재 상태를 말한다. timeout 로그에는 operation name, 마지막 operation status, 마지막 instance status와 elapsed time을 함께 남긴다.

stateDiagram-v2
  accTitle: Compute Engine과 Minecraft 준비 상태
  accDescr: VM은 STOPPING에서 TERMINATED를 거쳐 PROVISIONING, STAGING, RUNNING으로 바뀌고 그 뒤 OS와 systemd, JVM 준비를 거쳐 READY가 된다.
  [*] --> STOPPING: 종료 이벤트
  STOPPING --> TERMINATED
  TERMINATED --> PROVISIONING: instances.start
  PROVISIONING --> STAGING
  STAGING --> RUNNING
  RUNNING --> OS_READY: 부팅 완료
  OS_READY --> SERVICE_ACTIVE: systemd active
  SERVICE_ACTIVE --> LISTENING: TCP·UDP bind
  LISTENING --> READY: 외부 status 성공
  SERVICE_ACTIVE --> FAILED: 재시작 반복·초기화 오류

VM RUNNING 다음의 readiness

startup script는 OS 부팅 뒤 실행된다. 패키지 설치, 외부 다운로드, 백업 복원, 파일 권한과 unit 생성 중 하나가 실패해도 Compute Engine은 VM을 RUNNING으로 표시할 수 있다. systemd active도 Java 프로세스가 살아 있다는 뜻이지 월드와 플러그인 초기화가 끝났다는 뜻은 아니다.

sequenceDiagram
  accTitle: 계층별 Minecraft readiness 검사
  accDescr: 함수가 VM 실행을 확인한 뒤 게스트 에이전트, systemd, 로컬 포트, 서버 로그와 외부 Minecraft protocol을 차례로 확인한다.
  participant Fn as 복구 함수
  participant API as Compute API
  participant OS as guest OS
  participant SD as systemd
  participant MC as Minecraft
  participant Ext as 외부 probe
  Fn->>API: operation DONE·VM RUNNING 확인
  API-->>Fn: RUNNING
  OS->>SD: minecraft.service 시작
  SD->>MC: Java 실행
  MC->>MC: 월드·플러그인 로드
  MC-->>OS: Done 메시지·local listener
  Ext->>MC: public IP로 status 요청
  MC-->>Ext: protocol response
  Ext-->>Fn: READY 기록

VM 안의 검사와 밖의 검사는 발견하는 실패가 다르다.

systemctl is-active --quiet minecraft.service
journalctl -u minecraft.service -b --no-pager | tail -n 100
ss -lnt '( sport = :25565 )'

로컬 TCP 리스너 성공은 JVM이 포트를 열었다는 증거다. 외부 IP, VPC 방화벽과 경로는 검사하지 못한다. Geyser의 Bedrock UDP는 ss -lnu로 별도 확인한다. 마지막 probe는 VM 밖에서 Java server list ping과 Bedrock status를 각각 수행하고 응답에 기대한 server identity나 version을 확인한다.

Done (...) 문자열만 검사하면 버전과 로그 형식에 결합된다. 가능한 경우 Minecraft protocol probe를 최종 조건으로 사용하고 로그는 원인 분석 자료로 남긴다. 성공 알림에는 VM_RUNNINGMINECRAFT_READY를 구분해 잘못된 기대를 만들지 않는다.

Cloud Run functions 전환 시 변경점

현재 2세대 함수는 Cloud Run functions로 제공된다. Terraform에서는 google_cloudfunctions2_function의 build와 service 설정을 분리하고 Pub/Sub Eventarc trigger를 선언할 수 있다.

resource "google_cloudfunctions2_function" "restart_vm" {
  name     = "restart-minecraft-vm"
  location = var.region

  build_config {
    runtime     = "python312"
    entry_point = "restart_vm"
    source {
      storage_source {
        bucket = google_storage_bucket.function_source.name
        object = google_storage_bucket_object.function_zip.name
      }
    }
  }

  service_config {
    available_memory      = "256M"
    timeout_seconds       = 300
    service_account_email = google_service_account.restart.email
    max_instance_count    = 1
    max_instance_request_concurrency = 1
  }

  event_trigger {
    trigger_region = var.region
    event_type     = "google.cloud.pubsub.topic.v1.messagePublished"
    pubsub_topic   = google_pubsub_topic.vm_restart.id
    retry_policy   = "RETRY_POLICY_RETRY"
  }
}

max_instance_count = 1만으로 handler가 직렬 실행되지는 않는다. Cloud Run 인스턴스 한 개도 여러 요청을 동시에 처리할 수 있기 때문이다. 위 예시는 인스턴스당 동시성도 1로 제한하지만 정확히 한 번 실행을 보장하지는 않는다. Pub/Sub 재전달과 서로 다른 revision의 겹침을 고려해 handler 자체가 여전히 멱등적이어야 한다. timeout을 300초로 늘려도 VM과 Minecraft가 그 안에 반드시 준비되는 것은 아니다. 장시간 readiness는 함수 요청 하나를 붙잡기보다 recovery 상태를 기록하고 별도 probe가 갱신하는 방식이 더 관찰하기 쉽다.

권한과 구조화 로그

실제 함수는 기본 서비스 계정에 프로젝트 범위 roles/compute.instanceAdmin.v1roles/logging.viewer를 사용했다. 새 함수에는 전용 서비스 계정을 연결하고 실제 API 호출에서 권한을 역산한다.

  • instance 상태 조회
  • instance start
  • zone operation 조회
  • 함수 로그 작성
  • 필요하면 Secret Manager의 특정 webhook version 접근

함수가 활동 로그를 직접 검색하지 않으면 Logs Viewer는 제외한다. 거부 테스트도 포함한다. 대상 VM 시작은 성공하고 다른 VM 시작, 삭제, 디스크 변경과 방화벽 수정은 실패해야 한다.

모든 로그에 다음 필드를 공통으로 둔다.

{
  "recovery_id": "example-incident-id",
  "event_state": "open",
  "instance": "minecraft-server",
  "phase": "WAITING_FOR_READY",
  "operation": "operation-name",
  "elapsed_ms": 84231,
  "result": "pending"
}

사건 시간축은 배포 revision과 source hash까지 포함한다. 그래야 운영 중 관찰한 동작을 어느 코드가 만들었는지 연결할 수 있다.

timeline
  accTitle: 한 복구 사건의 상관관계 시간축
  accDescr: incident ID를 기준으로 메시지 수신, operation, VM 상태, systemd, protocol readiness와 최종 알림이 이어진다.
  title recovery_id로 연결할 기록
  Incident : alert open
  Function : message received : source hash 기록
  Compute : start operation created : operation DONE
  Instance : RUNNING
  Guest : startup script 종료 : systemd active
  Minecraft : protocol READY
  Notify : 복구 완료 알림

참고

Share

공유

이미지 확대