메인 콘텐츠로 이동하기
  1. Posts/

UptimeNinja.io를 구상하면서 생긴 고민들; Redis stream과 Producer, Consumer scale-out에 대하여

·1750 자

서론 #

취미로 개발을 하는 사람으로서 가장 힘든 것 중 하나는 “이번에는 무엇을 해볼까?“이다. 최근에 내 흥미를 이끈 것 중 하나는 Event-driven architecture와 Message Queue를 이용한 서비스 간의 dependency를 decoupling하는 것이었다.

평소에도 Event-driven architecture에 대한 것은 들었지만, 알게 된 것은 DEVSISTERS의 쿠키런 킹던 서버 아키텍처 라는 영상에서 이벤트 소싱에 대한 이야기를 듣고서부터였다. 그리고, 한동안 노트에만 적어두고 잊고 있었지만, 최근 들어 회사나 주변에서 Kafka에 대한 논의나 이야기들이 들려오면서 다시 한 번 마음에 불을 지피고 있다.

보통의 제품을 만들 때에는 사람들 혹은 유저의 니즈를 파악하고 그에 맞는 방향으로 개발을 시작하지만, 나는 정확히 그 반대로 진행한다. 내가 사용해보고 싶은 기술 스택이나 흥미를 이끄는 서비스 아키텍쳐를 설계하고 그에 맞는 서비스를 구상해본다. 아무래도 돈을 벌거나 창업보다는 개발 및 프로그래밍 그 자체에 더 큰 즐거움을 느끼기 때문인 것 같다. 그렇게 이번에 생각해낸 서비스는 Uptimeninja다.

🥷 UptimeNinja.io #

일단 uptimeninja.io라는 도메인은 이미 구매해 둔 상태다. uptimeninja.app과 고민했지만, 조금 더 비싸도 내 마음이 더 끌리는 쪽으로 선택했다. 현재 구상하고 있는 Uptimeninja는 굉장히 간단한 전형적인 서비스이다. 우리가 흔히 알고 있는 Uptimebot으로 생각하면 좋을 것 같다. 특정 주기마다 웹사이트 혹은 서버의 상태를 모니터링하는 도구다.

시작은 매우 단순했다. 지금 꾸준히 홈서버를 운영하는 중에 상태를 모니터링하고 그에 따른 알람을 받을 수단이 필요했다. 물론, 서버의 상태나 메트릭을 받는 Prometheus를 포함한 모니터링 스택 자체는 갖춰져 있지만, 이 또한 홈서버의 일부분이다. 홈서버와는 격리된 별도의 환경에서 알람을 받을 필요성이 있었고, 그렇기에 Uptimebot관련 외부 솔루션을 찾아봤다. 그런데, 아쉬웠던 점은 그냥 봤을 때는 되게 단순한 기능이지만 생각보다 유료 플랜의 가격이 비쌌다는 점이었다. 그래서, 이 김에 내가 새로 하나 만들어보자라고 생각하게 되었다.

그리고 지금 프로젝트를 구상하면서 드는 생각은 처음에는 매우 간단한 기능이라고만 생각했지만, 막상 scale-out을 고민하다보면 예상보다 고민해야할 포인트가 굉장히 많은 것을 깨닫게 되는 중이다. 그리고 이번 블로그 글은 어떻게 그런 문제를 해결했는가보다 어떤 고민들을 지금하고 있는가에 대한 나열에 가깝다.

  1. MQ로는 무엇을 선택할까? Kafka vs Redis
  2. 특정 interval마다 어떤 액션을 실행하려면 어떤 방식을 선택해야할까? 1) Cronjob(robfig/cron), time.Ticker, 2) pull from DB (tick_table)
  3. Event-driven 아키텍쳐에서 Producer와 Consumer들을 parallel하게 scale-out 하려면 어떤 고민이 필요할까? (w/ Redis stream)
  4. RDB보다 tsdb에 눈이 더 가는 이유

