카프카 사용중에 컨슈머가 컨슈머 그룹을 떠난 뒤 rejoin하지 않아 장애가 발생하여 기록한다.

 

"message": "[Consumer clientId=consumer-catchtable-biz-eventWorker-daysDailySumUpdater-3, groupId=catchtable-biz-eventWorker-daysDailySumUpdater] Request joining group due to: consumer pro-actively leaving the group",

 

카프카 컨슈머가 leave group 요청을 하며 컨슈머 그룹을 자발적으로 나간 로그에서 부터 이슈 파악을 시작했다.

 

사실, 컨슈머는 다양한 원인으로 컨슈머 그룹을 떠날 수 있어서, 위 로그 자체가 문제가 된 것은 아니다. 문제는 leave group 을 하게 되면 spring kafka가 poll() 호출 시에 다시 group을 rejoin 시키도록 구현되어 있는데 어째서 rejoin이 되지 않았냐는 것이었다.

 

좀 찾아보니, spring-kafka에 비슷한 이슈가 제보된 것이 있었다.

https://github.com/spring-projects/spring-kafka/issues/2337

 

여기서 큰 힌트를 얻었는데, 위 이슈는  poll() 을 요청하는 쓰레드에 행이 걸려 rejoin()을 요청하는 코드까지 오지 못하는 것이 문제였다.

the poll thread actually stuck in user code when make http connection without timeout. Thanks for your support.

 

애플리케이션 코드를 좀 더 확인해보니, 컨슈밍 시에 카프카 레지스트리를 조회하도록 되어 있었는데, 기존에도 카프카 레지스트리 조회 시에 행이 걸리는 케이스가 있어서 레지스트리 캐시를 적용하던 참이었다. 아마 여기서 행이 걸려 rejoin이 되지 않고 있었던 것으로 보인다.

 

정리하면,

 

이벤트 처리 중 블락이 5분 (heart beat interval default setting) 발생할 경우 join 에서 탈락 및 Rejoining 필요한 상태로 변경되었고,
이 상태에서 다음 이벤트 처리 시 자동으로 rejoin 하게 되는데 (spring kafka container) 이전 동작이 block 된 상태로 끝나지 않고 있는 이슈였다.

'Kafka' 카테고리의 다른 글

Kafka - Avro 스키마 하위호환  (0) 2024.10.20

MySQL 을 비롯한 RDB 들은 쿼리를 던지면 옵티마이저를 통해 가장 효율적인 쿼리 플랜을 생성하여 해당 방식으로 쿼리를 수행한다.

예를 들면 다음과 같은 테이블이 있다고 할 때,

create table delivery
(
    id                       int unsigned auto_increment primary key,
    seller_id                int unsigned,
    order_id                 int unsigned,
    status                   varchar(50),
    send_date_time           datetime(6),
)

 

datetime field 인 send_date_time를 기준으로 조회해보자. 인덱스는 걸려있다.

SELECT * FROM delivery WHERE send_date_time < $send_date_time;

 

 

보이는 것 처럼 이번에도 인덱스를 사용한다.

 

여기까지는 너무 당연하다.

 

이번에는 send_date_time를 기준으로 조회하고, id로 정렬해보자.

아마도 배송일자 기준으로 조회하고, 먼저 생성된 레코드 부터 처리하는 유스케이스에 사용될 것이다.

SELECT * FROM delivery WHERE send_date_time < $send_date_time ORDER BY id;

 

그런데 이렇게 조회하면 아래와 같이 인덱스를 타지 않고 PK 정렬을 먼저 하는 쿼리 플랜을 보여준다.

조회 조건으로 들어간 날짜가 현재 시간에 가까울 수록 아래와 같은 쿼리 플랜을 생성할 가능성이 높은듯 하다.

 

이러한 경우 인덱스를 타지 않기 때문에 성능이 크게 달라질 수 있어 유의해야 한다.

 

해결 방법은, 인덱스를 사용하도록 아래와 같이 직접 명시하는 방법이 있다.

SELECT *
FROM delivery
use index (delivery_send_date_time_index)
WHERE send_date_time < $send_date_time
ORDER BY id;

 

또한 애초에 pk를 정렬 조건으로 쓰는게 맞는지 한 번 더 고민해 봐야할 것 같다.

 

이번 케이스는 reg_date_time 등을 추가해서 레코드 생성 시점을 별도로 관리하는 것이 더 적합했던 것 같다.

id를 주로 sequential 하게 사용하지만, 때에 따라 uuid등으로 사용할 수도 있으니 말이다.

'SQL' 카테고리의 다른 글

Postgres - sorry, too many client already  (0) 2023.03.27

카프카를 사용하다보면 기존에 이벤트 스키마에 변경이 필요할 때가 많이 발생한다.

 

크게 두 가지 방법이 있다.

  1. 기존 스키마를 변경
  2. 새로 스키마를 추가하여 사용(v2 등)

만약 기존 스키마를 변경하기로 했다면, 변경된 스키마를 사용하지 않는 프로듀서를 고려하여 하위 호환성이 지켜지는지 확인해야 한다.

 

그러면 어떨 때에 하위 호환성이 지켜진다고 판단할까?

 

하위 호환성(Backward Compatibility)이 깨지는 경우

아래와 같은 변경이 발생하면 하위 호환이 지켜지지 않는다고 판단한다.

  1. 필드 삭제
  2. 필드의 타입 변경
  3. 필드의 필수 속성 변경:
    • 기존에 선택적(optional) 필드였던 것이 새로운 스키마에서 필수(required)로 변경되면, 하위 호환이 깨질 수 있다.
    • Avro의 경우 ["null", "string"]과 같은 유니언 타입을 단일 타입인 string으로 변경하는 경우도 포함된다. 새로운 데이터는 항상 값을 포함해야 하지만, 기존 데이터에서는 값이 없을 수 있기 때문이다.
  4. 필드 추가
    • 새로 추가되는 필드에 기본값(default value)을 설정하는 것이 필요하다.
{
  "name": "newField",
  "type": ["null", "string"],
  "default": null
}

 

스키마 레지스트리 validity rule

