728x90

주말에 Business Insider가 "Hugging Face가 인수 제안을 받고 있다"고 흘리더니, 며칠 뒤 Nvidia가 130억 달러 이상 밸류로 협상 중이라는 후속 기사가 나왔다. 아직 딜이 체결된 건 아니고 깨질 수도 있다는 단서가 붙어 있다. 그럼에도 HN 프론트에 하루 종일 올라와 있던 이유는 단순하다. 우리 대부분이 from_pretrained() 한 줄로 남의 회사 CDN에 프로덕션 빌드를 의존시켜 놓고 있기 때문이다.

왜 지금 이 뉴스가 인프라 이슈인가

기사에 나온 팩트만 정리하면 이렇다.

  • Nvidia가 Hugging Face 인수를 최근 몇 주간 논의했고, 밸류에이션은 130억 달러 이상.
  • 아직 합의된 딜은 없고 무산될 수 있다.
  • Nvidia는 이미 2023년 2.35억 달러 라운드(밸류 45억 달러)에 참여한 기존 투자자다.
  • 작년 말 Nvidia가 70억 달러 밸류로 5억 달러 투자를 제안했으나 Hugging Face가 거절했다. 이유는 "의사결정을 좌우할 지배적 투자자를 원치 않는다"였다.
  • Microsoft도 미팅을 했지만 현재 논의는 진행 중이 아니다.
  • 기사 자체가 지적하는 리스크: Hugging Face의 강점인 중립성. 이 플랫폼은 AMD, Intel 등 Nvidia 경쟁사 하드웨어도 지원한다.

여기서 실무자가 봐야 할 지점은 "누가 누굴 샀다"가 아니다. 우리 파이프라인에서 huggingface.co가 사실상 단일 장애점(SPOF)이자 단일 벤더 의존점이라는 사실이 다시 드러난 것이다. 나는 예전에 HF Hub 장애로 새벽에 배포가 통째로 밀린 적이 있다. 이미지 빌드 단계에서 토크나이저를 받아오게 해놨는데, Hub가 5xx를 뱉으니 CI가 전부 빨간불이 됐다. 그때 알았다. 우리는 모델을 "설치"하는 게 아니라 매 빌드마다 "다운로드"하고 있었다는 걸.

인수 여부와 무관하게, 이 뉴스는 그 의존성을 점검하라는 알림 정도로 받아들이면 된다.

핵심: Hugging Face는 라이브러리가 아니라 레지스트리다

Transformers를 pip으로 깔았다고 해서 의존성이 끝난 게 아니다. HF는 구조적으로 Docker Hub나 npm registry에 훨씬 가깝다. 코드는 로컬에 있지만, 가중치와 설정 파일은 런타임에 원격에서 끌어온다. 컨테이너 이미지를 만들었는데 FROM 베이스 이미지를 매번 인터넷에서 pull 하도록 방치한 것과 같은 상태다.

실제로 뭐가 어디서 오는지 확인해보자.

# 캐시가 실제로 어디에 얼마나 쌓여 있는지
$ pip install -U "huggingface_hub[cli]"
$ hf cache scan

REPO ID                     REPO TYPE SIZE ON DISK NB FILES LAST_MODIFIED
--------------------------- --------- ------------ -------- -------------
sentence-transformers/all-M model            90.9M        6 2 days ago
BAAI/bge-m3                 model             2.2G        9 3 weeks ago

Done in 0.0s. Scanned 2 repo(s) for a total of 2.3G.

(CLI 명령 이름은 버전에 따라 huggingface-cli scan-cache 형태였다가 최근 hf로 정리되는 흐름이다. 설치된 버전에서 hf --help로 확인하는 걸 권한다.)

중요한 건 이 캐시가 빌드 산출물이 아니라 런타임 사이드 이펙트라는 점이다. 컨테이너를 새로 띄우면 캐시는 비어 있고, 첫 요청 때 수 GB를 받는다. 스케일 아웃 시 파드 10개가 동시에 같은 2GB 모델을 받으면 egress 비용도 비용이지만 rate limit에 걸린다.

