:bulb: Claude Code가 API error · Retrying in 0s · attempt 1/10을 반복하며 느려졌다. 처음엔 클라이언트 버그를 의심했지만, DNS → MTU → conntrack → 경로 → 목적지 정책 순서로 계층을 좁혀 보니 원인은 사내 방화벽이 지정해 차단한 목적지 IP 단 하나였다. 가설을 세우고 깨뜨린 과정과, 같은 증상을 만났을 때 빠르게 좁혀 들어갈 수 있는 체크리스트를 정리한다.

먼저 결론부터.

  • 증상: Claude Code가 API error · Retrying을 반복하며 응답이 눈에 띄게 느려짐
  • 진짜 원인: api.anthropic.com의 여러 엣지 IP 중 하나만 사내 방화벽에서 차단/불안정. 그런데 사내 DNS가 하필 그 IP만 반환
  • 해결: 같은 대역의 살아있는 IP를 /etc/hosts에 고정해 차단된 IP를 우회
  • 교훈: “특정 서비스만 됐다 안 됐다”는 거의 항상 목적지 단위 정책의 신호다. 진단 중 잘못 지정한 테스트 IP 하나 때문에 원인 파악이 한참 지연될 수 있다

[01] 증상: 재시도 끝에 응답은 오지만 느리다

평소 잘 쓰던 Claude Code가 어느 날부터 다음 메시지를 반복했다.

1
✻ API error · Retrying in 0s · attempt 1/10

응답이 눈에 띄게 느려졌고, 재시도 끝에 결국 되긴 했다. 환경은 Ubuntu 22.04, 노드 두 대. 흥미롭게도 지난주까지는 멀쩡했고, 같은 망의 다른 환경에서는 문제가 없어 보였다. 클라이언트 버그를 먼저 의심했지만, 이 가설은 곧 반증됐다.


[02] 진단: 가설 수립과 반증

2-1. 우선 연결 자체를 의심하다

curl로 API 엔드포인트에 직접 요청했다.

1
2
3
curl -I https://api.anthropic.com
# 처음엔: SSL_connect: 상대편이 연결을 끊음
# 잠시 후: HTTP/2 404  ← 정상 (루트 경로엔 페이지가 없어 404가 정상 응답)

404는 연결·TLS가 모두 성공했다는 뜻이다(루트 경로에 콘텐츠가 없을 뿐). 그런데 연속으로 요청하니 결과가 일정하지 않았다.

1
2
3
4
5
6
7
for i in $(seq 1 10); do
  curl -s -o /dev/null -w "%{http_code} %{time_total}s\n" https://api.anthropic.com
done
# 000 94s  (타임아웃)
# 404 63s
# 000 94s
# ...간헐적으로 성공/실패가 섞임

94초라는 일정한 타임아웃 값과 000(연결 실패)의 반복은 간헐적 연결 단절을 뜻한다. 같은 머신에서 curl도 똑같이 실패하니 Claude Code 코드 문제는 배제됐다. 문제는 더 아래 계층에 있었다.

2-2. DNS·IPv6 가설 → 부분적으로 빗나감

리졸버를 확인하니 systemd-resolved(stub 127.0.0.53)를 거치고 있었고, api.anthropic.com이 IPv4와 IPv6를 모두 반환했다. 한때 “IPv6 폴백 지연”을 의심했지만, IPv4를 강제(curl -4)해도, 심지어 IP를 직접 지정해(--resolve) DNS를 우회해도 실패했다.

1
2
3
curl -4 --resolve api.anthropic.com:443:160.79.104.10 -o /dev/null -s \
  -w "%{http_code}\n" https://api.anthropic.com
# 그래도 타임아웃(000)이 섞여 나옴

DNS는 원인이 아니다. IPv6도 아예 연결성이 없어 폴백 가설도 폐기했다.

2-3. MTU·conntrack 가설 → 둘 다 정상

  • MTU 블랙홀? 큰 패킷(ping -M do -s 1472)이 손실 없이 통과 → MTU 정상
  • conntrack 포화? 테이블은 한가했고(nf_conntrack_count가 한도의 1% 미만), insert_failed/drop 카운터 전부 0 → conntrack 정상
