Discord·RCON 서버 운영 경계
Discord interaction을 제한된 RCON 작업으로 바꾸고 비동기 실행, 동시성, 감사 로그와 자격정보의 경계를 나눈 운영 설계.
Discord 봇은 Minecraft와 같은 VM에서 systemd service로 실행됐다. SSH나 GCP 콘솔을 열지 않고 서버 상태, 접속자와 통계를 조회하고 whitelist 같은 운영 작업을 실행했다. 시작·종료와 백업 결과도 Discord 채널로 전송했다.
Discord와 RCON은 서로 다른 인증 영역이다. Discord는 guild, channel, 사용자와 role을 제공한다. Minecraft는 하나의 RCON 비밀번호로 연결을 인증하고 원래 요청자가 누구였는지 모른다. 봇이 Discord 신원을 검증하고 제한된 작업으로 변환하지 않으면 RCON의 전체 콘솔 권한이 Discord 입력에 그대로 노출된다.
요청 접수와 작업 완료 분리
Discord interaction은 제한 시간 안에 첫 응답을 요구한다. 운영 코드는 먼저 defer하고
blocking RCON 호출을 executor로 옮긴 뒤 follow-up을 보냈다.
@tree.command(name="server_status")
async def server_status(interaction):
await interaction.response.defer(ephemeral=True)
loop = asyncio.get_running_loop()
result = await loop.run_in_executor(None, run_rcon, "list")
await interaction.followup.send(result, ephemeral=True)
defer는 Discord가 interaction을 접수했다는 뜻이다. Minecraft 명령의 성공이나 상태 변화
완료를 보장하지 않는다. RCON 문자열 응답도 명령이 처리됐다는 1차 결과일 뿐 백업, 종료와
같은 긴 작업 전체의 완료가 아니다.
sequenceDiagram
accTitle: Discord interaction에서 RCON 결과까지
accDescr: Discord 사용자의 slash command를 봇이 먼저 defer하고 신원과 작업 정책을 검사한 뒤 queue를 통해 RCON을 실행하며 접수와 완료를 따로 응답한다.
actor U as Discord 사용자
participant D as Discord API
participant B as Bot handler
participant P as 권한·입력 정책
participant Q as RCON queue
participant M as Minecraft
U->>D: slash command
D->>B: interaction
B-->>D: defer·request ID
B->>P: guild·user·role·operation
alt 거부
P-->>B: deny reason
B-->>D: ephemeral 거부 응답
else 허용
P->>Q: typed operation
Q->>M: RCON command
M-->>Q: response
Q-->>B: result·duration
B-->>D: follow-up
end
긴 작업은 operation_id를 먼저 반환하고 상태를 별도 기록한다. Discord interaction token의
수명에 전체 백업 시간을 맞추지 않는다. 완료·실패 알림에는 operation ID를 포함하고 원본
상태는 구조화 로그나 작은 작업 저장소에서 조회한다.
executor가 해결하지 않는 동시성
동기 RCON client를 async handler에서 직접 호출하면 event loop가 멈춘다. executor는 blocking을 worker thread로 옮기지만 공유 client가 동시에 사용돼도 안전하게 만들지는 않는다. 당시 봇에는 상태 변경 명령을 직렬화하는 공용 lock이나 queue가 없었다.
stateDiagram-v2 accTitle: RCON 명령 queue와 불확실한 결과 상태 accDescr: 검증된 작업은 queue에서 순서대로 실행되고 성공, 실패 또는 timeout 뒤 결과 불명 상태로 이동하며 변경 작업은 상태 확인 전 자동 재시도하지 않는다. [*] --> VALIDATED VALIDATED --> QUEUED QUEUED --> RUNNING RUNNING --> SUCCEEDED: 명확한 응답 RUNNING --> FAILED: 명확한 오류 RUNNING --> UNKNOWN: timeout·연결 단절 UNKNOWN --> RECONCILING: 실제 서버 상태 조회 RECONCILING --> SUCCEEDED: 효과 확인 RECONCILING --> FAILED: 미적용 확인
조회와 상태 변경을 구분한다. list나 tps는 짧은 timeout으로 다시 조회해도 부작용이
없다. stop, whitelist 변경, 백업 시작은 응답이 유실됐다고 즉시 반복하면 실제 상태를 더
불분명하게 만든다.
class RconExecutor:
def __init__(self):
self._mutation_lock = asyncio.Lock()
async def query(self, command: str) -> str:
return await asyncio.wait_for(
asyncio.to_thread(run_rcon, command), timeout=5
)
async def mutate(self, command: str) -> str:
async with self._mutation_lock:
return await asyncio.wait_for(
asyncio.to_thread(run_rcon, command), timeout=10
)
이 lock은 같은 bot process 안의 동시 실행만 막는다. 봇 인스턴스가 둘이거나 backup script도 RCON 상태를 바꾸면 전역 직렬화를 보장하지 않는다. 단일 VM·단일 bot에서는 충분한 시작점이고, 복수 실행기가 생기면 Pub/Sub, 데이터베이스 lease 또는 하나의 운영 worker로 제어면을 모은다.
raw console 대신 노출하는 작업 타입
원시 RCON 경로는 사용자가 입력한 문자열을 그대로 콘솔에 전달할 수 있었다. 지정 채널 검사는 있었지만 해당 실행 경로에 관리자 role 검사가 일관되게 적용되지 않았다. 채널을 볼 수 있는 권한이 Minecraft 운영 권한으로 확대되는 구조였다.
flowchart LR accTitle: Discord 신원에서 Minecraft 권한까지의 신뢰 경계 accDescr: Discord 신원은 봇의 권한 정책, 허용된 작업의 입력 계약, 단일 RCON 자격 증명이라는 세 경계를 거쳐 Minecraft 권한으로 바뀐다. ID[Discord user<br/>guild·roles] --> POLICY[봇 권한 정책<br/>허용된 operation] POLICY --> CONTRACT[인자 validator<br/>RCON command builder] CONTRACT --> PRIVILEGE[단일 RCON credential<br/>Minecraft console]
API 표면은 문자열이 아니라 작업으로 정의한다.
PLAYER_NAME = re.compile(r"^[A-Za-z0-9_]{3,16}$")
def build_command(operation: str, arguments: dict[str, str]) -> str:
if operation == "players.list":
return "list"
if operation == "whitelist.add":
player = arguments["player"]
if not PLAYER_NAME.fullmatch(player):
raise ValueError("invalid player name")
return f"whitelist add {player}"
raise PermissionError("operation is not allowed")
이 command builder는 Java 사용자 전용이다. Bedrock 사용자를 같은 whitelist add 문자열에
억지로 맞추지 않고 별도 bedrock_whitelist.add 작업으로 분리해 Floodgate의
fwhitelist add <player> 명령을 호출한다. fwhitelist에는 Floodgate username prefix를
수동으로 붙이지 않는다. Bedrock 이름은 Floodgate가 조회할 수 있는 identity인지 확인하고,
두 작업 모두 공백, 줄바꿈과 RCON 명령 구분자로 해석될 입력을 허용하지 않는다. 문자열
escape보다 허용된 operation과 인자 schema를 작게 유지하는 편이 안전하다.
권한은 작업별로 연결한다.
| 작업 | 허용 주체 | RCON 성격 | timeout 뒤 처리 |
|---|---|---|---|
| 상태·접속자 조회 | 서버 구성원 | 읽기 | 재조회 가능 |
| TPS·통계 조회 | 운영 구성원 | 읽기 | 재조회 가능 |
| whitelist 변경 | 관리자 | 상태 변경 | whitelist 상태 확인 |
| 백업 시작 | 관리자 | 긴 작업 | operation 상태 조회 |
| 서버 종료 | 소수 관리자 | 파괴적 상태 변경 | VM·service 상태 확인 |
| raw console | Discord에 노출하지 않음 | 전체 권한 | SSH 등 별도 관리 경로 |
guild, user ID와 role ID는 사용자에게 보이는 이름보다 안정적이지만 Discord 설정 변경 시 달라질 수 있다. 정책 변경은 코드·구성 review 대상으로 관리한다. 거부된 요청도 사용자, operation, 이유와 시각을 남기되 민감한 인자와 secret은 기록하지 않는다.
RCON 응답과 상태 변화의 차이
RCON은 명령 문자열과 텍스트 응답을 주고받는다. 네트워크 timeout이 발생하면 서버가 명령을 실행하기 전인지, 실행한 뒤 응답만 유실됐는지 알 수 없다. 그래서 상태 변경 작업은 command-response와 state observation을 분리한다.
예를 들어 whitelist 추가는 다음 순서로 추적한다.
- Discord 신원과
whitelist.add권한을 검사한다. - player identity를 검증해 명령을 만든다.
- operation ID와 요청자를 기록한다.
- queue에서 RCON 명령을 한 번 보낸다.
- 응답과 무관하게 whitelist 조회 또는 파일·서버 상태로 적용 여부를 확인한다.
- 적용됨, 적용 안 됨, 확인 불가 중 하나로 종료한다.
stop은 RCON 연결 자체를 끊기 때문에 정상 실행도 connection error처럼 보일 수 있다.
systemd 정책이 Restart=always라면 관리자가 stop을 실행해도 JVM이 다시 시작될 수 있다.
의도한 종료는 unit stop과 VM 중지 중 어느 계층의 동작인지 정하고 Discord 명령이 두 계층을
암묵적으로 섞지 않게 한다.
같은 VM에 있는 봇의 범위
봇 unit은 Minecraft unit을 Requires·After로 연결하고 같은 VM에서 실행됐다.
[Unit]
Description=Minecraft Discord bot
Requires=minecraft.service
After=minecraft.service
[Service]
WorkingDirectory=/opt/minecraft/discord-bot
EnvironmentFile=/etc/minecraft/discord-bot.env
ExecStart=/usr/bin/python3 bot.py
Restart=always
RestartSec=10
After는 시작 순서만 정하고 Minecraft readiness를 보장하지 않는다. Requires는 Minecraft
unit 시작 실패나 중지에 봇 생명주기를 결합한다. 상태 조회 봇이 Minecraft 장애 중에도
진단 메시지를 보내야 한다면 결합을 약하게 하고 handler가 RCON unavailable을 정상 상태로
표현하는 편이 낫다.
VM이 꺼지면 봇도 꺼진다. 따라서 같은 VM의 봇은 실행 중인 Minecraft를 관리할 수 있지만 중지된 VM을 깨울 수 없다. Discord에서 VM 시작을 제공하려면 bot/webhook receiver를 Cloud Run 같은 외부 제어면에 두고 Compute API 권한과 별도 인증 정책을 부여해야 한다. 운영 당시 VM 시작은 Discord가 아니라 Logging·Monitoring·Pub/Sub·함수 경로가 맡았다.
metadata와 .env를 거친 비밀값
운영 구성에서 Discord token, webhook과 RCON 비밀번호는 Terraform 변수, state, VM
metadata와 .env 파일을 거칠 수 있었다. .env 권한을 0600으로 둬도 secret의 생성,
전달, 교체와 Terraform state 노출 문제는 남는다. 일부 스크립트는 webhook URL을 debug
로그로 출력할 수도 있었다. webhook URL 전체는 곧 credential이므로 로그 대상이 아니다.
현재 구조에서는 secret 값과 IAM 관계를 분리한다.
resource "google_secret_manager_secret" "discord_token" {
secret_id = "minecraft-discord-token"
replication {
auto {}
}
}
resource "google_secret_manager_secret_iam_member" "bot_access" {
secret_id = google_secret_manager_secret.discord_token.id
role = "roles/secretmanager.secretAccessor"
member = "serviceAccount:${google_service_account.minecraft.email}"
}
Terraform은 secret container와 접근 권한만 만든다. 실제 secret version 값을 HCL에 적으면 state에 남을 수 있으므로 별도 보안 절차로 등록한다. VM의 전용 서비스 계정은 필요한 특정 secret만 읽고, 운영 사용자는 version 추가 권한과 읽기 권한을 분리할 수 있다.
systemd credentials를 사용할 수 있는 환경이면 unit에 평문 값을 직접 넣지 않고 service 시작 시 제한된 credential 파일로 전달한다. 애플리케이션은 파일 내용을 읽되 path나 값을 exception, process argument와 Discord 응답에 포함하지 않는다.
교체 순서는 새 version 등록, 서비스에서 새 version 읽기, bot 재시작, Discord·RCON 시험, 이전 version 비활성화다. 노출이 의심되면 저장 방식을 먼저 고치는 동안 기다리지 않고 Discord token, webhook, RCON password를 각각 폐기·교체한다.
감사 로그의 최소 필드
운영 명령 기록은 문제 분석에 충분하면서 secret과 개인 데이터를 과도하게 보존하지 않아야 한다.
{
"operation_id": "example-operation-id",
"guild_id": "example-guild",
"actor_id": "example-user",
"operation": "whitelist.add",
"authorization": "allowed",
"rcon_result": "unknown",
"reconciled_result": "applied",
"duration_ms": 812
}
raw command, token, password와 webhook URL은 기록하지 않는다. player name처럼 작업상 필요한 인자는 보존 기간과 접근 권한을 정하고 필요하면 hash나 별도 필드로 최소화한다. Discord 메시지는 사용자를 위한 표현이고 구조화 로그가 사건의 원본이다.
참고
Share
아직 댓글이 없습니다. 첫 댓글을 남겨주세요.
검토 대기 중