1) Kakfa vs Redis #

Event-driven 아키텍쳐 혹은 MQ(Message Queue)라고 한다면 가장 많이 이야기가 나오는 것 중 하나는 당연히 Apache Kafka다 (옛날에는 RabbitMQ 였던 것 같지만). 그리고, 나도 이 프로젝트를 처음 시작하게 된 계기도 카프카였다. 카프카의 몇 안 되는 단점 중 하나는 바로 운영 난이도일 것 같다. 특히 그리고 홈서버를 운영하는 나로서는 Managed 서비스인 MSK나 Confluent가 아닌 당연히 self-hosted를 생각하고 있었다. 물론 내 홈서버가 Kafka를 유지하는데에 스펙은 전혀 문제가 되지 않는다 😎.

내가 요즘 눈여겨보고 있는 건 바로 Strimzi Operator다. 다른 말로는 Kafka on Kuberentes 였다. K8s operator로 Kafka 클러스터를 관리할 수 있고, 쿠버네티스 CRD(Custom Resource Definition)으로 Kafka를 띄울 수 있었다. 그리고 요즘은 Strimzi에서 Kraft라는 것을 이용하면, Kafka의 zookeeper를 provisioning할 필요도 없다고 한다.

Kafka가 이렇게 내 흥미와 관심을 유발하고도, 내가 마지막으로 선택한 것은 Redis Stream이였다. 그 이유는 내가 Kafka에 대한 이해도가 많이 부족하기 때문이다. 이후에 서비스를 만든다고 했을 때, Kafka에 대한 이해도가 부족한 상태에서는 디버깅에 어려움이 있을 것이라고 판단했다. 또한, 지금은 홈서버 클러스터에서 운영을 계획하고 있지만, 이후에 클라우드로 마이그레이션을 한다고 해도 Redis가 그보다 훨씬 cost가 적을 것이라고 생각했다. 그러면, Redis Stream은 무엇일까?

Redis는 온-메모리 데이터베이스로 너무 유명하지만, Redis stream에 대해서는 많이 못 들어봤을 수 있다. Redis를 Event-driven 아키텍쳐에서 활용한다고 하면, 보통 떠올리는 것은 2가지이다. 1) Redis Pub/Sub이나 2) Redis List type이다.

하지만 Redis Stream을 포함한 이 3가지는 모두 서로 다른 특징을 가지고 있다. 간단히 표로 정리하면 다음과 같다. 여기서 각 용어 혹은 MQ에 대한 개념을 자세히 설명하지는 않도록 하겠다.

Delivery Persistence Complexity
Redis Pub/Sub At-most-once low
Redis List At-most-once AOF, RDB low
Redis Stream At-least-once AOF, RDB higth

Redis Pub/Sub은 하나의 Topic을 구독하고 있는 모든 Subscriber들이 해당 이벤트를 처리했기 때문에, 한 개의 monitor당 한 개의 request만을 트리거하면 되는 Uptimeninja와는 맞지 않았다. Redis List는 다음과 같이 작동한다.

flowchart LR Producer -->|LPUSH| Redis[(Redis)] Redis -.->|BRPOP| a[Consumer a] Redis -.->|BRPOP| b[Consumer B]

하나의 Queue에 새로운 element를 push하고 consumer들이 이를 pop하면서 이벤트를 처리하는 방식이다. 이 경우에는 Consumer가 여러개여도 하나에 이벤트에 대해서 한 번의 트리거만 할 수 있다. 하지만 이 경우에도 At-most-once 방식으로 처리한다는 단점이 있다. Consumer가 이벤트를 받은 이후에 액션에 실패한 경우에 이에 대응하기 어렵다는 것이다.

이 모든 단점을 커버하는 것이 바로 Redis stream이다.

redis-stream
Redis Docs에서

