🥳 200만 유저의 친구 ‘이루다’ 기술로 AI 캐릭터를 자유롭게 만들어보세요 ‘핑퐁 스튜디오’ 보러가기

Tech

루다 선톡을 대비하는법

트래픽이 갑자기 증가할 때 어떻게 대응할까요?

손민기 홍연준 | 2023년 11월 29일 | #Engineering

루다가 먼저 메시지를 보내는 기능(aka 선톡)은 루다가 더 사람처럼 느껴지도록 하는 중요한 기능입니다. 루다가 사용자들에게 먼저 메시지를 보내면 많은 사용자가 루다와 대화를 시작합니다. 이때에는 짧은 시간에 사용자가 빠르게 늘어나기에 서비스를 안정적으로 운영하려면 미리 대응해야 합니다. 이번 글에서는 핑퐁팀이 어떻게 선톡에 대비하는지를 공유하겠습니다.

기존 시스템의 문제점

핑퐁팀의 서버들은 쿠버네티스 클러스터에서 운영되고 있고, 트래픽의 변화에 유연하게 대응하기 위해 HPA(Horizontal Pod Autoscaler)를 사용해 서버들을 스케일 아웃하고 있습니다. 하지만 선톡 상황에서는 아래와 같은 이유로 HPA로 대응하기가 까다로웠습니다.

불규칙한 선톡 전송

루다는 다양한 상황에서 선톡을 전송하고 있습니다. 유저의 조건에 따라 미리 정해진 선톡이 발송되기도 하며, 루다가 사용자의 대화를 바탕으로 적절한 선톡을 생성하기도 합니다. 루다의 선톡은 정해진 시간 없이 조건에 따라 적절한 시간에 발송됩니다. 선톡을 받는 사용자 수와 선톡이 나가는 시간이 불규칙하기 때문에 고정적인 스케일링 전략을 설정하기 어렵습니다.

기존 HPA의 문제

사용자가 증가할 때 스케일아웃하는 가장 일반적인 방법은 HPA를 사용하는 것입니다. HPA를 사용하면 특정 리소스 메트릭을 확인하고 임계점을 넘을 시 스케일아웃 할 수 있습니다. 핑퐁팀에서는 이미 HPA와 Custom Metric Server를 사용하여 AutoScaling 정책을 관리해왔습니다.

그러나 선톡과 같이 트래픽이 짧은 시간 내에 급격하게 증가하는 경우, 기존 HPA 구성으로는 다음과 같은 이유로 효과적으로 대응하기 어려워집니다.

그 외 문제점

현재까지 Kubernetes에서는 Custom Metrics Server를 하나만 지원하고 있기 때문에 이미 Prometheus Metrics Server가 배포된 핑퐁팀 클러스터에 추가적인 Metrics Server를 배포할 수 없습니다.

루다, 다온, 세중이는 다양한 모델 및 서버로 구성되어 있고, 각 캐릭터가 선톡을 보내는 상황에서 다양한 서버가 각기 다른 트래픽을 받게 됩니다. 기존의 HPA 및 prometheus metric server로는 이러한 상황에서 적절한 스케일아웃 전략을 설정하기 어려웠습니다.

트래픽 증가 대응

Prescaler

기존 시스템으로는 선톡, 이벤트 등 예측 가능한 요인으로 인한 트래픽 급증에 대응하기 어려워 Prescaler라는 새로운 시스템을 개발하게 되었습니다.

Prescaler는 3가지 개념으로 트래픽을 예측하고 선제적으로 서버를 스케일아웃합니다.

발송될 메시지 개수 파악

발송될 메시지 개수는 얼마나 많은 사용자가 접속할지를 예측할 수 있게 해주는 중요한 데이터입니다. 발송될 메시지 개수를 미리 알고 있으면 기존의 지표를 고려하여 어느 정도로 스케일링이 필요한지 판단할 수 있습니다.