1
2
sysctl net.netfilter.nf_conntrack_count net.netfilter.nf_conntrack_max
conntrack -S | grep -E "insert_failed|drop"   # 전부 0

2-4. 결정적 증거: SYN은 나가는데 SYN-ACK이 안 온다

tcpdump로 실패하는 순간을 잡았다.

1
2
3
4
5
6
7
sudo tcpdump -ni any host 160.79.104.10 and tcp port 443
# 실패 케이스:
#  Out ... > 160.79.104.10.443: Flags [S]      ← SYN 발송
#  Out ... > 160.79.104.10.443: Flags [S]      ← 1초 후 재전송
#  (SYN-ACK 응답 없음 → connect timeout)
# 성공 케이스:
#  Out ... [S]  →  In ... [S.]  →  정상 핸드셰이크

클라이언트가 보낸 SYN은 NIC 밖으로 정상 송출되는데, 응답(SYN-ACK)이 돌아오지 않는다. 드롭 지점은 클라이언트 바깥의 경로나 중간 장비다. 정량화하면 이렇다.

1
나간 SYN: 48   →   돌아온 SYN-ACK: 37   (약 23% 유실)

OS도 무관했다. 같은 망의 Windows에서 Test-NetConnection을 30회 돌려도 비슷한 비율로 실패했다(약 13%). “Windows는 잘 된다”고 느낀 건, 당시 측정 방식의 아티팩트와 다른 경로를 타는 환경을 비교했기 때문이었다.

2-5. 진단 오류: 잘못 지정한 테스트 IP

목적지를 여러 개 동시에 비교하다가 큰 혼선을 겪었다. --resolve로 google IP를 임의로 지정했는데, 그 IP가 해당 망에서 유효하지 않아 google이 100% 실패하는 것처럼 보였다. 그대로 두었다면 “전면 차단”이라는 틀린 결론으로 이어질 상황이었다.

:warning: IP를 직접 지정할 때는 그 IP가 실제로 유효한지부터 확인한다. DNS가 주는 IP로 다시 재니 google·amazon·github는 100% 정상이었다. 잘못된 대조군 하나가 진단 결과 전체를 왜곡한다.

2-6. 진짜 원인: 목적지 IP 단위 차단

여러 목적지를 같은 라운드에서 동시에 측정하는 스크립트(부록 A)를 실행하자 결과가 분명해졌다. DNS가 주는 정상 IP 기준으로:

목적지 결과
google / amazon / github 100% 성공
api.anthropic.com (160.79.104.10) 거의 100% 실패

결정적인 측정 결과는 다음이다. 같은 대역의 바로 옆 IP는 정상 동작했다.

1
2
3
4
5
6
for ip in 160.79.104.10 160.79.104.11 160.79.104.12 160.79.104.13; do
  # .10 → 9/10 실패
  # .11 → 10/10 성공
  # .12 → 10/10 성공
  # .13 → 10/10 성공
done

160.79.104.10 하나만 막혀 있고, .11~.13은 정상 동작했다. 무작위 망 장애라면 IP 단위로 0%와 100%가 이렇게 나뉘지 않는다. 방화벽이나 IPS가 특정 목적지 IP 하나를 차단하고 있다는 신호다.

문제를 키운 건 DNS였다. 사내·외부 DNS 모두 api.anthropic.com에 대해 차단된 .10만 반환하고 있었다. 그래서 클라이언트는 항상 차단된 IP로만 연결을 시도했고, 실패율이 시점마다 오르내린 것은 .10이 “부분 차단/불안정” 경계에 걸쳐 있었기 때문이다.

:memo: 도메인 하나에 IP가 여러 개인 이유. api.anthropic.com 같은 도메인 뒤에는 여러 대의 엣지 서버가 있고, 각기 다른 IP가 붙는다. 모두 같은 서비스로 연결되는 진입점이다. 평소에는 DNS가 그중 하나를 선택한다. 이 환경에서는 그 선택이 차단된 IP에 고정돼 있었다.


