운영 클러스터의 mo-back이 하루에도 몇 번씩 먹통이 됐다. 요청을 받아 놓고 응답을 주지 않았다. 그날 오전에는 하도 자주 뻗어서 mo-back Pod를 하나씩 전부 지웠다. 그런데 지운 Pod 중 하나는 API에서 사라진 뒤에도 노드에 남아 FailedKillPod 이벤트를 계속 냈다. 이틀 전 배포 때 내려간 Pod도 같은 상태였다.

하나의 서비스를 여러 고객사가 각자 쓰도록 바꾸는 멀티 테넌트 SaaS 전환 프로젝트에서 겪은 일이다. SaaS 환경(B)의 앱들은 로그 파일을 EFS에 실시간으로 쓰고 있었고, 그 EFS는 기존 환경(A)의 VPC에 있어서 Transit Gateway(TGW)를 건너가야 닿았다. 처음 내 추측은 이랬다. EFS에 쓰기가 몰려 과부하가 왔고, EFS가 “못 쓴다”는 응답조차 돌려주지 않으니 앱이 마냥 기다리다 뻗는다.

반은 맞았다. 앱은 정말로 응답 없는 EFS를 마냥 기다리고 있었다. 하지만 EFS는 과부하가 아니었고, 일주일 가까이 파고도 EFS 연결이 왜 끊기는지는 끝까지 다 밝히지 못했다. 그래서 질문을 바꿨다. 끊김의 원인을 다 찾는 것보다, 끊겨도 앱이 죽지 않게 만드는 것이 먼저였다. 아래에서는 끊김 한 번이 앱 전체의 정지와 좀비 Pod로 커지는 메커니즘, 원인을 좇다 접은 가설들, 원인 규명 없이도 효과가 있는 조치 세 가지를 차례로 적는다. 이 조치들은 직접 적용하지 않았다. 원인과 조치 방향을 정리해 운영사에 전달하는 데까지가 내 몫이었다.

로그 한 줄이 다른 VPC로 가는 네트워크 요청이었다

보통 컨테이너 앱은 로그를 stdout으로 내보내고 수집기가 가져간다. 이 앱들은 달랐다. 컨테이너 안의 시작 스크립트가 EFS를 NFS로 직접 마운트하고, logback이 그 경로에 로그 파일을 쓴다. 앱 입장에서는 “파일에 한 줄 쓰기”지만, 실제로는 다른 VPC에 있는 서버로 네트워크 요청을 보내고 응답을 기다리는 일이다.

# SaaS 환경(B) VPC 라우팅 테이블: EFS가 있는 A VPC로 가는 길
10.0.0.0/16  →  tgw-xxxxxxxx

# Pod 안의 마운트 (/proc/mounts)
fs-0123456789abcdef0.efs.ap-northeast-2.amazonaws.com:/mo-java /var/efs nfs4
  rw,relatime,vers=4.1,rsize=1048576,wsize=1048576,namlen=255,
  hard,noresvport,proto=tcp,timeo=600,retrans=2,sec=sys,addr=10.0.2.10

logback 설정 파일 9개를 열어 보니 전부 같은 구조였다. 한 설정이 복사되어 퍼진 것이다.

<property name="LOG_FILE" value="/var/efs/${server.hostname}.log" />
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
    <file>${LOG_FILE}</file>
    <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
        <fileNamePattern>${LOG_FILE}.%d{yyyy-MM-dd}.%i.log</fileNamePattern>
        <maxFileSize>30MB</maxFileSize>
    </rollingPolicy>
    ...
</appender>

<root level="INFO">
    <appender-ref ref="CONSOLE" />
    <!-- AsyncAppender 없이 FILE을 바로 참조: 로그를 남기는 스레드가 직접 EFS에 쓴다 -->
    <appender-ref ref="FILE" />
</root>

문제가 된 건 이 두 가지, hard 마운트와 AsyncAppender 없이 바로 쓰는 FILE appender다.

EFS는 과부하가 아니었다

과부하 가설은 숫자 몇 개로 정리됐다. EFS는 throughput elastic 모드였고, CloudWatch PercentIOLimit은 평상시 0.06%, 사흘 중 최대가 0.59%였다. 결정적인 건 Pod별 NFS 통계였다. 각 Pod의 /proc/self/mountstats에서 쓰기 횟수와 재연결 횟수를 뽑아 비교했다.

