Computer >> 컴퓨터 >  >> 프로그래밍 >> Ruby

AppSignal 메트릭으로 백그라운드 대기열 작업자 효율적으로 자동 확장하기

대부분의 웹 애플리케이션은 백그라운드 대기열(background queue)을 활용하면 큰 이점을 얻을 수 있습니다. 백그라운드 작업은 오류가 발생하기 쉽거나 시간이 오래 걸리는 부가적인 작업들을 처리하는 데 주로 사용되며, 이메일 발송부터 캐시 업데이트, 심지어 핵심 비즈니스 로직 처리까지 그 범위는 다양합니다.

백그라운드 대기열 시스템이 처리해야 할 작업 수가 늘어나면, 이를 처리하는 워커(worker) 풀 역시 함께 확장되어야 합니다. 작업이 유입되는 속도가 일정하지 않은 환경에서는 대기열에 쌓인 작업량에 맞춰 워커 수를 늘리는 것이 처리 속도를 유지하는 핵심 요소입니다. 또한 트래픽이 적은 시간대에는 워커 수를 줄임으로써 상당한 비용 절감 효과도 기대할 수 있습니다.

안타깝게도 많은 대기열 백엔드에는 워커를 자동으로 켜고 끄는 스케일링 로직이 내장되어 있지 않습니다. 하지만 간단한 수학 공식과 성능 데이터만 있으면, 대기열에 대기 중인 작업량을 기반으로 최적의 워커 수를 산출할 수 있습니다.

대기열 법칙(Queueing Rule of Thumb)

작업이 워커가 처리하는 속도보다 빠르게 대기열에 쌓이면, 대기열의 깊이(queue depth)는 계속 증가하고 각 작업이 대기열에서 소비하는 시간 역시 길어집니다. 일반적으로 우리는 각 작업의 대기 시간(wait time)을 최대한 짧게 유지하고 싶어 합니다. 0초부터 어느 정도 허용 가능한 한계까지 말입니다.

원하는 대기 시간을 충족하기 위해 필요한 워커 수를 추정하려면 대기열 법칙(QROT, Queueing Rule of Thumb)을 활용할 수 있습니다. QROT는 보통 작업 대기열을 서비스하기 위해 필요한 서버 수를 나타내는 부등식으로 표현되지만, 다음과 같은 형태로도 쓸 수 있습니다.

workers = (number_of_jobs * avg_service_time_per_job) / time_to_finish_queue

예를 들어, 대기열을 30초 안에 모두 처리하고 싶다면 필요한 것은 두 가지뿐입니다. 바로 대기 중인 작업 수(대기열 크기)와 각 작업의 평균 실행 시간입니다. 만약 대기열에 7,500개의 작업이 있고 각 작업의 평균 실행 시간이 0.3초라면, 75개의 워커로 해당 대기열을 30초 안에 완료할 수 있습니다.

성능 메트릭에 접근하기

대기열에 있는 작업들의 평균 실행 시간(service time)을 추정하려면, 각 잡(job) 클래스별 성능 데이터에 접근할 수 있어야 합니다. 다행히 AppSignal은 일반적인 대기열 백엔드에 대한 성능 데이터를 별도 설정 없이(out-of-the-box) 수집합니다. 작업이 실행될 때마다 해당 메트릭이 기록됩니다.

곧 출시될 AppSignal GraphQL API를 사용하면 지난 24시간 동안의 잡 타입별 평균 소요 시간을 조회할 수 있습니다. 이 API는 아직 완전히 공개되지 않았지만, 현재 AppSignal의 성능 그래프와 여러 데이터 화면에서 사용되고 있습니다. GraphQL API는 기본적으로 자체 문서화(self-documenting)를 지향하므로 GraphiQL 같은 도구로 API를 인트로스펙션(introspection)하여 어떤 데이터 객체를 노출하는지 확인할 수 있습니다.

