728x90

사내 위키에 "릴리즈 노트 모아보는 대시보드 좀 만들어주세요"라는 요청이 들어왔다. Kubernetes, Terraform, 각종 CVE 공지... 제품마다 알림 채널이 제각각이라 결국 내가 꺼내든 건 20년 넘은 프로토콜, RSS였다. 그런데 막상 피드 URL을 모으다 보니 절반쯤은 죽어 있거나, 찾기가 이상하리만치 어려웠다.

왜 이렇게 됐는지 잘 정리한 글이 Open RSS의 "How Google helped destroy adoption of RSS feeds"다. Hacker News에서 주기적으로 다시 올라오는 글인데, 단순한 구글 욕이 아니라 "플랫폼이 개방형 프로토콜을 다루는 패턴"에 대한 케이스 스터디로 읽으면 실무자에게도 쓸모가 많다.

도입: 왜 피드 URL 찾기가 이렇게 어려워졌나

원문이 정리한 구글의 RSS 관련 행보를 시간순으로 요약하면 이렇다.

  • Chromium 초기 버전: 주소창에 RSS 버튼이 기본 내장. 피드가 있는 페이지면 아이콘이 떴다. 어느 날 예고도 이유도 없이 사라짐.
  • 2007년 FeedBurner 인수: 원래 피드를 구글이 소유한 사설 피드로 대체하고 광고·추적을 붙이는 서비스. 2012년 10월 API 종료, 2022년 7월에는 이메일 구독 등 대부분의 기능을 걷어냄. 구독 메일에 박혀 있던 피드 URL이 그대로 깨졌다.
  • 2013년 Google Reader 종료: "충성 사용자층은 있지만 사용량이 감소했다"는 것이 공식 사유. 당시 구글 엔지니어는 "프로젝트에 있는 내내 여러 사람이 이걸 죽이려 했다"고 말했다고 원문은 전한다.
  • Google Alerts: 2008년 10월 RSS 수신 추가 → 2013년 7월 제거 → 반발 후 복원. 다만 그땐 이미 많은 사용자가 떠난 뒤였다.
  • Chrome RSS 확장: 제거했다가 일주일 만에 "실수였다"며 복원.
  • Google News: RSS 지원을 deprecate 후 2017년 12월 완전 종료. 구글 자체 링크는 계속 잘 동작했다.
  • 2021년 5월: Chrome에 RSS 지원을 다시 넣겠다고 발표. 이후 공식 출시 소식은 없는 상태로 보인다.

원문은 이 패턴을 Embrace, Extend, Extinguish로 부른다. 개방형 프로토콜 위에 제품을 올려 신뢰와 점유율을 얻고, 락인이 끝나면 프로토콜 지원을 걷어내는 방식이다. 기술적으로 RSS가 죽은 게 아니다. 원문 첫 문장도 "RSS 피드는 살아 있고 지금도 많이 쓰인다"로 시작한다. 죽은 건 발견 가능성(discoverability)사용자 신뢰다.

핵심: RSS는 죽지 않았다, 눈에 안 보일 뿐

RSS의 동작 원리는 허무할 만큼 단순하다. 정적 XML 파일 하나를 HTTP로 GET 해오는 게 전부다. 서버 푸시도, 인증 협상도, SDK도 없다. 웹 페이지는 <head>에 다음 한 줄로 피드 위치를 알려준다.

<link rel="alternate" type="application/rss+xml"
      title="RSS" href="https://example.com/feed.xml">

브라우저의 RSS 버튼이 하던 일이 딱 이 태그를 파싱해서 아이콘을 띄우는 것이었다. 그 버튼이 사라지자, 피드는 존재하는데 일반 사용자 눈에는 안 보이는 상태가 됐다. 마치 서버에 서비스는 떠 있는데 로드밸런서에서 타겟 그룹을 빼버린 것과 같다. 헬스체크는 통과하는데 트래픽이 0인 상황.

직접 확인해보자. 임의의 블로그에서 피드 링크를 뽑아내는 한 줄짜리 명령어다.

$ curl -sL https://openrss.org/blog | \
    grep -oE '<link[^>]*application/(rss|atom)\+xml[^>]*>'

<link rel="alternate" type="application/rss+xml" title="Open RSS" href="/blog/feed">

