fos-blog/study
01 / 홈02 / 카테고리03 / 시리즈
01 / 홈02 / 카테고리03 / 시리즈

카테고리

  • AI 페이지로 이동
    • RAG 페이지로 이동
    • agent 페이지로 이동
    • langgraph 페이지로 이동
    • 사람용 CLI와 AI 에이전트용 CLI는 설계가 다르다
    • agents.md
    • BMAD Method — AI 에이전트로 애자일 개발하는 방법론
    • Claude Code 메모리: CLAUDE.md와 .claude/rules를 규칙으로 쓰는 법
    • Claude Code의 Skill 시스템 - 개발자를 위한 AI 자동화의 새로운 차원
    • Claude Code를 5주 더 쓴 결과 — 스킬·CLAUDE.md를 키워가는 방식
    • Claude Code를 11일 동안 쓴 결과 — 데이터로 본 나의 사용 패턴
    • Claude Code 멀티 에이전트 — Teams
    • AI 에이전트와 디자인의 새 컨벤션 — DESIGN.md, Google Stitch, Claude Design
    • Docling — IBM Research 의 문서 파싱 toolkit 상세 정리
    • 하네스 엔지니어링 실전 — 4인 에이전트 팀으로 코딩 파이프라인 구축하기
    • 하네스 엔지니어링 — 오래 실행되는 AI 에이전트를 위한 설계
    • 멀티모달 LLM (Multimodal Large Language Model)
    • AI 에이전트와 함께 MVP 만들기 — dooray-cli 사례
    • OpenClaw는 context와 memory를 어떻게 관리하나 — 나만의 에이전트를 구성하는 법
    • OpenClaw vs Hermes Agent — 갈아탈까 고민하며 정리한 비교
    • 스킬 문서를 신경망처럼 학습시킨다 — Microsoft SkillOpt 분석
  • ai 페이지로 이동
    • agent 페이지로 이동
    • [초안] AI 제품 백엔드 안정성 — 지연·비용·권한·관측·도구 실패·폴백/재시도/사람 에스컬레이션
    • [초안] LLM 평가 프레임워크: 골든셋, 회귀 테스트, LLM-as-a-judge, 사람 피드백 루프
  • algorithm 페이지로 이동
    • live-coding 페이지로 이동
    • 분산 계산을 위한 알고리즘
  • apartment 페이지로 이동
    • 구리 럭키아파트 24평 인테리어 레퍼런스 모음
  • architecture 페이지로 이동
    • [초안] 시니어 백엔드를 위한 API 설계 실전 스터디 팩 — REST · 멱등성 · 페이지네이션 · 버전 전략
    • [초안] API Versioning과 Backward Compatibility: 시니어 백엔드 관점 정리
    • 캐시 설계 전략 총정리
    • [초안] 커머스 Spring 서비스에 Clean/Hexagonal Architecture를 실용적으로 적용하기
    • [초안] 커머스 도메인 모델링: 주문·재고·노출의 세 축을 분리해서 설계하기
    • 커머스 주문 상태와 데이터 정합성 기본기
    • [초안] 쿠폰/프로모션 동시성과 정합성 기본기 — 선착순·중복 사용 방지·발급/사용/복구
    • [초안] DDD와 도메인 모델링: 시니어 백엔드 관점의 전술/전략 패턴 실전 가이드
    • [초안] Decorator & Chain of Responsibility — 행동을 체인으로 조립하는 두 가지 방식
    • 디자인 패턴
    • [초안] 분산 아키텍처 완전 정복: Java 백엔드 시니어 인터뷰 대비 실전 가이드
    • [초안] 분산 트랜잭션과 Outbox 패턴 — 왜 2PC를 피하고 어떻게 대신할 것인가
    • 분산 트랜잭션
    • [초안] e-Commerce 주문·결제 도메인 모델링: 상태머신, 멱등성, Outbox/Saga 실전 정리
    • [초안] Event Sourcing과 CQRS — 상태가 아니라 변화를 저장한다는 발상
    • [초안] F&B 쿠폰·프로모션·멤버십·포인트 설계
    • [초안] F&B · e-Commerce 디지털 채널 도메인 한 장 정리
    • [초안] F&B 주문/매장/픽업 상태머신 설계
    • [초안] F&B 이커머스 결제·환불·정산 운영 가이드
    • [초안] Hexagonal / Clean Architecture를 Spring 백엔드에 적용하기
    • [초안] 대규모 커머스 트래픽 처리 패턴 — 대규모 회원과 메가 프로모션을 버티는 설계
    • [초안] 레거시 JSP/jQuery 화면과 신규 API가 공존하는 백엔드 운영 전략
    • [초안] MSA 서비스 간 통신: Redis [Cache-Aside](../database/redis/cache-aside.md) × Kafka 이벤트 하이브리드 설계
    • [초안] Observability 입문: 시니어 백엔드가 장애를 탐지하고 대응하는 방식
    • [초안] Outbox / Inbox Pattern 심화 — 분산 메시징의 정합성 문제를 DB 트랜잭션으로 풀어내기
    • [초안] 결제 도메인 멱등성과 트랜잭션 재시도 기본기
    • [초안] 시니어 백엔드를 위한 Resilience 패턴 실전 가이드 — Timeout, Retry, Circuit Breaker, Bulkhead, Backpressure
    • [초안] REST API 버저닝과 모바일 앱 하위 호환성 — 디지털 채널 백엔드 관점
    • [초안] Spring Batch vs Event-Driven — 같은 비동기처럼 보이지만 전혀 다른 두 패러다임
    • [초안] Strategy Pattern — 분기문을 없애는 설계, 시니어 백엔드 인터뷰 핵심 패턴
    • [초안] 시니어 백엔드를 위한 시스템 설계 입문 스터디 팩
    • [초안] 템플릿 메서드 패턴 - 백엔드 처리 골격을 강제하는 가장 오래되고 가장 위험한 패턴
    • [초안] 대규모 트래픽 중 무중단 마이그레이션 — Feature Flag + Shadow Mode 실전
  • database 페이지로 이동
    • milvus 페이지로 이동
    • mysql 페이지로 이동
    • opensearch 페이지로 이동
    • qdrant 페이지로 이동
    • redis 페이지로 이동
    • vespa 페이지로 이동
    • 김영한의-실전-데이터베이스-설계 페이지로 이동
    • [초안] DB Connection Pool Saturation과 Thread Pool 격리
    • 커넥션 풀 크기는 얼마나 조정해야 할까?
    • 인덱스 - DB 성능 최적화의 핵심
    • [초안] JPA N+1과 커머스 조회 모델: 주문/메뉴/쿠폰 도메인에서 살아남기
    • [초안] MyBatis 기본기 — XML Mapper, resultMap, 동적 SQL, 운영 패턴 정리
    • [초안] MyBatis와 JPA/Hibernate 트레이드오프 — 레거시 백엔드를 다루는 시니어 관점
    • 한국어 형태소 분석기 Nori vs Lindera — OpenSearch에서 Milvus로 갈 때 어휘 검색은 유지되나
    • 벡터 DB 5종, 아키텍처는 어떻게 다른가
    • 벡터 DB 5종을 실제로 벤치마크했다 — 같은 recall에서 QPS는 얼마나 갈리나
    • 벡터 DB 어떻게 고를까 — OpenSearch · Milvus · Qdrant · Vespa · pgvector 비교
    • 벡터 DB를 실제로 도입한 사례 — 빅테크 프로덕션
    • 역정규화 (Denormalization)
    • 데이터 베이스 정규화
  • devops 페이지로 이동
    • docker 페이지로 이동
    • k8s 페이지로 이동
    • k8s-in-action 페이지로 이동
    • observability 페이지로 이동
    • [초안] 커머스/F&B 채널 장애 첫 5분과 관측성 기본기
    • [초안] 운영 데이터 정합성 장애 대응 — 결제 취소 누락과 중복 적재 런북
    • Envoy Proxy
    • [초안] F&B / e-Commerce 운영 장애 대응과 모니터링 — 백엔드 관점 정리
    • Graceful Shutdown
    • [초안] 시니어 백엔드를 위한 SLO와 Error Budget 기반 장애 대응
  • http 페이지로 이동
    • HTTP Connection Pool
    • HTTPS는 어떻게 안전한가 — TLS, 인증서, 그리고 termination
  • interview 페이지로 이동
    • [초안] AI 서비스 팀 경험 기반 시니어 백엔드 면접 질문 뱅크 — Spring Batch RAG / gRPC graceful shutdown / 전략 패턴 / 12일 AI 웹툰 MVP
    • Observability — 면접 답변 프레임
    • [초안] 시니어 Java 백엔드 면접 마스터 플레이북 — 김병태
    • [초안] NSC 슬롯팀 경험 기반 질문 은행 — 도메인 모델링·동시성·성능·AI 협업
  • java 페이지로 이동
    • concurrency 페이지로 이동
    • jdbc 페이지로 이동
    • opentelemetry 페이지로 이동
    • spring 페이지로 이동
    • spring-batch 페이지로 이동
    • testing 페이지로 이동
    • 더_자바_코드를_조작하는_다양한_방법 페이지로 이동
    • [초안] Java 동시성 락 정리 — 커머스 메뉴/프로모션 정책 캐시 갱신 관점
    • [초안] JVM 튜닝 실전: 메모리 구조부터 Virtual Threads, GC 튜닝, 프로파일링까지
    • Java의 로깅 환경
    • MDC (Mapped Diagnostic Context)
    • Java StampedLock — 읽기 폭주에도 쓰기가 밀리지 않는 락
    • Virtual Thread와 Project Loom
  • javascript 페이지로 이동
    • typescript 페이지로 이동
    • AbortController
    • Async Iterator와 제너레이터
    • CommonJS와 ECMAScript Modules
    • 제너레이터(Generator)
    • Http Client
    • Node 백엔드 운영 패턴 — Streams 백프레셔, pipe/pipeline, 멱등성 vs 분산 락
    • Node.js
    • npm vs pnpm — 어떤 기준으로 선택했나
    • `setImmediate()`
  • kafka 페이지로 이동
    • [초안] Kafka 기본 개념 — 토픽, 파티션, 오프셋, 복제
    • Kafka를 사용하여 **데이터 정합성**은 어떻게 유지해야 할까?
    • [초안] Kafka 실전 설계: 파티션 전략, 컨슈머 그룹, 전달 보장, 재시도, 순서 보장 트레이드오프
    • 메시지 전송 신뢰성
    • [초안] Spring Kafka 컨슈머 오프셋 커밋과 트랜잭션 정렬: AckMode, manual ack, 멱등 처리
  • linux 페이지로 이동
    • fsync — 리눅스 파일 동기화 시스템 콜
    • tmux — Terminal Multiplexer
  • mlops 페이지로 이동
    • llm-serving 페이지로 이동
    • Python CUDA 버전 생태계 — nvidia-smi, nvcc, pip, conda가 다 다른 버전을 말하는 이유
    • GPU 컨테이너의 CUDA 버전 호환성 — nvidia-smi부터 이미지 다이어트까지
    • Kubernetes GPU 노드에서 /run tmpfs가 꽉 차서 Pod가 안 뜰 때
    • GPU·CUDA·MPS 기초 — 자바 백엔드 개발자가 처음 만나는 그림
    • Multi-process GPU 워크로드 — 자바 ThreadPool 사용자가 만나는 모델 차이
    • ML 서비스 성능 분석 워크플로 — 자바 백엔드 트러블슈팅과 다른 점
    • 한 GPU 를 여러 프로세스가 나눠 쓰기 — Time-Slicing 과 MPS
  • network 페이지로 이동
    • Connection reset by peer는 누가 보낸 걸까 — 리버스 프록시 홉마다 TCP 연결은 따로 논다
    • L2(스위치)와 L3(라우터)의 역할 차이
    • L4와 VIP(Virtual IP Address)
    • IP Subnet
  • python 페이지로 이동
    • Python async/await — CompletableFuture·Reactor 와 다른 점, 그리고 blocking I/O 함정
    • Python 의존성 관리 — Java Maven/Gradle 사용자가 만나는 첫 충격
    • FastAPI 기초 — Spring Boot 사용자가 빠르게 익히는 법
    • Java 개발자를 위한 Python 심화 — OOP·데코레이터·컨텍스트 매니저
    • PyTorch 기초 — 텐서, 디바이스, 그리고 모델 로딩이 무거운 이유
    • Java 개발자를 위한 Python 문법 핵심
    • ThreadLocal 에서 contextvars 로 — Python 의 요청 컨텍스트 전파
    • OCR 동작 원리 — Layout · Text · Post-process 3단계
    • Python 서버의 RSS 가 안 줄어드는 이유 — gc.collect 의 한계와 malloc_trim
  • rabbitmq 페이지로 이동
    • [초안] RabbitMQ Basics — 실전 백엔드 관점에서 정리하는 메시지 브로커 기본기
    • [초안] RabbitMQ vs Kafka — 백엔드 메시징 선택 기준과 실전 운영 관점
  • security 페이지로 이동
    • [초안] 시니어 백엔드를 위한 보안 / 인증 스터디 팩 — Spring Security, JWT, OAuth2, OWASP Top 10
    • [초안] Spring Security 6.x OAuth2 + JWT 상용 인증 설계 — Grant 선택, Resource Server, Refresh Rotation, 로그아웃
  • task 페이지로 이동
    • ai-service-team 페이지로 이동
    • nsc-slot 페이지로 이동
    • sb-dev-team 페이지로 이동
    • the-future-company 페이지로 이동
  • testing 페이지로 이동
    • [초안] 시니어 Java 백엔드를 위한 테스트 전략 완전 정리 — 피라미드부터 TestContainers, 마이크로벤치, Contract까지
  • travel 페이지로 이동
    • 오사카 3박 4일 일정표: 우메다 쇼핑, USJ, 난바·도톤보리, 오사카성
  • web 페이지로 이동
    • [초안] HTTP / Cookie / Session / Token 인증 기본기 — 레거시 JSP와 모바일 API가 공존하는 백엔드 관점
