VM 재시작과 Minecraft readiness 검증

중복 메시지와 Compute operation을 처리하고 VM 실행 뒤 Minecraft 준비 상태까지 확인한 방법

복구 함수의 HTTP 응답, VM의 RUNNING, systemd의 active와 Minecraft 접속 가능 상태는 서로 다른 완료 조건이다. 운영 코드에는 TERMINATED와 RUNNING을 기다리는 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 NAMESPACE_URL, uuid5

SERVICE_NAMESPACE = uuid5(
    NAMESPACE_URL, "urn:juntiger:minecraft-recovery"
)

def request_id_for(incident_id: str) -> str:
    return str(uuid5(SERVICE_NAMESPACE, incident_id))

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

UUID v5는 같은 incident ID에서 같은 UUID를 만든다. 무작위 UUID를 매 실행마다 만들면 중복 억제 효과가 없다. 서비스별 namespace를 따로 정하면 다른 종류의 ID가 같은 문자열을 써도 요청 ID가 겹치지 않는다.

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

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

VM 한 대를 복구하는 현재 범위에서는 상태별 no-op과 안정적인 request ID부터 적용한다. 복구 대상이나 중복 부작용이 늘어나면 별도 DB나 분산 lock을 추가한다.

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의 status가 DONE이 돼도 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은 API 작업의 완료와 오류를, instance status는 대상 자원의 현재 상태를 말한다. 두 값을 차례로 확인해야 start 요청과 VM 상태 전이를 각각 검증할 수 있다. 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_RUNNING과 MINECRAFT_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과 인스턴스당 동시성 1은 평상시 병렬 실행을 줄인다. 급격한 요청 증가 때는 최대 인스턴스 설정을 일시적으로 넘을 수 있고, Pub/Sub 재전달이나 서로 다른 revision의 겹침도 남는다. 두 값을 모두 1로 둬도 정확히 한 번 실행을 보장하지 않으므로 handler가 여전히 멱등적이어야 한다. timeout을 300초로 늘려도 VM과 Minecraft가 그 안에 준비된다는 보장은 없다. 장시간 readiness는 recovery 상태를 기록하고 별도 probe가 갱신하면 단계별 지연과 실패를 각각 관찰할 수 있다.

권한과 구조화 로그

실제 함수는 기본 서비스 계정에 프로젝트 범위 roles/compute.instanceAdmin.v1과 roles/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 : 복구 완료 알림

참고

Comments

댓글

    이미지 확대