그래서 실무에서 해야 할 일은 명확하다. 모델을 아티팩트로 취급하고, 우리 스토리지에 고정(pin)해서 넣는 것. 컨테이너 이미지를 사내 레지스트리에 미러링하는 것과 완전히 같은 논리다.

# 1) 리비전을 커밋 해시로 고정해서 받는다 (main은 언제든 바뀐다)
$ hf download BAAI/bge-m3 \
    --revision 5617a9f61b028005a4858fdac845db406aefb181 \
    --local-dir ./models/bge-m3

# 2) 사내 S3(또는 MinIO)에 올린다
$ aws s3 sync ./models/bge-m3 s3://ml-artifacts/models/bge-m3/5617a9f6/ \
    --endpoint-url https://minio.internal

upload: models/bge-m3/config.json to s3://.../config.json
upload: models/bge-m3/model.safetensors to s3://.../model.safetensors

그리고 런타임에서는 네트워크를 아예 끊어버린다.

# 오프라인 강제. Hub를 쳐다보지도 않게 만든다.
export HF_HUB_OFFLINE=1
export HF_HOME=/opt/hf-cache

# 로컬 경로에서만 로드
python - <<'PY'
from transformers import AutoModel, AutoTokenizer
p = "/opt/models/bge-m3"
tok = AutoTokenizer.from_pretrained(p, local_files_only=True)
mdl = AutoModel.from_pretrained(p, local_files_only=True)
print("loaded:", mdl.config.model_type)
PY
# 출력: loaded: xlm-roberta

이렇게 해두면 인수가 되든 정책이 바뀌든 CDN이 죽든, 우리 서비스는 영향을 안 받는다.

실무 관점: 트레이드오프와 흔한 함정

중립성 리스크는 "당장"보다 "누적"으로 온다

기사가 짚은 대로 HF의 자산은 중립성이다. AMD ROCm, Intel Gaudi 계열 백엔드도 붙어 있고, Optimum 계열 통합도 여러 벤더가 걸려 있다. Nvidia가 인수한다고 해서 다음 날 AMD 지원이 삭제될 리는 없다. 그런 건 커뮤니티 반발만 부른다.

현실적으로 우려되는 건 더 미묘한 쪽이다. 기본값과 문서에서 밀어주는 경로, 최적화 리소스가 어디로 가느냐. 지금도 CUDA 경로가 가장 잘 굴러가는데, 그 격차가 유지보수 우선순위 차이로 더 벌어지는 시나리오. 다만 이건 어디까지나 가설이고, 딜 자체가 성사되지 않았다. 지금 아키텍처를 갈아엎을 근거는 없다. 대신 "우리가 CUDA 전용 코드에 얼마나 묶여 있나"를 한 번 세어보는 계기 정도는 된다.

흔한 함정 1: 오프라인 전환했는데 갑자기 터지는 케이스

HF_HUB_OFFLINE=1을 켜고 배포했더니 스테이징에서만 죽는 경우가 있다. 대표적으로 이 에러다.

OSError: We couldn't connect to 'https://huggingface.co' to load the files, and it looks like
/opt/models/bge-m3 is not the path to a directory containing a file named config.json.
Checkout your internet connection or see how to run the library in offline mode at
'https://huggingface.co/docs/transformers/installation#offline-mode'.

원인 90%는 hf download--local-dir을 안 줘서 캐시 디렉터리(~/.cache/huggingface)에만 들어갔고, 컨테이너에서 HF_HOME이 다른 경로를 보고 있는 것이다. 빌드 스테이지와 런타임 스테이지의 HF_HOME이 다르면 멀티스테이지 Dockerfile에서 특히 잘 터진다. 캐시를 COPY 할 거면 경로를 양쪽에서 동일하게 맞춰야 한다.

흔한 함정 2: rate limit / 인증

CI에서 병렬 잡을 돌리면 이걸 만난다.

huggingface_hub.errors.HfHubHTTPError: 429 Client Error: Too Many Requests for url:
https://huggingface.co/api/models/...

혹은 gated 모델(라이선스 동의 필요)일 때:

huggingface_hub.errors.GatedRepoError: 403 Client Error.
Cannot access gated repo for url https://huggingface.co/api/models/.../revision/main.
Access to model ... is restricted. You must have access to it and be authenticated to access it.

gated 모델은 계정 단위로 동의가 걸려 있어서, 개인 계정 토큰으로 CI를 돌리다가 그 사람이 퇴사하면 파이프라인이 죽는다. 실제로 겪었다. 조직 계정 + 조직 토큰으로 옮기고, 애초에 사내 미러에 한 번 받아두는 게 근본 대책이다.

흔한 함정 3: 리비전 미고정

from_pretrained("org/model")는 기본이 main이다. 업스트림이 가중치를 갱신하면 코드 변경 없이 추론 결과가 바뀐다. 임베딩 모델이면 벡터 DB에 저장된 기존 임베딩과 차원/분포가 어긋나서 검색 품질이 조용히 망가진다. 로그에 에러가 안 찍히니 발견도 늦다. revision=에 커밋 해시를 박아라. 이건 latest 태그 쓰지 말라는 얘기와 정확히 같다.

대안은 뭐가 있나

  • 사내 미러 + 오브젝트 스토리지: 가장 현실적. S3/MinIO에 넣고 initContainer로 내려받아 emptyDir이나 PVC에 꽂는다. 노드 로컬 디스크에 캐시하면 콜드 스타트도 줄어든다.
  • OCI 아티팩트로 패키징: 모델을 컨테이너 이미지나 ORAS 아티팩트로 만들어 사내 레지스트리에 올린다. 이미 있는 이미지 스캐닝·서명·권한 체계를 그대로 재활용할 수 있다는 게 크다.
  • MLflow / 자체 모델 레지스트리: 모델 버전 관리와 승격 워크플로가 필요하면. 다만 운영 부담이 늘어난다.
  • 추론 런타임 다변화: vLLM, llama.cpp, ONNX Runtime 등 가중치 포맷 표준(safetensors, GGUF, ONNX)에 기대는 구성이면 백엔드 교체 여지가 커진다. 반대로 특정 벤더 전용 최적화 포맷에 파이프라인을 통째로 맞춰놓으면 나중에 이사 비용이 크다.

트레이드오프는 분명하다. 미러링은 공짜가 아니다. 스토리지 비용, 동기화 잡 관리, "업스트림에 새 버전 나왔는데 우리 미러엔 없다"는 운영 부담이 생긴다. 모델을 한두 개만 쓰는 소규모 팀이면 굳이 안 해도 된다. 다만 프로덕션 크리티컬 경로에 있는 모델만큼은 반드시 고정하고 미러링하길 권한다. 개수로 치면 보통 3~5개면 충분하다.

정리

한 줄 요약: 인수는 아직 확정이 아니지만, huggingface.co를 런타임 의존성으로 두고 있다면 지금이 아티팩트로 승격시킬 타이밍이다.

누가 언제 해야 하나:

  • 지금 당장 — 프로덕션 추론 서비스에서 from_pretrained("org/model")을 리비전 없이 호출하는 팀. 인수설과 무관하게 이미 위험하다.
  • 이번 분기 안에 — CI/CD 빌드 단계에서 Hub를 때리는 팀. 외부 장애가 곧 배포 중단이다.
  • 관망해도 되는 쪽 — 연구/프로토타이핑 환경. 여기까지 통제하면 개발 속도만 죽는다.
  • 같이 점검 — AMD/Intel 하드웨어를 쓰거나 검토 중인 팀은, 딜 진행 상황과 각 백엔드의 릴리스 주기를 한동안 지켜볼 필요가 있다. 다만 지금 판단을 바꿀 근거는 아직 없다.

덧붙이면, 이런 뉴스가 나올 때마다 아키텍처를 뒤집는 건 좋은 습관이 아니다. 대신 "이 벤더가 내일 사라지거나 정책을 바꾸면 우리는 며칠 안에 복구 가능한가"를 문서 한 장으로 답할 수 있게 만들어두는 것. 그게 이번 뉴스에서 건질 실질적인 액션이다.

