티스토리 뷰

반응형
Confluence Data Center 9.2 · 2노드 클러스터 · 2026년 8월

temp 폴더 20GB가
노드 하나를 멈췄습니다.

첨부 최대 크기는 100MB로 걸려 있었습니다. 그런데 temp에는 upload_*.tmp*.part가 20GB 쌓여 있었고, 마운트가 100%가 되자 1번 노드는 로그인도 첨부 업로드도 응답하지 않았습니다. 제한이 있는데 왜 못 막았는지, 무엇을 고쳐야 재발하지 않는지 정리했습니다.

🗄️ Confluence DC 🧩 2노드 클러스터 💾 temp 20GB

죽은 건 노드 하나, 체감은 전사 장애

2노드 active-active 구성이면 한 노드가 빠져도 서비스는 남은 노드로 계속돼야 합니다. 그런데 실제로는 사용자 절반이 아무것도 못 했습니다. 증상은 세 가지로 나타났습니다.

🔑

로그인이 안 된다

세션 정보를 쓰려는 순간 디스크 write가 실패합니다. 1번 노드로 배정된 사용자는 화면이 그냥 멈춥니다.

📎

첨부 업로드 무응답

첨부는 반드시 temp를 경유합니다. temp가 꽉 차면 업로드는 100% 실패하고, 에러가 아니라 무응답으로 보입니다.

🧟

프로세스는 살아있다

JVM은 죽지 않았고 8090 포트도 열려 있습니다. 그래서 TCP 헬스체크는 계속 정상으로 판정합니다 — 이게 핵심입니다.

왜 한 노드 장애가 전체로 번졌나

번짐 경로무슨 일이 벌어지는가
TCP 헬스체크디스크가 꽉 찬 Confluence는 프로세스도 포트도 살아 있습니다. L4 체크만 하는 LB는 좀비 노드에 계속 트래픽을 보냅니다.
Sticky sessionConfluence DC는 세션 어피니티가 필수입니다. 1번 노드에 붙어 있던 사용자는 노드가 퇴출되지 않는 한 계속 그쪽으로 갑니다.
스레드 고갈로그·temp write가 블록되면 Tomcat 스레드가 점유된 채 쌓입니다. thread pool이 마르면 모든 요청이 hang합니다.
Hazelcast 판정 지연노드가 죽은 게 아니라 느려진 상태(long GC pause와 구분되지 않음)라 클러스터에서 늦게 퇴출됩니다.
2노드의 한계한 노드를 빼면 남는 건 50% 용량입니다. 남은 노드가 2배 부하를 못 버티면 연쇄 장애로 갑니다.
⚠️
디스크 full은 "죽음"이 아니라 "느려짐"으로 나타납니다. 그래서 감시 체계가 프로세스 생존만 보고 있으면 절대 잡히지 않습니다. 이 장애의 절반은 디스크 문제였고, 나머지 절반은 헬스체크 설계 문제였습니다.

temp에 남은 건 중단된 업로드의 잔해였습니다

파일명이 답을 말해줍니다. upload_*.tmp는 멀티파트 업로드의 스테이징 파일이고, *.part전송이 완결되지 않은 부분 파일입니다. 정상 종료된 업로드라면 둘 다 남지 않습니다.

첨부 업로드가 지나가는 길

첨부 업로드 경로
클라이언트 ─(멀티파트)─▶ local temp 에 upload_*.tmp 스테이징
                              │
                              ├─▶ 크기·확장자 검증
                              ├─▶ shared home/attachments 로 이동
                              └─▶ temp 파일 삭제
                                  ↑ 요청이 정상 종료돼야 실행된다

커넥션이 끊기면 마지막 단계가 실행되지 않는다 → 고아 파일
고아 파일은 사실상 재시작 때까지 회수되지 않는다

재시도 1회 = temp에 파일 크기만큼 영구 적립입니다. 500MB 파일을 40번 재시도하면 그것만으로 20GB가 됩니다.

먼저 확인할 것 — 진단 4단계

1

어느 마운트가 찼고, 무엇이 먹었는지

df는 100%인데 du는 작게 나오면 삭제됐지만 프로세스가 붙잡고 있는 파일입니다.

터미널
df -h

