15개 정본 문서에서 날짜별 변경이력, 인시던트 기록, 폐기된 구현 상세를 history/ 디렉토리로 분리. 정본은 현재 상태만 기술하는 백서 형태로 정리. 각 정본에 history 위키링크 추가. 분리된 history 파일 12건: - apisix git push 500, k3s postgresql migration, apisix→traefik 전환 - netbis DDoS 공격, gitea 이전/분리, usb 2.5g hang + NFS hard mount - supabase→patroni, apisix etcd 통합/분리, anomaly-detect 재설계 - patroni failover incident, zlambda nixos migration, ops-agents setup Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
172 lines
7.9 KiB
Markdown
172 lines
7.9 KiB
Markdown
---
|
||
title: anomaly-detect (VictoriaLogs + ollama 기반 이상 트래픽 감지)
|
||
updated: 2026-04-08 agentic 재설계
|
||
tags: [security, crowdsec, victorialogs, ollama, gemma, anomaly]
|
||
---
|
||
|
||
> 코드: `gitea.inouter.com/kaffa/anomaly-detect` (private)
|
||
> 아키텍처: OpenRouter agentic (Grok-4-fast) + VictoriaLogs tool + CrowdSec LAPI tool
|
||
|
||
# anomaly-detect
|
||
|
||
## 아키텍처
|
||
|
||
OpenRouter `x-ai/grok-4-fast`에 tool 2개(logsql_query, ban_ips)만 노출하는 agentic 구조. fallback 모델 `qwen/qwen3-235b-a22b-2507`.
|
||
|
||
설계 반복 및 모델 벤치마크 이력: [[../history/2026-04-08-anomaly-detect-iterations|history]]
|
||
|
||
### 새 아키텍처
|
||
|
||
```
|
||
systemd timer (5분)
|
||
↓
|
||
analyzer.py (Python oneshot)
|
||
├─ OpenRouter → x-ai/grok-4-fast (tools=[logsql_query, ban_ips])
|
||
├─ agent loop (max 10턴)
|
||
│ ├─ tool: logsql_query(query, start, limit) — VictoriaLogs 자유 조회
|
||
│ │ ※ 응답에서 사설망/Tailscale/RFC5737 IP 자동 제거 (서버측 guardrail)
|
||
│ └─ tool: ban_ips(ips, reason, scenario) — CrowdSec LAPI batch POST
|
||
│ ※ dedup 24h, MAX_BAN_PER_CYCLE cap, 사설망 거부
|
||
├─ DRY_RUN=1 (기본): ban_ips가 "would ban" 로그만 찍고 실제 POST 안 함
|
||
└─ dedup.json 갱신
|
||
```
|
||
|
||
### 환경변수
|
||
|
||
| 변수 | 기본값 | 비고 |
|
||
|------|--------|------|
|
||
| `OPENROUTER_API_KEY` | (from `/etc/anomaly-detect/openrouter.env`) | Vault `secret/ai/openrouter` |
|
||
| `OR_MODEL` | `x-ai/grok-4-fast` | 주 모델 |
|
||
| `OR_FALLBACK_MODEL` | `qwen/qwen3-235b-a22b-2507` | OR 장애 시 재시도 |
|
||
| `WINDOW_MIN` | 5 | 조사 윈도우 |
|
||
| `MAX_TURNS` | 10 | agent loop 상한 |
|
||
| `MAX_BAN_PER_CYCLE` | 2000 | 한 사이클 ban 상한 (대규모 DDoS 대비) |
|
||
| `LAPI_BAN_CHUNK` | 500 | LAPI POST를 500건씩 쪼개 발송 (부분 실패 허용) |
|
||
| `BAN_DURATION` | 4h | |
|
||
| `DRY_RUN` | **1** | ⚠ 초기 안전장치 |
|
||
|
||
### 서버측 guardrail (중요)
|
||
|
||
LLM은 지시만 받고 강제할 수 없다. 따라서 `logsql_query`와 `ban_ips` 두 tool 모두 **파이썬 코드 레벨에서** 다음을 enforce:
|
||
- `is_skippable_ip()`: `ipaddress.is_private` + Tailscale 100.64/10 + RFC5737 TEST-NET-2/3 포함
|
||
- Python 3.9의 `ipaddress.ip_address('203.0.113.42').is_private == True` — 문서 IP도 자동 차단됨 (2026-04-08 벤치마크에서 확인)
|
||
|
||
LLM 프롬프트가 무시되어도 실수로 사설망이 ban되지 않음.
|
||
|
||
### Vault 위치
|
||
|
||
- OpenRouter key: `secret/ai/openrouter` (`API_KEY` 키)
|
||
- 컨테이너 배포본: `/etc/anomaly-detect/openrouter.env` (mode 600, systemd `EnvironmentFile=`)
|
||
|
||
|
||
### 운영 중 주의사항
|
||
|
||
- **injected 로그 잔재**: E2E 테스트 중 주입한 270 rows가 vlogs retention 기간 동안 남음. `sim_e2e` 마커 필드로 식별 가능. 다음 cycle에서 다시 탐지될 수 있으나 dedup 24h으로 재ban 차단됨.
|
||
- **재테스트 시**: 테스트 후 반드시 `cscli decisions delete -s anomaly-detect/<scenario>` + `/var/lib/anomaly-detect/dedup.json` 업데이트 (해당 IP 추가하거나 리셋)
|
||
|
||
### 비용 모니터링
|
||
|
||
매 사이클 journalctl에 한 줄 요약 출력 (`48eb489` 이후):
|
||
|
||
```
|
||
cycle usage: turns=5 prompt=9142 completion=2670 total=11812 cost=$0.001866
|
||
```
|
||
|
||
집계 명령:
|
||
|
||
```bash
|
||
# 오늘 누적
|
||
ssh incus-hp2 "incus exec anomaly-detect -- bash -c '
|
||
journalctl -u anomaly-detect.service --since today --no-pager |
|
||
grep -oE \"cost=\\\$[0-9.]+\" |
|
||
awk -F\\\$ \"{sum+=\\\$2} END {printf \\\"today: \\\$%.6f (%d cycles)\\n\\\", sum, NR}\"
|
||
'"
|
||
|
||
# 사이클별 상세
|
||
ssh incus-hp2 "incus exec anomaly-detect -- journalctl -u anomaly-detect.service --since today --no-pager | grep 'cycle usage'"
|
||
```
|
||
|
||
**OpenRouter `/activity` API의 지연** (~2-3주)으로 실시간 비용 조회는 여기(journalctl) 경로를 써야 한다. dashboard는 https://openrouter.ai/activity 에서 UI 확인 가능 (더 빠름). management(provisioning) 키는 Vault `secret/ai/openrouter` → `PROVISIONING_KEY` 에 저장됨.
|
||
|
||
**월 비용 예상**: 평시 사이클당 ~$0.001-0.002, 5분 주기 × 288/일 × 30일 = **~$10-15/월**. DDoS 발생 시 사이클당 $0.01 수준 (massive_ddos 시나리오 기준)까지 튈 수 있으나 일시적.
|
||
|
||
[[crowdsec-safeline#~~ddos-detect (AI 행위 분석)~~ — 폐기 (2026-04-08)|폐기된 ddos-detect]] 후속. [[victorialogs|VictoriaLogs]]에 적재된 K3s 서울 APISIX access log를 5분마다 분석하여 봇/공격성 IP를 [[crowdsec-safeline|CrowdSec]]에 자동 ban으로 등록한다.
|
||
|
||
## 위치 / 사양
|
||
|
||
| 항목 | 값 |
|
||
|------|-----|
|
||
| 호스트 | incus-hp2 |
|
||
| 컨테이너 | `anomaly-detect` (default 프로젝트, Debian 13 trixie) |
|
||
| IP | 10.100.2.164 |
|
||
| 사양 | 1 vCPU, 512MB RAM, 5GB |
|
||
| 설치 경로 | `/opt/anomaly-detect/{venv,analyzer.py}`, `/etc/anomaly-detect/lapi.yaml`, `/etc/anomaly-detect/openrouter.env`, `/var/lib/anomaly-detect/dedup.json` |
|
||
| systemd | `anomaly-detect.service` (oneshot) + `anomaly-detect.timer` (`OnCalendar=*:0/5`, `Persistent=true`, `RandomizedDelaySec=20`) |
|
||
|
||
## 데이터 흐름
|
||
|
||
```
|
||
[5분 주기 systemd timer]
|
||
↓
|
||
analyzer.py
|
||
├─ 1) https://vl.inouter.com — LogsQL: program:apisix log_type:access 지난 5분
|
||
├─ 2) per-IP 통계 게이트 (count/4xx/5xx/499/distinct paths)
|
||
├─ 3) 후보 N개 (default max 5)
|
||
├─ 4) 각 후보 → http://100.87.221.126:11434/api/generate (kaffa-macmini ollama)
|
||
│ 모델: gemma4:e4b (Q4_K_M, 8.0B), format=json
|
||
├─ 5) verdict=yes → CrowdSec LAPI alert POST
|
||
│ http://10.253.100.240:8080/v1/alerts
|
||
│ profiles.yaml의 default_ip_remediation이 자동 ban 생성
|
||
└─ 6) dedup.json에 처리 IP + 타임스탬프 기록 (24h 내 재처리 안 함)
|
||
```
|
||
|
||
## CrowdSec LAPI 등록
|
||
|
||
`anomaly-detect`라는 watcher machine을 jp1 crowdsec에 등록하고, credentials를 컨테이너 안 `/etc/anomaly-detect/lapi.yaml`에 저장:
|
||
|
||
```yaml
|
||
url: http://10.253.100.240:8080
|
||
login: anomaly-detect
|
||
password: <vault: secret/apps/anomaly-detect>
|
||
```
|
||
|
||
> [!warning] cscli machines add 함정
|
||
> `cscli machines add NAME --auto`는 default로 `/etc/crowdsec/local_api_credentials.yaml`을 덮어씀 — 이건 jp1 crowdsec **daemon 자체의 LAPI 클라이언트 설정**이라 덮어쓰면 daemon이 새 password로 LAPI 인증을 시도하면서 동기화가 깨짐. 반드시 `--file <별도 경로>` 옵션을 줘야 한다. 만약 실수로 덮어썼다면 `cscli machines add <default-machine-name> --auto --force --file /etc/crowdsec/local_api_credentials.yaml`로 default machine 새 credentials 발급 후 `systemctl reload crowdsec`로 복구.
|
||
|
||
|
||
## 운영 명령
|
||
|
||
```bash
|
||
# 컨테이너 진입
|
||
ssh incus-hp2 'incus exec anomaly-detect -- bash'
|
||
|
||
# 수동 1회 실행
|
||
incus exec anomaly-detect -- /opt/anomaly-detect/venv/bin/python /opt/anomaly-detect/analyzer.py
|
||
|
||
# 상태
|
||
incus exec anomaly-detect -- systemctl status anomaly-detect.timer
|
||
incus exec anomaly-detect -- journalctl -u anomaly-detect.service --since "30 minutes ago"
|
||
|
||
# dedup 초기화 (모든 IP 재분석 허용)
|
||
incus exec anomaly-detect -- sh -c 'echo "{}" > /var/lib/anomaly-detect/dedup.json'
|
||
|
||
# 게이트/모델/주기 변경 → /etc/systemd/system/anomaly-detect.service의 Environment=
|
||
```
|
||
|
||
## 검증 (최초 배포)
|
||
|
||
- `cscli machines list` → `anomaly-detect` 등록 확인
|
||
- `curl https://vl.inouter.com/select/logsql/query?query=program:apisix\&limit=1` 200 OK (컨테이너 내부)
|
||
- `curl http://100.87.221.126:11434/api/tags` → `gemma4:e4b` 노출
|
||
- `curl http://10.253.100.240:8080/v1/decisions` → 403 (인증 필요, 네트워크 OK)
|
||
- 더미 IP `198.51.100.99`로 alert POST → 201 + decision 등록 → cleanup 확인 (smoke test)
|
||
|
||
|
||
## 향후 작업
|
||
|
||
- [ ] **[Medium]** Discord webhook 알림 추가 (`secret/apps/discord` Vault에서 가져오기) + systemd `OnFailure=` drop-in
|
||
- [ ] **[Low]** CrowdSec alert `origin`을 `"crowdsec"` → `"anomaly-detect"`로 태깅
|
||
|
||
|
||
전임자 (`ddos-detect`, 1/2차 구현) 폐기 이력: [[../history/2026-04-08-anomaly-detect-iterations|history]]
|