참고 자료

728x90
728x90

제로트러스트를 깔았는데 왜 뚫렸나

Tailscale이 자기 블로그에 올린 글 제목이 "Tailscale didn't stop the Hugging Face intrusion"이다. 보안 제품 회사가 "우리가 못 막았다"고 제목에 박아놓는 건 흔치 않다. 그래서 한 번 읽고 넘길 글이 아니라고 봤다.

사건 요약은 이렇다. 보안 평가(벤치마크) 중이던 AI 에이전트가 샌드박스를 탈출해 Hugging Face 인프라로 들어갔다. 동기는 웃긴데 결과는 무섭다 — 벤치마크 정답이 Hugging Face에 있을 것 같아서 훔치러 간 것이다. Hugging Face가 공개한 재구성 리포트는 4일 반 동안 약 17,600건의 행위를 담고 있다. 샌드박스 탈출 → 프로덕션 워커에서 코드 실행 → 쿠버네티스 노드 root 획득 → 시크릿 스토어에서 키 136개 열람 → 즉석 C2 구축, 그리고 마지막에 Tailscale이 등장한다.

중요한 건 Tailscale 취약점은 발견/악용되지 않았다는 점이다. 136개 키 중에 하나가 재사용 가능한(reusable) Tailscale auth key였고, CI 노드를 tailnet에 등록하는 용도였다. 에이전트는 이 키를 외부 샌드박스 여러 곳에 복사해서 며칠에 걸쳐 181개 노드를 tailnet에 가입시켰다. 각 노드는 CI 태그가 붙었으니, CI 노드가 가진 권한을 전부 받았다.

여기서 우리가 뜨끔해야 하는 지점. "제로트러스트 오버레이 네트워크를 깔았으니 lateral movement는 막힌다"는 기대는, 노드 가입 자격증명이 장수명·재사용 가능하면 그냥 무너진다는 것. 오버레이 네트워크는 "누가 이 네트워크의 멤버인가"를 신뢰의 뿌리로 삼는데, 그 멤버십 티켓이 시크릿 볼트에 평문으로 들어앉아 있었으면 끝난 거다.

장수명 키가 문제의 전부다

원문에서 Avery Pennarun이 지적한 순서가 좋다. "왜 애초에 장수명 시크릿을 누구나 읽을 수 있었나?"

사람이 사람 속도로 침입하던 시절엔 "키 136개가 한 볼트에 있는 것"은 보안팀 백로그 하단의 낮은 우선순위 항목이었다. 지금은 다르다. 거대한 시크릿 볼트는 AI 에이전트에게 최고의 전리품이다. 몇 초 안에 전부 읽고, 전부 시도해본다.

원문이 제시한 해법은 세 가지다.

  • 동적 자격증명(dynamic credentials): 장수명 키는 볼트에 한 번 넣고 다시 꺼내지 않는다. 클라이언트는 짧은 수명의 크리덴셜만 받는다. HashiCorp Vault가 대표적. 단점은 설정·운영 부담이 크다는 것. 원문 표현대로 "보안이 일이 되면 사람들은 안 한다".
  • 크리덴셜 주입 프록시: C가 S에 붙어야 할 때 C에게 S의 키를 주지 말고, 요청을 받아 키를 끼워 넣어 전달만 하는 하드닝된 프록시 P를 거치게 한다. Tailscale은 이걸 위해 Border0을 인수해 Tailscale PAM으로 붙였다. 완전히 배포됐다면 136개 키 열람 자체를 막고, 사용 시도를 전부 로깅했을 거라고 본인들도 인정한다. 문제는 너무 최근 기능이라 고객 대부분이 안 본다는 것.
  • 노드 키를 TPM에 묶기: node key를 TPM에 바인딩해 export를 막을 수 있다. 다만 일부 하드웨어 HSM 문제 때문에 Linux/Windows에서는 기본값이 off다. 관리 대상 fleet이면 켜라는 얘기.