모듈WRITE 횟수재연결/일
auth-back (8일 가동)409,7236.2
c2c-sst (8일 가동)07.2
mo-ecommerce-front (17일 가동)05.3
cp-front (17일 가동)04.4

40만 번 쓴 Pod와 한 번도 안 쓴 Pod가 똑같이 하루 대여섯 번 끊겼다. 부하가 원인이라면 나올 수 없는 숫자다. 평소 WRITE 응답도 13ms 정도로 빨랐다. 못 버틴 쪽은 EFS가 아니었다. 연결이 가끔 끊겼고, 그 순간을 앱이 버티지 못했다.

hard 마운트는 포기하지 않는다

NFS 마운트에는 서버가 응답하지 않을 때의 태도를 정하는 옵션이 있다.

  • soft: 정해진 만큼 기다리고 재시도한 뒤 포기한다. 앱은 I/O 에러를 받는다.

  • hard: 서버가 답할 때까지 무한히 재시도한다. 앱에는 에러조차 가지 않는다.

지금은 hard,timeo=600,retrans=2였다. 60초를 기다리고, 다시 기다리고, 끝이 없다. 그 사이 파일에 쓰던 스레드는 커널 안에서 I/O 완료를 기다리는 D 상태(uninterruptible sleep)가 된다. 이 상태의 스레드에는 시그널이 전달되지 않는다. kill -9도 듣지 않는다.

스레드 하나가 멈추는 것으로 끝나지 않았다. 먹통 시각에 남은 app.log의 스택을 아래에서 위로 읽으면 이렇다(패키지명은 바꿨다).

ch.qos.logback.core.rolling.RollingFileAppender.subAppend(RollingFileAppender.java:229)   ← EFS 파일에 쓰는 지점
ch.qos.logback.core.OutputStreamAppender.append(OutputStreamAppender.java:102)
...
org.apache.commons.logging.LogAdapter$Slf4jLocationAwareLog.warn(LogAdapter.java:440)
org.apache.http.client.protocol.ResponseProcessCookies.processCookies(ResponseProcessCookies.java:130)   ← 쿠키 헤더 경고
...
org.springframework.web.client.RestTemplate.exchange(RestTemplate.java:651)
com.example.mo.util.InterfaceApiUtil.exchange(InterfaceApiUtil.java:56)   ← mo-back이 auth 모듈을 호출
...
(맨 아래) Tomcat Http11Processor → NioEndpoint 워커 스레드

mo-back이 auth 모듈을 호출했고, 응답의 쿠키 헤더가 이상하다는 WARN을 HTTP 클라이언트가 남기려 했다. 그 WARN을 EFS 파일에 쓰는 순간에 스레드가 멈춰 있었다. 맨 아래 줄을 보면 사용자 요청을 처리하는 Tomcat 워커 스레드가 EFS에 직접 쓰고 있었다.

RollingFileAppender는 쓰는 동안 스트림 락을 잡는다. 락을 쥔 스레드가 D 상태로 굳으면, 로그를 남기려는 다른 워커가 전부 그 락 앞에 줄을 선다. 로그를 한 줄도 안 남기는 요청은 없으니 결국 워커 풀이 바닥난다.

flowchart TD
    A["EFS 연결 끊김<br/>(원인은 두 갈래, 아래에서)"] --> B["hard 마운트: 에러 없이 무한 재시도"]
    B --> C["로그 쓰던 워커가 D 상태로 굳음<br/>(appender 락을 쥔 채)"]
    C --> D["다른 워커들이 락 앞에서 대기<br/>워커 풀 고갈"]
    D --> E["앱 먹통"]
    E --> F["뻗었으니 재시작<br/>(수동 삭제, 배포, HPA)"]
    F --> G["SIGKILL이 굳은 스레드에 닿지 않음"]
    G --> H["좀비 Pod<br/>API에서는 사라지고 노드에는 남음"]

좀비 Pod의 마지막 로그는 화면에만 찍혀 있었다

좀비가 된 Pod 두 개를 노드에서 직접 열어 봤다. 둘 다 같은 모양이었다. 그중 하나의 노드 /var/log/pods/에 남은 stdout 끝은 이랬다.

12:53:10  [WARN ] ResponseProcessCookies - processCookies.Invalid cookie header: "set-cookie: ..."
          (22초간 아무 출력 없음)