Redis에서 자체적으로 stream 이라는 데이터타입을 지원한다. 이 경우에 XADD로 이벤트를 더하고, XREAD로 해당 이벤트를 읽어온다. 하지만 특이한 점은 XREADGROUP이라는 커맨드를 통해서 Consumer Group 기능을 자체 지원한다. 이를 통해서 Parallel한 Consumer들을 쉽게 구성할 수 있다. Redis stream에서 이벤트의 state는 다음과 같은 상태를 가진다.

stateDiagram state "Added to Stream" as Added state "Pending (PEL) of A" as PendingA state "Pending (PEL) of B" as PendingB state "Acknowledged" as Acknowledged [*] --> Added: XADD Added --> PendingA: XGROUPREAD from 'A' Added --> PendingB: XGROUPREAD from 'B' PendingA --> Acknowledged: XACK PendingB --> Acknowledged: XACK PendingB --> PendingA: XCLAIM PendingA --> PendingB: pending event

Redis stream에 와서는 state에 PENDING 상태가 부여되었다. 처음 Consumer Group의 Consumer가 XGROUPREAD로 이벤트를 읽어오면, Consumer의 PEL(Pending Entries List) 에 추가된다.

그 이후에 이벤트의 액션을 성공적으로 처리한 후에 명시적으로 XACK으로 해당 이벤트가 온전히 처리됐음을 표시할 수 있다. 또한, 한 이벤트가 consumer에서 오랫동안 Pending 상태에 머물러있다면, XCLAIM으로 다른 consumer에서 해당 이벤트를 받아서 처리할 수도 있다. 이러한 특징을 이용해서 At-least-once behavior를 달성할 수 있는 것이다.

2) Stateful vs Stateless Producer #

Uptimeninja에서 발행하는 가장 중요한 이벤트는 HealthCheckRequest 이벤트이다. 그 구조는 다음과 같다.

type HealthCheckReq struct {
	ID        string `json:"id"`
	MonitorID string `json:"monitor_id"`
	Url       string `json:"url"`
	Method    string `json:"method"`
	// Headers map[string]string `json:"headers"`
	Timeout int64 `json:"timeout"`
	// Body    string            `json:"body"`
}

Healthcheck를 위해서 어떤 HTTP 요청을 해야하는 지에 대한 이벤트이다. 이 때 Producer는 서버 내부적으로 모니터마다 특정 interval을 가지고 각기 다른 주기로 HealthCheckReq 이벤트를 발행할 수 있어야한다. 이때 방법은 크게 2가지 방법이 있다.

  1. 서버 내부적으로 Cronjob(robfig/cron) 혹은 golang의 time.Ticker를 이용
  2. DB에 따로 ticks와 같은 table에 last_tick, next_tick과 같은 state를 기록해두고, 서버에서 일정한 주기로 DB에서 SELECT해서 이벤트를 발행하는 방식

결국 Producer 서버가 1) Stateful vs 2) Stateless한지에 대한 고민이다.

Cronjob and *time.Ticker #

type Producer struct {
	redisClient *redis.Client
	dbClient    *sql.DB
	config      *config.Config

	monitorTickers map[string]*time.Ticker
	monitorMutex   sync.RWMutex
}

Producer에 state로 Monitor*time.Ticker를 각각 key와 value를 가지는 Map을 가질 수 있다. 이때 다음과 같은 psuedocode를 가질 수 있다.