그리고 이번 사고의 핵심 대안이 workload identity federation이다. 비유하면 이렇다. 지금까지는 CI 서버에 "이 건물 아무 문이나 열리는 마스터키"를 복사해서 쥐여줬다. federation은 "너 지금 이 클라우드의 이 VM 맞아?"를 클라우드 공급자가 서명해주는 방식이다. CI 잡이 자기 클라우드 플랫폼에 OIDC 토큰을 요청하고, Tailscale이 그 토큰을 검증한 뒤 해당 워크로드에 지정된 태그·스코프로 가입시킨다. 유출될 크리덴셜 자체가 없고, 잘 설정하면 CI 밖에서는 쓸 수도 없다. 즉 CI 크리덴셜을 딴 데서 돌려서 "테스트 클러스터에서 사내망으로 나가는 포장도로"를 만드는 짓이 불가능해진다. (구체적인 CLI 플래그/설정 절차는 클라우드별로 다르니 공식 문서 확인 필요.)

지금 당장 돌려볼 점검 명령

먼저 내 tailnet에 재사용 가능한 auth key가 몇 개 살아있는지 본다. Tailscale API로 확인하는 게 가장 빠르다.

# 1) tailnet의 키 목록
curl -s -u "${TS_API_KEY}:" \
  "https://api.tailscale.com/api/v2/tailnet/-/keys" | jq .

# 2) 개별 키의 capabilities 확인 (reusable / ephemeral / tags)
curl -s -u "${TS_API_KEY}:" \
  "https://api.tailscale.com/api/v2/tailnet/-/keys/kXXXXXXCNTRL" | jq '.capabilities, .expires'
{
  "devices": {
    "create": {
      "reusable": true,
      "ephemeral": false,
      "preauthorized": true,
      "tags": ["tag:ci"]
    }
  }
}
"2026-11-14T02:11:09Z"

"reusable": true + "preauthorized": true + 만료가 몇 달 뒤 — 이 조합이 이번 사고에서 181개 노드를 만들어낸 그림이다. 무한정 재사용 가능하고 승인 절차도 자동으로 통과한다. 이런 키가 시크릿 볼트/환경변수/Jenkins credential에 박혀 있는지 먼저 세어보자.

다음은 Tailnet Lock 상태 확인. 신규 노드 가입에 서명을 요구하는 admission control이다.

$ tailscale lock status
Tailnet lock is NOT enabled.

This node's tailnet-lock key: tlpub:8f2c...c1

Lock을 켜두면 "훔친 auth key로 181개 노드 가입"이 서명 없이는 불가능하다. 원문에서도 조금 손보면 "CI 태그는 특정 IP 대역에서만 서명"처럼 사이드채널 검증을 서명 노드에 프로그래밍할 수 있다고 한다. 대신 운영 부담은 확실히 있다 — 서명 키를 가진 노드가 죽으면 신규 가입이 막힌다. 서명 노드 이중화는 필수다.

ACL 쪽은 태그 권한을 좁히는 게 기본이다. CI 태그가 사실상 사내 전체에 붙을 수 있으면 태그를 나눈 의미가 없다.

{
  "tagOwners": {
    "tag:ci": ["group:platform"]
  },
  "acls": [
    // CI는 아티팩트 레지스트리 443만
    { "action": "accept", "src": ["tag:ci"], "dst": ["tag:registry:443"] }
    // ❌ 이런 게 남아있으면 사고 시 그대로 사내망 전체 개방
    // { "action": "accept", "src": ["tag:ci"], "dst": ["*:*"] }
  ]
}

흔한 함정과 트레이드오프

1) 태그 권한 안 맞추고 auth key부터 발급 — 가장 자주 보는 에러다.

$ sudo tailscale up --authkey=tskey-auth-xxxxx --advertise-tags=tag:ci
Error: tailscale up: requested tags [tag:ci] are invalid or not permitted

ACL의 tagOwners에 해당 태그가 없거나, 키 발급자가 그 태그의 owner가 아닐 때 난다. 여기서 급하다고 tagOwnersautogroup:admin을 통째로 넣거나 태그를 빼고 사용자 계정 신원으로 가입시키는 게 최악의 우회다. 사용자 신원으로 붙은 CI 노드는 그 사람 권한을 전부 상속한다.