12:53:32  Session terminated, killing shell... ...killed.     ← SIGTERM 도착
          (이후 출력 없음)

프로세스는 State Z였고, D 상태 스레드가 정확히 하나 남아 있었다. 그 스레드의 이름은 http-nio-8080-e, Tomcat 워커였다. 커널 스택은 이랬다.

__do_sys_newfstatat
 vfs_statx
  nfs_getattr [nfs]
   filemap_write_and_wait_range
    folio_wait_writeback      ← EFS로 밀린 쓰기가 끝나기를 기다리는 중

logback은 이벤트 하나를 CONSOLE과 FILE에 차례로 쓴다. 12:53:10의 WARN은 CONSOLE까지만 성공했다. 이어서 FILE로 들어가 롤링 여부를 판단하려 파일 크기를 조회(stat)했는데, NFS에서 stat은 밀린 쓰기를 먼저 flush해야 해서 그 대기에서 굳었다. 그 뒤 22초의 침묵은 쓸 로그가 없어서가 아니라 쓰려는 스레드들이 전부 락 앞에 서 있었기 때문이다. SIGTERM이 와도 종료 로그 한 줄 찍히지 않은 이유도 같다.

굳은 게 먼저였고 kill은 나중이었다. 운영 중에 이미 먹통이 된 Pod에 재시작(배포, 수동 삭제)이 들어왔고, SIGKILL이 다른 스레드를 모두 죽였지만 커널에 붙잡힌 워커 하나는 죽이지 못했다. 스레드가 하나라도 남아 있으면 프로세스를 회수할 수 없으니 좀비가 된다. 그날 오전 내가 뻗은 mo-back을 지운 일도 정확히 이 경로였다. 먹통의 표준 응급처치인 재시작이 EFS가 흔들리는 동안에는 좀비를 만드는 방아쇠였다. app.log의 appender 에러와 좀비 Pod는 별개의 문제가 아니라 같은 사건의 앞뒤 장면이었다.

좀비로 가는 문은 하나 더 있다. Pod 종료 때 Istio 사이드카가 15초 drain 뒤 먼저 죽으면, 사이드카를 거치던 앱의 NFS 연결이 갈 곳을 잃고 종료 경로의 flush에서 굳는다. 이 경로는 Terminating에서 멈춘 Pod 이야기에서 따로 다뤘다. 이번에 열어 본 두 좀비는 그쪽이 아니라 운영 중에 굳은 경우였다.

좀비는 생각보다 비쌌다. 그 시점 좀비 7개가 실제 메모리 약 4.7GiB를 쥐고 있었다. API에서 Pod가 지워졌으니 스케줄러 장부에서는 반납된 메모리인데, 실제 RAM은 좀비 JVM이 쥐고 있다. kubelet은 좀비마다 2분 간격으로 kill을 재시도하며 이벤트 카운트를 300~800회씩 쌓고 있었다. kubectl get pods나 k9s의 Pod 화면에는 보이지 않으니 이벤트로 찾아야 한다.

# 좀비는 Pod 목록에 없다. kubelet의 kill 실패 이벤트로 찾는다
kubectl get events -A --field-selector reason=FailedKillPod \
  -o custom-columns='NS:.metadata.namespace,POD:.involvedObject.name,NODE:.source.host,COUNT:.count,LAST:.lastTimestamp'

끊김의 원인을 좇다 접은 가설들

앱이 왜 죽는지는 여기까지로 설명이 됐다. 남은 질문은 “EFS 연결이 왜 끊기는가”였다. 이 질문에는 가설을 여럿 세웠고 대부분 데이터로 접었다.