FOS-BLOG · FOOTERall systems normal·v0.1 · 2026.04.27·seoul, kr
Ffos-blog/study

개발 학습 기록을 정리하는 블로그입니다. 공부하면서 기록하고, 기록하면서 다시 배웁니다.

visitors
01site
  • Home↗
  • Posts↗
  • Categories↗
  • About↗
02policy
  • 소개/about
  • 개인정보처리방침/privacy
  • 연락처/contact
03categories
  • AI↗
  • Algorithm↗
  • DB↗
  • DevOps↗
  • Java/Spring↗
  • JS/TS↗
  • React↗
  • Next.js↗
  • System↗
04connect
  • GitHub@jon890↗
  • Source repositoryjon890/fos-study↗
  • RSS feed/rss.xml↗
  • Newsletter매주 1 회 · 한 편의 글→
© 2026 FOS Study. All posts MIT-licensed.
built with·Next.js·Tailwind v4·Geist·Pretendard·oklch
fos-blog/task/문서 파싱 서비스, 리소스가 새는 자리를 하…
system

문서 파싱 서비스, 리소스가 새는 자리를 하나씩 틀어막은 기록

진행 기간: 2026.05 2026.07 > 기여 전체 개요는 Playground 문서 파싱 파이프라인 — 기여 개요 참고. 본 글은 그중 메모리·프로세스·이미지 크기 안정화만 모은 기록이다. 문서 파싱 서비스를 운영하면서 겪은 리소스 문제 다섯 건을 정리한다. 다룬 문제는 다음과 같다. - 워커 강제 종료 - 대용량 엑셀 메모리 - 컨테이너 좀비 프로세스...