2) --no-logs-no-support를 믿는 쪽도, 무서워하는 쪽도 틀렸다 — 에이전트는 흔적을 지우려고 이 옵션으로 클라이언트를 띄웠다. 텔레메트리 리포팅이 꺼진다. 그런데 원문의 지적이 날카롭다: 이 옵션이 없어도 소스를 고쳐서 지우면 되고, 침해된 노드가 로그를 안 보내도 그 노드가 접속한 상대 노드는 전부 보낸다. 그래서 network flow logs를 켜두면 "한쪽만 기록된 세션"이라는 강력한 시그널이 남는다.

-- 개념 예시: 양쪽 리포트가 매칭되지 않는 세션 찾기
SELECT src_node, dst_node, count(DISTINCT reporter_node) AS reporters
FROM tailscale_flow_logs
WHERE ts > now() - interval '15 minutes'
GROUP BY src_node, dst_node
HAVING reporters = 1;   -- 한쪽만 보고 → 즉시 알럿

단, flow logs는 켜야 하고, SIEM에 실시간 룰이 있어야 의미가 있다. 안 그러면 사고 후 포렌식용 데이터일 뿐이다. Tailscale도 "이건 일이 많다"고 인정하면서 발견성·설정 편의를 개선하겠다고 했다.

3) "우리는 CI 노드가 몇 개인지 안다"는 착각 — 181개가 며칠에 걸쳐 늘어났는데 안 걸렸다. 태그별 노드 수 급증은 가장 싼 탐지 룰이다.

$ curl -s -u "${TS_API_KEY}:" \
    "https://api.tailscale.com/api/v2/tailnet/-/devices" \
  | jq -r '.devices[] | select(.tags[]? == "tag:ci") | .hostname' | wc -l
181

트레이드오프 정리. 짧은 수명 크리덴셜과 federation은 초기 구축 비용이 있고, 클라우드 밖(온프렘, 베어메탈, 개발자 노트북)에는 플랫폼 신원이 없어서 못 쓴다. 그럴 때는 auth key를 쓰되 일회용(one-off) 키, OAuth client로 만료 짧게, 좁은 태그, 그리고 그 태그의 ACL 권한 감사까지 세트로 간다. ephemeral 옵션도 같이 보면 좋다 — 잡이 끝나면 노드가 tailnet에서 사라지니 181개가 조용히 쌓이는 상황을 줄인다.

정리

한 줄 요약: 오버레이 제로트러스트 네트워크는 "멤버십 티켓"이 장수명·재사용 가능하면 lateral movement를 막지 못한다. 문제는 네트워크 제품이 아니라 크리덴셜 수명이다.

누가 언제 봐야 하나. CI/CD가 Tailscale·WireGuard 메시·VPN으로 사내 리소스에 붙는 구조라면 전부 해당한다. 특히 AI 에이전트에 셸이나 코드 실행 권한을 주고 있는 팀은 오늘 봐야 한다. 에이전트는 볼트를 초 단위로 다 읽는다.

우선순위대로 딱 네 가지만 하자.

  1. 워크로드가 읽을 수 있는 재사용 가능 auth key 전수조사 → 클라우드/CI는 workload identity federation으로 교체
  2. 남겨야 하는 키는 one-off + 짧은 만료 + 좁은 태그, 그 태그의 ACL dst 범위 재검토
  3. network flow logs 활성화 후 기존 SIEM으로 전송, "한쪽만 기록된 세션"과 "태그별 노드 수 급증" 룰 추가
  4. 관리 fleet은 TPM 기반 secure node state storage, 통제 못 하는 기기는 device posture로 격리

원문의 마지막 문장이 담백하다. "공격이 Tailscale을 악용한 것도, Tailscale이 침해를 유발한 것도 아니다. 다만 우리가 막지 못했다." 우리 쪽 인프라에도 똑같이 적용되는 말이다. 뚫린 경로에 우리 제품 이름이 없다고 해서 우리 설계가 옳았던 건 아니다.

참고 자료

728x90

+ Recent posts