가설왜 그럴듯했나무엇으로 접었나
TGW MTU 블랙홀노드 MTU는 9001, TGW는 8500을 넘는 패킷을 ICMP 안내 없이 버린다. wsize는 1MBTGW를 거치지 않는 기존 환경(A), EFS와 같은 VPC의 모듈에서도 같은 증상이 났다
t3.medium Istio gateway버스터블 인스턴스는 크레딧이 바닥나면 느려진다gateway CPU 16~20m, 크레딧 사흘 내내 최대치. 애초에 gateway는 인바운드 입구라 NFS 경로에 없다
자정 로그 롤링, 배치끊김 파도가 KST 자정 직후에 몰렸다export가 모듈별로 나뉘어 동시 rename/create가 30~80건 수준. EFS에는 미미한 양이다
mount target 보안 그룹 아웃바운드 공백AWS 블로그에 같은 증상 사례가 있었고, 실제로 아웃바운드 규칙이 0개였다순수 RST(flags=4)가 클라이언트 노드 ENI까지 도달해 있었다. 차단됐다면 보일 수 없다
Envoy 설정 갱신 실패 42%update_failure 카운터가 높았고 NFS 재연결 횟수와 자릿수가 맞았다update_rejected는 0이었다. 실패 수가 istiod의 30분 연결 재수립 횟수와 같았다. 실패가 아니라 재수립을 세는 카운터였다

시간 상관관계는 가설을 세우는 데는 좋았지만 매번 규모나 반례에서 무너졌다. 특히 MTU와 Envoy 42%는 조건이 실재하고 숫자도 맞아 보여서 오래 붙들고 있었다. 보안 그룹 아웃바운드 공백은 지금의 원인은 아니지만, 블로그 사례처럼 EFS 백엔드가 교체되며 “모르는 연결”에 RST를 보내는 상황이 오면 그때 터질 수 있는 지뢰로 남겨 두었다.

끊김은 두 갈래였고 둘 다 끝까지 닫지 못했다

끝까지 남은 사실은 이렇다. 끊김은 하나가 아니라 두 갈래였다.

층 1은 EFS mount target이 먼저 끊는 끊김이다. S3에 쌓여 있던 VPC Flow Log에서 EFS로 가는 NFS 연결의 최초 등장 시각을 뽑자, 다섯 시간 넘게 새 연결이 0건이다가 몇 분 사이에 수십~백여 건이 몰려 생기는 파도가 보였다. 확장 포맷의 tcp-flags 필드에는 누가 먼저 끊었는지가 남아 있었다.

# mount target ENI 기준 (8/23, Flow Log 10분 집계)
280건  mount target → 클라이언트   flags=4    RST
346건  mount target → 클라이언트   flags=22   RST+ACK
707건  클라이언트 → mount target   flags=2    SYN (재연결 시도)

# CloudWatch ClientConnections (1분 해상도, UTC)
15:00  168
15:03  116   ← 46개 동시 절단
15:52  169
15:53  128   ← 41개 동시 절단

RST는 mount target에서 나갔고, SYN 폭증은 그 뒤에 왔다. 클라이언트가 연결을 많이 열어서 거부당한 게 아니라 서버 쪽이 먼저 끊었다. 같은 날 다른 가용 영역의 mount target에 붙은 클라이언트는 멀쩡했고, 8/21에는 반대쪽 mount target이 같은 일을 겪었다. 이 끊김은 TGW를 타지 않는 기존 환경(A)의 클라이언트도 똑같이 맞았다. 8/6에는 서로 다른 계정의 서로 다른 EFS 두 개가 같은 오후에 80분 간격으로 차례로 같은 패턴을 보였다. 여기까지가 패킷으로 확인한 범위다. 끊은 주체가 EFS mount target이라는 것은 증명됐지만, 왜 끊었는지는 AWS 내부 로그에만 있다.

층 2는 SaaS 환경(B)만 겪는 끊김이다. 기존 환경(A)은 파도 때 말고는 조용한데, B의 Pod는 하루 5~20회씩 따로 끊겼다. 끊긴 뒤 복구도 달랐다. A는 대체로 한 번에 다시 붙었고, B는 Pod당 재연결을 5~6번 시도했다. mountstats에는 NFSv4.1 CREATE_SESSION 13회 중 6회가 에러로 남아 있었다. B 경로에만 있는 요소는 Envoy 사이드카, TGW, 노드 SNAT 세 가지다. NFS가 Envoy를 거친다는 것은 Pod 안의 2049 소켓이 uid 0(커널 NFS)과 uid 1337(Envoy) 두 개로 나오는 것으로 확인했지만, Envoy의 어떤 동작이 끊는지는 증명하지 못했다. 층 2는 주체조차 확정되지 않았고 용의자가 셋으로 좁혀진 상태다.