피드를 찾았으면 실제로 긁어본다. XML 파싱은 xmllint(libxml2-utils 패키지)로 충분하다.

$ curl -sL https://news.ycombinator.com/rss | \
    xmllint --xpath '//item/title/text()' - | head -3

Show HN: I built a thing
Ask HN: How do you monitor your homelab?
The unreasonable effectiveness of plain text

여기서 중요한 포인트. 이 두 명령어는 API 키도, OAuth 토큰도, rate limit 협의도 필요 없다. 요즘 어떤 SaaS든 알림을 받으려면 앱 등록하고 스코프 정하고 토큰 로테이션 정책까지 세워야 하는 걸 생각하면, RSS의 운영 비용은 거의 0에 수렴한다. 인프라 엔지니어가 RSS를 아직 좋아하는 이유가 바로 이거다.

실무 관점: 어디에 쓰고, 어디서 터지는가

실제로 쓰는 자리

내가 굴려본 용도는 대략 이렇다.

  • 업스트림 릴리즈 추적: GitHub 릴리즈는 https://github.com/{org}/{repo}/releases.atom로 피드가 나온다. 태그는 /tags.atom. 웹훅 설정 없이 조회만으로 끝난다.
  • 보안 공지 수집: 벤더 시큐리티 어드바이저리 페이지 상당수가 여전히 RSS/Atom을 제공한다. 슬랙 채널로 흘려보내면 담당자 눈에 들어온다.
  • 상태 페이지: Statuspage 기반 서비스는 대부분 /history.rss를 제공한다. 클라우드 장애를 이메일보다 먼저 잡을 때가 있다.

파이프라인도 단순하다. cron이나 CronJob으로 주기 실행 → 마지막 처리 GUID 저장 → 신규 항목만 Webhook 전송. Python 기준 핵심만 추리면 20줄 안에 끝난다.

import feedparser, json, pathlib, requests

STATE = pathlib.Path("/var/lib/feedbot/seen.json")
seen = json.loads(STATE.read_text()) if STATE.exists() else {}

url = "https://github.com/kubernetes/kubernetes/releases.atom"
d = feedparser.parse(url, etag=seen.get("etag"))

if d.status == 304:          # 변경 없음 → 조용히 종료
    raise SystemExit(0)

known = set(seen.get("ids", []))
for e in reversed(d.entries):
    if e.id in known:
        continue
    requests.post(WEBHOOK, json={"text": f"{e.title}\n{e.link}"}, timeout=5)
    known.add(e.id)

STATE.write_text(json.dumps({"etag": d.get("etag"), "ids": list(known)[-200:]}))

etag를 넘겨서 304를 받으면 바로 빠지는 부분이 핵심이다. 이걸 빼먹고 5분마다 전체 피드를 받아오면 상대 서버 입장에서는 그냥 봇 트래픽이다. 실제로 차단당한다.

흔한 함정

1) 파서는 관대하지만, 서버 응답은 아니다. 가장 자주 보는 에러가 이거다.

xmllint --xpath '//item/title' feed.xml
feed.xml:1: parser error : Start tag expected, '<' not found
<!DOCTYPE html>
^

XML인 줄 알았는데 HTML이 왔다는 뜻이다. 원인은 대개 셋 중 하나다. (a) 피드 URL이 옮겨졌는데 301이 아니라 홈으로 200 리다이렉트, (b) Cloudflare 챌린지 페이지, (c) User-Agent 없는 요청 거절. curl -IContent-Type부터 확인하는 습관을 들이자.

$ curl -sIL -A "feedbot/1.0" https://example.com/feed | grep -iE '^(HTTP|content-type|location)'
HTTP/2 301
location: https://example.com/feed.xml
HTTP/2 200
content-type: application/rss+xml; charset=UTF-8

2) FeedBurner 경유 URL. 오래된 블로그는 아직도 feeds.feedburner.com/...을 걸어둔 경우가 있다. 원문에 나오듯 구글은 2012년에 API를, 2022년 7월에 이메일 구독을 포함한 대부분의 기능을 정리했다. 이 URL을 파이프라인에 박아두면 언제 끊길지 모르는 SPOF다. 가능하면 원본 사이트의 피드로 갈아타라.