apicurio 와 같은 스키마 레지스트리를 사용하면 validity rule 을 사용하여 하위 호환성을 지켜낸다.

 

 

  • 별도의 설정을 하지 않으면 BACKWARD 호환성을 유지하려고 하며, 이는 새로운 스키마가 기존 스키마로 읽힐 수 있어야 한다는 의미이다.
  • FULL: 새로운 스키마가 기존 스키마로 읽힐 수 없더라도 호환성을 인정한다.
  • Syntax Only: 스키마에 문법적 오류가 있는지만 확인한다.
  • NONE 모드: 호환성 검사를 완전히 비활성화 한다. 하지만 이는 가능하면 피해야 하며, 테스트 환경에서만 사용하도록 권장

 

 

'Kafka' 카테고리의 다른 글

Kafka - 컨슈머 rejoin 실패  (0) 2024.11.29

Objects.hash를 객체의 식별자로 사용하다가 장애를 내는 경험을 했다.

 

문제가 된 코드는 다음과 같다.

var itemGroup = itemRepository.findByOrderSeqIn(orderIds)
        .stream().collect(Collectors.groupingBy(item -> Objects.hash(item.getItemSeq(), item.getOrderSeq())));

hash 는 중복이 일어날 수 있다는 것을 간과한 채, item의 pk와 order의  pk의 조합으로 hash값을 만들고 이를 식별자로 사용한 것이다.

 

그럼 hash값은 얼마나 자주 중복이 발생할까?

 

Objects.hash()의 구현을 보면 아래와 같다.

public static int hash(Object... values) {
    return Arrays.hashCode(values);
}

public static int hashCode(Object[] a) {
        if (a == null)
            return 0;

        int result = 1;

        for (Object element : a)
            result = 31 * result + (element == null ? 0 : element.hashCode());

        return result;
    }

 

element.hashCode() 메소드의 구현체는 타입별로 좀 상이한데 long 기준으로 살펴보자. 

 

public static int hashCode(long value) {
  return (int)(value ^ value >>> 32);
}

 

이는 상위 32비트와 하위 32비트를 XOR 연산한 , 결과를 int 캐스팅하여 출력하라는 뜻이다.

 

예를 들어 값이 다음과 같으면,

orderSeq: 246385, itemSeq: 5215

orderSeq: 246411, itemSeq: 4409

(31 + 246385) * 31 + 5215 = (31 + 246411) * 31 + 4409 로 중복이 발생한다.

 

 element가 하나라면 중복 가능성은 훨씬 높아지는 것이다.

 

그럼 해시 중복이 문제가 될까?

해시는 애초에 식별자로 사용되기 위한 목적을 가진 것이 아니다. 물론 식별이 가능하긴 하지만, 정확한 목적은 효율적인 검색이며, 해시 테이블이나 해시 맵과 같은 자료 구조에서 빠르게 검색하고 저장하기 위해 사용하는 것이다.

 

또한 해시 함수의 입력 데이터의 크기는 무한할 수 있지만, 출력 값은 고정된 크기로 제한되기 때문에 중복이 발생할 수 밖에 없고, 해시를 사용하는 자료구조들은 이를 감안하여 구현되어 있다.

 

Java 해시 맵으로 확인해보자.

 

해시 맵에 원소를 추가하는 putVal 메소드에 해시 충돌을 해결하는 부분을 보면 아래와 같다.

...

if (p.hash == hash &&
    ((k = p.key) == key || (key != null && key.equals(k))))
    e = p;
else if (p instanceof TreeNode)
    e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
    for (int binCount = 0; ; ++binCount) {
        if ((e = p.next) == null) {
            p.next = newNode(hash, key, value, null);
            if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                treeifyBin(tab, hash);
            break;
        }
        if (e.hash == hash &&
            ((k = e.key) == key || (key != null && key.equals(k))))
            break;
        p = e;
    }
}

...

 

기본 적으로는 linked list를 만들어 처리하고, 리스트가 길어지면 tree 구조를 만들어 처리하는 것을 볼 수 있다.

 

추가로 확인할 수 있는 부분은,

HashMap 의 탐색 복잡도가 O(1) 인데, 해시 값이 충돌되면 정확히 O(1)은 아니고 추가적으로 linked list나 tree 탐색이 필요하니,

hash 값이 겹치지 않도록 구성하는게 성능 향상에 도움이 되는 것을 확인할 수 있다.

HTTP/3?

2022년에 표준 스펙이 발표된 HTTP/3는 QUIC(Quic UDP Internet Connection) 기반이다.

기존 TCP 기반의 HTTP/1.1 과 HTTP/2와는 다른 프로토콜로 작동하여, 웹 트래픽의 속도와 신뢰성이 개선되며, 특히 높은 지연 시간이나 패킷 손실이 발생하는 네트워크 환경에서 더욱 효율적인 통신이 가능하다.

좀 더 자세히 살펴보면, 다음과 같은 특징이 있다.

  • 0-RTT(Zero Round Trip Time)QUIC의 보안 기능은 TLS1.3에 기반한다.
  • TLS1.3의 기능으로, 클라이언트와 서버 간의 통신을 시작할 때 앞으로 통신을 하겠다는 일종의 handshaking 이 필요한데, 세션을 재사용하는 경우 이전 세션에서 사용했던 정보를 사용하여 handshaking 없이 바로 데이터를 심어서 보낼 수 있다.
  • Head-of-line Blocking 해결그러나 QUIC의 경우 하나의 연결 위에 여러 개의 스트림을 생성할 수 있어, 하나의 스트림에서 패킷이 지연되거나 손실되더라도, 다른 스트림은 영향을 받지 않는다.
  • TCP의 경우 데이터 패킷이 순차적으로 전달되어야 하기 때문에 하나의 패킷이 지연되거나 손실되면 그 뒤의 패킷들에 영향을 끼치게 된다.
  • 내장된 보안 기능
  • 위에서 언급했듯이, QUIC은 TLS 1.3을 기반으로 하여 기본적으로 암호화가 적용되므로 추가적인 보안 설정이 필요하지 않다.

QUIC(Quic UDP Internet Connection)

커널 레벨의 프로토콜을 새로 만들면 OS 대응이 필요하므로 보편화 되기 까지 오랜 시간이 걸린다. 이러한 이유로 QUIC는 유저 스페이스에서 동작하도록 설계되었다.

한 가지 궁금증이 생긴다.