2026.07.13·8 min read·0 views·SERIES · AI 서비스 실전 구축·운영 · 5/8

진행 기간: 2026.05 ~ 2026.07

기여 전체 개요는 Playground 문서 파싱 파이프라인 — 기여 개요 참고. 본 글은 그중 메모리·프로세스·이미지 크기 안정화만 모은 기록이다.

문서 파싱 서비스를 운영하면서 겪은 리소스 문제 다섯 건을 정리한다. 다룬 문제는 다음과 같다.

  • 워커 강제 종료
  • 대용량 엑셀 메모리
  • 컨테이너 좀비 프로세스
  • 운영 자동화 러너의 방치된 프로세스
  • 컨테이너 이미지 용량

계층은 다 다른데 공통점이 하나 있었다. 평소엔 아무 문제 없이 돌아가다가, 특정 조건(대용량 입력, 반복 실행, 오래 켜둔 서버)이 쌓여야만 터진다는 점이다. 그래서 다섯 건 모두 "왜 지금까지 안 보였나"를 같이 적어둔다.

처리 시간 초과 워커를 죽이는 방식을 signal.alarm 에서 감시 스레드로 바꿨다

문서 하나를 변환하는 워커 프로세스가 비정상적으로 오래 걸리는 경우가 있었다. 입력 파일이 깨졌거나 OCR 라이브러리가 특정 페이지에서 멈추는 경우다.