du -sh /var/atlassian/application-data/confluence/temp/* | sort -rh | head -20

# df 와 du 가 안 맞을 때 — 열린 채 삭제된 파일
lsof +L1 | grep -i confluence
2

mtime이 전부 같다면 두 가지 해석이 가능하다

이 구분을 건너뛰면 엉뚱한 대책으로 갑니다. 한 번의 버스트일 수도 있고, 디스크가 꽉 찬 순간 모든 in-flight 쓰기가 동시에 멈춘 흔적일 수도 있습니다.

터미널
# size / mtime / ctime / birth 를 한 번에
stat --printf='%s\t%y\t%z\t%w\t%n\n' /var/conf-temp/upload_*.tmp | sort -n | head -40

# birth 도 같은 시각에 몰려 있다  → 한 번의 버스트
# birth 는 흩어져 있고 mtime 만 같다 → 디스크 full 의 증상
3

파일 개수를 세면 시나리오가 좁혀진다

20GB를 채우려면 최대 크기(100MB)로 꽉 채워도 최소 205건, 평균 20MB라면 약 1,000건이 필요합니다.

터미널
ls -1 /var/conf-temp/upload_*.tmp | wc -l
ls -1 /var/conf-temp/*.part       | wc -l

# 크기 분포 — 특정 값에 몰려 있으면 잘린 것, 제각각이면 실제 업로드
find /var/conf-temp -maxdepth 1 -type f \
     \( -name 'upload_*.tmp' -o -name '*.part' \) \
     -printf '%s\n' | sort -n | uniq -c | tail -20
파일 개수유력 원인
200~300개
각각 100MB 근처
100MB를 넘는 파일을 반복 업로드 → 매번 거부 → 재시도 (다음 장의 함정)
1,000개 이상
크기 제각각
WebDAV·Office 연동 동기화 루프, 마이그레이션 앱, REST API 봇의 재시도 루프
수십 개temp 외에 다른 것도 같이 찬 것 — heap dump나 일일 XML 백업을 함께 확인
4

access log에 사용자명까지 남아 있다

Confluence의 Tomcat access log는 X-AUSERNAME을 기록합니다. 누가, 어떤 경로로, 몇 번 시도했는지 그대로 나옵니다. 소요시간이 특정 값에 딱 붙어 있으면 그 값이 프록시 타임아웃입니다.

터미널
LOG=/opt/atlassian/confluence/logs/conf_access_log.2026-08-27.log

grep -E '(doattachfile|drag-and-drop/upload|child/attachment)' "$LOG" \
  | awk '{print $2, $(NF-2), $(NF-1)}' | sort | uniq -c | sort -rn | head -20
#              ↑사용자   ↑status  ↑bytes

# 결정적 증거 — 업로드가 완료 전에 끊겼는가
CLOG=/var/atlassian/application-data/confluence/logs/atlassian-confluence.log
grep -cE 'ClientAbortException|SocketTimeoutException|Broken pipe' "$CLOG"
💡
ClientAbortException이 대량이면 확정입니다. 업로드가 서버 처리 완료 전에 클라이언트/프록시 쪽에서 끊겼다는 뜻이고, 그게 곧 회수되지 않은 스테이징 파일의 개수입니다.

첨부 100MB 제한은 temp 소비를 막지 못합니다

이 장애에서 가장 반직관적인 부분입니다. "첨부 최대 크기를 걸어놨는데 왜 디스크가 차죠?"의 답은 검증 시점에 있습니다. 크기 검증은 파일을 temp에 다 받은 뒤에 일어납니다.

100MB 를 넘는 파일을 올렸을 때
1.  100MB 를 넘는 파일을 올린다
    │
    ▼
2.  temp 에 파일 전체를 기록한다
    │      └─ 여기서 이미 디스크를 다 먹는다
    ▼
3.  "첨부 최대 크기 초과" 로 거부한다
    │      └─ 크기 검증은 이 시점에 처음 일어난다
    ▼
4.  요청이 비정상 종료되면 temp 파일은 회수되지 않는다
    │
    ▼
5.  사용자가 재시도한다 ──▶ 1. 로 되돌아간다

    재시도 1회 = temp 에 파일 크기만큼 영구 적립
⚠️
거부된 업로드가 오히려 temp를 채웁니다. 제한을 걸어두면 "안 올라가는" 것은 맞지만, 디스크를 안 쓰는 것은 아닙니다. 사용자는 실패를 보고 다시 시도하고, 시도할 때마다 잔해가 하나 더 쌓입니다.

그래서 제한은 앱이 아니라 앞단에 걸어야 합니다

nginx는 Content-Lengthclient_max_body_size를 넘으면 body를 읽지 않고 즉시 413을 반환합니다. Confluence의 temp는 손도 대지 않습니다.

제한을 두는 위치초과 파일 업로드 시 temp 소비사용자 피드백
Confluence 앱 설정만파일 크기만큼 소비 — 다 받은 뒤 거부느림 (전송 완료 후)
프록시 + 앱0 — body 를 받기 전에 413즉시
💡
단, Content-Length가 없는 chunked 전송은 이 검사를 우회합니다. 일부 WebDAV 클라이언트나 curl -T가 그렇습니다. 그래서 뒤에 나오는 reaper가 여전히 필요합니다.

우선순위는 앞단에서 끊기 → 회수 → 격리

순서가 중요합니다. 1번은 원인을 제거하고, 2번은 새는 것을 주워 담고, 3번은 그래도 터졌을 때 피해 범위를 업로드 기능 하나로 묶습니다.

1

프록시에서 초과 요청을 차단한다

값은 Confluence 첨부 최대 크기 + 5MB 정도로 맞춥니다. 너무 크게 잡으면 초과 파일이 Confluence까지 도달해 temp를 먹으니, 넉넉하게 잡는 것이 오히려 위험합니다.

nginx.conf
client_max_body_size    105m;   # Confluence 100MB + 오버헤드
client_body_timeout     300s;
proxy_request_buffering off;    # ★ nginx 임시파일 이중 스테이징 방지
proxy_read_timeout      300s;
proxy_send_timeout      300s;
send_timeout            300s;

HAProxy는 body 크기 제한이 없으니 헤더 기반 룰로 처리합니다.

haproxy.cfg
# Content-Length 가 105MB 를 넘으면 body 를 받기 전에 413
http-request deny deny_status 413 if { req.hdr_val(content-length) -m int gt 110100480 }

timeout client   300s
timeout server   300s
timeout tunnel  3600s   # WebSocket(Synchrony) 용
⚠️
proxy_request_buffering on(기본값)이면 nginx도 body를 임시파일로 받습니다. nginx가 Confluence와 같은 호스트에 있으면 /var/lib/nginx/body또 하나의 디스크 소진 벡터입니다.
2

고아 스테이징 파일을 자동 회수한다

핵심 논리는 하나입니다 — 업로드 중인 파일은 mtime이 계속 갱신됩니다. 따라서 "mtime이 2시간 이상 멈춤 + 프로세스가 열고 있지 않음"이면 확정 고아입니다. lsof 확인 없이 지우면 진행 중인 업로드를 깨뜨립니다.

/usr/local/bin/conf-temp-reaper.sh
#!/bin/bash
set -u
TEMP_DIRS=(/var/conf-temp /var/atlassian/application-data/confluence/temp)
STALE_MIN=120          # 2시간 이상 mtime 이 멈춘 것만
FREED=0

for d in "${TEMP_DIRS[@]}"; do
  [ -d "$d" ] || continue
  while IFS= read -r -d '' f; do
    # JVM 이 아직 열고 있는 파일은 절대 건드리지 않는다
    if ! lsof -- "$f" >/dev/null 2>&1; then
      sz=$(stat -c %s "$f" 2>/dev/null || echo 0)
      rm -f -- "$f" && FREED=$((FREED + sz))
    fi
  done < <(find "$d" -maxdepth 1 -type f \
             \( -name 'upload_*.tmp' -o -name '*.part' \) \
             -mmin +$STALE_MIN -print0)
done

[ "$FREED" -gt 0 ] && logger -t conf-temp-reaper "reclaimed $((FREED/1024/1024)) MB"
등록 — cron + systemd
# crontab -e
*/15 * * * * /usr/local/bin/conf-temp-reaper.sh