기존에는 선톡 받을 유저를 검색하고 메시지를 발송하는 두 과정을 한 번에 처리하여 실제로 발송될 메시지를 예측하고 대응하기 어려웠습니다. 따라서 해당 두 과정을 10분 간격으로 분리하여, 유저를 검색하는 시점에 발송될 메시지 개수를 파악하고 대응할 시간을 만들었습니다.

또한 Prescaler에서 발송할 메시지의 개수를 파악할 수 있도록 이벤트 시작 시각, 이벤트 종료 시각 그리고 해당 이벤트에 대한 변숫값을 받을 수 있도록 아래와 같이 API를 열어두었습니다.

{
  "items": [
    {
      "start": 1700545800,
      "end": 1700545860,
      "values": {
        "scheduledMessage": 50
      }
    }
  ]
}

Custom Resource 정의

Custom Resource는 쿠버네티스에서 제공하는 익스텐션입니다. Custom Resource를 사용하면 구조화된 데이터를 저장하고 검색할 수 있기 때문에 Custom Controller와 결합하여 쿠버네티스가 기존에 제공하는 기능뿐만 아니라 개발자가 원하는 기능도 추가할 수 있습니다. 조금 더 자세한 내용이 필요한 경우 k8s 공식 문서를 참고해주세요

Custom Resource는 Custom Resource Definition을 통해서 정의해야 합니다. Custom Resource Definition은 다음 코드와 같이 Resource에 대한 정의만 하면 되기 때문에 큰 어려움 없이 작성할 수 있습니다.

apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
  name: testresource.test.pingpong.ai
spec:
  group: test.pingpong.ai
  scope: Namespaced
  names:
    plural: testresource
    singular: testresource
    kind: TestResource
  versions:
    - name: v1
      served: true
      storage: true
      schema:
        openAPIV3Schema:
          type: object
          properties:
            spec:
              type: object
              properties:
                target:
                  type: string
                replicas:
                  type: integer

위와 같이 정의된 Custom Resource Definition은 쿠버네티스에 등록하면 아래와 같은 리소스를 사용할 수 있게 됩니다.

apiVersion: "test.pingpong.ai/v1"
kind: TestResource
metadata:
  name: test-resource
spec:
  target: service-a
  replicas: 3

먼저 Custom Resource를 정의하는 데 필요한 기능을 정리하였습니다.

위의 요구사항을 바탕으로 정의된 Custom Resource는 다음과 같습니다.

Virtual Metric

예측해야하는 어떠한 메트릭을 정의합니다. 이 리소스로 정의된 metric으로 HorizontalPodPrescaler가 스케일링합니다.

하나의 Resource에 대한 스케일링 전략을 다양한 곳에서 설정할 수 있기 때문에 mergeStrategy를 통해 중복되는 Resource들의 값에 대하여 병합 전략을 설정할 수 있어야 합니다.

apiVersion: prescaler.pingpong.ai/v1
kind: VirtualMetric
metadata:
  name: rps
spec:
  name: rps           # 메트릭 이름
  defaultValue: 0     # 기본 값
  mergeStrategy: Sum  # 리소스가 중복될 시 메트릭 병합 처리 전략

Virtual Metric Event

Virtual Metric의 이벤트를 정의합니다. 이벤트는 예측해야 하는 메트릭의 변화를 의미합니다.

이곳에서 Metric을 가져올 Source를 지정할 수 있습니다. 또한, 값에 대한 expression을 제공하여 각 서비스의 요구사항에 따라 다르게 값을 변경할 수 있어야 합니다.

apiVersion: prescaler.pingpong.ai/v1
kind: VirtualMetricEvent
metadata:
  name: stage-vme
spec:
  source:
    api:                                 # 메트릭을 가져올 주소
      url: https://stage.pingpong.ai/service-a/metrics 
  metrics:
    - name: rps                          # 메트릭 이름
      resource:                          # 적용할 리소스 정보
        name: service-a
        namespace: service-a
      value:
        expression: scheduledMessage * 2 # expression 사용도 가능하도록 정의
    - name: rps
      resource:
        name: service-b
        namespace: service-b
      value:
        expression: scheduledMessage

