VM metadata와 startup script의 배포 계약
Terraform 입력이 부팅 시점의 파일과 systemd 서비스가 되던 경로와 소비 코드가 없던 값들
2025년 구성은 google_compute_instance의 metadata 블록으로 서버를 만드는 데 필요한
거의 모든 입력을 전달했다.
metadata = {
startup-script = file("${path.module}/scripts/startup.sh")
shutdown-script = file("${path.module}/scripts/shutdown.sh")
server-properties = file("${path.module}/scripts/minecraft/server.properties")
backup-script = file("${path.module}/scripts/backup.sh")
minecraft-service = file("${path.module}/scripts/systemd/minecraft-server.service")
backup-timer = file("${path.module}/scripts/systemd/minecraft-backup.timer")
discord-bot-zip = filebase64("${path.module}/scripts/discord-v2.zip")
}
셸 스크립트, systemd 유닛, 서버 설정 파일, 그리고 Discord 봇 애플리케이션 전체가
base64로 인코딩돼 같은 통로로 들어갔다. Terraform은 이 값을 인스턴스에 붙이고,
부팅한 VM 안에서 startup.sh가 하나씩 꺼내 파일 시스템에 배치했다.
Terraform이 보낸 값과 VM에서 읽은 키를 당시 저장소의 코드로 대조했다. 아래 인용도 모두 그 저장소에 남은 코드다. plan과 apply 로그가 없으므로 실제 배포 성공 여부와 각 값이 올라간 시점은 확인할 수 없다.
두 종류의 metadata 키
metadata 키는 Compute Engine이 직접 처리하는 이름과 시작 스크립트가 읽는 사용자 정의 이름으로 나뉘었다.
한쪽은 Compute Engine이 규약으로 정한 이름이다. startup-script와
shutdown-script는 guest agent가 부팅과 종료 시점에 알아서 실행한다.
ssh-keys도 플랫폼이 로그인 경로에 사용한다. guest agent와 VM 설정이 갖춰진
환경에서는 플랫폼이 이 키들을 정해진 용도로 처리한다.
나머지는 직접 만든 이름이었다. backup-bucket이나 rcon-port 같은 설정값,
backup-script나 minecraft-service 같은 파일 본문, discord-bot-zip 같은
애플리케이션 번들이 여기 속한다. Compute Engine은 이 값들을 저장하고, VM 안의
스크립트가 metadata server에서 읽어 사용했다.
get_md(){
curl -fsS -H "Metadata-Flavor: Google" ... \
"http://metadata.google.internal/computeMetadata/v1/$1" || true
}
startup.sh의 이 함수가 사용자 정의 키를 읽었다. Terraform이 값을 보내고 셸
스크립트가 읽었지만, 두 파일의 키 이름을 대조하는 검사는 없었다. 키 이름을 잘못
적어도 plan은 통과하고, 값을 보내 놓고 읽지 않아도 apply는 성공한다.
부팅 한 번에 일어난 일
startup.sh의 main()은 열네 단계를 순서대로 호출했다.
configure_sshd SSH 설정 변경
fetch_metadata 인스턴스 이름, 존, 외부 IP 조회
install_packages 기본 패키지 설치
install_java Temurin 21 설치
prepare_world 디렉터리 생성과 백업 자동 복원 시도
download_purpur Purpur 1.21.6 내려받기
agree_eula eula.txt 작성
extract_metadata_files metadata → 스크립트 파일
setup_discord_env metadata → .env 파일
extract_and_unzip_... metadata(base64) → 봇 애플리케이션
extract_service_files metadata → systemd 유닛
set_script_permissions 실행 권한 부여
install_python_... Python 의존성 설치
enable_and_start_... 서비스 기동과 Discord 알림
VM 생성 뒤 한 번의 부팅에서 운영체제 구성, 런타임 설치, 애플리케이션 배포와 데이터 복원을 모두 실행했다. Terraform은 스크립트를 metadata에 저장했고, 이후 파일 배치와 서비스 시작은 셸 스크립트가 실행했다.
extract_service_files는 metadata에서 꺼낸 본문을 그대로 유닛 경로에 쓴다.
declare -A SVC_MAP=(
[minecraft-service]="/etc/systemd/system/minecraft-server.service"
[backup-service]="/etc/systemd/system/minecraft-backup.service"
[backup-timer]="/etc/systemd/system/minecraft-backup.timer"
...
)
for attr in "${!SVC_MAP[@]}"; do
get_md "instance/attributes/$attr" > "${SVC_MAP[$attr]}" ...
done
Terraform의 file() 함수가 읽은 저장소의 파일이 metadata를 지나 VM의
/etc/systemd/system에 도착하는 경로다. 중간에 검증은 없다. 값이 비어 있으면 빈
유닛 파일이 만들어진다.
코드에서 확인한 어긋남 다섯 가지
보내는 코드와 읽는 코드가 서로 다른 파일에 있었고, 둘을 대조하는 검사는 없었다. metadata 키를 보낸 쪽과 받은 쪽을 나란히 놓으면 읽는 코드가 없는 키를 찾을 수 있다.
flowchart LR TF["Terraform metadata 블록"] --> K1["startup-script<br/>shutdown-script"] TF --> K2["backup-script<br/>systemd 유닛 4종"] TF --> K3["discord-bot-zip"] TF --> K4["server-properties<br/>minecraft-port"] K1 --> GA["guest agent가 실행"] K2 --> SH["startup.sh가 파일로 배치"] K3 --> SH K4 --> NONE["읽는 코드 없음"]
코드에서 확인되는 어긋남은 다섯 가지였다.
읽는 코드가 없는 값
server-properties는 metadata에 실려 VM까지 갔지만 어떤 스크립트도, 봇 코드도 이
키를 읽지 않는다. project-id도 마찬가지다. Terraform 쪽만 보면 서버 설정을 코드로
관리하는 구성처럼 보이지만, 실제 server.properties는 Purpur가 처음 생성한
파일이거나 복원된 백업 안의 파일이었다.
다른 프로그램이 읽고 있던 값
minecraft-port는 조금 다르다. startup.sh는 이 키를 읽지 않고 시작 알림 문구에
포트를 상수로 적어 두었다. 대신 같은 metadata를 타고 배포된 Discord 봇이 이 키를
조회해 접속 정보를 만든다.
같은 값을 한쪽은 무시하고 다른 쪽은 사용한다. 어느 쪽이 맞는 동작인지는 코드만으로 알 수 없고, 포트를 바꾸면 봇의 안내와 시작 알림 문구가 갈라진다. 키 하나에 소비자가 둘인데 그 둘이 다른 곳을 보고 있었다.
키 이름을 잘못 적어 도달하지 못한 값
이름 철자도 어긋났다. Terraform은 하이픈으로 키를 보냈는데, 받는 쪽 스크립트는 언더스코어로 조회했다.
# main.tf가 보낸 키
discord-webhook-url-backup
# backup.sh가 찾은 키
get_metadata "discord_webhook_url_backup"
조회는 언제나 빈 값을 돌려주고, 스크립트는 자기 안에 적어 둔 폴백 상수를 썼다. 종료 알림도 같은 형태였다. 두 경우 모두 metadata로 값을 관리하는 것처럼 보이지만 실제로 쓰인 값은 스크립트에 박힌 상수였고, 그 사실을 알려 주는 오류도 나지 않았다.
같은 유닛 파일을 두 경로에 쓴 곳
backup-timer는 두 번 기록된다. extract_metadata_files가
/opt/minecraft/scripts/systemd/에 한 번 쓰고, extract_service_files가
/etc/systemd/system/에 다시 쓴다. systemd가 읽는 것은 후자뿐이다. 앞의 파일은
같은 내용을 가진 채 아무도 보지 않는 자리에 남는다.
당시에 이 사본을 왜 만들었는지는 코드만으로 알 수 없다. 타이머를 고칠 때 어느 파일을 봐야 하는지가 한눈에 보이지 않게 된 것은 분명하다.
실행될 수 없었던 복원 경로
백업과 복원 사이에는 두 결함이 겹쳐 있었다. 새 VM이 부팅할 때 월드를
되살리는 함수는 prepare_world다.
prepare_world(){
...
mkdir -p "$MINECRAFT_DIR"/{world,scripts/discord,scripts/systemd}
cd "$MINECRAFT_DIR"
if [ ! -d world ]; then
log "백업 복원 시도"
...
else
log "world 디렉토리 존재—복원 스킵"
fi
}
mkdir이 world 디렉터리를 만들고, 두 줄 뒤에서 그 디렉터리가 없는지 묻는다.
조건은 언제나 거짓이다. 부팅할 때마다 스크립트는 world 디렉토리 존재—복원 스킵을
기록하고 지나갔다. 안에 무엇이 들어 있든 실행되지 않는 코드였다.
복원 분기 안의 압축 해제 명령도 백업 형식과 달랐다. backup.sh는 tar와 pigz로 압축한다.
BACKUP_FILE="minecraft-backup-$(date +%Y%m%d-%H%M%S).tar.gz"
tar -I 'pigz -9' -cvf "$BACKUP_PATH" -C "$TMP_BACKUP_DIR" .
도달할 수 없는 복원 분기는 같은 버킷에서 가장 최근 객체를 받아 unzip으로
푼다.
latest=$(gsutil ls "gs://$BUCKET/backups/" | sort | tail -n1)
gsutil cp "$latest" "$tmp"
unzip -q "$tmp" -d "$MINECRAFT_DIR"
.tar.gz는 unzip으로 열리지 않는다. 두 스크립트는 같은 저장소에서 같은
metadata 블록을 통해 같은 VM에 배포됐지만 서로 다른 압축 형식을 가정했다. 결함이
두 겹이라 하나를 고쳐도 복원은 되지 않는다. 조건문을 바로잡으면 형식 불일치가
드러나고, 형식을 맞춰도 조건문이 분기를 막는다.
디스크를 유지한 채 VM을 다시 시작하면 월드는 디스크에 그대로 있었다. 자동 복원은
디스크까지 새로 만드는 경우에 필요했다. mkdir이 먼저 만든 world 디렉터리
때문에 새 디스크에서도 복원 분기에 들어가지 못했다.
안내와 실제가 달랐던 백업 주기
startup.sh에는 백업 주기가 상수로 있었다.
BACKUP_FREQ_MIN=60
이 값은 Discord 알림 문구를 만드는 데만 쓰였다. 서버가 뜰 때마다 “매 60분마다 서버가 자동으로 백업됩니다”라는 메시지가 채널에 올라갔다. 실제 실행을 결정하는 타이머 유닛은 달랐다.
[Timer]
OnCalendar=*-*-* HH:00:00
AccuracySec=1s
Persistent=true
하루 한 번이었다. 채널에 올라간 안내는 60분마다, 타이머 유닛은 하루 한 번이었다. 두 값 모두 같은 사람이 같은 저장소에서 관리했지만, 한쪽은 문구고 한쪽은 설정이라 함께 바뀌지 않았다.
이 계약이 만든 제약
metadata를 사용하면 별도 아티팩트 서버 없이 파일을 VM에 넣고 Terraform plan에서 인프라와 파일 변경을 함께 확인할 수 있다. 이 구성에는 세 가지 제약이 있었다.
스크립트 한 줄을 고쳐도 인스턴스 변경이 된다. file()이 읽는 파일이 바뀌면
metadata 값이 바뀌고, plan에는 VM 속성 변경이 나타난다. 애플리케이션 수정과
인프라 수정이 같은 계획에 섞인다.
반영하려면 다시 부팅해야 한다. metadata가 바뀌어도 이미 떠 있는 VM의
/etc/systemd/system은 그대로다. startup-script는 부팅 때만 실행되기 때문이다.
게다가 유닛 파일을 새로 쓴 뒤 systemctl daemon-reload를 호출하는 곳이 없어,
파일이 처음 생기는 첫 부팅과 내용이 바뀐 재부팅의 동작이 같다고 보기 어렵다.
비밀값이 디스크로 내려간다. setup_discord_env는 metadata에서 꺼낸 값으로
환경 파일을 만든다.
cat > "$env_file" << EOF
DISCORD_BOT_TOKEN=$bot_token
RCON_PASSWORD=$rcon_password
EOF
chmod 600 "$env_file"
권한은 0600으로 제한했다. 그래도 metadata를 거쳐 온 값이 VM 디스크의 파일로 남는다는 사실은 그대로다.
여기에 서버 유닛 자체의 제약이 하나 더 있다. heap이 고정값이다.
ExecStart=/usr/bin/java -Xms8G -Xmx8G ... -jar purpur.jar nogui
Restart=always
머신 유형을 키워도 이 숫자는 따라 오르지 않는다. 메모리를 늘리려면 유닛 파일을
고치고, metadata를 다시 적용하고, VM을 재부팅해야 한다. 두 서비스 모두 User=root로
실행됐고, configure_sshd가 PermitRootLogin yes를 강제한 것도 같은 시기의
선택이었다.
지금 공개 예제가 줄인 것
구축 트랙의 minecraft-one-root도
metadata를 배포 통로로 쓴다. 대신 통로에 싣는 양을 줄이고 계약을 좁혔다.
애플리케이션 번들을 넣지 않는다. 서버 JAR는 metadata로 URL과 SHA-256 해시만 전달하고, VM이 직접 내려받아 해시를 검증한 뒤에만 서비스를 시작한다.
읽지 않는 키를 두지 않는다. 게임 포트와 백업 설정은 metadata로 보내고 시작
스크립트가 실제로 그 값을 사용해 server.properties를 만든다.
백업과 복원이 같은 형식을 쓴다. 백업은 tar.gz와 .sha256 manifest를 함께
올리고, 복원과 검증 명령은 같은 형식을 전제로 동작한다.
격리 복원 시험은 그
archive를 실제로 열어 서버가 뜨는지 확인한다.
그래도 남는 성질이 있다. 시작 스크립트를 고치면 여전히 VM 변경이 되고, 반영에는 재부팅이 필요하다. metadata를 배포 통로로 쓰는 한 이 성질은 사라지지 않는다. 서버 한 대를 개인이 운영하는 규모에서는 받아들일 만한 비용이고, 운영 편이 그 재부팅 절차를 변경 작업의 일부로 다룬다.
2025년 구성은 이 통로에 스크립트, 유닛, 설정, 애플리케이션과 비밀값을 한꺼번에
실었다. 읽지 않는 키, 두 번 쓰는 파일과 도달하지 않는 분기가 있어도 apply는
성공할 수 있었다. 첫 부팅 경로를 끝까지 실행하는 시험이 있었다면 이 코드 경로들을
검사 대상으로 삼을 수 있었다. 공개 예제에는 같은 누락을 찾기 위해 컨테이너 시험을
붙였다.
참고
Comments
아직 댓글이 없습니다. 첫 댓글을 남겨주세요.
검토 대기 중