[03] 해결: 살아있는 엣지 IP를 /etc/hosts에 고정

3-1. 원리: IP를 바꿔도 안전한 이유 (SNI)

HTTPS는 목적지를 IP가 아니라 도메인 이름(SNI)으로 식별한다. 어느 엣지 IP로 접속하든 연결 시 “나는 api.anthropic.com을 찾는다”는 이름을 함께 보내고, 서버는 그 이름에 맞는 인증서와 콘텐츠로 응답한다. 따라서 .10 대신 .11로 연결해도 같은 서비스의 다른 진입점이며, 인증서 검증도 도메인 기준이라 그대로 통과한다.

3-2. /etc/hosts로 살아있는 IP 고정

이름→IP 변환은 /etc/hosts가 DNS보다 먼저 참조된다. 여기에 살아있는 IP를 적으면 DNS 질의를 건너뛰고 곧장 그 IP로 향한다. 이 서버 한 대에만 적용되고, 사내 DNS/방화벽 설정은 건드리지 않는다.

1
2
echo "160.79.104.11 api.anthropic.com" | sudo tee -a /etc/hosts
sudo resolvectl flush-caches

3-3. 적용/롤백 자동화

/etc/hosts는 민감 파일이라, 적용 전 자동 백업·중복 방지·사전 연결 검증·원클릭 롤백을 갖춘 스크립트로 다뤘다. 핵심 동작은 다음과 같다(전체 코드는 부록 B).

  • apply [IP]: 지정 IP를 먼저 5회 테스트해 살아있을 때만 hosts에 고정(백업 후)
  • rollback: 추가했던 항목만 정확히 제거하고 캐시 flush
  • status: 현재 해석 IP와 연결 결과 확인
  • probe: .11~.20 중 살아있는 대체 IP 자동 탐색

스크립트가 추가한 줄에 식별 마커를 달아, 여러 번 실행해도 줄이 쌓이지 않고 롤백 시 그 줄만 안전하게 지운다(다른 hosts 항목은 보존).

3-4. 주의: 기존 세션은 재시작해야 새 IP로 연결된다

/etc/hosts는 새 연결을 시작할 때 한 번만 참조된다. 이미 차단된 IP로 맺어진 연결이나 프로그램 내부 DNS 캐시는 그대로 남을 수 있으므로, 캐시 flush와 함께 해당 프로그램을 재시작해야 새 IP로 연결된다.


[04] 결과: 네 단계로 검증

.11로 고정한 뒤 네 단계로 검증했다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 1) hosts 항목
grep anthropic /etc/hosts
#  160.79.104.11 api.anthropic.com

# 2) 이름 해석
getent ahostsv4 api.anthropic.com
#  160.79.104.11  STREAM api.anthropic.com

# 3) 실제 연결 (remote_ip 확인)
curl -4 -o /dev/null -s -w "%{remote_ip} → %{http_code} (%{time_total}s)\n" https://api.anthropic.com
#  160.79.104.11 → 404 (0.06s)   ← .11로 빠르게 붙음

# 4) 살아있는 세션 — 가장 중요
ss -tnp | grep 160.79.104
#  ESTAB ... 160.79.104.11:443  users:(("claude",pid=...))   (연결 다수, 전부 .11)
#  → .10은 하나도 없음

특히 4번이 결정적이었다. Claude Code의 실제 연결이 전부 .11로 맺어졌고 .10은 하나도 남지 않았다. 차단된 IP로는 더 이상 연결되지 않는다. 이후 API error · Retrying는 사라졌고 응답 속도도 정상으로 돌아왔다.

남은 일

  • 다른 노드에도 동일 적용: 같은 망의 두 번째 노드도 같은 방식으로 처리
  • 다른 anthropic 도메인 점검: 클라이언트가 쓰는 보조 도메인이 막힌 IP를 받으면 같은 증상이 재발할 수 있으니, 이상 징후 시 동일하게 확인
  • 근본 해결은 관리자 몫: /etc/hosts 우회는 임시방편이다. 엣지 IP는 바뀔 수 있으므로, 방화벽에서 해당 IP 차단 해제 또는 도메인 대역 허용을 받아두면 hosts 관리에서 자유로워진다