이미 OS 커널 레벨에서 동작하는 UDP가 있는데, 왜 QUIC라는 프로토콜을 새로 만든걸까?

기존의 UDP는 단순히 데이터그램을 보내고 받는 기본적인 서비스만을 제공하기 때문에, UDP 위에 HTTP를 바로 구현하려면 TCP가 해주는 많은 기능들을 (신뢰성 있는 데이터 전송, 혼잡 제어, 데이터 스트림의 순서 보장 등) 수동으로 구현해야 한다.

QUIC은 UDP 위에 구현되어 있지만, 이러한 TCP의 고급 기능들을 포함하고 있다.

PostgreSQL HA

클러스터 vs 레플리케이션

클러스터는 기본적으로 멀티 마스터 구조로, 모든 노드가 읽기 및 쓰기 작업을 수행할 수 있기 때문에 기본적으로 높은 가용성과 병렬 처리가 가능하지만, 데이터 일관성 유지를 위한 복잡한 문제를 일으킬 수 있다. 또한, 모든 마스터 노드가 일관된 상태를 유지해야 하므로, 데이터가 동시에 여러 위치에서 변경될 때 충돌이 발생할 수 있다.

Replication 구조는 일반적으로 마스터와 하나 이상의 슬레이브로 구성된다. 모든 쓰기 작업은 마스터에서 이루어지고, 슬레이브는 읽기 작업과 가용성을 위한 백업 역할을 한다. 이 구조는 데이터 일관성 유지가 비교적 쉽지만, 마스터가 실패하면 복구 시간 동안 쓰기 작업이 중단될 수 있다. 다만, auto failover 설정을 통해 다른 슬레이브를 마스터로 승격시키는 방법으로 이 문제를 완화할 수 있다.

Replication 방식

  • WAL(Write Ahead Log) 기반 Physical Replication 방식
    • WAL: DB 변경 사항을 저장한 Log
      • Log Shipping: WAL 파일 자체를 전달하는 방식
      • Streaming: 로그 내용을 전달하는 방식
    • DB의 모든 변경 사항을 물리적으로 복제
    • Standby 서버는 Primary 서버의 정확한 복사본이 됨
    • HA 및 DR의 용도
  • Logical Replication
    • 복제 식별자(PK)를 기반으로 변경 사항을 복제하는 방식 (블록 address 를 바이트 단위로 복제하는 physical replication과 반대 개념)
    • Pirmary 서버에서 발생하는 변경사항을 선택적으로 복제 가능
      • 뿐만 아니라, 다른 버전의 PostgreSQL 로도 복제 가능
      • 운영 중인 DB를 새 버전으로 마이그레이션 할 때 사용
    • pub sub 모델 사용
      • 변경 사항을 synchronization worker가 감지

Pgpool-II

PostgreSQL 의 HA 를 구성할 때, auto failover 를 구성할 수 있는 선택지 중 하나이다. 뿐만 아니라, load balancing, connection pool 등의 기능도 제공하고 있다.

Pgpool 이 클라이언트 요청을 DB 서버로 분배하는 동작 모드는 다음과 같다.

  • Load Balance mode: 읽기 쿼리를 여러 PostgreSQL 서버에 분산하여 요청한다.
    • statement_level_load_balance 값에 따라 load balancing 전략이 조금 달라진다. on 이면 매 쿼리마다 요청하는 DB 서버가 달라지고, off 이면 세션을 연결할 때 쿼리를 요청할 DB가 결정된다.
  • Master/Slave mode: 쓰기 쿼리는 마스터 서버에, 읽기 쿼리는 슬레이브 서버에 라우팅한다.
  • Replication mode: 여러 PostgreSQL 서버가 같은 데이터를 가지도록 분산 복제 저장한다.

Pgpool 은 어떤 DB가 마스터인지 어떻게 알 수 있을까?

위의 기능들을 보면 Pgpool은 마스터를 감지하여 쿼리를 라우팅한다.
이는 pgpool.confbackend_hostname 에 명시되어 있는 PostgreSQL 인스턴스에 따라 구분한다. 해당 설정에서 첫 번째로 명시된 호스트를 마스터로 간주한다.

Replication mode 와 WAL-based-replication은 무엇이 다른걸까?

Pgpool 이 제공하는 Replication mode 는 statement 기반으로 작동한다.

즉, 클라이언트로 부터 받은 쿼리를 모든 DB 서버에 전달하는 것으로, 서버의 변경 사항을 전달하는 WAL-based-replication 과는 차이가 있다.

Watchdog

Pgpool 역시 SPOF(Signe Point of Failure)가 될 수 있다. 이를 위해 Pgppol 노드를 추가하여 HA를 구성해야 할 필요가 있는데, 이를 수행해 주는 것이 Watchdog이다.

기본적으로, keepalived 와 유사하게 Pgpool 인스턴스 간 heartbeat check를 통해 VIP를 이전하는 active-stand by 구조이다.

HA 구성 Example

Replication WAL Streaming 방식으로 Master(read, write)-Slave(read only) 을 구성하고, Pgpool2를 통해 auto failover 를 구성하는 방식이다.

os: ubuntu

PosgreSQL 버전: 14

PostgreSQL 설치

  • postgreSQL 설치
    • sudo apt-get install postgresql-14 -y
  • postgres 비밀 번호 test로 변경
    • sudo -iu postgres psql -c "ALTER USER postgres WITH PASSWORD 'test';"
  • pg_hba.conf 수정
    • 디폴트 설정으로 모든 외부 connection 차단되어 있다. 외부 connection을 허용해야 한다.
    • host all all 0.0.0.0/0 md5 host all all ::/0 md5
  • postgresql.conf 수정
    • listen address의 주석을 풀어 listen 을 허용해줘야 한다.

