삽질블로그

대규모 트래픽에서의 connection reset by peer 트러블 슈팅 본문

고찰 & 트러블슈팅

대규모 트래픽에서의 connection reset by peer 트러블 슈팅

삽질블로그 2024. 8. 29. 13:54

 

문제상황

현재 진행하고 있는 분산 시스템 프로젝트에서 K6를 통해 10초 동안 1000명의 가상 유저로 스트레스 테스트를 진행하던 중

클라이언트에서 connection reset by peer가 발생했다.

서버에서는 에러 메시지가 뜨지 않았고 쿠폰도 정상적으로 발급이 되었다.

코드와 에러는 다음과 같다.

 

Nestjs를 이용한 백엔드 코드 ( 코드 수정 전)

@Injectable()
export class CouponService {
  constructor(
    @InjectRepository(Coupon)
    private readonly couponRepository: Repository<Coupon>,
    @InjectRepository(CouponWallet)
    private readonly couponWalletRepository: Repository<CouponWallet>,
    private readonly redisService: RedisService,
  ){}

  async issueCouponRedisLock(body: IssueCouponDto) : Promise<CouponWallet>{
    let issuedCoupon : CouponWallet;
    let lock: Lock = await this.redisService.acquireLock(`coupon:${body.code}`);

      try{
        await this.couponRepository.manager.transaction(async (transaction: EntityManager) =>{

          const coupon: Coupon = await transaction.findOne(
            Coupon, {where : {code: body.code}}
          )

          if(!coupon){
            throw new NotFoundException("존재하지 않는 쿠폰입니다.")
          }
          await this.checkAlreadyIssuedCoupon(body.userId, coupon, transaction);

          const updatedCoupon = await this.updateCouponAmount(coupon, transaction);
          const wallet = new CouponWallet(body.userId, updatedCoupon);

          issuedCoupon = await transaction.save(CouponWallet, wallet);
        })
        return issuedCoupon;
      }
      catch(error){
        throw new Error(error);
      }finally{
        if(lock){
          await lock.release();
        }
      }
  }

  async issueCouponPessimisticLock(body : IssueCouponDto) : Promise<CouponWallet>{
    try {
      let issuedCoupon : CouponWallet;

      await this.couponRepository.manager.transaction(async (transaction: EntityManager) =>{

        const coupon: Coupon = await transaction.findOne(
          Coupon, {where : {code: body.code}, lock: { mode: 'pessimistic_write' }}
        )

        if(!coupon){
          throw new NotFoundException("존재하지 않는 쿠폰입니다.")
        }
        await this.checkAlreadyIssuedCoupon(body.userId, coupon, transaction);

        const updatedCoupon = await this.updateCouponAmount(coupon, transaction);
        const wallet = new CouponWallet(body.userId, updatedCoupon);

        issuedCoupon = await transaction.save(CouponWallet, wallet);
      })
      return issuedCoupon;
    }
    catch(error){
      throw error;
    }
  }

  async getCouponList() : Promise<Coupon[]>{
    return await this.couponRepository.find();
  }


  async checkAlreadyIssuedCoupon(userId : string, coupon : Coupon, transaction : EntityManager){
    const checkUserHaveCoupon = await transaction.findOne(CouponWallet, {where : {userId : userId}})
    if(!checkUserHaveCoupon){
      return;
    }
    else if(JSON.stringify(checkUserHaveCoupon.coupon) !== JSON.stringify(coupon)){
      throw new BadRequestException('이미 쿠폰을 발급받은 유저입니다.');
    }
  }

  async updateCouponAmount(coupon : Coupon, transaction : EntityManager) : Promise<Coupon>{
    if(coupon.leftAmount <= 0){
      throw new BadRequestException('쿠폰 수량이 모두 소진되었습니다.')
    }
    coupon.leftAmount--;
    const updatedCoupon : Coupon = await transaction.save(Coupon, coupon);
    return updatedCoupon;
  }

}

 

 

에러메시지

WARN[0000] Request Failed   error="Post \"http://localhost:8000/coupons/issue\": dial tcp 127.0.0.1:8000: connect: connection reset by peer"
WARN[0000] Request Failed   error="Post \"http://localhost:8000/coupons/issue\": read tcp 127.0.0.1:63447->127.0.0.1:8000: read: connection reset by peer"
WARN[0000] Request Failed   error="Post \"http://localhost:8000/coupons/issue\": read tcp 127.0.0.1:63446->127.0.0.1:8000: read: connection reset by peer"
WARN[0000] Request Failed   error="Post \"http://localhost:8000/coupons/issue\": read tcp 127.0.0.1:63445->127.0.0.1:8000: read: connection reset by peer"

 

 