층 1: 양쪽 다 맞는 끊김층 2: SaaS 환경(B)만 겪는 끊김
빈도하루 1~2회, 가끔 8/6 같은 대규모 파도Pod당 하루 5~20회
누가 끊나EFS mount target (RST를 패킷으로 확인)미확정. 용의자 Envoy, TGW, 노드 SNAT
왜 끊나미확정. AWS 내부 사정미확정
답을 얻는 길AWS 서포트 티켓사이드카에서 NFS를 빼 보는 것 자체가 판별 실험

끊김은 앞으로도 온다는 전제

두 갈래를 다 규명해도 끊김을 0으로 만들 수는 없다. 층 1은 AWS 쪽 운영의 일부일 가능성이 크고, 네트워크 연결은 원래 가끔 끊긴다. 실제로 끊김 대부분은 몇 초 안에 재연결되며 아무 일 없이 지나갔다. 앱이 죽은 건 그중 일부, 하필 로그를 쓰던 스레드가 그 순간에 걸렸을 때뿐이었다.

그래서 방향을 이렇게 정했다. 끊김은 앞으로도 온다는 전제에서, 끊겨도 죽지 않는 구조로 바꾸고 줄일 수 있는 끊김만 줄인다. 남은 미지 두 칸은 조치와 병행해 추적한다.

원인을 몰라도 듣는 조치 세 가지

1. hard를 soft로: 대기에 상한을 둔다

처음에는 앱 쪽에서 해결할 수 있다고 생각했다. FILE appender에 30초 타임아웃을 걸면 되지 않을까. 되지 않는다. 동결이 일어나는 곳이 Java보다 아래이기 때문이다.

[Java]   logback → FileOutputStream.write()
-------------------------------------------------
[커널]   write/stat 시스템콜 → NFS 클라이언트 → 서버 응답 대기   ← 여기서 D 상태

파일 I/O에는 소켓의 setSoTimeout() 같은 타임아웃 API가 Java에도 POSIX에도 없다. 시스템콜 안에 들어간 스레드는 interrupt()를 확인할 기회도 없다. 별도 스레드에서 쓰게 하고 Future.get(30초)로 기다리는 흉내는 낼 수 있지만, 호출자만 풀려날 뿐 실제로 쓰던 스레드는 커널에 남는다. 로그를 쓸 때마다 굳은 스레드가 하나씩 쌓인다. 실체는 네트워크인데 앱에게는 파일로 보여서, 네트워크라면 당연히 걸었을 타임아웃을 걸 자리가 없다.

타임아웃을 걸 수 있는 유일한 층은 동결이 일어나는 커널 NFS 계층이고, 그게 soft 마운트다.

# 각 모듈 이미지 안의 시작 스크립트에서 mount 옵션만 바꾼다
# hard,timeo=600,retrans=2  →  soft,timeo=100,retrans=2
mount -t nfs4 -o soft,timeo=100,retrans=2,... <EFS>:/<export> /var/efs

soft면 재시도가 끝난 뒤 앱이 IOException을 받는다. D 상태가 무한히 이어질 수 없으니 좀비도 생길 수 없다. logback의 FileAppender는 쓰기 예외를 받으면 백오프 뒤 다시 열어 보는 복원 로직이 있어서, 연결이 돌아오면 알아서 이어 쓴다.

2. AsyncAppender와 neverBlock: 워커를 대기에서 떼어 낸다

soft만으로는 부족하다. 대기에 상한이 생겼을 뿐, 그 시간 동안 동기로 로그를 쓰는 워커는 여전히 멈춘다. 이 대기에서 워커를 떼어 내려고 FILE appender를 AsyncAppender로 한 겹 감싼다. 워커는 큐에 로그를 넣고 바로 돌아가고, EFS에는 전용 writer 스레드 하나만 쓴다.

<!-- 기존 FILE appender를 감싼다 -->
<appender name="ASYNC_FILE" class="ch.qos.logback.classic.AsyncAppender">
    <appender-ref ref="FILE"/>
    <queueSize>2048</queueSize>
    <!-- 핵심. 기본값 false면 큐가 찬 순간 워커가 다시 큐 앞에서 기다린다 -->
    <neverBlock>true</neverBlock>
    <!-- 종료 때 큐를 비우며 기다리는 상한(ms) -->
    <maxFlushTime>3000</maxFlushTime>
</appender>

<root level="INFO">
    <appender-ref ref="CONSOLE"/>
    <appender-ref ref="ASYNC_FILE"/>   <!-- FILE 대신 이것을 참조 -->