GraphQL 쿼리를 작성하는 과정 자체는 이 글의 범위를 벗어나지만, 아래는 인기 있는 Faraday HTTP 클라이언트 라이브러리를 사용해 AppSignal GraphQL API에 연결하고 기본적인 메트릭 집계를 조회하는 Ruby 클래스 예제입니다.

require 'json'
require 'faraday'
 
class AppsignalClient
  BASE_URL = 'https://appsignal.com/'
  DEFAULT_APP_ID = ENV['APPSIGNAL_APP_ID']
  DEFAULT_TOKEN = ENV['APPSIGNAL_API_TOKEN']
  # 선택된 앱의 "평균(mean)" 메트릭을 가져오는 GraphQL 쿼리
  METRICS_QUERY = <<~GRAPHQL.freeze
    query($appId: String!, $query: [MetricAggregation!]!, $timeframe: TimeframeEnum!) {
      app(id: $appId) {
        metrics {
          list(timeframe: $timeframe, query: $query) {
            start
            end
            rows {
              fields {
                key
                value
              }
            }
          }
        }
      }
    }
  GRAPHQL
 
  def initialize(app_id: DEFAULT_APP_ID, client_secret: DEFAULT_TOKEN)
    @app_id = app_id
    @client_secret = client_secret
  end
 
  # 특정 잡 클래스 perform 액션의 평균 소요 시간 조회
  # 기본 조회 범위는 최근 24시간
  def average_job_duration(job_class, timeframe: 'R24H')
    response =
      connection.post(
        'graphql',
        JSON.dump(
          query: METRICS_QUERY,
          variables: {
            appId: @app_id,
            timeframe: timeframe,
            query: [
              name: 'transaction_duration',
              headerType: legacy,
tags: [
                { key: 'namespace', value: 'background' },
                { key: 'action', value: "#{job_class.name}#perform" },
              ],
              fields: [{ field: 'MEAN', aggregate: 'AVG' }],
            ],
          }
        )
      )
    data = JSON.parse(response.body, symbolize_names: true)
    rows = data.dig(:data, :app, :metrics, :list, :rows)
    # 선택된 기간에 메트릭이 없을 수 있습니다
    return 0.0 if rows.empty?
 
    rows.first[:fields].first[:value]
  end
 
  private
 
  def connection
    @connection ||= Faraday.new(
      url: BASE_URL,
      params: { token: @client_secret },
      headers: { 'Content-Type' => 'application/json' },
      request: { timeout: 10 }
    ) do |faraday|
      faraday.response :raise_error
      faraday.adapter Faraday.default_adapter
    end
  end
end

이 클래스를 활용하면 특정 ActiveJob 클래스의 평균 실행 시간을 밀리초(ms) 단위로 받아올 수 있습니다.

AppsignalClient.new.average_job_duration(MyMailerJob)
# => 233.1

기본값으로 이 호출은 최근 24시간 데이터를 기준으로 잡의 평균 트랜잭션 소요 시간을 반환합니다. 만약 잡이 그보다 훨씬 자주 실행된다면 조회 범위를 더 짧게 조정하여 최근 실행 결과에 더 높은 가중치를 두는 것이 좋습니다. 예를 들어 한 시간에 수백 번 실행되는 잡이라면 timeframe을 한 시간(R1H)으로 변경하면 "지금 당장 실행했을 때"의 소요 시간을 더 정확하게 추정할 수 있습니다.

주목할 점은 이 성능 데이터가 CPU 사용률 같은 서버 유틸리티 지표와는 별개라는 사실입니다. 이 데이터는 각 잡에 필요한 실제 작업 수행에 걸리는 시간을 알려줍니다. 워커 스케일링 관점에서는 사용률 지표 같은 외부 측정값보다 훨씬 유용합니다.

대기열 들여다보기

다음으로, 처리해야 할 작업을 파악하기 위해 대기열 자체를 살펴봐야 합니다. Ruby 진영에서 널리 쓰이는 대기열 백엔드인 Resque는 ActiveJob과도 매끄럽게 통합됩니다. Resque에서 특정 대기열에 적재된 잡 목록에 접근한 뒤, 각 잡의 클래스를 기반으로 앞서 만든 AppsignalClient 클래스를 사용해 실행 시간을 추정할 수 있습니다.