3) 상대 경로 href.grep 예시에서 href="/blog/feed"처럼 상대 경로가 나온다. 자동화에서 그대로 GET 하면 InvalidURL로 죽는다. 반드시 base URL과 join 해야 한다.

4) GUID 신뢰 문제. 어떤 CMS는 글을 수정할 때마다 GUID를 새로 발급한다. 그러면 같은 글이 반복 알림된다. 이럴 땐 GUID 대신 link의 정규화된 형태(쿼리스트링 제거)를 키로 쓰는 게 안전하다. 반대로 pubDate만 믿으면 타임존 없는 값 때문에 하루치가 통째로 재발송되는 사고가 난다.

트레이드오프와 대안

RSS는 폴링 기반이다. 실시간성이 필요한 알림(장애 탐지 같은)에는 부적합하다. 5분 폴링이면 최대 5분 지연이고, 폴링 간격을 줄이면 상대 서버에 부담이다. 실시간이 중요하면 Webhook을, 그게 없으면 RSS를 쓰는 식으로 계층을 나누는 게 맞다.

대안으로는 GitHub Releases API(rate limit·토큰 필요), 벤더 SNS/이메일(파싱 지옥), 유료 SaaS 모니터링 등이 있다. 셋 다 RSS보다 운영 비용이 높다. 반대로 RSS는 제공자가 언제든 끌 수 있다는 게 최대 리스크다. 이 글이 지적하는 바로 그 지점이다. 그래서 나는 중요한 피드는 응답 자체를 아카이빙해두고, 피드가 죽었을 때 알림이 오도록 "N일간 신규 항목 0건" 조건도 같이 건다. 조용히 죽는 게 제일 무섭다.

정리

한 줄 요약: RSS는 기술적으로 멀쩡한데 플랫폼이 발견 경로를 걷어내서 안 보이게 된 것이고, 자동화 관점에서는 여전히 가장 싸고 튼튼한 알림 소스다.

누가 언제 쓰면 되냐면,

  • 쓰기 좋은 경우: 업스트림 릴리즈/보안 공지/상태 페이지 추적, 인증 없이 읽기만 하면 되는 정보, 지연 수 분을 감내할 수 있는 알림.
  • 피해야 할 경우: 초 단위 실시간성이 필요한 장애 알림, 인증이 필요한 사내 데이터, 항목 수가 수만 건인 대용량 동기화.
  • 운영 시 필수: ETag/If-Modified-Since 조건부 요청, GUID 정규화, "피드가 조용히 죽었는지" 감시, FeedBurner 같은 중개 URL 제거.

그리고 한 가지 더. 이 글의 진짜 교훈은 RSS가 아니라 의존성 선택에 있다. 플랫폼이 무료로 얹어주는 편의 기능은 그 회사의 전략이 바뀌는 순간 사라진다. Google Reader가 그랬고, FeedBurner API가 그랬다. 우리 스택에서 "그 회사가 내일 없애도 괜찮은가"를 한 번쯤 점검해볼 만하다. 개방형 프로토콜 위에 직접 얇게 구현해두면, 벤더가 손을 떼도 curl은 계속 돈다.

참고 자료

728x90
728x90

새벽 2시, 컨테이너 안에 curl이 없을 때

한 번쯤 겪어봤을 상황이다. 운영 중인 서비스에서 컨테이너 간 통신이 안 되는 것 같아서 `kubectl exec`로 파드에 들어갔는데, curl도 wget도 nc도 없다. distroless나 scratch 기반으로 알뜰하게 말아둔 이미지라 셸조차 없는 경우도 있다.

이게 왜 흔한 일이냐면, 요즘 운영 이미지는 의도적으로 도구를 다 빼기 때문이다. 공격 표면(attack surface)을 줄이려고 distroless를 쓰고, 이미지 크기를 줄이려고 alpine을 쓴다. curl 하나가 끌고 오는 라이브러리 의존성도 무시 못 하고, 컨테이너 안에 디버깅 도구가 많다는 건 그만큼 침해 시 공격자가 쓸 무기도 많다는 뜻이다. 그래서 "최소 이미지"라는 철학 자체는 옳다. 문제는 그 철학이 디버깅하는 우리를 엿먹인다는 점이다.