# /etc/systemd/system/confluence.service.d/override.conf
# temp 는 기동 시 재생성되므로 시작 전 초기화가 안전하다
[Service]
ExecStartPre=/bin/bash -c 'find /var/conf-temp -mindepth 1 -delete || true'
3

temp와 heap dump를 별도 볼륨으로 격리한다

이번 장애의 본질은 "temp가 local home과 같은 볼륨이라, temp가 차면 Confluence 전체가 죽는다"입니다. 20GB가 차더라도 업로드만 실패하고 로그인·조회는 정상이어야 합니다.

heap dump는 더 중요합니다. OOM 한 번에 힙 크기만큼(수십 GB) 쓰기 때문에, local home과 같은 볼륨에 두면 같은 장애가 반드시 재발합니다.

setenv.sh
CATALINA_OPTS="${CATALINA_OPTS} -Djava.io.tmpdir=/var/conf-temp"
CATALINA_OPTS="${CATALINA_OPTS} -XX:+HeapDumpOnOutOfMemoryError"
CATALINA_OPTS="${CATALINA_OPTS} -XX:HeapDumpPath=/var/dumps"
export CATALINA_OPTS
마운트담는 것사이징 기준
/var/conf-temp업로드 스테이징, export 산출물첨부최대 × 동시업로드 × 3
/var/dumpsheap dump (*.hprof)힙 크기 × 1.5
local homeLucene 인덱스, 설정, 앱로컬 디스크 — NFS 금지
logscatalina.outlogrotate 필수
⚠️
"마운트된 디렉터리가 100%"라면 local home이 NFS 위에 있는지 확인하세요. Atlassian은 인덱스 손상 위험 때문에 local home의 NFS 배치를 금지합니다. shared home만 NFS입니다.
4

