K3s 클러스터를 5 노드 굴리다 보니 매번 SSH 들어가서 kubectl get pods --all-namespaces 치는 게 귀찮아졌습니다. 그래서 텔레그램 봇 하나로 K3s 핵심 명령들을 다 호출할 수 있게 만들었습니다. 시작은 7 개 명령이었는데 지금은 16 개.

이 글에서 다루는 것

  • 봇 아키텍처 — Go + SSH 한 다리로 모든 노드 접근
  • 16 개 명령어 분류 (조회 / 운영 / 리포트)
  • /디스크 텍스트 바 그래프 트릭
  • /파드 노드별 그룹핑
  • 자동 알림과 수동 호출의 분리

1. 왜 ChatOps 인가

서버 모니터링은 Grafana + AlertManager 로 충분히 됩니다. 그런데 “새벽에 알림 받고 즉시 확인” 시나리오 에서 문제가 생겨요.

  • 휴대폰 화면에서 Grafana 대시보드 열기 → VPN 연결 → 로그인 → 패널 찾기 → 약 2 분
  • 텔레그램 봇 /파드 → 약 3 초

알림 → 진단 → 1 차 액션 까지 한 화면에서 끝나는 게 ChatOps 의 진짜 가치입니다.


2. 아키텍처 — 한 다리만 건너면 다 됨

┌─────────────┐
│ Telegram    │  사용자 메시지
└──────┬──────┘
       ↓
┌─────────────┐
│ server-     │  Go 봇 (Mac mini 에서 상시 실행)
│  monitor    │
└──────┬──────┘
       ↓ SSH (key-based)
┌─────────────────────────────────────┐
│ 르무엘 (control-plane)              │
│   kubectl / docker 직접 실행         │
└─────────────────────────────────────┘

봇은 르무엘 에 SSH 로 접속해서 kubectl 을 직접 호출합니다. 다른 노드 정보가 필요해도 르무엘 의 kubectl 이 다 가져옴 (control-plane 은 모든 노드 상태를 앎). 그래서 5 노드 × 5 SSH 연결 같은 복잡함 없이 단 한 통로로 끝.

// internal/bot/bot.go
func (b *Bot) sshLemuel(cmd string) string {
    out, err := exec.Command("ssh",
        "-p", "2652",
        "-i", "/Users/lms/.ssh/id_ed25519",
        "iamipro@192.168.219.101",
        cmd,
    ).Output()
    if err != nil {
        return fmt.Sprintf("❌ %v", err)
    }
    return string(out)
}

3. 16 개 명령어 — 분류표

조회 (read-only)

명령 단축 무엇을
/상태 /s 5 노드 가동 / 사이트 응답 시간
/파드 /p K3s 모든 namespace pod 노드별 그룹
/노드 /n kubectl get nodes -o wide + load
/도커 /d docker ps 노드별
/이미지 /i ghcr 최근 push 된 이미지
/디스크   df -h 5 노드 텍스트 바 그래프
/도움 /h 전체 명령어 + 설명

운영 (action)

명령 무엇을
/마이그 옛 docker → K3s 마이그 자동화 스크립트 호출
/백업 velero backup create + 진행률
/argocd sync, refresh, app list
/재시작 <svc> systemctl restart 또는 kubectl rollout restart

리포트 / 알림

명령 무엇을
/알람 현재 발화중인 AlertManager alerts
/오늘 오늘 깃 커밋 + Trivy 결과 + uptime
/주간 주간 활동 요약 (PR / 배포 / 알람)
/가격 비트코인 / 이더 / LMUL 시세 (잡탕)

4. /디스크 — 텍스트 바 그래프

화면 좁은 휴대폰에서 5 노드 디스크를 한 눈에 보이게 하려고 ASCII 바를 직접 그렸습니다.

func renderBar(pct int) string {
    width := 20
    filled := pct * width / 100
    bar := strings.Repeat("█", filled) + strings.Repeat("░", width-filled)
    return fmt.Sprintf("%s %d%%", bar, pct)
}

func (b *Bot) cmdDisk(chatID int64) {
    nodes := []string{"르무엘", "루이스", "데이비드", "일원", "솔로몬"}
    msg := "💾 디스크 사용\n\n"
    for _, n := range nodes {
        pct := getDiskPct(n)  // SSH df -h 호출
        msg += fmt.Sprintf("%s\n%s\n\n", n, renderBar(pct))
    }
    b.send(chatID, msg)
}