기존에는 워커 프로세스 안에서 signal.alarm으로 자기 자신에게 시간 초과를 걸어뒀다. 시간이 지나면 시그널 핸들러가 sys.exit(1)을 불러 스스로 종료하는 방식이다.

문제는 이 방식이 가끔 안 먹었다는 것이다. signal.alarm이 보낸 SIGALRM은 프로세스의 메인 스레드가 파이썬 바이트코드 실행 루프로 돌아와야 처리된다. 그런데 워커가 docling·OCR 같은 C 확장 라이브러리 안에서 오래 머무는 동안에는 제어권이 파이썬 인터프리터로 돌아오지 않는다. 시그널은 큐에 쌓여 있지만 핸들러가 실행될 기회를 못 잡는다. 그 결과 정말 멈춰야 할 워커가 회수되지 않고 CPU 를 계속 점유하는 상황이 생겼다.

바꾼 방식은 워커 자신이 스스로를 죽이는 대신, 부모 프로세스 안의 별도 감시 스레드가 밖에서 지켜보다 강제로 죽이는 구조다.

SIGKILL은 프로세스가 무엇을 하고 있든 커널이 강제로 회수하는 시그널이라, 워커가 C 확장 안에 갇혀 있어도 상관없이 통한다. SIGALRM처럼 대상 프로세스의 인터프리터 루프에 의존하지 않는다는 게 핵심 차이다.