[05] 진단 체크리스트 (재사용용)

비슷한 “간헐적 API 연결 실패”를 만났을 때 빠르게 좁히는 순서.

  1. 클라이언트 vs 시스템: 같은 목적지가 curl로도 재현되면 클라이언트는 원인이 아니다
  2. DNS: IP를 직접 지정해도(--resolve) 실패하면 DNS는 원인이 아니다
  3. MTU: 큰 패킷(ping -M do -s 1472)이 통과하면 MTU는 원인이 아니다
  4. conntrack: nf_conntrack_count가 한도에 닿았는지, insert_faileddrop 카운터가 0인지 확인한다
  5. 경로: tcpdump로 SYN은 나가는데 SYN-ACK이 오지 않으면 외부에서 드롭된 것이다
  6. 목적지 단위: 여러 목적지를 동시에 측정한다. 특정 IP만 결과가 다르면 목적지 단위 정책이다
  7. 대역 내 이웃 IP: 같은 대역의 옆 IP(.11, .12)가 정상이면 개별 IP 차단으로 확정한다

:bulb: 무엇보다 직접 지정한 테스트 IP가 유효한지 항상 확인한다. 잘못된 대조군 하나가 진단 결과 전체를 왜곡할 수 있다.


부록 A. 다중 목적지 동시 진단 스크립트 (conn_check.sh)

여러 목적지를 같은 라운드에서 동시에 측정해, 막히는/풀리는 구간이 목적지 공통인지(=경로/게이트웨이 전면) vs 특정 목적지인지를 시계열로 판별한다. 2~3회 반복 실행하면 평균 실패율과 주기가 또렷해진다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
#!/usr/bin/env bash
# conn_check.sh
# 여러 목적지(IP/포트)에 대한 신규 TCP 443 연결 성공/실패를 같은 라운드에서
# 동시 측정하여 "전면 간헐 차단"인지 "특정 목적지만 차단"인지 시계열로 판별한다.
# 사용: ./conn_check.sh   |   ROUNDS=100 ./conn_check.sh   |   TIMEOUT=2 ./conn_check.sh
set -u

ROUNDS="${ROUNDS:-50}"        # 측정 라운드 수
TIMEOUT="${TIMEOUT:-3}"       # connect 타임아웃(초)
SLEEP="${SLEEP:-0.3}"         # 라운드 간 간격(초)

# 측정 대상: "이름|호스트헤더|IP". IP를 직접 박아 DNS 변수를 제거(순수 TCP/TLS 경로만 측정).
# ※ 주의: 여기 박는 IP가 "그 환경에서 실제 유효한 IP"인지 반드시 확인할 것.
#   잘못된 IP를 박으면 해당 목적지가 100% 실패하는 것처럼 보여 진단을 오염시킨다.
#   확실히 하려면 --resolve 없이 도메인으로 직접 재거나, getent ahostsv4 로 실제 IP를 먼저 확인.
TARGETS=(
  "anthropic|api.anthropic.com|160.79.104.10"
  "google|www.google.com|142.250.207.110"
  "cloudflare|one.one.one.one|1.1.1.1"
  "github|github.com|140.82.112.3"
  "amazon|www.amazon.com|54.239.28.85"
)

TS="$(date +%Y%m%d_%H%M%S)"
CSV="./conn_check_${TS}.csv"

NAMES=()
for t in "${TARGETS[@]}"; do NAMES+=("${t%%|*}"); done

declare -A FAILS TOTAL
for n in "${NAMES[@]}"; do FAILS["$n"]=0; TOTAL["$n"]=0; done

{ printf "timestamp,round"
  for n in "${NAMES[@]}"; do printf ",%s_code,%s_time" "$n" "$n"; done
  printf "\n"; } > "$CSV"

echo "측정 시작: ROUNDS=$ROUNDS TIMEOUT=${TIMEOUT}s  로그=$CSV"
printf "%-10s %-3s" "time" "#"
for n in "${NAMES[@]}"; do printf " %-12s" "$n"; done
printf "\n"