일일 XML 백업을 끈다

기본으로 켜져 있고, Atlassian이 프로덕션에서 명시적으로 비권장하는 기능인데 매일 temp와 backups를 GB 단위로 채웁니다. 직접 원인이 아니어도 같은 볼륨을 함께 좁히고 있던 공범입니다.

⚙ 관리 → 백업 관리 → "매일 백업 수행" 해제 후 DB dump로 대체합니다.

대체 백업 — 매일 02:00
pg_dump -Fc confluence > /backup/conf_$(date +%F).dump
rsync -a --delete /nfs/confluence-shared/attachments/ /backup/attachments/
find /backup -name 'conf_*.dump' -mtime +14 -delete

노드 하나가 죽어도 서비스는 살아 있어야 합니다

디스크 대책을 다 세워도 좀비 노드를 걸러내지 못하면 다음 장애도 똑같이 전사 장애가 됩니다. Confluence DC 구조를 한 장으로 보고, 헬스체크를 고칩니다.

Confluence Data Center — 2노드
            [ users ]
                │
       ┌────────▼─────────┐
       │  Load Balancer   │
       └───┬──────────┬───┘
     ┌─────┘          └────┐
┌────▼─────┐          ┌────▼─────┐
│  Node 1  │          │  Node 2  │
│   JVM    │<── HZ ──>│   JVM    │
│local home│          │local home│
└────┬─────┘          └────┬─────┘
     └─────┐          ┌────┘
       ┌───▼──────────▼───┐
       │ Shared Home (NFS)│
       ├──────────────────┤
       │ Shared Database  │
       └──────────────────┘

LB           sticky + WebSocket + /status 체크
HZ           Hazelcast 5801 / Synchrony 5701
local home   인덱스·로그·temp (노드별 로컬 디스크)
Shared Home  첨부·아바타·export  (NFS)
Database     본문·권한·사용자
저장 위치담는 것이번 장애와의 관계
Local home
(노드별)
Lucene 인덱스, 로그, 캐시, temp여기가 찼습니다. 노드마다 독립이라 1번 노드만 죽었습니다
Shared home
(NFS)
첨부파일, 아바타, export/import여기가 차면 두 노드가 동시에 죽습니다
Database페이지 본문·버전, 권한, 사용자정상 — 그래서 데이터 손실은 없었습니다
Hazelcast분산 캐시, 클러스터 락, 멤버십느려진 노드를 죽은 노드로 즉시 판정하지 못합니다

헬스체크를 /status 로 바꾸는 것만으로 등급이 내려갑니다

Confluence는 노드 상태를 HTTP로 노출합니다. 이걸 보면 "프로세스는 살아 있지만 서비스는 못 하는" 상태를 정확히 잡아냅니다.

터미널
$ curl -s http://node1:8090/status

{"state":"RUNNING"}    # 200 — LB 에 투입
{"state":"ERROR"}      # 503 — 즉시 빼야 한다
{"state":"STARTING"}   # 503 — 아직 준비 안 됨
haproxy.cfg
backend confluence
    balance roundrobin
    cookie CONFLUENCE_LB insert indirect nocache   # sticky + failover 허용
    option httpchk GET /status
    http-check expect string "RUNNING"
    default-server inter 5s fall 3 rise 2
    server node1 10.0.0.11:8090 check cookie n1
    server node2 10.0.0.12:8090 check cookie n2