결과 (텔레그램 메시지):

💾 디스크 사용

르무엘
██████░░░░░░░░░░░░░░ 30%

루이스
████░░░░░░░░░░░░░░░░ 20%

데이비드
███████████░░░░░░░░░ 55%

일원
█░░░░░░░░░░░░░░░░░░░  4%

솔로몬
█░░░░░░░░░░░░░░░░░░░  4%

이게 그라파나 대시보드 한 패널보다 읽기 쉬워요. 모바일에서 특히.


5. /파드 — 노드별 그룹핑

kubectl get pods --all-namespaces -o wide 의 출력을 그대로 보내면 너무 길어서 휴대폰에서 안 보입니다. 노드별로 그룹핑 + 한 줄 요약.

type podInfo struct {
    namespace, name, status, node string
}

func (b *Bot) cmdPods(chatID int64) {
    raw := b.sshLemuel("sudo k3s kubectl get pods -A -o wide --no-headers")
    pods := parsePods(raw)
    byNode := make(map[string][]podInfo)
    for _, p := range pods {
        byNode[p.node] = append(byNode[p.node], p)
    }

    var msg strings.Builder
    msg.WriteString("🧊 K3s pods (노드별)\n\n")
    for _, node := range []string{"lemuel", "louise", "david", "ilwon", "solomon"} {
        ps := byNode[node]
        running, total := countRunning(ps)
        msg.WriteString(fmt.Sprintf("%s — %d/%d\n", node, running, total))
        for _, p := range ps {
            icon := iconByStatus(p.status)  // ✅ ⚠️ ❌
            msg.WriteString(fmt.Sprintf("  %s %s/%s\n",
                icon, p.namespace, p.name))
        }
        msg.WriteString("\n")
    }
    b.send(chatID, msg.String())
}

결과:

🧊 K3s pods (노드별)

lemuel — 7/7
  ✅ kube-system/coredns-xxx
  ✅ kube-system/metrics-server-xxx
  ✅ argocd/argocd-server-xxx
  ...

louise — 12/13
  ✅ velero/node-agent-xxx
  ⚠️  academy-staging/admin-xxx (ImagePullBackOff)
  ...

solomon — 9/9
  ✅ jen-prod/jen-postgres-0
  ✅ dart-prod/dart-postgres-0
  ...

6. 자동 알림 vs 수동 호출

봇은 두 종류 메시지 를 보냅니다:

자동 (5 분 주기)

# config.yml
interval_seconds: 300
threshold:
  cpu_load: 0.8
  memory_pct: 85
  disk_pct: 80
  response_ms: 5000

이 임계 넘으면 자동 푸시. 알림 받았을 때만 보고 평소엔 조용 한 게 ChatOps 의 핵심 — 너무 자주 메시지 오면 무시하게 됨.

수동 (사용자가 명령 입력)

/파드, /상태, /디스크 등. 언제든 현재 상태를 확인 가능.

이 둘이 같은 포맷을 공유하면 좋습니다 — 자동 알림이 /상태 출력의 한 단락 같아 보이게.


7. 봇 추가하면서 배운 것

  1. 명령은 한국어로. /파드/pods 보다 빨라요. 한글 IME 안 켜고 그냥 한국어로 칠 수 있음 (자동완성 + 메모리에 박힘)
  2. 단축키는 한 글자로 (/p, /d, /n). 새벽 3시 졸린 상태로 칠 수 있어야 함
  3. 출력은 짧게. 휴대폰 한 화면에 못 들어가면 아무도 안 봄. 페이지네이션 절대 X
  4. 게이트는 봇이 아니라 K3s 가. RBAC 으로 봇이 destructive 명령 못 치게 막아두는 게 안전 (봇 탈취 시나리오)

8. 다음 단계

  • /논스 명령 — Cloudflare Tunnel 로 ArgoCD UI 노출 시 OTP 발급
  • /롤백 <서비스> — 이미지 한 단계 이전으로 (kubectl rollout undo)
  • /긴급 /패닉 — 모든 staging 정지, 운영 PriorityClass 만 살리기

홈랩 K3s 기준 텔레그램 봇 하나로 50% 운영이 가능합니다. Grafana 가 “지표” 라면 봇은 “조작” 의 영역. 둘이 합쳐져야 진짜 운영 화면이 됩니다.