require 'resque'
 
class ResqueEstimator
  def initialize(queue: 'default')
    @queue = queue
    @cache = {}
    @appsignal_client = AppsignalClient.new
  end
 
  def enqueued_duration_estimate
    Resque.data_store.everything_in_queue(queue).map do |job|
      estimate_job_duration decode_activejob_args(job)
    end.sum
  end
 
  def estimate_job_duration(job)
    @cache[job['job_class']] ||= @appsignal_client
                                 .average_job_duration job['job_class']
  end
 
  private
 
  # ActiveJob 전용 메서드: ActiveJob+Resque 통합 환경에서
  # 잡 인수(arguments)를 파싱합니다
  def decode_activejob_args(job)
    decoded_job = job
    decoded_job = Resque.decode(job) if job.is_a? String
    decoded_job['args'].first
  end
end

사용법은 아주 간단합니다.

ResqueEstimator.new(queue: 'my_queue').enqueued_duration_estimate
# => 23000 (ms)

여기서 estimate_job_duration 메서드에 간단한 메모이제이션(memoization)을 적용해 AppSignal API의 중복 호출을 방지했다는 점에 주목하세요. 하나의 대기열에는 대개 같은 클래스의 잡이 수십~수백 개씩 담겨 있으므로, 클래스당 한 번만 실행 시간을 추정하면 오버헤드를 크게 줄일 수 있습니다.

성능 데이터 기반으로 워커 스케일링하기

이제 모든 조각을 하나로 모을 차례입니다. 최신 성능 데이터를 활용해 대기열의 내용물에 따라 워커 수를 늘리거나 줄일 수 있습니다! 언제든 대기열의 잡 목록을 확인하고, 원하는 처리时限 내에 대기열을 비우는 데 필요한 워커 수를 산출할 수 있습니다.

먼저 몇 가지 값을 정해야 합니다. 첫째는 목표 대기 시간 한계(어떤 잡도 대기열에서 기다려야 하는 최대 시간), 예컨대 30초입니다. 둘째는 최소/최대 워커 수입니다. 대기열이 한동안 비어 있다가 새 잡이 처음 유입될 때를 대비해 최소 1개의 워커는 항상 가동해 두는 것이 좋습니다. 또한 너무 많은 워커가 생성되어 데이터베이스 커넥션이나 서버 비용이 과도하게 늘어나지 않도록 최대 워커 수도 지정해야 합니다.

이 로직을 담당하는 클래스를 만들면 되는데, 사실상 앞서 살펴본 대기열 법칙(QROT)의 구현체입니다.

class ResqueWorkerScaler
  def initialize(queue: 'default', workers_range: 1..100, desired_wait_ms: 300_000)
    @queue = queue
    @workers_range = workers_range
    @desired_wait_ms = desired_wait_ms
    @estimator = ResqueEstimator.new(queue: @queue)
  end
 
  def desired_workers
    total_time_ms = @estimator.enqueued_duration_estimate
    workers_required = [(total_time_ms / desired_wait_ms).ceil, workers_range.last].min
    [workers_required, workers_range.first].max
  end
 
  def scale
    # 플랫폼별 스케일링 인터페이스를 사용해 desired_workers로 조정
  end
end

워커를 정기적으로 스케일링해야 수요에 따라 탄력적으로 늘리고 줄일 수 있습니다. ResqueWorkerScaler 클래스를 호출하는 Rake 태스크를 만들어 보겠습니다.

# lib/tasks/resque_workers.rake 내부
 
namespace :resque_workers do
  desc '적재된 잡을 기준으로 워커 풀 스케일링'
  task :scale, [:queue] => [:environment] do |_t, args|
    queue = args[:queue] || 'default'
    ResqueWorkerScaler.new(queue: queue).scale
  end
end

그다음 cron 잡을 등록해 이 Rake 태스크를 주기적으로 실행하면 됩니다.

