VM 재시작과 Minecraft readiness 검증
중복 복구 이벤트부터 Minecraft readiness까지, 분리된 단계를 하나의 검증 경로로 묶는 방법.
복구 함수의 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 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·STAGING | start 재호출 없이 기존 전이 관찰 |
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의 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 완료 뒤 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_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만으로 handler가 직렬 실행되지는 않는다. Cloud Run 인스턴스 한
개도 여러 요청을 동시에 처리할 수 있기 때문이다. 위 예시는 인스턴스당 동시성도 1로
제한하지만 정확히 한 번 실행을 보장하지는 않는다. Pub/Sub 재전달과 서로 다른 revision의
겹침을 고려해 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 : 복구 완료 알림
참고
- Google Cloud: Pub/Sub subscription overview
- Google Cloud: Pub/Sub exactly-once delivery
- Compute Engine API: instances.start
- Google Cloud: Compute API 요청과 응답
- Google Cloud: Cloud Run functions 세대 비교
- Google Cloud: Cloud Run 최대 동시성 설정
- Google Cloud: Terraform으로 함수 배포
- Terraform Registry: Cloud Functions 2nd generation
- Google Cloud: Linux VM startup scripts
Share
아직 댓글이 없습니다. 첫 댓글을 남겨주세요.
검토 대기 중