이 에러는 서버 애플리케이션 단에서는 어떤 예외도 확인할 수 없었고, 클라이언트에서만 확인할 수 있었기 때문에

더 자세하게 분석하기 위해 와이어샤크로 패킷분석을 진행했다.

아래 사진은 와이어샤크로 일부 TCP패킷을 캡쳐 이미지이다.

 

와이어샤크 패킷 일부

 

No. 3452 : 클라이언트(50212)에서 서버(8000)으로 TCP연결을 설정하기 위해 SYN 패킷을 전송

No. 3485 : 서버(8000)에서 클라이언트(50212)에게 SYN-ACK패킷은 전송하여 연결 요청을 수락하고, 클라이언트의 SYN 패킷 확인

No. 3531 : 클라이언트(50212)에서 서버(8000)에게 ACK 패킷을 전송하여 SYN-ACK에 대한 확인 응답을 보낸다. -> TCP 연결 수립

No. 3537 : 3-way handshake과정이 정상적으로 진행되었으므로 서버에 POST요청을 전송한다.(쿠폰 발급 요청)

No. 3577 : 서버가 연결을 강제로 종료(RST 패킷) - 서버가 TCP연결을 강제로 종료한다.

No. 3588 : 3577과 같음

 

정상적으로 서버가 클라이언트에게 요청에 대한 응답을 했을 경우 RST패킷 대신 4-way handshake과정을 거치며

TCP 통신을 정상적으로 종료해야 한다.

 

하지만 위와 같이 TCP통신이 정상적으로 종료되지 않고 서버가 RST 패킷을 보내면서

정상적으로 종료되지 않는 상황이 발생하는 것을 확인할 수 있다.

 

위 상황에서 클라이언트는 쿠폰을 정상적으로 발급받았음에도 불구하고 서버에서 응답을 받지 못했기 때문에

에러상황, 쿠폰 발급을 확인할 수 없는 상황 등이 발생한다.

 

그럼 RST패킷은 뭐고 보내는 이유가 뭘까 찾아봤다.

 

RST패킷은 TCP 프로토콜에서 사용되는 reset 패킷으로 TCP 연결을 즉시 종료하는 데 사용되며,

발신자에게 다른 연결을 만들어 트래픽을 다시 보내도록 알리는 패킷이다. 즉, 재설정을 요구하는 패킷이다.

참고자료 : https://www.extrahop.com/blog/tcp-resets-rst-prevent-command-and-control-dos-attacks

 

TCP Resets (RST): Prevent Command and Control & DoS Attacks | ExtraHop

Prevent DoS attacks and block C2 traffic with automated containment methods. Learn more about TCP RST, TCP reset attacks and IPS at ExtraHop.

www.extrahop.com

 

 

반면 TCP연결에서 RST패킷을 보내는 이유는 너무나 다양해서 하나씩 가정하면서 원인을 찾아보기로 했다.

가정  1 : 데이터베이스의 작업시간

검증

데이터베이스의 작업이 너무 길어서 실행중인 쿼리가 끝나지 못한 채로 많은 트래픽이 몰려 타임아웃이 발생하지 않았을까?

하는 생각에 DB작업이 없는 console.log.만 찍는 다음과 같은 코드를 작성하여 부하테스트를 진행해봤다.

@Get('test')
  async test(){
    console.log('test');
  }

 

 

결론 

결과는 클라이언트에서 똑같은 에러가 발생했다.

그렇게 첫 번째 데이터베이스의 문제가 아닐까 하는 첫 번째 가정은 아니였다.

 

가정 2 :  호스트 머신의 리소스 부족문제

검증

과도한 트래픽으로 호스트 머신의 리소스(CPU, RAM 등)가 부족해서 발생하는 문제가 아닐까? 

하는 생각에 프로메테우스와 그라파나를 연동해 호스트 머신의 리소스를 모니터링 해봤다.

호스트 머신 리소스 모니터링

 

결론

10초 동안 1000명의 부하를 줬을 때 다음과 같이 cpu, ram, memory 등 리소스가 여유로운 것을 볼 수 있다.

