Cloudflare 안 쓰고 nginx + fail2ban 으로 봇 트래픽 막은 이야기
Cloudflare 가 SSE 스트리밍과 충돌해서 끊기는 문제를 겪고 GCP 단일 인스턴스 운영으로 돌아갔다. 대신 nginx limit_req + fail2ban + sysctl 만으로 스캐너/봇 방어선을 직접 구축했다.
왜 Cloudflare 를 안 쓰는가
aicoreutility.com 의 챗봇은 Server-Sent Events(SSE) 로 스트리밍 응답을 보낸다. Cloudflare Free 플랜에서 이게 자주 끊겼다.
- 장시간 연결을 프록시가 중간에 닫음
- 버퍼링 옵션을 꺼도 100KB 이상 청크에서 막힘
- 응답 헤더 일부가 다시 쓰여서 Last-Event-ID 동작 깨짐
유료 플랜으로 가면 해결되지만 1인 운영 서비스에 ARGO 비용은 부담이다. 결국 Cloudflare 를 빼고 GCP 단일 인스턴스 + 자체 nginx 로 SSL 종단을 처리하는 구조로 돌아왔다.
그러자 다른 문제가 생겼다. 스캐너 봇 트래픽이 그대로 들어온다는 점.
실제 들어오던 트래픽
nginx access log 에서 하루치를 뽑아보니:
GET /.env HTTP/1.1 (157 회)
GET /wp-admin/admin-ajax.php (89 회)
GET /.git/config (76 회)
GET /actuator/health (54 회)
GET /phpmyadmin/ (43 회)
POST /api/auth/login (brute force) (211 회)
전부 자동화된 스캔이다. 무시해도 되지만 CPU 와 로그 디스크를 갉아먹는다.
방어 1: nginx 444 즉시 차단
의미 없는 경로는 응답조차 안 한다. 444 는 nginx 전용으로 connection 자체를 닫는다.
location ~* (/.env|/wp-admin|/wp-login|/.git|/phpmyadmin|/actuator) {
return 444;
}
봇이 timeout 까지 기다리게 만들어 자원을 소모시키는 효과도 있다.
방어 2: limit_req zone 5종
경로별로 다른 속도 제한을 둔다.
# /etc/nginx/conf.d/zz-security.conf
limit_req_zone $binary_remote_addr zone=rl_general:10m rate=30r/s;
limit_req_zone $binary_remote_addr zone=rl_api:10m rate=10r/s;
limit_req_zone $binary_remote_addr zone=rl_auth:10m rate=5r/m;
limit_req_zone $binary_remote_addr zone=rl_track:10m rate=20r/s;
limit_req_zone $binary_remote_addr zone=rl_chat:10m rate=2r/s;
limit_conn_zone $binary_remote_addr zone=conn_per_ip:10m;
핵심은 auth 는 분당 5회. 일반 사용자는 1초에 로그인 5번 안 한다. 봇만 걸린다.
방어 3: fail2ban 4 jail
nginx 가 차단한 IP 를 fail2ban 이 학습해서 iptables 레벨에서 막는다. 다음 요청은 nginx 까지 도달도 못한다.
# /etc/fail2ban/jail.d/nginx-aicoreutility.conf [nginx-rate-limited] enabled = true filter = nginx-rate-limited logpath = /var/log/nginx/error.log maxretry = 10 findtime = 60 bantime = 3600[nginx-scanner] enabled = true filter = nginx-scanner logpath = /var/log/nginx/access.log maxretry = 3 findtime = 600 bantime = 86400
[nginx-bad-request] enabled = true filter = nginx-bad-request logpath = /var/log/nginx/access.log maxretry = 20 findtime = 60 bantime = 3600
[sshd] enabled = true maxretry = 3 findtime = 600 bantime = 86400
scanner 는 한 번만 시도해도 1일 차단, brute force 는 1시간 차단으로 차이를 뒀다.
방어 4: 커널 튜닝
SYN flood 와 spoofed IP 대응은 sysctl 에서 해결한다.
# /etc/sysctl.d/99-network-security.conf
net.ipv4.tcp_syncookies = 1
net.ipv4.conf.all.rp_filter = 1
net.ipv4.tcp_max_syn_backlog = 4096
net.core.somaxconn = 4096
net.ipv4.tcp_synack_retries = 2
tcp_syncookies 만 켜도 SYN flood 는 사실상 무력화된다.
방어 5: slowloris 타임아웃
nginx 기본 타임아웃은 후하다. 줄여놓는다.
client_body_timeout 10;
client_header_timeout 10;
keepalive_timeout 5 5;
send_timeout 10;
운영 1주차 결과
| 지표 | 이전 | 이후 |
|---|---|---|
| nginx 일일 access log 크기 | ~80MB | ~12MB |
| fail2ban 차단 IP (24h) | 0 | 약 200~400 |
| /.env 등 스캐너 응답 시간 | 200ms (404) | 0ms (444 close) |
| auth brute force 시도 | 211/일 | 3~5/일 |
주의할 점
- limit_req 너무 빡세면 본인이 막힌다. 배포 직후 캐시 갱신으로 정적 자원 요청이 폭주할 수 있다.
nodelay옵션과burst값을 충분히 - fail2ban 의 자기차단: 관리자 IP 를
ignoreip에 등록해두지 않으면 본인이 ssh 막힐 수 있다 - SSE endpoint 는 limit_conn 제외. 한 사용자가 여러 탭 열면 여러 SSE connection 을 동시에 쓴다
📌 2026년의 코멘트
Cloudflare 를 안 써도 1인 인스턴스에 자체 방어선을 까는 건 충분히 가능하다. 다만 모니터링이 같이 필요하다. fail2ban 차단 로그를 매일 확인하고, 정상 사용자가 잘못 막히지 않았는지 확인하는 루틴이 있어야 한다.
다음 단계로는 GeoIP 기반 차단(특정 ASN 의심 트래픽), 그리고 비용 한도 알람을 더 넣을 생각이다.
태그
📨 박주니에게 한마디
스팸·악성 메시지 방지를 위해 구글 로그인 후 메시지를 보낼 수 있어요. 비공개로 전달되며, 운영자 외에는 볼 수 없습니다.
Google 로그인 후 메시지 남기기