RabbitMQ 메시지 손실 방지: 일반적인 함정과 해결책
확인, 승인, 내구성 있는 큐, DLQ 및 안전한 재시도 동작을 통해 RabbitMQ 메시지 손실을 줄이는 실용적인 방법
RabbitMQ 메시지 손실 방지: 일반적인 함정과 해결책
RabbitMQ 메시지 손실은 대개 극적인 브로커 장애 하나로 발생하지 않습니다. 더 자주, 게시 또는 소비 경로의 작은 간격에서 발생합니다: 게시자가 소켓 쓰기가 브로커가 메시지를 수락했다고 가정하거나, 소비자가 데이터베이스 커밋이 완료되기 전에 승인하거나, 큐는 내구성이 있지만 전송된 메시지가 일시적인 경우입니다.
RabbitMQ 안정성을 해결하는 가장 안전한 방법은 생산자에서 브로커로, 그 다음 브로커에서 소비자로 메시지를 따라가는 것입니다. 각 단계에서 "이 메시지는 이제 안전합니다"라고 말할 수 있는 사람을 결정하십시오. 그 결정은 코드에서 명시적이고 모니터링에서 볼 수 있어야 합니다.
메시지 수명 주기 및 잠재적 손실 지점 이해
솔루션을 살펴보기 전에 RabbitMQ 여정에서 메시지가 손실될 수 있는 위치를 이해하는 것이 필수적입니다:
- 게시자 측: 메시지가 게시자에 의해 전송되었지만 네트워크 문제, 브로커 사용 불가 또는 게시자 오류로 인해 RabbitMQ 브로커에 도달하지 못할 수 있습니다.
- 브로커 측: 메시지가 RabbitMQ에 있으면 브로커가 메시지를 디스크에 저장하기 전에 충돌하거나 메시지가 있는 큐가 예기치 않게 삭제되면 손실될 수 있습니다.
- 소비자 측: 소비자가 메시지를 수신했지만 애플리케이션 오류, 충돌 또는 조기 승인으로 인해 성공적으로 처리하지 못하여 메시지가 삭제될 수 있습니다.
메시지 손실 방지를 위한 주요 기술
RabbitMQ는 메시지 내구성과 안정성을 향상시키기 위해 여러 내장 기능과 권장 패턴을 제공합니다. 이를 구현하는 것은 데이터 손실을 방지하는 데 중요합니다.
1. 게시자 확인
게시자 확인은 게시자가 메시지가 브로커에 의해 성공적으로 수신되고 처리되었을 때 브로커로부터 알림을 받을 수 있는 메커니즘을 제공합니다. 이는 게시자와 브로커 사이에서 메시지가 사라지지 않도록 보장하는 데 중요합니다.
작동 방식:
- 게시자가 RabbitMQ에 메시지를 보냅니다.
- RabbitMQ는 메시지를 수신하면 게시자에게 확인을 보내도록 구성할 수 있습니다. 이 확인은 메시지가 수락되었음을 나타냅니다.
- RabbitMQ가 메시지를 수락할 수 없는 경우(예: 큐가 가득 차거나 라우팅 키가 잘못된 경우), 부정 확인(nack)을 보냅니다.
구성:
게시자 확인은 채널에서 confirm.select를 설정하여 활성화됩니다. 이는 채널이 확인 모드에서 작동해야 함을 RabbitMQ에 알립니다.
예시 (Python의 pika 라이브러리 사용):
import pika
connection = pika.BlockingConnection(pika.ConnectionParameters('localhost'))
channel = connection.channel()
channel.confirm_delivery()
try:
channel.basic_publish(
exchange='',
routing_key='my_queue',
body='Hello, World!',
properties=pika.BasicProperties(delivery_mode=2) # 메시지를 영구적으로 만듦
)
print(" [x] Sent 'Hello, World!'")
# 예외가 발생하지 않으면 메시지가 브로커에 의해 확인됨
except pika.exceptions.UnroutableMessageError as e:
print(f"Message could not be routed: {e}")
except pika.exceptions.ChannelClosedByBroker as e:
print(f"Channel closed by broker: {e}")
# 연결 또는 브로커 문제 처리
except Exception as e:
print(f"An unexpected error occurred: {e}")
connection.close()
모범 사례: 게시자 확인을 사용할 때는 nack 또는 채널 폐쇄를 적절히 처리하기 위해 항상 basic_publish 호출 주변에 오류 처리를 구현하십시오.
2. 소비자 승인(Ack/Nack)
소비자 승인은 메시지가 소비자에게 전달된 후 손실되지 않도록 하는 데 중요합니다. 이를 통해 소비자는 메시지가 성공적으로 처리되었는지 RabbitMQ에 신호를 보낼 수 있습니다.
승인 유형:
- 자동 승인 (
auto_ack=True): RabbitMQ는 메시지가 전달된 것으로 간주하고 소비자에게 보내는 즉시 큐에서 제거합니다. 소비자가 처리 전에 충돌하면 메시지가 손실됩니다. - 수동 승인 (
auto_ack=False): 소비자는 메시지 처리를 완료했을 때 RabbitMQ에 명시적으로 알립니다. 이를 통해 소비자가 실패할 경우 재전송이 가능합니다.
수동 승인 흐름:
- 소비자가 메시지를 수신합니다.
- 소비자가 메시지를 처리합니다.
- 처리가 성공하면 소비자는 RabbitMQ에
basic_ack을 보냅니다. - 처리가 실패하면 소비자는 다음을 수행할 수 있습니다:
requeue=True와 함께basic_nack(또는basic_reject)을 보내 메시지를 큐에 다시 넣어 다른 소비자가 가져가도록 합니다.requeue=False와 함께basic_nack(또는basic_reject)을 보내 메시지를 폐기하거나 데드 레터 교환(DLX)으로 보냅니다.
예시 (Python의 pika 라이브러리 사용):
import pika
import time
def callback(ch, method, properties, body):
print(f" [x] Received {body}")
try:
# 처리 시뮬레이션
if b'error' in body:
raise Exception("Simulated processing error")
# 처리가 성공하면:
ch.basic_ack(delivery_tag=method.delivery_tag)
print(" [x] Acknowledged message")
except Exception as e:
print(f"Processing failed: {e}")
# 메시지 거부 및 재큐
ch.basic_nack(delivery_tag=method.delivery_tag, requeue=True)
print(" [x] Rejected and requeued message")
connection = pika.BlockingConnection(pika.ConnectionParameters('localhost'))
channel = connection.channel()
channel.queue_declare(queue='my_queue')
channel.basic_consume(queue='my_queue', on_message_callback=callback, auto_ack=False)
print(' [*] Waiting for messages. To exit press CTRL+C')
channel.start_consuming()
경고: requeue=True를 무기한 사용하면 메시지가 계속 처리에 실패할 경우 메시지 루프가 발생할 수 있습니다. 이때 데드 레터링이 중요해집니다.
3. 메시지 지속성
기본적으로 RabbitMQ의 메시지는 일시적입니다. 브로커가 다시 시작되면 모든 일시적 메시지가 손실됩니다. 이를 방지하려면 메시지와 큐를 내구성 있는 것으로 선언해야 합니다.
내구성 있는 큐:
큐를 선언할 때 durable 매개변수를 True로 설정합니다.
channel.queue_declare(queue='my_durable_queue', durable=True)
영구 메시지:
메시지를 게시할 때 delivery_mode 속성을 2로 설정합니다.
channel.basic_publish(
exchange='',
routing_key='my_durable_queue',
body='Persistent message',
properties=pika.BasicProperties(delivery_mode=2) # 영구적
)
중요 참고 사항: 메시지 지속성은 만병통치약이 아닙니다. 메시지는 큐에 기록된 후에만 디스크에 저장됩니다. 게시자 확인은 게시자가 메시지를 보낸 것으로 간주하기 전에 메시지가 브로커에 도달하고 내구성 있는 큐에 기록되었음을 보장하는 데 여전히 필요합니다. 또한 디스크 자체가 실패하면 적절한 디스크 중복성 없이 저장된 메시지가 여전히 손실될 수 있습니다.
4. 데드 레터링(DLX)
데드 레터링은 성공적으로 처리할 수 없거나 만료된 메시지를 처리하기 위한 강력한 메커니즘입니다. 이러한 메시지는 폐기되거나 무한정 재큐되는 대신 지정된 '데드 레터 교환'으로 다시 라우팅될 수 있습니다.
데드 레터링 시나리오:
- 소비자가
requeue=False로 메시지를 명시적으로 거부합니다. - TTL(Time-To-Live) 설정으로 인해 메시지가 만료됩니다.
- 큐가 최대 길이 제한에 도달합니다.
구성:
- 데드 레터 교환(DLX) 선언: 메시지가 전송될 일반 교환입니다.
- 데드 레터 큐(DLQ) 선언: DLX에 바인딩된 큐입니다.
- 원래 큐 구성: 데드 레터 메시지를 생성할 수 있는 큐를 선언할 때
x-dead-letter-exchange및x-dead-letter-routing-key인수를 지정합니다.
예시:
import pika
connection = pika.BlockingConnection(pika.ConnectionParameters('localhost'))
channel = connection.channel()
# 1. DLX 및 DLQ 선언
channel.exchange_declare(exchange='my_dlx', exchange_type='topic')
channel.queue_declare(queue='my_dlq')
channel.queue_bind(queue='my_dlq', exchange='my_dlx', routing_key='dead')
# 2. DLX/DLQ 인수를 사용하여 기본 큐 선언
channel.queue_declare(
queue='my_processing_queue',
durable=True,
arguments={
'x-dead-letter-exchange': 'my_dlx',
'x-dead-letter-routing-key': 'dead'
}
)
# 처리 큐를 의도된 소비자 교환에 바인딩(있는 경우)
# 간단히 하기 위해 이 예에서는 큐에 직접 게시한다고 가정
# 소비자에서 메시지가 실패하면 거부:
# channel.basic_nack(delivery_tag=method.delivery_tag, requeue=False)
print("Queues and exchanges set up for dead-lettering.")
connection.close()
my_processing_queue에서 requeue=False로 메시지가 거부되면 라우팅 키 dead와 함께 my_dlx로 라우팅된 다음 my_dlq로 이동합니다. 그런 다음 별도의 소비자를 설정하여 my_dlq를 모니터링하고 검사, 재처리 또는 보관할 수 있습니다.
5. 고가용성 및 복제
중요한 애플리케이션의 경우 단일 RabbitMQ 노드는 단일 장애 지점입니다. 클러스터링 및 복제된 큐 유형은 노드 장애 시 가동 중지 또는 데이터 손실 위험을 줄일 수 있지만 RabbitMQ 버전 및 워크로드에 맞게 선택하고 테스트해야 합니다.
- 클러스터링: 여러 RabbitMQ 노드가 단일 단위로 함께 작동합니다. 큐는 노드 간에 선언될 수 있습니다.
- 복제된 큐: 최신 RabbitMQ 배포는 복제된 내구성 워크로드에 대해 일반적으로 쿼럼 큐를 사용합니다. 기존 클래식 HA 패턴은 새로운 사용 전에 현재 RabbitMQ 지침에 대해 평가해야 합니다.
복제는 가용성을 향상시키지만 네트워크 및 디스크 작업도 추가합니다. 중요한 워크플로우에 신뢰하기 전에 게시자 확인 대기 시간, 장애 조치 동작 및 소비자 재전송을 테스트하십시오.
실제로 필요한 신뢰성 계약
RabbitMQ 메시지 손실을 방지하는 것은 각 큐에 대한 계약을 작성할 때 추론하기 더 쉽습니다. 모든 큐가 동일한 보호를 받을 자격이 있는 것은 아닙니다. 캐시 무효화 이벤트를 전달하는 큐는 캐시가 만료되거나 재구축될 수 있으므로 누락된 메시지를 허용할 수 있습니다. 결제 캡처 요청, 비밀번호 재설정 이메일 요청, 배송 상태 변경 또는 감사 이벤트를 전달하는 큐는 일반적으로 훨씬 더 강력한 계약이 필요합니다.
계약은 네 가지 간단한 질문에 답해야 합니다:
- 게시자가 전송 후 충돌하면 안전하게 재시도할 수 있습니까?
- RabbitMQ가 다시 시작되면 메시지가 여전히 존재해야 합니까?
- 소비자가 작업 중간에 충돌하면 메시지를 다시 시도해야 합니까?
- 메시지가 계속 실패하면 어디로 가고 누가 그것을 봅니까?
대부분의 실제 메시지 손실 사고는 이러한 질문 중 하나에 답변되지 않았기 때문에 발생합니다. 코드는 큐를 사용할 수 있지만 시스템은 "전송됨" 또는 "처리됨"의 의미에 대한 합의가 없습니다.
더 안전한 게시자는 브로커가 확인한 후에만 메시지를 전송된 것으로 처리합니다. 더 안전한 큐는 메시지가 브로커 재시작에서 살아남아야 할 때 내구성이 있습니다. 더 안전한 메시지는 내용이 중요할 때 영구적으로 게시됩니다. 더 안전한 소비자는 내구성 있는 부작용이 완료된 후에만 승인합니다. 더 안전한 실패 경로는 무한 회전 대신 데드 레터 큐로 포이즌 메시지를 보냅니다.
많은 것처럼 들리지만 실제로는 모든 중요한 워크플로우에 적용할 수 있는 짧은 체크리스트가 됩니다.
실제 실패 패턴: 조기 승인
제가 보는 가장 흔한 RabbitMQ 메시지 손실 버그는 이국적이지 않습니다. 다음과 같습니다:
- 소비자가 주문 이벤트를 수신합니다.
- 소비자가 메시지를 즉시 승인합니다.
- 소비자가 외부 결제 API를 호출합니다.
- 프로세스가 충돌하거나 API 요청이 시간 초과됩니다.
RabbitMQ는 정확히 지시받은 대로 수행했습니다. 소비자가 "완료했습니다"라고 말했으므로 브로커가 메시지를 제거했습니다. 비즈니스 작업은 완료되지 않았지만 브로커는 이를 알 방법이 없었습니다.
해결책은 되돌릴 수 없는 작업 후에 승인을 이동하는 것입니다:
def callback(ch, method, properties, body):
try:
event = parse_order_event(body)
charge_id = charge_customer(event)
save_charge_result(event["order_id"], charge_id)
ch.basic_ack(delivery_tag=method.delivery_tag)
except TemporaryBillingError:
ch.basic_nack(delivery_tag=method.delivery_tag, requeue=True)
except InvalidOrderError:
ch.basic_nack(delivery_tag=method.delivery_tag, requeue=False)
그래도 여전히 미묘한 문제가 하나 남아 있습니다: 소비자가 청구 결과를 저장한 다음 basic_ack 전에 충돌하면 어떻게 됩니까? RabbitMQ는 메시지를 재전송합니다. 손실은 아니지만 중복 처리가 될 수 있습니다. 안정적인 RabbitMQ 소비자는 일반적으로 멱등성을 가져야 합니다. 메시지 ID, 주문 ID 또는 비즈니스 키를 사용하여 동일한 메시지를 반복해도 실제 부작용이 반복되지 않도록 하십시오.
예를 들어, 고유 제약 조건이 있는 테이블에 order_id 및 charge_id를 쓰는 소비자는 재전송을 안전하게 처리할 수 있습니다. 두 번째 실행에서 레코드가 이미 존재하는 것을 확인하고 다시 청구하지 않고 메시지를 승인합니다.
게시자 확인은 중요한 메시지에 필수입니다
게시자 확인이 없으면 게시자는 소켓에 바이트를 썼다는 것만 알 수 있습니다. RabbitMQ가 메시지를 수락했는지, 라우팅했는지, 저장했는지, 또는 브로커가 처리하기 전에 연결이 끊어졌는지 알 수 없습니다.
방화 후 망각 원격 측정의 경우 허용될 수 있습니다. 비즈니스 작업을 나타내는 작업 큐의 경우 충분하지 않습니다.
좋은 게시자 경로는 일반적으로 세 가지 작업을 수행합니다:
- 채널에서 게시자 확인을 활성화합니다.
- 중요한 메시지를 영구적으로 표시합니다.
mandatory=True또는 대체 교환을 사용하여 라우팅할 수 없는 메시지를 처리합니다.
라우팅할 수 없는 메시지 부분은 놓치기 쉽습니다. 큐와 일치하는 라우팅 키로 교환에 게시하면 RabbitMQ는 게시를 수락할 수 있지만 알려달라고 요청하지 않는 한 아무데도 라우팅하지 않습니다. 애플리케이션 관점에서 메시지 손실처럼 보입니다.
pika에서 정확한 동작은 채널 모드 및 예외 처리에 따라 다르지만 의도는 다음과 같습니다:
channel.confirm_delivery()
channel.basic_publish(
exchange="orders",
routing_key="created",
body=payload,
mandatory=True,
properties=pika.BasicProperties(
delivery_mode=2,
message_id=order_id,
content_type="application/json",
),
)
게시가 실패하면 주의하여 재시도하십시오. 재시도 루프는 맹목적으로 중복 비즈니스 이벤트를 생성해서는 안 됩니다. 먼저 애플리케이션 데이터베이스에 발신 이벤트를 저장하고 게시한 다음 확인 후 게시된 것으로 표시하십시오. 이 "아웃박스" 패턴은 데이터베이스 커밋과 메시지 게시 사이의 어색한 간격을 처리하기 때문에 일반적입니다.
지속성에는 세 가지 요소가 있습니다
RabbitMQ의 내구성은 둘 이상의 스위치가 있기 때문에 종종 오해됩니다.
교환은 다시 시작 후에도 존재해야 하는 경우 내구성이 있어야 합니다. 큐는 다시 시작 후에도 존재해야 하는 경우 내구성이 있어야 합니다. 메시지는 내용이 다시 시작 후에도 유지되어야 하는 경우 영구적이어야 합니다.
이 중 하나라도 생략하면 놀랄 수 있습니다. 내구성 없는 큐로 전송된 영구 메시지는 큐를 내구성 있게 만들지 않습니다. 일시적인 메시지를 수신하는 내구성 있는 큐는 다시 시작 중에 해당 일시적인 메시지를 여전히 잃을 수 있습니다. 내구성 있는 교환 및 내구성 있는 큐는 배포가 토폴로지를 잘못 삭제하고 다시 생성하는 경우 도움이 되지 않습니다.
시작 코드 또는 인프라 자동화를 사용하여 토폴로지를 일관되게 선언하십시오:
channel.exchange_declare(
exchange="orders",
exchange_type="topic",
durable=True,
)
channel.queue_declare(
queue="order_processing",
durable=True,
arguments={
"x-dead-letter-exchange": "orders.dlx",
"x-dead-letter-routing-key": "order_processing.failed",
},
)
channel.queue_bind(
queue="order_processing",
exchange="orders",
routing_key="created",
)
지속성은 브로커 재시작 중 손실을 줄이지만 백업, 디스크 중복성, 쿼럼 복제 또는 게시자 확인을 대체하지는 않습니다. 또한 비용이 있습니다. 영구 메시지는 디스크 작업이 필요하며 높은 게시 속도는 느린 스토리지를 빠르게 노출할 수 있습니다. 이것이 중요한 데이터에 대해 지속성을 피해야 하는 이유는 아닙니다. 노트북 벤치마크가 프로덕션에 적용된다고 가정하는 대신 실제 워크로드를 테스트해야 하는 이유입니다.
포이즌 메시지 루프를 생성하지 않고 재시도
basic_nack(..., requeue=True)는 일시적인 오류에 유용하지만 위험할 수 있습니다. 메시지가 항상 실패하면 계속해서 전달됩니다. 브로커는 재전송하는 데 작업을 소비합니다. 소비자는 실패하는 데 작업을 소비합니다. 그 뒤에 있는 좋은 메시지는 예상보다 오래 기다릴 수 있습니다.
더 나은 패턴은 빠른 재시도와 지연된 재시도 및 최종 실패를 분리하는 것입니다.
간단한 설정 하나:
- 첫 번째 실패: 오류가 명백히 일시적인 경우 한 번 재큐합니다.
- 반복 실패:
requeue=False로 거부합니다. - 데드 레터 큐: 헤더 및 라우팅 컨텍스트와 함께 실패한 메시지를 저장합니다.
- 재생 도구: 운영자 또는 예약된 작업이 근본 원인이 수정된 후 검사하고 다시 게시할 수 있도록 합니다.
지연된 재시도의 경우 많은 팀이 TTL이 있는 재시도 큐와 원래 큐로 다시 데드 레터 교환을 사용합니다. 이렇게 하면 실패하는 종속성이 매 밀리초마다 두드리지 않고 복구할 시간을 줍니다.
헤더에 주의하십시오. RabbitMQ는 x-death와 같은 데드 레터 메타데이터를 추가합니다. 소비자는 이를 읽어 메시지가 이미 너무 많이 재시도되었는지 결정할 수 있습니다. 소비자 프로세스 내부의 메모리에만 의존하지 마십시오. 해당 상태는 다시 시작 시 사라집니다.
큐를 신뢰하기 전 운영 점검
코드를 구성한 후 의도적으로 나쁜 경우를 테스트하십시오.
메시지를 게시하는 동안 소비자를 중지하십시오. 큐 깊이가 증가해야 하며 내구성이 있어야 하는 경우 브로커 재시작 후 메시지가 남아 있어야 합니다. 소비자를 다시 시작하고 큐를 소진하는지 확인하십시오.
처리 중에 소비자를 종료하십시오. 수동 승인을 사용하면 채널이 닫힌 후 진행 중인 메시지가 다시 준비 상태가 되어야 합니다. 사라지면 너무 일찍 승인하거나 어딘가에서 자동 승인을 사용하는 것입니다.
잘못된 라우팅 키로 게시하십시오. 게시자는 반환, 확인 관련 오류 또는 대체 교환 경로를 통해 실패를 알아차려야 합니다. 게시 호출이 성공적으로 나타나고 메시지가 아무데도 도착하지 않으면 라우팅 안전망이 불완전한 것입니다.
알려진 잘못된 메시지로 데드 레터 큐를 채우십시오. 실패한 이유, 시도 횟수 및 안전하게 재생할 수 있는지 확인할 수 있어야 합니다. 소유자가 없는 DLQ는 메시지를 잃는 더 느린 방법일 뿐입니다.
테스트 중에 다음 메트릭을 관찰하십시오:
messages_ready: 소비자를 기다리는 메시지.messages_unacknowledged: 전달되었지만 아직 승인되지 않은 메시지.- 클라이언트 측의 게시 확인 대기 시간.
- 소비자 오류율 및 재시도 횟수.
- 데드 레터 큐 깊이.
- 메모리 및 디스크 알람.
목표는 RabbitMQ가 모든 비즈니스 결과를 마술처럼 보장하도록 만드는 것이 아닙니다. 목표는 모든 실패를 표시하고 복구 가능하게 만드는 것입니다.
최종 신뢰성 확인
모든 중요한 RabbitMQ 워크플로우에 대해 게시자가 브로커 확인을 기다리는지, 교환 및 큐가 다시 시작 후에도 유지되어야 하는 경우 내구성이 있는지, 메시지 자체가 내용이 중요할 때 영구적인지, 소비자가 실제 작업이 완료된 후에만 승인하는지 확인하십시오. 그런 다음 실패 사례를 테스트하십시오: 잘못된 라우팅 키, 브로커 재시작, 소비자 충돌, 반복 처리 실패 및 DLQ 재생.
이러한 테스트가 비즈니스가 기대하는 방식으로 동작한다면 더 이상 RabbitMQ가 메시지를 안전하게 유지하기를 바라는 것만이 아닙니다. 문제가 발생했을 때 복구 경로가 있는 것입니다.