감시 스레드가 각 워커의 시작 시각을 딕셔너리에 기록해두고, 주기적으로 순회하며 임계값(기본 20분)을 넘긴 워커를 찾아 죽인다. 여기서 두 가지를 더 손봐야 했다.

  • 엉뚱한 프로세스를 죽이는 문제(stale PID) — 어떤 워커가 이미 죽고 그 자리에 운영체제가 새 프로세스를 띄웠는데, 감시 스레드가 옛 PID 기록만 보고 죽이려 들면 방금 새로 뜬 무관한 프로세스가 죽는다. 죽이기 직전에 그 PID 가 지금도 실제로 이 워커 풀 소속인지 확인하는 절차를 넣어 막았다.
  • 같은 종료를 두 번 집계하는 문제 — 감시 스레드가 시간 초과로 죽였는데, 뒤이어 풀 관리 로직이 "워커가 죽었다"는 사실을 다시 감지하면서 원인 불명(unknown) 으로 한 번 더 집계했다. 시간 초과로 죽인 직후에는 그 사실을 잠깐 기억해뒀다가, 뒤따라오는 재집계를 한 번 걸러내는 방식으로 고쳤다.

결과로 종료 사유별 지표(시간 초과·원인 불명 등)가 갈라져 나오고, 멈춘 워커가 확실히 회수돼 CPU 과점유를 방어할 수 있게 됐다. 이 감시 스레드는 죽은 워커가 남긴 metrics .db 파일을 치우는 역할도 겸하는데, 그 쪽은 문서 파싱 서비스 관측성 체계 구축 — 초기화 순서 함정과 지표 단일화에서 다룬다.

대용량 xlsx 파일을 이제는 행 단위로 스트리밍한다

엑셀 파일을 markdown 으로 바꿀 때 문제가 됐던 건 셀 수가 아주 많은 파일이었다. 워커가 파일을 읽으면서 전체 시트를 메모리에 통째로 올리는 구조라, 셀 수에 비례해서 메모리가 늘어났다. 실제로 1,000만 셀짜리 파일 4개가 거의 동시에 들어왔을 때 컨테이너 메모리 27GiB 제한을 그대로 다 채운 사례가 있었다.

평소엔 이 경로가 문제였던 적이 없다. 보통 들어오는 xlsx 는 시트가 커봐야 몇천 행이라, 통째로 읽어도 메모리가 눈에 띄게 늘지 않았다. 큰 시트가 낀 파일이 몰릴 때만 드러나는 문제였다.

해결 방향은 큰 시트가 있는 파일만 골라서 행 묶음 단위로 읽는 것이다.

  • 행 수가 임계값(기본 5만)을 넘는 시트가 하나라도 있으면 그 파일 전체를 스트리밍 경로로 보낸다. openpyxl 을 read_only=True 모드로 열어 시트 메타데이터에서 행 수를 확인하는데, 이 메타데이터가 비어 있어 행 수를 못 읽는 경우도 있다. 이때는 "작은 시트"로 낙관하지 않고 큰 시트로 간주해 스트리밍으로 보낸다 — 판단이 안 서면 안전한 쪽(메모리를 덜 쓰는 쪽)으로 기운다는 뜻이다.
  • 큰 시트가 없는 파일과 csv 는 기존 경로를 그대로 둔다. 결과물이 달라지면 안 되기 때문에 손댈 이유가 없는 곳은 안 건드렸다.
  • 수식이 들어간 셀은 수식 문자열이 아니라 엑셀이 마지막으로 계산해둔 값을 읽도록 맞췄다. 스트리밍 경로와 기존 경로가 같은 파일을 다르게 렌더링하면 안 되는데, 계산값 대신 수식 문자열이 노출되면 그 자체로 회귀였다.