그런데 의외로 많은 이미지에 `bash`는 들어있다. 그리고 bash에는 잘 안 알려진 기능이 하나 있다. /dev/tcp/host/port 리다이렉션이다. 이걸로 패키지 설치 없이 TCP 연결을 열고 HTTP 요청을 손으로 짜서 보낼 수 있다. GeekNews에 올라온 글(원문)이 딱 이 트릭을 다룬다.

/dev/tcp는 진짜 파일이 아니다

핵심부터 말하면, /dev/tcp는 디스크에 존재하는 파일이 아니다. bash가 리다이렉션 구문을 파싱할 때 특별 취급하는 "가짜 경로"다. 그래서 이런 게 안 된다.

# 일반 파일처럼 접근하면 실패한다
$ ls /dev/tcp
ls: cannot access '/dev/tcp': No such file or directory

# dash(데비안의 /bin/sh)에서도 안 된다
$ /bin/sh -c 'cat /dev/tcp/example.com/80'
/bin/sh: 1: cannot create /dev/tcp/example.com/80: Directory nonexistent

bash 매뉴얼에 따르면, /dev/tcp/host/port 형태의 리다이렉션을 만나면 bash가 직접 DNS 조회를 하고 connect(2) 시스템 콜로 TCP 소켓을 연다. 비유하자면, 파일을 여는 척하는 문법을 빌려서 실제로는 네트워크 소켓을 파일 디스크립터에 매달아 놓는 것이다. 한번 디스크립터에 묶이면 그 다음부터는 평범한 파일처럼 읽고 쓰면 된다. 유닉스의 "모든 것은 파일이다" 철학을 bash가 셸 레벨에서 흉내 낸 셈이다.

실제로 example.com에 GET 요청을 날려보자.

# 파일 디스크립터 3번에 소켓을 연다
exec 3<>/dev/tcp/example.com/80

# HTTP/1.1 요청을 직접 작성해서 보낸다
printf 'GET / HTTP/1.1\r\nHost: example.com\r\nConnection: close\r\n\r\n' >&3

# 응답을 읽는다
cat <&3

실행하면 이런 응답이 나온다.

HTTP/1.1 200 OK
Content-Type: text/html
ETag: "..."
Cache-Control: max-age=...
Date: ...
Connection: close

<!doctype html>
<html>
...

응답에는 상태 줄, 헤더, 빈 줄, 본문이 한꺼번에 들어온다. 헤더를 더 붙이고 싶으면 요청을 끝내는 빈 줄(\r\n\r\n) 앞에 줄을 추가하면 된다. 예를 들어 인증 토큰을 실어야 한다면:

exec 3<>/dev/tcp/api-service/8080
printf 'GET /v1/models HTTP/1.1\r\nHost: api-service\r\nAuthorization: Bearer %s\r\nConnection: close\r\n\r\n' "$API_KEY" >&3
cat <&3

여기서 Connection: close가 진짜 중요하다. HTTP/1.1은 기본적으로 connection을 유지(keep-alive)하기 때문에, 이 헤더가 없으면 서버가 연결을 안 끊고 cat <&3이 EOF를 기다리며 영원히 멈춰 있는다. 디버깅하다가 터미널이 멈춰서 당황하지 말자.

실무 헬스체크 스크립트로 만들기

한 줄짜리 명령으로 끝내지 말고, 내부 서비스 연결성 확인용으로 다듬어보자. 타임아웃과 상태 코드 파싱까지 넣은 버전이다.

#!/usr/bin/env bash
# check_health.sh — curl 없는 컨테이너에서 헬스체크
HOST="${1:-service}"
PORT="${2:-8642}"
PATH_="${3:-/health}"