</root>

EFS가 끊기면 writer가 굳고 큐가 찬다. 여기서 neverBlock이 갈림길이다. 기본값 false면 워커가 큐가 빌 때까지 기다리니 먹통이 그대로 돌아온다. true면 넘치는 로그를 버리고 워커는 계속 일한다. 대가는 끊긴 구간의 로그 일부다.

반대로 AsyncAppender만으로도 부족하다. 먹통은 사라지지만 writer 한 마리는 여전히 D 상태로 굳을 수 있고, 그때 Pod가 종료되면 그 한 마리 때문에 좀비가 된다. 두 조치의 역할은 이렇게 나뉜다.

조치하는 일먹통좀비
soft 마운트대기에 상한을 둔다최대 대기 시간만큼의 지연으로 줄어듦D 상태가 무한히 갈 수 없어 막힘
AsyncAppender + neverBlock워커를 대기에서 떼어 낸다막힘보조 효과뿐

3. 사이드카에서 NFS를 뺀다: 끊김 경로 하나를 없앤다

SaaS 환경(B)에만 해당한다. istio-init 인자가 -i "*" -x "", 즉 모든 아웃바운드를 가로채고 제외는 없는 설정이라 컨테이너 안의 NFS 트래픽까지 Envoy를 거치고 있었다. Envoy는 NFS를 해석하지 못하니 암호화도 관측도 재시도도 해 주지 않는다. 얻는 것 없이 다리만 하나 늘어난 셈이다. Pod 템플릿에 어노테이션 한 줄이면 뺄 수 있다.

spec:
  template:
    metadata:
      annotations:
        # 2049(NFS)는 istio-init이 가로채기 대상에서 뺀다
        traffic.sidecar.istio.io/excludeOutboundPorts: "2049"

이렇게 하면 종료 때 사이드카가 먼저 죽어도 NFS는 커널이 EFS와 직접 들고 있으니 영향이 없다. 좀비로 가는 두 번째 문이 닫힌다. 이 적용은 그 자체로 층 2의 판별 실험이기도 하다. 적용 뒤 B의 재연결 빈도가 A 수준으로 떨어지면 Envoy가 범인이었던 것이고, 안 떨어지면 TGW와 SNAT를 다음으로 보면 된다. 대역을 excludeOutboundIPRanges로 넓게 빼는 방법도 있지만, A VPC 전체를 빼면 그쪽 API를 부르는 HTTP 트래픽까지 메시를 우회하게 된다. NFS는 항상 2049라 환경별 IP를 관리할 필요가 없는 포트 방식을 골랐다.

왜 원인 규명 없이도 듣는가

1번과 2번은 “끊긴 다음”을 다룬다. 누가 왜 끊었는지와 상관없이, 끊긴 뒤 앱이 어떻게 행동할지만 바꾼다. 층 1이든 층 2든, 앞으로 생길 다른 원인이든 똑같이 받아낸다. 3번은 끊김이 생길 수 있는 경로 하나를 물리적으로 없앤다. 그 경로가 실제 범인이었는지 몰라도 Envoy가 NFS에 해 주던 일이 없으니 잃을 것이 없다.

권장 순서와 보조 조치

1. 사이드카 NFS 제외 배포
   - 어노테이션만 바꾸므로 이미지 빌드가 필요 없고 롤링 재시작만 일어난다
   - ArgoCD selfHeal이 켜져 있으니 kubectl patch가 아니라 테넌트 매니페스트 저장소를 고쳐 sync
   - 이 롤링에서 종료되는 구 Pod는 아직 옛 iptables 규칙이라 마지막 좀비가 생길 수 있다
2. 남아 있는 좀비 정리
   - 1번 롤링이 끝난 뒤에 한 번에 (두 번 일하지 않도록)
   - 노드에서 좀비 PID를 파드 이름(HOSTNAME 환경변수)으로 대조한 뒤 그 마운트 네임스페이스에서
     umount -f -l /var/efs. 대기 I/O가 EIO로 끝나며 프로세스가 회수된다
3. soft 마운트 + AsyncAppender를 한 번의 이미지 재빌드로 반영
   - 시작 스크립트와 logback 설정이 모두 이미지 안에 있어 같이 묶는 편이 싸다