결과로 같은 입력에서 메모리 사용량이 평탄하게 유지됐다(실측 RSS 92MB 수준). 작은 xlsx·pptx·docx 는 전후 출력이 바이트 단위로 동일했고, 스트리밍 경로에 대한 단위 테스트 15건을 새로 추가했다.

LibreOffice 가 남기는 좀비 프로세스를 tini 로 정리했다

doc·ppt 계열 파일을 markdown 으로 바꾸는 경로는 LibreOffice 를 호출한다. 그런데 LibreOffice 가 작업을 끝내고 종료돼도 그 하위 프로세스(soffice.bin)가 완전히 회수되지 않고 좀비 상태로 남는 경우가 있었다. 운영 인스턴스 하나를 기준으로 컨테이너를 띄운 지 약 1주일 만에 좀비가 420개까지 쌓인 걸 실측으로 확인했다.

이 문제가 평소엔 안 보이는 이유는 좀비 프로세스 자체가 CPU 나 메모리를 거의 안 쓰기 때문이다. 다만 PID 는 유한한 자원이라, 좀비가 계속 쌓이면 언젠가 새 프로세스를 만들 PID 자리가 고갈될 위험이 있다.

원인은 컨테이너 안에서 애플리케이션이 PID 1로 뜬다는 점이었다. 리눅스 커널은 좀비가 된 자식 프로세스를 부모가 wait() 계열 호출로 거둬가길 기다리는데, 부모 프로세스가 죽거나 미리 그 자식을 잊어버리면 커널은 그 좀비를 PID 1(그 네임스페이스의 init)에게 재입양시킨다. 그런데 PID 1은 원래 각종 기본 시그널 처리를 하지 않는 특별한 프로세스라, 우리 애플리케이션이 직접 PID 1이 되면 이 재입양된 좀비를 거둬가는 책임까지 떠안았지만 그 책임을 이행하는 코드가 없었다.

해결은 컨테이너 시작 프로세스를 tini(좀비를 회수하는 역할만 하는 아주 작은 init)로 감싸는 것이다. 자세한 배경과 동작 원리는 Docker에서 좀비 프로세스가 쌓이는 이유 — PID 1 문제와 tini에 정리해뒀다. 이 서비스에 적용하면서 신경 쓴 부분은 두 가지다.

  • 실행 경로가 어떻게 바뀌든(로컬 실행, 배포 스크립트) 이미지 자체의 ENTRYPOINT를 tini 로 고정해서, 특정 실행 방식에 의존하지 않고 항상 좀비 회수가 보장되게 했다.
  • 컨테이너를 내릴 때 보내는 SIGTERM을 tini 가 애플리케이션에 그대로 전달하도록 설정했다. tini 를 새로 끼워 넣으면서 기존의 정상 종료 흐름(진행 중인 요청을 마저 처리하고 종료)이 깨지면 안 됐기 때문이다.

결과로 좀비 누적으로 인한 PID 고갈 위험이 사라졌다.

사내 PR 리뷰 자동화가 러너에 방치한 프로세스를 정리했다

운영 중인 self-hosted 러너 서버에서, PR 이 취소되면(새 커밋이 push 되어 이전 실행이 자동 취소되는 경우 등) 특정 단계의 프로세스가 정리되지 않고 계속 살아 있는 걸 발견했다. 취소 신호를 못 받은 프로세스는 다음 작업을 계속 기다리며 재시도를 반복했고, 그중 두 개는 각각 18일·7일 동안 CPU 를 40%씩 물고 있었다.

원인은 셸 파이프라인 구조였다. 명령 | while read ...; do ... done 형태로 짜면, while 루프는 파이프 오른쪽에서 서브셸로 실행된다. 작업이 취소돼 셸에 종료 신호가 전달돼도, 그 신호가 파이프로 연결된 하위 서브셸까지 확실히 전파된다는 보장이 없다. 게다가 루프 안에서 부르는 API 호출 자체가 타임아웃 없이 걸려 있으면, 취소 신호와 무관하게 그 호출이 끝날 때까지 프로세스가 남는다.