func (p *Producer) updateMonitorTickers(monitors models.MonitorSlice) {
	p.monitorMutex.Lock()
	defer p.monitorMutex.Unlock()

	// ...

	// Stop and remove tickers for monitors that no longer exist
	for id, ticker := range p.monitorTickers {
		exist := lo.ContainsBy(monitors, func(m *models.Monitor) bool {
			return m.ID == id
		})
		if !exist {
			ticker.Stop()
			delete(p.monitorTickers, id)
			log.Println("Removing monitor ticker for", id)
		}
	}

	// Update or add tickers for monitors
	for _, monitor := range monitors {
		if ticker, exists := p.monitorTickers[monitor.ID]; exists {
			// Update existing ticker's interval
			ticker.Reset(time.Duration(monitor.Interval) * time.Second)
			go p.startMonitorTicker(monitor, ticker)
			// TODO: need to stop original goroutines
		} else {
			// Create new ticker
			ctx, cancel := context.WithCancel(context.Background())
			p.monitorContexts[monitor.ID] = cancel

			ticker := time.NewTicker(time.Duration(monitor.Interval) * time.Second)
			p.monitorTickers[monitor.ID] = ticker

			log.Println("Starting monitor ticker for", monitor.URL)
			go p.startMonitorTicker(ctx, monitor, ticker)
		}
	}
}

하지만, 이 경우에 DB에 요청을 줄일 수 있지만, 서버 내에 goroutines을 관리해야하는 코드 복잡성이 증가한다. 특히 중간에 HealthCheck 모니터가 늘어나거나 삭제되거나, interval이 변하거나, 이때 이미 running하는 goroutines를 수정하는 데 많은 고민이 필요하다. 또한 이후에 Parallel한 Producer가 있다고 했을 때, 동일한 모니터에 대해서 여러 개의 중복된 이벤트가 발행될 수도 있다. 이때 cronjob에 대한 정보가 서버 내의 메모리에 정의되어있기에 서버들간의 상태를 동기화할 수 있는 방법도 마련해야한다.

Pull from DB #

CREATE TABLE ticks (
    id VARCHAR(255) PRIMARY KEY,
    monitor_id VARCHAR(255) NOT NULL,
    last_tick TIMESTAMP WITH TIME ZONE DEFAULT NULL,
    next_tick TIMESTAMP WITH TIME ZONE DEFAULT NULL,
    counter INT NOT NULL DEFAULT 0,
    updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,

    CONSTRAINT fk_monitor_id FOREIGN KEY (monitor_id) REFERENCES monitors (id)
        ON DELETE CASCADE ON UPDATE CASCADE
);

이렇게 tick 테이블을 명시하고 Consumer에서 해당 이벤트를 처리한 이후에 last_tick을 기록하고, next_ticklast_tick에 interval을 더해서 저장한다. 그러면, 서버에서는 일정한 주기로 now() > next_tick인 레코드만 골라서 이벤트를 발행하면 된다. state를 DB에 저장함으로써, state 관리 자체는 굉장히 간편해진다.

하지만, 이 경우에 치명적인 단점은 DB 요청이 정말 많아진다는 점이고, 그와 동시에 HealthCheck 모니터의 interval이 DB를 에서 next_tick을 받아오는 주기보다 짧을 수 없다. 즉, 1초와 같은 짧은 interval은 설정하는 것이 굉장히 힘들어진다.

3) Producer와 Consumer들을 어떻게 scale-out 해야할까? #

Consumer를 scale-out하거나 동시에 여러 개의 Consumer들이 있는 것은 이미 Redis stream의 Consumer Group들이 충분히 그 역할을 잘 해주기에 큰 문제가 되지 않는다. 하지만 Producer의 경우에는 궤가 다르다.

Producer에서 각 HealthCheck 모니터에 따른 서로 다른 interval을 가진 Cronjob이 실행되는데, 이 때 1) 서로 다른 Producer에서 같은 Cronjob을 실행하지 않도록 해야한다. 같은 Cronjob을 여러번 실행해도 Uptimeninja 특성상 큰 문제는 없지만, 이가 누적되면 이후에 의도치 않은 리소스 낭비로 이어질 수 있다. 또한, 2) Interval이 중간에 변경되거나 새로운 HealthCheck 모니터가 생성될 때 이를 Producer 서버에 반영해주어야한다.

