회고 개요
최근 내부 프로젝트로 Gabia IDC Labs. 개발하고 있는데, Async-Blocking 이슈를 만나 간단하게 회고를 작성해 본다.

로컬에서 정상적으로 동작하던 FastAPI 애플리케이션을 Docker Compose로 배포했는데, 컨테이너는 떠 있지만 서비스가 응답하지 않는 기묘한 현상을 마주했다.
별다른 오류 로그도 나오지 않고, FastAPI 애플리케이션의 Docker Container에 직접 들어가서 서버 실행했을 때도 정상적으로 실행이 잘 됐다.
처음에는 Docker 네트워크 설정이나 포트 매핑 문제라고 생각했다. 하지만 진짜 문제는 훨씬 더 근본적인 곳에 숨어 있었다. 바로 비동기 함수 안에서 동기 블로킹 I/O를 호출한 것이다.
이 글에서는 왜 이것이 Docker-Compose 환경에서만 발생되었는지, 그리고 어떻게 해결했는지 기록한다.
증상
- 로컬 환경: uvicorn main:app --reload 실행 시 정상 동작, Swagger UI 접근 가능
- Docker 환경: 컨테이너 상태는 unhealthy, Swagger UI 접근 불가, 로그 출력 없음
의심했던 문제들
- Docker 네트워크 구성 문제?
- 컨테이너 간 통신 이슈?
- 포트 매핑 설정 오류?
- healthcheck 설정이 너무 엄격한가?
최종 원인과 해결 방안
아래 코드에서 발생한 문제이다.
최초 FastAPI Application 이 실행될 때 VM 세션을 관리하는 Redis Queue 에서 값을 불러와 작업을 이어 처리하게 구현되어 있다.
다른 API도 처리할 수 있어야 하므로 당연히 async 로 구현했으나, 서비스 로직 내부에서 사용하는 redis_client.brpop() 은 서버로부터 데이터가 올 때까지 대기하는 네트워크 I/O 작업으로 이벤트 루프 전체가 Blocking 된다.
당연히 docker-compose의 헬스체크 요청도 처리할 수 없게돼 문제가 발생한 것이다.
async def process_vm_queue():
"""FastAPI startup 시 실행되는 큐 처리 함수"""
while True:
# 여기가 문제!
message = redis_client.brpop(queue_name, timeout=5)
if message:
await process_message(message)
즉, 내 코드는 async 함수였지만, 내부에서 blocking I/O를 직접 호출해 async의 이점을 완전히 무효화시키고 잠재적 위험을 가진채 docker-compose 환경까지 보내진 것이다.
앞으로는 async 함수에서는 동기 블로킹 I/O를 직접 호출하지 않아야겠다.
비동기 Redis 클라이언트(aioredis, redis[asyncio])를 사용하거나 해당 작업을 별도의 쓰레드로 분리하여 처리해야 한다.
# 동기 Redis에서 비동기 Redis로 전환 #
# Before
import redis
redis_client = redis.Redis(...)
# After
import redis.asyncio as redis
redis_client = redis.Redis(...) # 같은 인터페이스, 다른 구현
...
# 모든 Redis 호출에 await 추가 #
# Before (블로킹)
result = redis_client.brpop(queue_name, timeout=5)
data = redis_client.get(key)
redis_client.set(key, value)
# After (논블로킹)
result = await client.brpop(queue_name, timeout=5)
data = await client.get(key)
await client.setex(key, ttl, value)
...
# 연결 종료도 비동기로 #
# Before
if redis_client:
redis_client.close()
# After
if redis_client:
await redis_client.aclose()