1차 대응은 파이프-서브셸 구조를 프로세스 치환(< <(명령))으로 바꾸는 것이었다. 이렇게 하면 while 루프가 서브셸이 아니라 지금 실행 중인 셸 그 자체에서 도니까, 종료 신호가 파이프를 거치지 않고 직접 전달된다.

그런데 이 방식에도 허점이 있었다. 프로세스 치환 안의 명령이 타임아웃으로 잘리면, 뒤의 while 루프는 그게 "지울 게 없어서 빈 목록"인지 "중간에 잘려서 빈 목록"인지 구분할 수 없었다. 최종적으로는 목록 조회를 임시 파일에 먼저 저장하고, 그 조회가 성공했는지 종료 코드로 확인한 뒤에만 삭제 루프를 도는 방식으로 정리했다. 모든 API 호출에는 개별 타임아웃을 걸어 취소 신호를 못 받아도 스스로 끝나게 만들었다.

결과로 방치돼 있던 문제 프로세스 6개를 정리했고, 러너의 CPU 사용률이 20%대에서 0.5%대로 떨어졌다.

컨테이너 이미지에 CUDA 를 세 번 중복해서 담고 있었다

운영 이미지의 압축 크기가 9.89GB(디스크에 풀면 약 30GB)까지 불어나 있었다. 원인을 뜯어보니 CUDA 관련 라이브러리가 이미지 안에 중복으로 여러 벌 들어가 있었고, 빌드에만 필요한 도구가 런타임 이미지에까지 그대로 남아 있었다.

CUDA 중복이 생긴 이유는 Python CUDA 버전 생태계에서 다룬 것처럼, 파이썬 라이브러리마다 CUDA 를 가져오는 방식이 다르기 때문이다. torch 는 자체 wheel 안에 CUDA 런타임을 통째로 번들링해서 시스템 CUDA 와 무관하게 동작하는데, 이 wheel 버전을 기본값(PyPI 최신 CUDA 태그)으로 두면 베이스 이미지의 CUDA(11.8) 와 다른 버전을 하나 더 들고 오게 된다. 여기에 시스템 CUDA 를 직접 참조하는 paddle 까지 더해지면, 결과적으로 서로 다른 두세 벌의 CUDA 관련 바이너리가 이미지 안에 겹쳐 쌓인다.

정리한 항목은 세 가지다.

  • torch·torchvision을 베이스 이미지의 CUDA(11.8)와 같은 태그로 정렬 — paddle 이 시스템 CUDA 11.8 을 참조하는 것과 torch 의 번들 CUDA 버전을 맞춰서, 이미지 안에 서로 다른 CUDA 버전이 공존하지 않게 했다.
  • 빌드·런타임 멀티스테이지 분리 — 컴파일러·헤더 같은 빌드 도구는 빌드 단계 이미지에만 두고, 최종 런타임 이미지에는 실행에 필요한 결과물만 복사해 넣었다.
  • 미사용 의존성 제거 — 코드 어디에서도 import 하지 않는 langchain 계열 패키지 6종과 sentence-transformers 를 뺐다. 이 패키지들을 쓰던 텍스트 로더 하나는 표준 파일 읽기 + 인코딩 자동 감지(신뢰도 낮으면 UTF-8 로 대체)로 바꿔서, 무거운 프레임워크 없이 같은 기능을 유지했다.

결과로 압축 이미지 크기가 9.89GB 에서 6.82GB 로 31% 줄었고, 이미 구축해둔 회귀 검증 체계로 동작 회귀 0건을 확인했다.

돌아보면

다섯 건을 나란히 놓고 보니 전부 "정상적으로 도는 동안에는 절대 안 보이는" 종류의 문제였다. signal.alarm 은 워커가 안 멈추는 한 문제가 없었고, xlsx 메모리는 큰 시트가 안 몰리는 한 문제가 없었고, 좀비 프로세스는 컨테이너를 오래 안 띄워두면 안 보였고, 러너 프로세스는 취소가 자주 안 일어나면 안 보였고, CUDA 중복은 디스크 용량을 신경 쓰지 않는 한 안 보였다.