Replication 설정

  • 마스터
    • WAL 아카이브 활성화:
      • WAL(Write Ahead Log) 는 변경 내용을 설명하는 로그 레코드를 영구적 저장소에 먼저 기록한 후에 데이터 파일의 변경 내용을 작성하여 무결성을 보장한다.
      • 마스터의 postgresql.conf 파일에서 아래의 설정을 활성화한다.
      • wal_level = replica max_wal_senders = 5 # 동시 연결 복제 서버 최대 개수 max_replication_slots = 10 # 복제 슬롯 최대 개수 max_wal_size = 10GB # hot_standby = on
      • max_replication_slotmax_wal_senders 는 독립적으로 사용될 수 있다. 복제 서버마다 슬롯을 할당할 수도 있지만, 슬롯을 공유할 수도 있다.
      • streaming replication은 기본적으로 async 방식으로 동작한다.
        • async vs sync
          • 복제의 안정성과 성능의 trade off
          • async: 마스터는 트랜잭션을 커밋하고 클라이언트에 바로 반환 후, WAL 레코드를 전송. 마스터의 응답시간은 빠르나 복제가 지연되거나 실패하는 경우 데이터 손실이 발생할 수 있다.
          • sync: WAL 레코드부터 전송하고 슬레이브가 복제를 한 이후에 클라이언트에 커밋 결과를 반환. 마스터와 슬레이브의 데이터가 항상 일치하지만, 그 만큼 결과 반환이 늦어지므로 성능은 내려간다.
    • 슬레이브에서의 접근 허용
      • 마스터의 pg_hba.conf 파일에 슬레이브의 접근을 허용하는 항목을 추가한다.
      • host replication all [슬레이브 IP 주소]/32 trust
    • PostgreSQL 을 재시작한다.
      • sudo systemctl restart postgresql
  • 슬레이브
    • postgres 유저로 스위치 한다.
      • sudo su postgres
    • data_directory 내의 내용을 삭제한다.
      • show data_directory; : data directory 위치를 알려준다.
      • 보존해야할 데이터가 있다면 미리 백업을 해두어야 한다.
      • rm -r /var/lib/postgresql/14/main/
    • 마스터의 데이터를 가져온다.
    • pg_basebackup -D /var/lib/postgresql/14/main/ -h [마스터 IP 주소] -p 5432 -Xs -R -P
    • -D = data directory
      
      -h  = IP address of primary server
      
      -p = Port on which primary instance is running
      
      -Xs = WAL method - stream
      
      -P = Progress information
      
      --slot=SLOTNAME  #optional
      
      -R = Write configuration parameters for replication
    • PostgreSQL을 재시작한다.
      • sudo systemctl restart postgresql
  • 확인
    • replication 프로세스가 작동 되고 있는지 마스터에서 확인한다.
      • SELECT * FROM pg_stat_replication;
  • WAL Streaming 방식은 물리적 복제 방식으로, 데이터베이스 레벨의 변경만을 동기화하며, 데이터베이스 생성 및 삭제와 같은 클러스터 레벨의 DDL 작업은 동기화하지 않는다.
  • 마스터에서 테이블을 하나 생성하고, 슬레이브에도 동기화가 되었는지 확인한다.
  • 슬레이브는 read-only이기 때문에 슬레이브에서 Write를 시도하면 fail 이 발생한다.