따라서 호스트 머신의 리소스 부족 문제도 아닌 것으로 결론지었다.

 

가정 3 :  OS  or TCP/IP

검증

패킷 분석에서 서버가 RST 패킷을 전송한 걸 확인했을 때 애플리케이션에는 RST 패킷을 전송하는 로직이나 패킷을

보낼 조건을 결정하는 로직이 없기 때문에 애플리케이션 레벨이 아닌 OS 혹은 TCP/IP단에서 발생한 것이 아닐까 하는 가정을 세웠다.

 

 

결론

친구의 홈 서버를 빌려서 리눅스 ubuntu환경에서 테스트를 진행해봤는데, Connection reset by peer 예외가 발생하지 않았다.

따라서 RST패킷을 전송하는 이유가 OS 혹은 TCP/IP 프로토콜 레벨에서 이루어지고 있을 가능성을 확인할 수 있다.

이에 관련하여 connection reset by peer에 대한 고찰을 따로 작성하려고 한다.

 

가정 4:  포트 재사용

검증

기존에 TCP연결에 사용된 포트를 재사용해서 발생한 문제일까 하는 가정을 세웠다.

 

결론

와이어샤크로 RST패킷을 보내는 클라이언트의 소켓과 TCP 연결 상태를 모니터링하여 비교하였지만 포트 재사용과 관련한 문제가 없음을 확인하였다.

 

가정 5:  수신 버퍼 오버플로우

검증

TCP 수신 버퍼가 가득차서 발생한 오버플로우로 인해 데이터를 수신하지 못해 발생한 문제인가?

 

결론

서버에서 사용하고 있는 8000번 포트의 TCP 연결 상태를 모니터링하여 수신 버퍼 상태를 확인했지만 문제가 없음을 확인했고,

혹시나 하는 마음에 수신 버퍼의 크기를 최대로 늘려 다시 부하테스트를 진행했지만 여전히 똑같은 상황 발생해서 문제가 아님을 확인했다.

 

가정 6: 과도한 커넥션 처리

검증

OS 시스템 호출인 listen()으로 만들어진 두 개의 큐 1. SYN,  2. Completed Connection Queue 중에서
1번 큐에 2번 큐의 크기를 초과하는 요청이 몰릴 경우 1번 큐에서 2번 큐로 이동하는 과정에서 오버플로가 발생하면 그 오버 플로된 요청에 한해서 Connection reset by peer가 발생하는데, 그와 관련된 문제인가?

 

결론

2번 큐의 크기를 조정하는 서버의 커널 파라미터인 somaxconn를 기본 값인 256에서 1000으로 올려봤지만 똑같은 에러가 발생했고,

somaxconn의 크기를 5로 줄여서 100명의 부하를 줬지만 에러가 발생하지 않아서 문제가 아님을 확인했다.

 

 

이와 관련된 정확한 원인을 찾지 못해서 Connection reset by peer에 대한 고찰을 따로 정리해서 작성했다.

원인을 찾지 못한건 아쉽지만 정리하면서 얻은게 많아서 나름 괜찮다고 생각했다.

또한 대규모 트래픽에서 RST패킷을 던지는건 클라이언트에 요청을 재시도하라고 하는 패킷이라

흔한 일이라서 따로 신경쓰지 않아도 된다고 한다. - 아니면 댓글로 알려주시면 감사하겠습니다..!

 

 

 

참고자료 : 

https://px201226.github.io/socket_internal/#fn-7

 

요청이 급증하는 상황의 Connection reset by peer 트러블 슈팅

문제 상황 소켓을 활용하여 서버-클라이언트 간 통신을 진행하는 과정에서, 클라이언트로부터의 요청이 일시적으로 급증하는 상황을 테스트하던 중, 클라이언트 측에서 라는 예외가 간헐적으

px201226.github.io

https://fire-programmer.tistory.com/127

 

(디버깅) Connection reset by peer 에러 원인과 해결 방법

상황 spring으로 만든 서비스가 정상적으로 동작하다가, 작업이 안 되는 경우가 발생해서 로그를 찾아보니, 아래의 내용이 남아있음 에러내용 Cause: java.sql.SQLTransientConnectionException: Lost connection to b

fire-programmer.tistory.com

 

 

 

'고찰 & 트러블슈팅' 카테고리의 다른 글

Connection reset by peer에 대한 고찰  (1) 2024.08.29
Typescript Cannot find module에러  (0) 2024.04.16
Comments