공통적으로 실측이 없었으면 이 중 어느 것도 고칠 근거를 못 만들었을 거라는 생각이 든다. "좀비가 쌓이는 것 같다"가 아니라 420개라는 숫자, "메모리를 많이 쓴다"가 아니라 27GiB 제한을 채웠다는 숫자, "러너가 좀 걸린다"가 아니라 CPU 40%를 18일 물고 있었다는 숫자가 있어야 그 방어 코드를 넣을 이유가 생긴다. 반대로 말하면 겉보기에 멀쩡하게 돌아가는 시스템일수록, 어디를 실측해야 다음 사고가 보이는지를 계속 물어야 한다는 뜻이기도 하다. 이런 숫자를 뽑아낼 수 있었던 것도 문서 파싱 서비스 관측성 체계 구축 — 초기화 순서 함정과 지표 단일화에서 먼저 관측 체계를 갖춰둔 덕분이었다.

이 중 메모리 회수 자체(gc.collect·malloc_trim)에 관한 더 자세한 진단 과정은 Python 서버 RSS 가 안 줄어들어 malloc_trim 을 박은 이야기에 따로 정리해뒀다. 그 글이 "회수 안 된 메모리를 어떻게 강제로 되돌리나"를 다룬다면, 이 글은 "애초에 회수해야 할 자원(워커·좀비·프로세스·이미지 용량)이 어디서 새는지"를 다뤘다는 점에서 서로 보완 관계다.

on this page
  • 01처리 시간 초과 워커를 죽이는 방식을 signal.alarm 에서 감시 스레드로 바꿨다
  • 02대용량 xlsx 파일을 이제는 행 단위로 스트리밍한다
  • 03LibreOffice 가 남기는 좀비 프로세스를 tini 로 정리했다
  • 04사내 PR 리뷰 자동화가 러너에 방치한 프로세스를 정리했다
  • 05컨테이너 이미지에 CUDA 를 세 번 중복해서 담고 있었다
  • 06돌아보면
tags
📚 AI 서비스 실전 구축·운영
← PREVIOUS문서 파싱 서비스 성능 최적화 — OCR 병렬화부터 GPU 직렬 추론 실측까지NEXT →문서 파싱 서비스 — 코드 구조 정리와 파싱 품질 회귀 검증

이런 글도

  • 문서 파싱 서비스 — 코드 구조 정리와 파싱 품질 회귀 검증
    진행 기간: 2026.05 \ 2026.07 모놀리식 파서 서비스 하나를 두 달 동안 도메인별로 쪼개고, 그 과정에서 파싱 결과가 안 깨졌는지 확인할 방법을 처음부터 다시 설계한 기록이다. 문서 파싱 서비스(Playground의 OCR 기반 문서→markdown 변환 API)는 PDF·이미지·오피스 문서를 마크다운으로 변환하는 FastAPI 서비스다. 이...
    📁 system
    system
    2026.07.13
  • 문서 파싱 서비스 성능 최적화 — OCR 병렬화부터 GPU 직렬 추론 실측까지
    진행 기간: 2026.05 2026.07 문서 파싱 서비스(Playground 문서 파싱 파이프라인 — 기여 개요에서 전체 맥락을 다뤘다)의 처리 속도를 여섯 차례에 걸쳐 손봤다. OCR 호출 구조를 병렬로 바꾸고, 안 쓰는 렌더링을 지우고, PPTX 이미지 처리 방식을 통째로 갈아엎었다. 그리고 마지막 최적화에서는 로컬 벤치에서 30% 빨라진 것을 운영...
    📁 system
    system
    2026.07.13
  • 문서 파싱 서비스 관측성 체계 구축 — 초기화 순서 함정과 지표 단일화
    진행 기간: 2026.05 2026.07 문서 파싱 서비스(Playground의 OCR 기반 문서→markdown 변환 API)를 운영하면서 관측성(observability) 체계를 처음부터 만들었다. 다음 다섯 가지를 다뤘다. - /metrics 엔드포인트 신규 도입 - 운영 배포 직후 터진 메트릭 누락 사고 대응 - 무중단 컨테이너 교체를 위한 drai...
    📁 system
    system
    2026.07.13
  • Playground 문서 파싱 파이프라인 — 기여 개요
    진행 기간: 2026.05 (진행 중) AI 서비스 개발팀의 사내 LLM 워크플로 제품 Playground는 다양한 형식의 문서를 입력으로 받는다. 이 입력을 LLM이 다룰 수 있는 markdown으로 정규화하는 문서 파싱 서비스를 맡아 운영·개선했다. OCR 제품을 만드는 게 아니라, OCR을 활용해 문서를 markdown으로 변환하는 서비스다. - 스택...
    📁 system
    system
    2026.06.17

댓글 (0)