Pgpool-II 구성 (docs)

  • 제공기능
    • Connection Pooling
    • Load Balancing
    • Automated fail over
    • Replication
  • sub process
    • PCP
      • CLI
      • Pgpool Control Program
      • 서버 상태 확인, 연결 종료, 노드 추가 및 제거 등 작업을 원격으로 가능하게 한다.
    • Watchdog (여기서는 사용하지 않는다.)
      • Pgpool 역시 SPOF(Signe Point of Failure)가 될 수 있기 때문에, 이를 위해 Pgppol 노드를 추가하여 HA를 구성해야 할 필요가 있다. 이를 수행해 주는 것이 Watchdog.
      • 기본적으로, keepalived 와 유사하게 Pgpool 인스턴스 간 heartbeat check를 통해 VIP를 이전하는 active-stand by 구조이다.
  • inbound 방화벽 구성
  • Pgpool2 설치
    • apt install -y pgpool2 postgresql-14-pgpool2
    • Pgpool 은 프록시일 뿐이지만 psql과 같은 명령어 사용을 위해 PostgreSQL 설치가 필요하다.
  • /etc/pgpool2/pgpool.conf 설정(man page 참고)
  • listen_addresses = '*'
    
    port = 9999 # Pgpool port
    
    backend_hostname0 = '[마스터 IP 주소]' 
    backend_port0 = 5432
    backend_weight0 = 1
    backend_data_directory0 = '/var/lib/postgresql/14/main'
    backend_flag0 = 'ALLOW_TO_FAILOVER'
    backend_application_name0 = 'server0'
    
    backend_hostname1 = '[슬레이브 IP 주소]'
    backend_port1 = 5432
    backend_weight1 = 1
    backend_data_directory1 = '/var/lib/postgresql/14/main'
    backend_flag1 = 'ALLOW_TO_FAILOVER'
    backend_application_name1 = 'server1'
    
    sr_check_user = 'postgres'
    sr_check_password = 'test'
    sr_check_database = '[DB 이름]'
    
    health_check_period = 5 # 단위 초
    health_check_timeout = 5 # 단위 초
    
    health_check_user = 'postgres'
    health_check_password = 'makina'
    health_check_database = '[DB 이름]'
    health_check_max_retries = 3
  • 모드 설정
    • Pgpool 이 클라이언트 요청을 DB 서버로 분배하는 동작 모드는 다음과 같다.
      • Load Balance mode: 읽기 쿼리를 여러 PostgreSQL 서버에 분산하여 요청한다.
        • statement_level_load_balance 값에 따라 load balancing 전략이 조금 달라진다. on 이면 매 쿼리마다 요청하는 DB 서버가 달라지고, off 이면 세션을 연결할 때 쿼리를 요청할 DB가 결정된다.
      • Master/Slave mode: 쓰기 쿼리는 마스터 서버에, 읽기 쿼리는 슬레이브 서버에 라우팅한다.
      • Replication mode: 여러 PostgreSQL 서버가 같은 데이터를 가지도록 분산 복제 저장한다.
        • Pgpool 이 제공하는 Replication mode 는 statement 기반으로, WAL을 전달하여 복제하는 방식과는 차이가 있다.
    • 여기서는 Load Balance mode와 Master/Slave mode를 사용한다.
      /etc/pgpool2/pgpool.conf 를 추가로 설정한다.
    • load_balance_mode = on master_slave_mode = on
  • 연결된 데이터베이스에서 Pgpool의 접근을 허용해야 한다.
    • DB 서버들의 pg_hba.conf에 아래 레코드를 추가한다. 오버라이드가 되기 때문에 0.0.0.0/0 레코드 위에 명시해야 한다.
    • **host all all [Pgpool IP 주소] trust** host all all 0.0.0.0/0 md5
  • Pgpool2 재시작
    • sudo systemctl restart pgpool2.service
  • 연결된 PostgreSQL 확인
    • systemctl status pgpool2.service
  • 로그를 보면 node[0](마스터) 는 1로 on 상태이지만, node[1](슬레이브)는 0으로 off상태이다.
  • off 상태의 node 부착
    • pcp_attach_node 를 통해 노드를 부착할 수 있다.
    • 해당 명령어를 사용하려면 pcp.conf 에 정의된 사용자로 인증해야 한다.
    • /etc/pgpool2/pcp.conf에 다음과 username:pasword를 저장한다.
    • password 는 md5로 해싱된 값을 입력해야 하고, 해싱된 값은 pg_md5 명령어로 얻을 수 있다.
    • test를 해싱하여 저장하는 예시입니다.
    postgres:098f6bcd4621d373cade4e832627b4f6
  • 저장을 하고 pgpool2를 재시작하면 pcp_attach_node 명령어를 사용할 수 있다.
  • 연결된 PostgreSQL 확인
    • load_balance_mode를 사용했기 때문에 db 세션을 맺을 때 두 서버 중 하나에 붙는다.
  • failover script 작성
    • 마스터가 다운되면 슬레이브를 마스터로 승격시킬 수 있어야 한다.
    • 마스터가 fail 하면 슬레이브에 pg_ctl promote 명령어를 날려 새로운 마스터로 승격시킨다.
    • 아래 스크립트가 정상 작동 하기 위해서는 Pgpool 에서 양쪽 DB 서버와 공유하는 ssh 키가 있어야 한다. (참고)
    • failover.sh
    • #!/bin/bash
      
      # This script is run by failover_command.
      
      set -o xtrace
      exec > >(logger -i -p local1.info) 2>&1
      
      # Special values:
      #   %d = failed node id
      #   %h = failed node hostname
      #   %p = failed node port number
      #   %D = failed node database cluster path
      #   %m = new master node id
      #   %H = new master node hostname
      #   %M = old master node id
      #   %P = old primary node id
      #   %r = new master port number
      #   %R = new master database cluster path
      #   %N = old primary node hostname
      #   %S = old primary node port number
      #   %% = '%' character
      
      FAILED_NODE_ID="$1"
      FAILED_NODE_HOST="$2"
      NEW_MASTER_NODE_ID="$3"
      NEW_MASTER_NODE_HOST="$4"
      OLD_MASTER_NODE_ID="$5"
      
      if [ $# -ne 4 ]
      then
              echo "failover failed_node new_master trigger_file"
              exit 1
      fi
      
      logger -i -p local1.info failover.sh: start: failed_node_id=$FAILED_NODE_ID failed_host=$FAILED_NODE_HOST new_master_host=$NEW_MASTER_NODE_HOST
      
      ## If there's no master node anymore, skip failover.
      if [ $NEW_MASTER_NODE_ID -lt 0 ]; then
          logger -i -p local1.info failover.sh: All nodes are down. Skipping failover.
          exit 0
      fi
      
      ## If Standby node is down, skip failover.
      if [ $FAILED_NODE_ID -ne $OLD_PRIMARY_NODE_ID ]; then
          logger -i -p local1.info failover.sh: Standby node is down. Skipping failover.
          exit 0
      fi
      
      ## Promote Standby node.
      logger -i -p local1.info failover.sh: Primary node is down, promote standby node ${NEW_MASTER_NODE_HOST}.
      
      ssh -T ${NEW_MASTER_NODE_HOST} -i=/etc/pgpool2/maf.pem "sudo -u postgres /usr/lib/postgresql/14/bin/pg_ctl promote -D /var/lib/postgresql/14/main/"
      
      logger -i -p local1.info failover.sh: end: new_master_node_id=$NEW_MASTER_NODE_ID started as the master node
      exit 0
  • 기존의 마스터가 장애를 극복하면, 데이터를 동기화하고 슬레이브로서 동작해야 한다.
    • data directory에 있는 내용을 삭제하고, 동기화를 받는다.
      • rm -r /var/lib/postgresql/14/main/
      • pg_basebackup -D /var/lib/postgresql/14/main/ -h [마스터 IP 주소] -p 5432 -Xs -R -P
    • PostgreSQL 을 가동한다.
      • sudo systemctl start postgresql
  • 복구된 PostgreSQL 을 pgpool 에 붙는지 확인한다.
    • 자동으로 붙지 못하면 pcp_attach_node 를 통해 붙여줘야 한다.

Why Cluster

수집한 데이터를 바탕으로 여러 피처를 제공하는 서비스를 하다보니, 데이터 수집을 하는 부분에 높은 신뢰도가 필요했다.

싱글 노드로 사용하는 현 상황에도 Throughput에 대한 문제는 아직 발생하지 않았고, 예상 되는 데이터의 양보다 높은 부하를 걸어봐도 CPU, Memory, Throughput 3가지가 문제가 되지 않는 것을 확인했다.

따라서 본 포스트는 Throughput을 위한 Cluster 보다는, 고 가용성을 보장하기 위한 Cluster를 구성하는 방법에 대해 적어보려 한다.

RabbitMQ의 공식 문서를 보면, 고 가용성을 위해 Quorum Queue를 사용하라고 말한다.

Quorum Queue

Quorum Queue는 다음과 같은 특징을 가지고 있다.

  • 분산 복제: Quorum Queue는 Raft 합의 알고리즘을 사용하여 큐 데이터를 클러스터 내 여러 노드에 분산 복제한다. 이를 통해 개별 노드 장애가 발생해도 메시지 손실 없이 시스템이 계속 작동할 수 있다.
  • 순차적 일관성: Quorum Queue는 메시지의 순서를 유지하고, 메시지는 항상 동일한 순서로 소비된다.
  • 성능 최적화: Quorum Queue는 대규모 워크로드에 대해 Classic Queue Mirroring 에 비해 더 적은 리소스를 사용하도록 최적화되어 있다. 또한, 메시지 TTL(Time to Live), 데드 레터 큐 등의 기능도 제공한다.
  • 설정 간소화: Quorum Queue는 구성을 간소화하고, Classic Queue에서 발생할 수 있는 일부 구성 문제를 방지하는 데 초점을 맞추고 있다. 실제로 Classic Queue와 비교했을 때 설정이 매우 간편하다.

Raft 알고리즘

Raft 알고리즘은 분산 컴퓨팅 시스템에서 여러 노드 간의 합의를 이루는 데 사용되는 알고리즘이다.

핵심 원칙은 다음과 같다.

  • 리더 선출 (Leader Election): Raft 클러스터 내에는 한 시점에 하나의 노드만 리더로 동작한다. 나머지 노드들은 팔로워로 동작한다. 클러스터가 처음 시작될 때, 노드들은 서로간에 투표를 통해 리더를 선출한다. 리더가 실패하거나 네트워크 분할 등의 문제로 팔로워들과 연결이 끊어질 경우, 새로운 리더 선출 프로세스가 시작된다.
  • 로그 복제 (Log Replication): 리더 노드는 클러스터에 변경(예: 새로운 데이터 추가)이 발생하면 이를 로그에 기록하고, 이 로그를 팔로워 노드들에게 복제한다. 팔로워들은 이 로그를 받아 자신들의 로그에 추가한다. 리더는 팔로워가 로그를 성공적으로 저장했음을 확인한 후에만 해당 로그 항목을 커밋 상태로 변경한다.
  • 안정성과 일관성 (Safety and Consistency): Raft 알고리즘은 네트워크 지연, 패킷 손실, 노드 장애 등과 같은 분산 시스템의 문제 상황에서도 데이터의 일관성을 유지한다. 이를 위해 Raft는 로그 항목들의 커밋 여부를 명확히 정의하고, 노드 장애 시에도 안전한 리더 선출을 보장한다.

Raft 알고리즘이 널리 사용되는 이유 중 하나는 분산 시스템에서 합의 문제를 해결하는 과정을 상대적으로 단순하고 이해하기 쉽게 설명해주기 때문이다. Raft는 분산 데이터베이스, 메시징 시스템, 분산 로그 시스템 등 다양한 분산 시스템에서 합의 알고리즘으로 사용된다.

이제 실제로 클러스터를 구성해보자.

Cluster 구성

  • 동일한 VPC 내부에 VM 3개를 할당해야 한다.
  • 아래의 포트 목록을 inbound, outbound 모두 열어줘야 한다.
    • 1883: MQTT용 포트
    • 4369 : epmd, 여러 rabbitmq 서버끼리 서로를 찾을 수 있는 네임 서버 역할을 하는 데몬에서 사용
    • 5671, 5672 : AMQP 를 사용한 메시지 전달 - 5672가 기본
    • 25672 : inter-node 와 CLI Tool 연결
    • 15672 : HTTP API, Management UI
  • RabbitMQ 설치
    • sudo apt-get update
    • sudo apt-get install rabbitmq-server --assume-yes
    • sudo rabbitmq-plugins enable rabbitmq_management
    • sudo rabbitmq-plugins enable rabbitmq_mqtt
  • 각각 서버에 대해 1,2,3 으로 호스트 이름 변경
    • sudo hostnamectl set-hostname mq-1
    • sudo hostnamectl set-hostname mq-2
    • sudo hostnamectl set-hostname mq-3
  • 모든 VM에서 호스트 파일에 각 노드의 ip 명시
    • sudo vi /etc/hosts
    • 아래와 같이 명시(ip는 예시)
    • 10.0.7.169 mq-1 10.0.7.206 mq-2 10.0.7.42 mq-3
    • 시스템 재시작
      • sudo reboot
  • Erlang 쿠키 맞추기
    • mq-1 기준으로 맞춘다.
    • sudo vi /var/lib/rabbitmq/.erlang.cookie 에 있는 값을 mq-2, mq-3에도 명시
    • 변경후 rabbitmq 서비스를 재시작
      • sudo systemctl restart rabbitmq-server
  • mq-2, mq-3을 mq-1의 클러스터로 등록
    • 아래 명령어는 mq-2, mq-3 에서만 작업
    • rabbitmq 중단
      • sudo rabbitmqctl stop_app
    • mq-1 에 클러스터로 등록
      • sudo rabbitmqctl join_cluster rabbit@mq-1
    • RAM 모드로 변경
      • sudo rabbitmqctl change_cluster_node_type ram
    • rabbitmq 재시작
      • sudo rabbitmqctl start_app
  • 클러스터 상태 확인
    • sudo rabbitmqctl cluster_status
    • 대시보드를 통해서도 클러스터가 맺어진 것을 확인할 수 있다.
  • Quorum Queue 생성
    • mq-1을 리딩 노드로 하여 Quorum Queue 를 생성
    • 클러스터 내에 존재하는 노드들이 메시지를 공유한다.
  • 테스트를 위해 mq-1 을 종료해보자.
    • online 목록에서 rabbit@mq-1 이 빠진 것을 확인할 수 있다. 메시지는 그대로 남아있다. 또한 Leader Node가 mq-2로 바뀌었다.
    • 또한, Message 가 들어온 시간이 변경되었는데, 이는 메시지가 새로운 Lead Node로 복제되는 과정에서 메시지가 재전송된 것이다. 이는 메시지가 다른 노드에 이미 복제되었지만, 새 Lead Node가 해당 메시지를 인지하지 못한 경우 발생할 수 있다.
  • 다시 mq-1을 살리면
    • online 목록에 rabbit@mq-1 이 다시 들어오는 것을 확인할 수 있다.
  • 메시지를 consume 한다.

 

책의 배경이 되는 2010년 초반은 지금과는 확실히 많이 다른듯하다. 특히나, AWS 등 클라우드 서비스에 대한 신뢰도가 높지 않고 대부분의 것들을 직접 구현하는 것으로 보인다. 심지어는 검색 엔진까지도 말이다.

물론 요새도 검색 엔진을 최적화 하고 fit 하게 만들기 위해 직접 구현하는 서비스들이 있을 것이다. 그러나 대부분은 Elastic Search 등 이미 구현이 잘 되어 있는 것들을 사용하지 않을까?

그 만큼, 요즘의 서비스는 안정화가 비교적 용이하다. 대량의 트래픽을 어떻게 처리할 수 있을까 하면 가장 먼저 드는 생각이 ‘컨테이너 기술을 활용하여 컨테이너를 여러개 늘려버리면 부하를 분산시키자’일 정도로, 컨테이너, 로드 밸런싱, 스케일링, 오케스트레이션 등의 기술을 어렵지 않게 다룰 수 있다.

스터디를 함께한 동료는 이러한 편의성이 서비스를 문제없이 돌아간다는 착각에 빠지게 한다고 말했는데, 공감이 가는 말이다. 어쩌면 편의성이 너무 높아진 탓에 본질을 놓지고 있을지도 모른다. 위에서 언급한 기술들은, 이제는 간편하게 다룰 수 있을지는 몰라도 자원을 더욱 많이 쓰고 그 만큼 비용이 높아지게 된다. 그러나 비즈니스 로직의 비효율적인 알고리즘을 개선하여 성능을 증가시킨다면 비용을 들이지 않고 성능 문제를 해결할 수도 있다. 어쩌면 스케일링이 필요 없을지도 모른다.

그러한 의미에서 다음 책으로는 클린코드를 읽어보려 한다. 가독성, 유지 보수성 등을 챙긴 코드가 1단계여야 하는데, 그 동안 이런 저런 기술들을 쫓으며 1단계에 소홀했던 것을 반성하게 된다.

대량의 시계열 raw 데이터를 리턴하는 API가 있다. 조회하는 기간에 비례하여 조회하는 raw 데이터의 개수도 늘어난다.

오랜 기간에 대한 성능을 확인해보기 위해 약 1만개의 더미 데이터를 생성하고 API를 호출해보았다. (end user 입장에서의 속도를 체감해보기 위해 테스트는 크롬에서 진행했다.)

보면 데이터의 크기가 7.8 MB로 큰 편이고, 불러오는 시간도 1.79초로 긴 편이다.

그렇다면 데이터를 압축해서 보내면 네트워크 I/O가 줄어드니 성능이 좋아지지 않을까? 하는 생각에 응답 데이터를 gzip으로 압축하는 middleware를 작성해보기로 했다.

 

코드는 다음과 같다.

class MafGzipMiddleWare:
    def __init__(self, get_response):
        self.get_response = get_response

    def __call__(self, request):
        response = self.get_response(request)
        # 특정 API에 대해서만 압축 적용
        if 'certain-api' in request.path:
            # gzip 을 받아주는 브라우저에만 압축
            if 'gzip' in request.META.get('HTTP_ACCEPT_ENCODING', ''):
                # 압축 가능한 형식인지 확인 (예: JSON 형식)
                if response.get('Content-Type', '').startswith('application/json'):
                    # 압축 수행
                    response.content = gzip.compress(response.content)
                    response['Content-Encoding'] = 'gzip'
                    response['Content-Length'] = str(len(response.content))

        return response

테스트 결과, 응답 데이터가 확연히 줄어든 것을 확인할 수 있었다.

그러나 성능은 오히려 더 안좋게 나왔는데, 데이터를 압축하는 데에 사용하는 서버 CPU 자원, 압축을 해제하는 데에 사용하는 client의 CPU 자원을 간과한 것이다.

 

결론은 real time으로 사용하는 api는 압축하여 사용하지 않는 편이 나을것 같다.

다만, 해당 응답 결과를 redis 등 캐시 서버에 저장해 둘 경우에는 압축하여 저장하면 메모리를 상당 부분 아낄 수 있으므로 유의미한 결과를 가질 수 있을것 같다.

Docker Compose 로 서빙하고 있는 서버가 있다.

기존에는 배포 시에 docker-compose down, docker-compose up 으로 배포를 하고 있었고, 그러다 보니 서비스가 컨테이너가 내려가고 올라오는 약 10~20초 사이 가량 서버가 멈춰야 했다. 

해당 프로세스를 개선하기 위해 blue-green 배포 전략을 적용해보았다.

 

blue-green

blue 혹은 green 버전 둘 중 하나로 배포되어 있는 상태에서 새로운 버전을 동시에 띄우고 로드밸런서를 통해서 스위칭하는 방식이며, 한 번에 두 개의 버전을 동시에 띄우기 때문에 배포 도중에는 시스템 자원이 두배로 든다는 단점이 있다.

 

이번에는 로드 밸런서 대신, nginx의 설정을 변경하여 돌고 있는 버전에 라우팅 하도록 작업했다. 이렇게 하면 새로운 VM을 띄우는 비교적 무거운 작업 대신 컨테이너만 새로 띄우는 비교적 가벼운 작업으로 대신할 수 있기 때문이다. 다만, VM의 자원이 docker compose 세트 2개를 감당할 수 있는지는 모니터링이 필요하다.

 

Docker Compose 분리

# docker-compose.yaml

version: "3.9"
services:
  web:
  	image: nginx
  	...

  app1:
  	...
    
  app2:
  	...

  celery-worker:
    ...
    
  batch:
    ...

volumes:
  ...

기존의 docker-compose.yml 파일은 위와 같다. 자세한 내용은 생략했는데, 자세히 설명하면 web server(nginx), app server, batch server 등 5개의 컨테이너가 하나의 docker-compose.yaml 에 정의되어 있다.

 

이렇게 되면 blue-green 전략을 사용하기가 까다로워 진다. 왜냐하면 새로운 docker compose 세트를 띄우면, nginx가 2개가 동시에 뜨게 되어 포트 충돌이 나게되기 때문이다. 그렇기 때문에 nginx는 따로 관리하는 것이 필요하다.

 

한 가지를 더 고려해야 하는데, celery-worker 컨테이너와 batch 컨테이너가 동시에 뜨면 안된다. celery-worker의 경우 broker 에 담겨 있는 task를 pull 하여 작업하는데, 컨테이너 2개가 동시에 뜨면 race condition이 발생한다. batch 컨테이너는 배치 작업이 중복되어 일어날 수 있기 때문에 따로 분리해야 한다.

 

무엇보다도, 무중단 배포의 목적은 app 서버가 지속적으로 api 서빙을 해줘야 하는 것에 있기 때문에 백그라운드 작업을 하는 celery-worker, batch 컨테이너들은 중단 배포가 되어도 괜찮다. 

 

따라서, 다음과 같이 docker-compose.yaml 파일을 분리했다.

# docker-compose.blue.yaml

version: "3.9"
services:
  app1:
  	...
    
  app2:
  	...
    
volumes:
  ...

 

# docker-compose.green.yaml

version: "3.9"
services:
  app1:
  	...
    
  app2:
  	...
    
volumes:
  ...

 

# docker-compose.background.yaml

version: "3.9"
services:
  celery-worker:
  	...
    
  batch:
  	...
    
volumes:
  ...

 

# docker-compose.nginx.yaml

version: "3.9"
services:
  web:
  	image: nginx
    volumes:
      - ./nginx-conf:/etc/nginx/conf.d/

사실 nginx는 굳이 도커로 띄우지 않아도 되지만, 추후에 ECS로 서비스를 옮길 것에 대비해 nginx또한 도커로 띄웠다.

 

blue-green.sh

CD 파이프라인에 끼워 넣을 무중단 배포 스크립트를 작성해야 한다.

# blue-green.sh
#!/bin/bash
DOCKER_APP_NAME="APP"

# 작동중인 nginx 컨테이너의 이름 조회
NGINX_CONTAINER_NAME=$(docker ps --filter "id=$(docker-compose -p ${DOCKER_APP_NAME}-nginx -f docker-compose.nginx.yaml ps -q)" --format "{{.Names}}")

# Blue 를 기준으로 새로운 이미지 pull
docker-compose -f docker-compose.app.blue.yaml pull

# Blue 를 기준으로 현재 떠있는 컨테이너를 체크
EXIST_BLUE=$(docker-compose -p ${DOCKER_APP_NAME}-app-blue -f docker-compose.app.blue.yaml ps | grep Up)

# 컨테이너 스위칭
if [ -z "$EXIST_BLUE" ]; then
    echo "blue up"
    docker-compose -p ${DOCKER_APP_NAME}-app-blue -f docker-compose.app.blue.yaml up -d
    BEFORE_COMPOSE_COLOR="green"
    AFTER_COMPOSE_COLOR="blue"
else
    echo "green up"
    docker-compose -p ${DOCKER_APP_NAME}-app-green -f docker-compose.app.green.yaml up -d
    BEFORE_COMPOSE_COLOR="blue"
    AFTER_COMPOSE_COLOR="green"
fi

sleep 10

# 새로운 컨테이너가 제대로 떴는지 확인
EXIST_AFTER=$(docker-compose -p ${DOCKER_APP_NAME}-app-${AFTER_COMPOSE_COLOR} -f docker-compose.app.${AFTER_COMPOSE_COLOR}.yaml ps | grep Up)
if [ -n "$EXIST_AFTER" ]; then
    # 미리 작성된 nginx.blue, nginx.green 을 버전에 맞게 default.conf로 override
    cp ./new-nginx-conf/nginx.${AFTER_COMPOSE_COLOR} ./new-nginx-conf/default.conf
    # nginx reload
    docker exec ${NGINX_CONTAINER_NAME} bash -c "nginx -s reload"

    # 이전 컨테이너 종료
    docker-compose -p ${DOCKER_APP_NAME}-app-${BEFORE_COMPOSE_COLOR} -f docker-compose.app.${BEFORE_COMPOSE_COLOR}.yaml down
    echo "$BEFORE_COMPOSE_COLOR down"
fi

# background 컨테이너 배포
docker-compose -f docker-compose.back.yaml pull
docker-compose -p ${DOCKER_APP_NAME}-back -f docker-compose.back.yaml down
docker-compose -p ${DOCKER_APP_NAME}-back -f docker-compose.back.yaml up -d

 

nginx 설정을 변경하는 방식은 다양할텐데, 나는 nginx.blue, nginx.green 두 개의 config 파일을 만들어 버전에 맞게 config를 적용하는 방식으로 접근했다. (두 파일은 port를 라우팅 해주는 부분만 차이가 있다.)

 

$ source green-blue.sh
Pulling app    ... done
Pulling app-v2 ... done
green up
2023/05/06 09:39:51 [notice] 72#72: signal process started
Stopping app-blue_app-v2_1 ... done
Stopping app-blue_app_1    ... done
Removing blue_app-v2_1 ... done
Removing blue_app_1    ... done
Removing network app-blue_default
blue down
Pulling celery-worker ... done
Pulling route-treon   ... done
Pulling batch         ... done
Stopping back_celery-worker_1 ... done
Stopping back_route-treon_1   ... done
Stopping back_batch_1         ... done
Removing back_celery-worker_1 ... done
Removing back_route-treon_1   ... done
Removing back_batch_1         ... done
Removing network back_default
Creating network "back_default" with the default driver
Creating back_route-treon_1   ... done
Creating back_batch_1         ... done
Creating back_celery-worker_1 ... done

잘 작동하는 것을 확인할 수 있다.

 

References

https://jay-ji.tistory.com/m/99

 

docker-compose 무중단 배포 1편 (blue, green)

안녕하세요! 개발자 Jay입니다! 오늘은 CI/CD를 위한 docker-compose 무중단 배포에 대해서 정리해봤습니다! 다들 로컬에서 테스트를 하거나 서버에서 docker-compose up, down 등으로 새로 컨테이너를 띄우

jay-ji.tistory.com

https://www.nginx.com/resources/wiki/start/topics/examples/full/

 

Full Example Configuration | NGINX

Full Example Configuration nginx.conf user www www; ## Default: nobody worker_processes 5; ## Default: 1 error_log logs/error.log; pid logs/nginx.pid; worker_rlimit_nofile 8192; events { worker_connections 4096; ## Default: 1024 } http { include conf/mime.

www.nginx.com

 

'기타' 카테고리의 다른 글

RabbitMQ - Cluster 적용  (0) 2023.07.07
웹 개발자를 위한 대규모 서비스를 지탱하는 기술 서평  (0) 2023.06.19
Systemd - service 데모나이즈 하기  (0) 2022.11.28
세션, 쿠키, 토큰  (0) 2021.10.04
xAPI 찍먹하기  (0) 2021.09.25

+ Recent posts