*/5 * * * * /path/to/our/rake resque_workers:scale
# 기본 대기열이 아닌 경우:
*/5 * * * * /path/to/our/rake resque_workers:scale['my_queue']

스케일링 태스크를 5분 간격으로 실행하도록 설정했습니다. 새로 띄운 워커가 온라인 상태가 되어 잡을 처리하기 시작하기까지는 코드베이스 크기와 의존하는 gem 수에 따라 대략 10~40초 정도 걸립니다. 따라서 1분마다 스케일링을 시도하면, 원하는 변경 사항이 반영되기도 전에 다시 스케일 업/다운을 반복하는 문제가 생길 수 있습니다. 애플리케이션의 대기열 사용량이 하루 중 시간대에 따라서만 변동한다면 시간 단위 실행으로도 충분할 것입니다. 반대로 한 시간 안에도 대기열 크기가 크게 변동한다면 위처럼 5분 간격 등 더 짧은 주기로 대기열을 살펴야 합니다.

다음 단계

실측 성능 데이터를 기반으로 인프라를 확장하는 이런 시스템은 수요 변화에 민첩하게 반응하고 다양한 사용 패턴에도 견고하게 대응할 수 있습니다. 특히 백그라운드 처리 환경에서는 메모리 사용량이나 로드 애버리지(load average) 같은 호스트 지표가 거의 변하지 않는 경우가 많으므로, 성능 메트릭 기반 스케일링이 훨씬 적합합니다.

다른 스케일링 구현 방식으로는 전체 대기열을 들여다보는 대신 잡당 평균 대기 시간을 측정하는 방법도 있습니다. 하지만 이 지표는 대기열의 내용과 크기가 빠르게 변하는 상황에서는 실제 상황을 제대로 반영하지 못할 수 있습니다. 시스템의 부하 편차가 크고, 잡이 한꺼번에 몰려 유입되거나 잡 실행 시간이 들쑥날쑥하다면, 대기열 직접 조회(queue introspection) 방식이 훨씬 빠르게 반응하고 신뢰할 수 있게 정확합니다.

다만 대기열 조회 방식에도 고려해야 할 한계가 있습니다. 대기열이 충분히 크다면 모든 잡을 하나씩 확인해 실행 시간을 추정하는 것 자체가 감당하기 어렵게 느려질 수 있습니다. 이런 경우에는 전체 잡 개수를 먼저 파악한 뒤, 대기열에서 무작위 표본 샘플을 추출해 샘플 기반 평균 실행 시간을 계산하는 편이 낫습니다. 또한 아직 성능 데이터가 하나도 기록되지 않은 잡 클래스는, 실제로 몇 번 실행되어 데이터가 쌓일 때까지 가정된 실행 시간을 사용해야 합니다.

앞서 소개한 시스템은 몇 가지 개선을 통해 더욱 강력해질 수 있습니다. 각 잡 클래스의 실행 시간 추정은 서로 독립적이고 멱등(idempotent)하므로 병렬로 처리할 수 있습니다. 현재 워커가 실행 중인 잡들까지 대기열 조회 범위에 포함하면 전체 소요 시간 추정의 정확도도 높아집니다. 여러 개의 대기열을 운영하는 백그라운드 처리 아키텍처라면, 우선순위에 따라 대기열마다 서로 다른 목표 대기 시간을 부여하고 그에 맞게 워커를 배분할 수도 있습니다.

대기열 시스템은 프로젝트에서 변동성이 가장 큰 작업들이 모이는 곳입니다. 대기열 잡들의 실제 실행 성능 데이터가 있다면, 우리는 모든 작업을 민첩하고 효율적으로 처리할 수 있도록 리소스를 탄력적으로 확장할 수 있습니다.

즐거운 스케일링 되세요!

P.S. Ruby Magic 글이 발행되자마자 읽고 싶으시다면 Ruby Magic 뉴스레터를 구독하세요. 단 하나의 글도 놓치지 않을 수 있습니다!