# timeout으로 감싸서 연결이 안 끊겨도 6초 후 빠져나오게 한다
response=$(timeout 6 bash -c "
  exec 3<>/dev/tcp/${HOST}/${PORT} || exit 1
  printf 'GET ${PATH_} HTTP/1.1\r\nHost: ${HOST}\r\nConnection: close\r\n\r\n' >&3
  cat <&3
")

if [ $? -ne 0 ]; then
  echo "연결 실패: ${HOST}:${PORT}"
  exit 1
fi

# 상태 줄에서 코드만 뽑기
status=$(echo "$response" | head -1 | awk '{print $2}')
echo "상태 코드: ${status}"
echo "$response" | head -1

실행 결과:

$ ./check_health.sh api-service 8080 /health
상태 코드: 200
HTTP/1.1 200 OK

여기서 timeout 6 bash -c '...'로 감싼 게 핵심이다. 앞에서 말한 keep-alive 멈춤 문제를 안전망으로 막아준다. 무인 환경(예: 라이브니스 프로브 같은 데)에 쓸 거라면 이런 타임아웃은 필수다.

흔한 함정과 한계

1. bash가 아니라 dash/sh로 실행하면 안 된다. /dev/tcp는 POSIX 표준이 아니라 bash 고유 기능이다. 데비안의 /bin/sh는 dash로 링크돼 있어서 무심코 sh script.sh로 돌리면 이런 에러를 만난다.

$ sh check_health.sh
check_health.sh: 9: cannot create /dev/tcp/service/8642: Directory nonexistent

반드시 bash로 직접 호출하거나 셔뱅을 #!/usr/bin/env bash로 정확히 박아야 한다.

2. bash 빌드 옵션이 꺼져 있을 수 있다. /dev/tcp는 bash를 컴파일할 때 --enable-net-redirections 옵션으로 켜진다. 대부분의 주류 빌드는 켜져 있지만, 데비안은 과거 오랫동안 이걸 꺼둔 적이 있다. 원문 댓글에 따르면 2009년경(Debian Bug #146464) 입장이 바뀌어 활성화된 것으로 보인다. 오래된 베이스 이미지를 쓰고 있다면 동작 안 할 가능성을 염두에 두자.

3. HTTPS는 안 된다. /dev/tcp는 평문 TCP 소켓만 연다. TLS 핸드셰이크를 직접 할 능력이 없으니 443 포트에 이걸로 붙으면 서버가 못 알아듣고 깨진다. HTTPS를 손으로 확인해야 한다면 openssl s_client가 필요한데, 그건 또 distroless엔 없을 가능성이 높다.

4. 진짜 HTTP 클라이언트가 아니다. 이건 리다이렉트(3xx), chunked transfer-encoding, gzip 압축, 재시도를 하나도 처리 못 한다. 응답이 chunked로 오면 본문에 청크 크기 숫자가 그대로 섞여 나온다. 그러니 무인 자동화에 진지하게 쓰면 언젠가 발등 찍힌다. 어디까지나 "지금 이 포트가 열려 있고 HTTP로 응답하나?" 정도를 빠르게 보는 디버깅 도구로만 쓰자.

대안은 이런 것들이 있다.

  • kubectl debug / ephemeral container: 쿠버네티스 1.25+ 환경이라면 kubectl debug로 디버깅용 사이드카를 임시로 붙여서 curl이 든 이미지를 끼워 넣을 수 있다. 운영 이미지를 건드리지 않는 가장 깔끔한 방법이다.
  • nsenter: 노드에 SSH로 들어갈 수 있다면 호스트에서 대상 컨테이너의 네트워크 네임스페이스에 진입해 호스트의 도구로 확인할 수 있다.
  • 그냥 운영 이미지에 도구를 넣기: 원문 댓글에서도 "운영 이미지에 curl 정도는 거의 필수"라는 의견이 나온다. 보안 트레이드오프를 따져보고, 도구를 빼서 얻는 이득이 디버깅 비용보다 작다면 넣는 게 맞을 수도 있다.

정리

한 줄 요약: exec 3<>/dev/tcp/host/port는 패키지 설치 없이 TCP 연결성과 평문 HTTP 응답을 즉석에서 확인하는 bash 내장 트릭이다.

이걸 써야 할 때는 명확하다. distroless나 alpine 같은 최소 이미지 안에서, 도구 설치가 막혀 있고, 지금 당장 "저 서비스에 닿는지"만 빠르게 보고 싶을 때다. 반대로 HTTPS가 필요하거나, 리다이렉트·압축을 처리해야 하거나, 자동화 파이프라인에 진지하게 넣을 거라면 손대지 말고 제대로 된 클라이언트를 쓰거나 ephemeral container를 띄워라. 화재 진압용 손도끼지, 일상 작업 공구가 아니다.

참고 자료

728x90

+ Recent posts