핵심 셋과 별개로 여유가 될 때 할 일도 함께 적었다.

  • AWS 서포트 티켓. 층 1의 “왜”를 묻는다. 파일시스템 ID, mount target, 절단 시각, Flow Log의 RST 근거, 같은 시각 PercentIOLimit 0.06%를 붙여 “부하가 없는데 mount target이 RST로 연결을 끊는 사유”를 묻는 문안을 만들었다.

  • 시작 스크립트의 su를 exec로. 좀비의 마지막 출력 Session terminated, killing shell...은 su의 메시지다. 시작 스크립트가 su로 java를 띄우는 구조라면 SIGTERM이 java에 전달되지 않아, 좀비와 무관하게 모든 배포에서 graceful shutdown이 안 되고 있었을 수 있다. 이미지 안이라 직접 확인하지는 못했다.

  • mount target 보안 그룹 아웃바운드 기본값 복원. 지금의 원인은 아니지만 확인된 지뢰다.

  • 기존 환경(A)의 배치 정리. dev에서 매시 도는 배치 Job 하나가 4시간 넘게 끝나지 않고 다섯 개가 겹쳐 쌓여 있었다. 같은 hard 마운트 구조의 피해자로 보인다. concurrencyPolicy: Forbid, activeDeadlineSeconds를 권했다.

주의할 점

  • soft 마운트는 데이터를 버릴 수 있는 옵션이다. 재시도가 끝나면 쓰기가 실패한 채로 넘어간다. 로그처럼 일부를 잃어도 되는 데이터에만 맞고, 같은 마운트에 유실되면 안 되는 데이터가 있다면 마운트를 나눠야 한다. AWS 문서도 EFS에는 hard를 권하고, soft를 쓴다면 timeo를 최소 150(15초)으로 두라고 한다. 전달한 timeo=100은 이보다 짧으니 적용할 때 다시 맞춰 보는 것이 좋다.

  • soft 마운트의 대기 시간은 생각보다 길 수 있다. 전달할 때는 timeo=100,retrans=2를 “약 30초 뒤 포기”로 설명했다. 그런데 nfs(5)에 따르면 TCP에서는 재시도마다 대기가 timeo만큼 늘어나는 선형 백오프라, 10초, 20초, 30초를 더해 1분 가까이 걸릴 수 있다. 동기 로깅이 남아 있다면 그 시간 동안 워커가 멈추니 AsyncAppender가 같이 들어가야 한다.

  • AsyncAppender는 기본 설정만으로는 로그를 조용히 버린다. 큐가 80% 차면 INFO 이하 이벤트를 버리는 discardingThreshold가 기본으로 켜져 있다. neverBlock=true까지 더하면 끊긴 구간의 로그는 사라진다. 장애 시각의 로그가 꼭 필요하면 CONSOLE(stdout) 쪽 수집을 같이 갖춰야 한다.

  • 좀비는 강제 삭제로 사라지지 않는다. kubectl delete --force는 API 객체만 지우고 노드의 프로세스와 메모리는 그대로 남는다. 강제 삭제는 오히려 좀비를 장부에서 감춘다.

전달한 것과 남은 미지

이 조치들은 우리가 적용하지 않았다. 마운트 옵션과 logback 설정은 각 모듈 이미지 안에 있고, 그 이미지와 운영 환경은 운영사 운영팀의 영역이다. 내가 한 일은 조회만으로 원인과 메커니즘을 좁히고, 위의 조치 세 가지와 권장 순서, 보조 조치, AWS 티켓 문안을 정리해 운영사에 전달한 데까지다. 그래서 적용 전후 수치는 없다.

일주일 가까이 끊김의 원인을 쫓으며 가설을 여러 번 세우고 접었다. 돌아보면 처음 질문인 “왜 앱이 자꾸 먹통이 되나”에 대한 답은 첫날 이미 나와 있었다. 그 뒤의 시간은 대부분 “왜 끊기나”에 쓰였고, 그 질문은 아직 열려 있다. 남은 미지는 두 칸이다.

  • 층 1: EFS mount target이 왜 하루 1~2회, 가끔은 대규모로 연결을 RST로 끊는가. AWS만 답할 수 있다.

  • 층 2: SaaS 환경(B)만 겪는 하루 5~20회의 끊김은 누가 만드는가. 사이드카 NFS 제외를 적용해 봐야 Envoy인지 가려진다.