flowchart LR Manager -->|Manages States| A[Producer A] Manager -->|Assigns Cronjob| A Manager -->|Add/Delete/Edit Cronjob| B[Producer B] Manager --> C[Producer C] A & B & C --> Redis[(Redis stream)] Redis --> Consumers[Consumer Groups] Consumers <-->|HTTP Request| Internet Consumers -->|Persist| RDB[(RDB)]

이 때 가장 쉽게 떠올릴 수 있는 방법 중 하나는 Producer에서 본인의 state를 export 및 edit할 수 있는 endpoint를 제공하고, 외부에 별도의 Manager가 이를 관리하는 것이다.

flowchart LR A[Producer A] -->|Acquire Lock| Lock[(Cronjob RDB)] B[Producer B] --> Lock C[Producer C] --> Lock A & B & C -->|event| Redis[(Redis Stream)] Redis --> Consumers(Consumer Groups) Consumers <-.->|HTTP Request| Internet((Internet)) Consumers -->|Persist| RDB[(Log RDB)]

별도의 Manager 서버를 만드는 것이 부담스럽다면, 위와 같이 RDB에 Producer들이 각 Cronjob에 대한 소유권을 명시해둔 테이블을 생성할 수도 있다. Parallel하게 여러개의 Producer를 운영하기에는 조금 더 많은 고민이 필요할 것 같다.

더 나아가, 각 Producer의 Graceful shutdown 및 restart에 대한 고민 또한 필수적이다. 30초나 1분과 같은 짧은 주기의 healthcheck에는 큰 문제가 되지 않지만, 예를 들어 22시간 주기의 Cronjob에서 Producer 서버가 재시작이 잦다면, 해당 Healthcheck는 계속해서 지연될 수 있다.

4) RDB(관계형 데이터베이스)보다 tsdb(시계열 데이터베이스)가 낫지 않을까? #

CREATE TABLE reports (
    id VARCHAR(255) PRIMARY KEY,
    monitor_id VARCHAR(255) NOT NULL,
    status_code INT NOT NULL,
    response_time INT NOT NULL,
    created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
    status VARCHAR(255) NOT NULL, -- "UP" or "DOWN"

    CONSTRAINT fk_monitor_id FOREIGN KEY (monitor_id) REFERENCES monitors (id)
        ON DELETE CASCADE ON UPDATE CASCADE
);

HealthCheck 이벤트에 대한 정보를 다음과 같은 테이블에 저장한다고 가정하자. 해당 테이블의 레코드는 기하급수적으로 늘어날 것이다. 물론 오늘날의 RDB는 매우 heavy한 write workload도 견딜 수 있지만, RDB는 index가 커질수록 그 성능이 계속해서 저하된다는 단점이 있다. 이를 해결하고자 오늘날 InfluxDB와 같은 tsdb(Time Series Database)도 나오고 있다. tsdb는 방금 언급했던 RDB의 단점을 해결하고 쓰기와 조회에 최적화되어 있으며, 날씨나 주가와 같이 한 번 기록한 이후 불변한 데이터를 저장하는 데에 적합하다. 즉, 지금 uptimeninja 서비스와도 부합하다고 할 수 있다.

하지만, Kafka를 선택하지 않은 것과 같은 이유로 아직 tsdb에 대해 아는 바가 거의 없기에 더 많은 고민은 필요해보인다. 그리고, tsdb를 쓰지 않는다 하더라도 RDB에 무작정 모든 지난 event를 저장하는 것도 정답은 아니라는 생각이 든다.

결론 #

이러한 아키텍처 고민들은 매우 흥미로웠으며, 앞으로도 UptimeNinja.io 서비스 개발에 많은 영감을 주고 있습니다. 기술적 도전과 해결책을 찾아가는 과정이 매우 즐거웠고, 여러분의 많은 응원과 관심 부탁드립니다. 앞으로도 UptimeNinja.io가 성장하고 발전하는 모습을 함께 지켜봐 주시길 바랍니다.

Reference #