Horizontal Pod Prescaler

autoscaling할 HPA 및 해당 HPA의 목표 metric을 지정합니다. 전체적인 구조는 HPA와 유사하지만, scaleTargetRef로 HPA를 지정합니다.

averageValue로 보장되어야 할 Metric을 지정할 수 있어야 합니다.

apiVersion: prescaler.pingpong.ai/v1
kind: HorizontalPodPrescaler
metadata:
  name: service-a
  namespace: service-a
spec:
  metrics:
    - metric:
        name: rps                  # 메트릭 이름
      target:
        averageValue: 10           # 보장되어야 할 메트릭
  scaleTargetRef:                  # 실제 변경할 기존 HPA 정보
    apiVersion: autoscaling/v2
    kind: HorizontalPodAutoscaler
    name: service-a-hpa

이처럼 Custom Resource에 대한 정의를 했지만, 동작까지 하는 것은 아닙니다. Custom Resource에 대한 정의를 하게 되면 쿠버네티스에서 사용할 수 있는 리소스의 형태로만 존재합니다. 이 리소스가 실제로 동작하려면 Custom Controller를 통해 해당 리소스에 대한 구현을 직접 해주어야 합니다.

Prescaler 구현

Prescaler는 Custom Controller로 Custom Resource에 대한 관리와 실제 동작을 담당합니다. 지정된 시간마다 다음과 같은 작업으로 실제 스케일링을 진행하고 있습니다.

현재 Virtual Metrics 가져오기

먼저 VirtualMetricsVirtualMetricEvent에 정의된 source를 통해 가져옵니다. 핑퐁팀 서비스의 Source Metric API는 이벤트 시작 시각, 이벤트 종료 시각 그리고 값으로 이루어진 타임시리즈로 반환할 수 있게 열어두었습니다. 가져온 Metric과 Resource에 설정한 기본값 및 expression에 대해서도 같이 계산을 진행해줍니다.

fun getCurrentMetrics(): List<Metric> {
    val virtualMetricEvents = virtualMetricEventService.list()

    return virtualMetricEvents.map {
        val value = virtualMetricEventSourceService.resolve(it.source)
        it.metrics.map { metric ->
            Metric(
                name = metric.name,
                resource = metric.resource,
                value = expressionService.resolve(metric.value, value)
            )
        }
    }
}

데이터 병합

여러 VirtualMetricEvent를 사용하다 보면 같은 Resource를 각각 다른 VirtualMetricEvent에 등록할 수도 있습니다. 상기에서 계산된 값과 mergeStrategy를 바탕으로 중복된 값을 처리하였습니다.


fun merge(metrics: List<Metric>): Map<Resource, Metric> {
    val virtualMetrics: List<VirtualMetric> = virtualMetricService.list()
    
    metrics.groupBy { it.resource }.mapValues { (_, metrics) ->
        mergeStrategyService.resolve(virtualMetrics, metrics)
    }
}

실제 값 계산

메트릭을 통해 Replicas로 설정할 수를 먼저 계산합니다. 계산 공식은 k8s 공식 페이지를 참고하였습니다.

desiredReplicas = ceil[currentReplicas * ( currentMetricValue / desiredMetricValue )]

가져온 모든 Prescaler에 대해 위의 공식을 바탕으로 반영되어야 할 minReplicas를 계산하였습니다.

fun calculateReplica(currentMetrics: Map<Resource, List<Metric>>): Map<Resource, Int> {
    val prescalers = horizontalPodPrescalerRepository.list()

    return prescalers.associate { prescaler ->
        val metrics = currentMetrics.getValue(prescaler.metadata).associateBy { it.name }
        val replicas: Int = prescaler.metrics.maxOf { metric ->
            val currentMetric = metrics.getValue(metric.name)
            val targetMetric = metric.value
            calculateReplica(currentMetric, targetMetric)
        }
        prescaler.metadata to replicas
    }
}