probe() {  # 반환: "<http_code> <time_total>" (실패 시 code=000)
  local host="$1" ip="$2"
  curl -4 -o /dev/null -s -w "%{http_code} %{time_total}" \
    --connect-timeout "$TIMEOUT" --resolve "${host}:443:${ip}" \
    "https://${host}" 2>/dev/null || echo "000 0"
}

for ((r=1; r<=ROUNDS; r++)); do
  now="$(date +%H:%M:%S)"; printf "%-10s %-3d" "$now" "$r"; csv_line="$now,$r"
  for t in "${TARGETS[@]}"; do
    name="${t%%|*}"; rest="${t#*|}"; host="${rest%%|*}"; ip="${rest##*|}"
    read -r code time <<< "$(probe "$host" "$ip")"
    TOTAL["$name"]=$(( TOTAL["$name"] + 1 ))
    if [[ "$code" == "000" ]]; then
      FAILS["$name"]=$(( FAILS["$name"] + 1 )); printf " %-12s" "FAIL"
    else
      printf " %-12s" "ok ${time}s"
    fi
    csv_line+=",${code},${time}"
  done
  printf "\n"; echo "$csv_line" >> "$CSV"; sleep "$SLEEP"
done

echo ""; echo "================== 요약 (실패율) =================="
for n in "${NAMES[@]}"; do
  tot="${TOTAL[$n]}"; f="${FAILS[$n]}"
  (( tot > 0 )) && rate=$(( f * 100 / tot )) || rate=0
  printf "  %-12s 실패 %2d/%-2d  (%d%%)\n" "$n" "$f" "$tot" "$rate"
done
echo "==================================================="
echo "CSV 로그: $CSV"

해석 가이드

  • 모든 목적지가 같은 라운드에서 동시에 FAIL/OK → 전면(경로/게이트웨이) 간헐 차단
  • 특정 목적지만 FAIL이 두드러짐 → 목적지 특정 차단
  • FAIL 구간이 뭉쳐 있으면 → 주기적 차단(세션/NAT 포화 의심)

부록 B. 적용/롤백 스크립트 (anthropic_ip_fix.sh)

/etc/hosts는 민감 파일이므로, 적용 전 자동 백업·중복 방지·사전 연결 검증·원클릭 롤백을 갖췄다. 스크립트가 추가한 줄에 식별 마커를 달아 여러 번 실행해도 줄이 쌓이지 않고, 롤백 시 그 줄만 정확히 제거한다(다른 hosts 항목은 보존).

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
#!/usr/bin/env bash
# anthropic_ip_fix.sh
# api.anthropic.com 이 차단된 엣지 IP 대신 살아있는 IP로 붙도록 /etc/hosts 에
# 항목을 고정(apply)하거나 원상복구(rollback)한다. 적용 전 백업 + 적용 후 검증.
# 사용:
#   sudo ./anthropic_ip_fix.sh apply              (기본 IP 사용)
#   sudo ./anthropic_ip_fix.sh apply 160.79.104.12 (IP 지정)
#   sudo ./anthropic_ip_fix.sh rollback
#   sudo ./anthropic_ip_fix.sh status
#   sudo ./anthropic_ip_fix.sh probe              (대체 IP 후보 자동 탐색)
set -euo pipefail

HOSTS="/etc/hosts"
DOMAIN="api.anthropic.com"
DEFAULT_IP="160.79.104.11"                   # 환경에 맞는 살아있는 IP로 교체
MARK="# added-by-anthropic_ip_fix"
BACKUP_DIR="/var/backups/anthropic_ip_fix"

need_root() {
  [[ "$(id -u)" -eq 0 ]] || { echo "ERROR: root 필요. 'sudo $0 ...'" >&2; exit 1; }
}
flush_dns() { command -v resolvectl >/dev/null 2>&1 && resolvectl flush-caches 2>/dev/null || true; }