⚠️
sticky는 유지하되 failover는 허용해야 합니다. 노드가 퇴출됐는데도 세션이 그 노드에 고정되면 헬스체크를 고친 의미가 없습니다. HAProxy는 cookie ... indirect, F5는 fallback persistence로 처리합니다.
💡
2노드는 최소 구성입니다. 한 노드가 빠지면 남는 건 50% 용량이고, 남은 노드가 2배 부하를 못 버티면 연쇄 장애로 갑니다. 3노드면 1노드 손실 시 67%가 남고, rolling upgrade도 훨씬 안전해집니다.

20GB가 차기 전에 알아야 합니다

df 80% 알람은 몇 분 만에 20GB가 차는 상황에서는 늦습니다. 감시 지표를 용량이 아니라 증가율과 파일 개수로 바꿔야 개입할 시간이 생깁니다.

conf-temp-watch.sh — 5분마다
#!/bin/bash
TEMP=/var/conf-temp
STATE=/var/tmp/.conf_temp_state

CNT=$(find "$TEMP" -maxdepth 1 -type f \
        \( -name 'upload_*.tmp' -o -name '*.part' \) | wc -l)
SZ=$(du -sm "$TEMP" | cut -f1)
PREV=$(cut -d' ' -f2 "$STATE" 2>/dev/null || echo 0)
echo "$CNT $SZ" > "$STATE"
DELTA=$((SZ - PREV))

# 파일 100개 초과 또는 5분간 2GB 이상 증가 → 즉시 알림
if [ "$CNT" -gt 100 ] || [ "$DELTA" -gt 2048 ]; then
  curl -s -X POST "$ALERT_HOOK" -H 'Content-Type: application/json' \
    -d "{\"text\":\"temp 급증 $CNT개 / ${SZ}MB (+${DELTA}MB/5m)\"}"
fi

터졌을 때 — 복구 절차

temp는 Confluence 정지 상태에서 전체 삭제해도 안전합니다(기동 시 재생성). 단 index, attachments, confluence.cfg.xml은 절대 건드리면 안 됩니다.

복구 runbook
# ① LB 에서 문제 노드 제외 (헬스체크가 아직 안 걸러낸다면 수동)
echo "disable server confluence/node1" | socat /var/run/haproxy.sock stdio

# ② 정지 후 공간 확보
systemctl stop confluence
find /var/conf-temp -mindepth 1 -delete
find /var/dumps -name '*.hprof' -mtime +0 -delete   # 분석 필요하면 먼저 이관
df -h

# ③ 기동 → RUNNING 확인 후 LB 재투입
systemctl start confluence
curl -s localhost:8090/status

체크리스트

프록시 client_max_body_size = 첨부 최대 크기 + 5MB
proxy_request_buffering off — nginx 이중 스테이징 차단
temp reaper cron 15분 주기 (lsof 확인 포함)
temp · heap dump · 로그를 각각 별도 볼륨으로 분리
LB 헬스체크 = /status + RUNNING 문자열 검사
sticky session에 failover 허용 설정
temp 파일 개수 · 증가율 알람 (용량 알람만으로는 늦다)
일일 XML 백업 해제 → DB dump + shared home rsync
ExecStartPre로 기동 시 temp 초기화
catalina.out logrotate 등록 (없으면 무한 증가)

삽질 노트

🧨

lsof 없이 지우면 안 된다

진행 중인 업로드의 스테이징 파일을 지우면 사용자 업로드가 깨집니다. mtime 정지 + lsof 미점유 두 조건을 모두 봐야 합니다.

🕳️

df 는 100%, du 는 작다

삭제됐지만 프로세스가 붙잡고 있는 파일입니다. lsof +L1로 찾고, 결국 재시작해야 반환됩니다.

🌀

chunked 는 프록시 차단을 우회

Content-Length가 없으면 헤더 기반 룰이 통과됩니다. 앞단 차단만으로 끝나지 않고 reaper가 같이 있어야 합니다.

📁

nginx 도 임시폴더를 쓴다

/var/lib/nginx/body를 확인하세요. Confluence와 같은 호스트면 여기도 디스크를 채우는 벡터입니다.

🚫

local home 을 NFS 에 두지 말 것

인덱스 손상 위험으로 Atlassian이 금지합니다. NFS는 shared home 전용입니다.

🤖

자체 스크립트의 재시도 루프

첨부를 올리는 사내 자동화에 지수 백오프와 재시도 상한이 없으면 정확히 이 형태의 장애를 만듭니다.

제한은 앱에만 걸면 절반만 걸린 것입니다

거부될 업로드가 디스크를 먹지 않게 앞단에서 끊고,
남는 잔해는 회수하고, 그래도 터지면 업로드 하나만 아프게 격리하세요.