fun calculateReplica(
    currentMetric: Metric,
    targetMetric: Double
): Int {
    return ceil(currentMetric.value / targetMetric).toInt()
}

HPA 값 변경

이제 HPA마다 배포되어야 할 Replica 개수가 정해졌으므로 실제 배포를 해줍니다. 다만 Prescaler는 급격히 트래픽이 증가할 때를 맞춰 설계되었으므로 일반적인 상황에서는 기존 HPA의 규칙을 따라야 합니다.

Prescaler는 현재 상태를 따로 저장하고 있지 않기 때문에 기존 Min Replicas 정보를 HPA의 메타데이터 어노테이션에 저장하고, 만약 이보다 작을면 기존 HPA 설정을 따라가도록 하였습니다.

Prescaler 배포

Prescaler는 배포를 위해 Helm Chart를 사용하였습니다. 다만 몇 가지 유의해야 할 점들이 있습니다.

Prescaler는 앞서 정의한 Custom Resource인 HorizontalPodPrescalers, VirtualMetrics, VirtualMetricEvents에 대한 Get, List 권한이 필요하고 실제 HPA의 minReplica를 변경해야 하기 때문에 HorizontalPodAutoscalers에 대한 Get, Update 권한을 지정해주어야 합니다.

따라서 ClusterRole을 추가하여 해당 권한에 대해 지정해주었습니다.

apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
  name: 
rules:
  - verbs:
      - get
      - list
    apiGroups:
      - prescaler.pingpong.ai
    resources:
      - horizontalpodprescalers
      - virtualmetrics
      - virtualmetricevents
  - verbs:
      - get
      - update
    apiGroups:
      - autoscaling
    resources:
      - horizontalpodautoscalers

만든 ClusterRole은 ServiceAccount를 생성한 후 ClusterRoleBinding을 통해 바인딩하였으며 Deployment에서 사용하도록 처리하였습니다.


# service-account.yaml
{{- if .Values.serviceAccount.create -}}
apiVersion: v1
kind: ServiceAccount
metadata:
  name: {{ include "prescaler.serviceAccountName" . }}
  labels:
    {{- include "prescaler.labels" . | nindent 4 }}
  {{- with .Values.serviceAccount.annotations }}
  annotations:
    {{- toYaml . | nindent 4 }}
  {{- end }}
{{- end }}

---
# cluster-role-binding.yaml
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
  name: {{ include "prescaler.name" . }}
  namespace: {{ .Release.Namespace }}
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: ClusterRole
  name: {{ include "prescaler.name" . }}
subjects:
  - kind: ServiceAccount
    name: {{ include "prescaler.serviceAccountName" . }}
    namespace: {{ .Release.Namespace }}

---
# deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: {{ include "prescaler.fullname" . }}
  labels:
    {{- include "prescaler.labels" . | nindent 4 }}
spec:
  template:
    metadata:
      ...
    spec:
      serviceAccountName: {{ include "prescalerpr.serviceAccountName" . }}
      ...

마무리

지금까지의 과정을 통하여 선톡으로 인한 급격한 사용자 증가에 상황에 대응할 수 있게 되었습니다. 물론 글에서 다루지는 않았지만, 현재 선톡 응답률에 따라서 Prescaler target value및 Event expression또한 조정하였습니다.

이처럼 Custom Resource와 Custom Controller를 이용하면 쿠버네티스가 지원하는 기능뿐만 아니라 더 다양한 것들을 쉽게 구현할 수 있으니 여러 문제상황에서 사용해보시는 것을 추천해 드립니다.

핑퐁팀은 더욱더 안정적인 서비스를 구축하기 위해 같이 고민하실 분들을 기다리고 있습니다. 저희와 같이 고민하고 싶으신 분들은 채용공고를 확인해주세요!

스캐터랩이 직접 전해주는
AI에 관한 소식을 받아보세요

능력있는 현업 개발자, 기획자, 디자이너가
지금 스캐터랩에서 하고 있는 일, 세상에 벌어지고 있는 흥미로운 일들을 알려드립니다.