show_status() {
  echo "----- 현재 상태 -----"
  echo -n "해석 IP : "; getent ahostsv4 "$DOMAIN" | awk '{print $1}' | sort -u | tr '\n' ' '; echo
  echo -n "연결 결과(5회): "
  for i in $(seq 1 5); do
    curl -4 -o /dev/null -s -w "%{http_code} " --connect-timeout 3 "https://$DOMAIN" || echo -n "000 "
  done; echo
  echo -n "$HOSTS 내 항목: "
  grep -q "$MARK" "$HOSTS" && grep "$MARK" "$HOSTS" || echo "(없음 - DNS 기본값 사용 중)"
  echo "---------------------"
}

probe_ips() {
  echo "대체 IP 후보 탐색 (160.79.104.11 ~ .20):"
  local best=""
  for last in $(seq 11 20); do
    local ip="160.79.104.${last}" ok=0
    for i in $(seq 1 5); do
      code=$(curl -4 -o /dev/null -s -w "%{http_code}" --connect-timeout 3 \
              --resolve "${DOMAIN}:443:${ip}" "https://${DOMAIN}" 2>/dev/null || echo 000)
      [[ "$code" != "000" ]] && ok=$((ok+1))
    done
    printf "  %-16s %d/5 성공\n" "$ip" "$ok"
    [[ -z "$best" && "$ok" -eq 5 ]] && best="$ip"
  done
  [[ -n "$best" ]] && echo ">> 추천 IP: $best"
}

apply_fix() {
  need_root; local ip="${1:-$DEFAULT_IP}"
  [[ "$ip" =~ ^[0-9]{1,3}(\.[0-9]{1,3}){3}$ ]] || { echo "ERROR: 잘못된 IP: $ip" >&2; exit 1; }

  echo "사전 검증: $ip 연결 테스트..."
  local pre_ok=0
  for i in $(seq 1 5); do
    code=$(curl -4 -o /dev/null -s -w "%{http_code}" --connect-timeout 3 \
            --resolve "${DOMAIN}:443:${ip}" "https://${DOMAIN}" 2>/dev/null || echo 000)
    [[ "$code" != "000" ]] && pre_ok=$((pre_ok+1))
  done
  echo "  $ip : $pre_ok/5 성공"
  (( pre_ok >= 3 )) || { echo "ERROR: $ip 불안정. 'probe'로 살아있는 IP를 먼저 찾으세요." >&2; exit 1; }

  mkdir -p "$BACKUP_DIR"
  local backup="${BACKUP_DIR}/hosts.$(date +%Y%m%d_%H%M%S)"
  cp -a "$HOSTS" "$backup"; echo "백업: $backup"

  sed -i "/$MARK/d" "$HOSTS"                      # 기존 우리 항목 제거(중복 방지)
  echo "${ip} ${DOMAIN} ${MARK}" >> "$HOSTS"; echo "추가: ${ip} ${DOMAIN}"
  flush_dns; echo; show_status
}

rollback_fix() {
  need_root
  if ! grep -q "$MARK" "$HOSTS"; then
    echo "롤백할 항목 없음 (이미 기본 상태)"; flush_dns; show_status; return 0
  fi
  mkdir -p "$BACKUP_DIR"
  cp -a "$HOSTS" "${BACKUP_DIR}/hosts.before_rollback.$(date +%Y%m%d_%H%M%S)"
  sed -i "/$MARK/d" "$HOSTS"
  echo "제거 완료: ${DOMAIN} 고정 항목 삭제"; flush_dns; echo; show_status
}

case "${1:-}" in
  apply)    shift; apply_fix "${1:-}";;
  rollback) shift; rollback_fix;;
  status)   show_status;;
  probe)    probe_ips;;
  *)        echo "usage: sudo $0 {apply [IP]|rollback|status|probe}";;
esac

권장 순서: statusprobeapply → (Claude Code 실행 테스트) → 이상 시 rollback. 적용 후에는 캐시 flush와 함께 실행 중이던 클라이언트를 재시작해야 새 IP로 연결된다.


환경: Ubuntu 22.04 / Claude Code. 본문의 사내 IP·호스트명 등 식별 정보는 일반화 처리했으며, 160.79.104.x는 공개적으로 관측되는 엣지 IP 예시다. 차단 정책과 IP는 환경·시점에 따라 다르다.