스토리지 최적의 스펙 관리 시스템 만들기

Nov.02.2023 손구영

Backend Data Infra

스펙 관리 시스템 도입 배경

DB 스토리지 서비스를 운영하면서, 장애를 겪지 않기 위해 할 수 있는 보편적인 방법은 어떤 걸까요?
기존에 사용하는 서비스의 모니터링 지표(CPU, 트래픽, Connection …)를 고려하여
서비스 안정성을 보장하는 방법 중 고스펙 장비를 사용하여 여유 있는 리소스를 준비하는 것이 보편적이라고 생각합니다.

실제로 우아한형제들에서도, 장애 영향도가 큰 서비스의 경우 예기치 못한 장애 상황을 피하기 위해 사용량보다 여유 있게
고스펙 장비를 사용하는 케이스가 많았습니다.
그러다가 한 가지 문제점이 떠오르게 됐는데… 바로 높아진 운영비용이었습니다. 🤑

높아진 인프라 비용을 점검하고 낭비되는 부분을 찾아 절감할 필요성을 느꼈습니다.

초기에는 비용 절감을 위해 고스펙 장비에 대해서,
그라파나 메트릭(CPU / Memory / Connection)을 기준으로
일주일간 사용량이 현저히 낮은 장비를 수작업으로 확인했는데요.

MSA 시스템 아키텍처
출처: [MSA] MSA란 무엇인가? 개념 이해하기

하지만.. MSA(Micro-Service Architecture) 환경에서 계속해서 증가하는 수십~수백 개의 장비를 모두 확인하기에는 어려운 상황이었습니다.
이러한 이유로 신경 쓰지 못한 소규모 서비스들에 대한 비용 관리에 허점이 발견됐습니다.
그 외 확인되지 않은 미사용 장비에 대한 정보도 전혀 없고 파악하기 어려운 상황이었습니다.

위와 같이 누락되는 케이스를 방지하고, MSA 환경 속 계속해서 증가하는 장비 관리를 위해
기준을 마련하여 주기적으로 감지하고 알림을 발송해 주는 시스템 구성을 결심하게 됐습니다.
스토리지 관리를 위한 최적 스펙 관리 시스템 개발은 위와 같은 이유로 개발을 진행하게 되었습니다.

스펙 관리 시스템 개발 과정

1. 감지 기준 설정하기

서비스 안정성을 파악하기 위해 도움이 되는 지표가 DBMS 타입별로 다른 부분이 존재하기 때문에,
주요 DBMS별 관리 시스템 감지 기준을 마련하는 것이 처음으로 하게 된 고민이었습니다.

오픈된 지 얼마 안 된 서비스나, 특정 요일에만 서비스 사용률이 높아지는 케이스를 최대한 감지되지 않게
하기 위해 장비별 데이터 감지 기간은 최근 2주간 데이터를 수집하는 것으로 결정했습니다.

OverSpec

RDS의 경우 CPU 지표 수치가 상승할수록, 전체 지연 시간(latency)이 상승하고 서비스 지연을 초래하는데요.
CPU 수치가 100% 가까이 치솟을 경우, 장애로 이어지므로
50% 이상의 CPU 지표는 장애 예방을 위해 주의 깊게 살펴보고 있는 알람 기준이었습니다.

위 기준에서 영감을 얻어, 특정 이벤트로 인해 피크 타임에 트래픽이 평소 대비 약 2~3배 증가하더라도
CPU 최대 수치가 50%에 도달하지 않는다면,
사용량에 비해 overspec 장비라는 판단하에 기준을 15% 이하로 결정했습니다.

ElastiCache의 경우, 사용 중인 캐시 데이터가 Expire 되는 Eviction을 회피하기 위해
최대 Memory 사용률에 대해 30% 이하로 기준을 설정했습니다.
또한 네트워크 I/O 임계치 초과로 인한 서비스 지연도 예방해야 했는데요.
이때 CloudWatch에서 제공하는 I/O 임계치 관련 지표로 CloudWatch의 Network I/O allowance_exceeded를 선정했습니다.
위 메트릭 값이 0 이상인 경우, 네트워크 임계치를 초과한 Burst I/O를 사용했다는 의미입니다.

  • RDS
    기준 지표: CPU 15% 이하

  • ElastiCache (Redis)
    기준 지표: memory 30% 이하 & Network I/O allowance_exceeded = 0

미사용 장비

위에서 마련한 수집 기준을 통해 사용량 대비 고스펙 장비를 찾아내면서,
필터링된 장비들 중, 미사용되고 있는 장비가 꽤 많다는 사실을 알 수 있었습니다.
생성 후 미사용 운영 / Dev / Test 장비에 대한 분류 및 관리가 필요했습니다.

RDS의 경우 Connection 유/무를 판별하여, 쉽게 미사용 장비인지 확인이 가능했으며
ElastiCache 경우 유입되는 cmd 명령어 유/무를 기준으로 설정했습니다.

  • RDS
    기준 지표: Connection = 0

  • ElastiCache (Redis)
    기준 지표: get/set cmd = 0

2. 데이터 수집하기

OverSpec

데이터를 수집하기 위해 Boto3, CloudWatch API를 활용하여 Metric 데이터를 수집했습니다.
아래는 RDS / ElastiCache 기준 데이터 수집을 하기 위해 사용한 코드 예시입니다.

## RDS CPU 지표 수집
def rds_get_metric_statistics(self,aws_account, date,zone):
    client = session.client('cloudwatch')
    cpu_usage=[]

    ### CloudWatch api를 활용한 데이터 수집
    response = client.get_metric_statistics(        
        Namespace = 'AWS/RDS',
        MetricName = 'CPUUtilization',
        Dimensions = [{'Name': 'DBInstanceIdentifier', 'Value': '인스턴스명'}],
        StartTime = datetime(yy1, mm1,dd1),
        EndTime = datetime(yy2, mm2,dd2),
        Period = 1800,
        Statistics = ['Maximum']
        )

    ### CPU 15% 이하인 장비 판별
    for r in response['Datapoints'] :
        if r['Maximum'] < 15: 
            cpu_usage.append(r['Maximum'])
        else:
            cpu_usage.append(99)  ## 샘플링 데이터 중 15% 초과하는 케이스가 존재하면, non_target_list에 포함
            non_target_list.append((aws_account,'클러스터명'))
            break;
    max_cpu=max(cpu_usage)

## ElastiCache Memory / Network Allowance Exceeded 지표 수집
def redis_get_metric_statistics(self,aws_account, date,zone):
    client = session.client('cloudwatch')

    memory_usage = []
    ex_network_out = []

    ### max Memory 사용률 수집
    response = client.get_metric_statistics(
        Namespace = 'AWS/ElastiCache',
        MetricName = 'DatabaseMemoryUsagePercentage',
        Dimensions = [{'Name': 'CacheClusterId', 'Value': '인스턴스명'}],
        StartTime = datetime(yy1, mm1,dd1),
        EndTime = datetime(yy2, mm2,dd2),
        Period = 1800,
        Statistics = ['Maximum']
        )

    ## max Memory 사용률 30% 이하 장비 판별
    for r in response['Datapoints'] :
        if r['Maximum'] < 30:
            memory_usage.append(r['Maximum'])
        else:
            memory_usage.append(99)
            skip_instance_list.append((aws_account,redis_server.instance_name))
            non_target_list.append((aws_account,redis_server.cluster_name))
            break;

    ### NetworkBandWidth I/O 제한 초과 여부 확인
    response = client.get_metric_statistics(
        Namespace = 'AWS/ElastiCache',
        MetricName = 'NetworkBandwidthInAllowanceExceeded',
        Dimensions = [{'Name': 'CacheClusterId', 'Value': redis_server.instance_name}],
        StartTime = datetime(yy1, mm1,dd1),
        EndTime = datetime(yy2, mm2,dd2),
        Period = 300,
        Statistics = ['Maximum']
        )
    for r in response['Datapoints'] :
        ex_network_out.append(r['Maximum']) 

    response = client.get_metric_statistics(
        Namespace = 'AWS/ElastiCache',
        MetricName = 'NetworkBandwidthOutAllowanceExceeded',
        Dimensions = [{'Name': 'CacheClusterId', 'Value': redis_server.instance_name}],
        StartTime = datetime(yy1, mm1,dd1),
        EndTime = datetime(yy2, mm2,dd2),
        Period = 300,
        Statistics = ['Maximum']
        )
    for r in response['Datapoints'] :
        ex_network_in.append(r['Maximum']) 
미사용 장비
def rds_get_metric_statistics(self,aws_account, date,zone):
    client = session.client('cloudwatch')
    cpu_usage=[]

    ### CloudWatch api를 활용한 데이터 수집
def rds_no_use_metric(aws_account, instance):
    ### 2주간 max Connection = 0인 장비.
    response = client.get_metric_statistics(
    Namespace = 'AWS/RDS',
    MetricName = 'DatabaseConnections',
    Dimensions = [{'Name': 'DBInstanceIdentifier', 'Value': mysql_server.instance_name}],
    StartTime = datetime(yy1, mm1,dd1),
    EndTime = datetime(yy2, mm2,dd2),
    Period = 1800,
    Statistics = ['Maximum']
    )
def redis_no_use_metric(aws_account, instance):
    ### Get type cmd 수집
    response = client.get_metric_statistics(
        Namespace = 'AWS/ElastiCache',
        MetricName = 'GetTypeCmds',
        Dimensions = [{'Name': 'CacheClusterId', 'Value': redis_server.instance_name}],
        StartTime = datetime(yy1, mm1,dd1),
        EndTime = datetime(yy2, mm2,dd2),
        Period = 60,
        Statistics = ['Maximum']
    )
    ### Set Type Cmd 수집
    response = client.get_metric_statistics(
        Namespace = 'AWS/ElastiCache',
        MetricName = 'SetTypeCmds',
        Dimensions = [{'Name': 'CacheClusterId', 'Value': redis_server.instance_name}],
        StartTime = datetime(yy1, mm1,dd1),
        EndTime = datetime(yy2, mm2,dd2),
        Period = 60,
        Statistics = ['Maximum']
    )

하지만 위 기준들을 토대로 2주간의 데이터를 한 번에 수집하면서 문제가 발생했습니다.
CloudWatch API를 활용하여 긴 시간 데이터를 수집할 경우,
불러올 수 있는 샘플링 데이터 수에 API limit이 존재하여
실제로 2주치의 데이터를 모두 불러오게 되면 샘플링 주기(=Period)를 크게 늘리지 않을 경우,
에러 메시지가 발생했습니다.

for date in daterange(date_range,today): ## date_range에 선언된 2주치 일별 max CPU 계산
            mysql_statics.get_metric_statistics(aws,date,zone)

Period 주기를 늘리면 해결되지만, 샘플링 데이터 신뢰도가 떨어지게 되어 문제가 발생하므로
위 코드와 같이 일별 데이터 수집을 loop 문을 통해 14번 반복하여,
일별로 14일 치 max Value 값을 비교 후 임계 기준 미만인 장비들을 분류하도록 했습니다.

3. 알람 구성

미사용 장비와 고스펙 장비를 판별 후 사내 Slack 채널을 통해 해당 장비들에 대한 알람을 주기적으로 받도록
Slack API를 통해 특정 알람 채널에 해당 장비 List를 받을 수 있도록 구성했습니다.

def slackPost(header,text):
    url = '채널 URL'
    payload = {
        "color": "#36a64f",
        "blocks": [
            {
                "type": "header",
                "text": {
                    "type": "plain_text",
                    "text": header
                }
            },
            {
                "type": "section",
                "text": {
                    "type": "plain_text",
                    "text": "알람 Description"
                }
            } 
        ]
    }
    requests.post(url, json=payload)

위 API를 활용하여 아래 포맷의 Slack 알람을 매일 보낼 수 있도록 구성했으며
포함된 내용 항목은 아래와 같습니다.

  • AWS 계정 정보
  • 클러스터명 (RDS / ElastiCache)
  • 인스턴스 타입 (=Spec)
  • Reader (=Replica) 수
  • 장비별 사용 중인 월별 비용 ($)
    (ex) 
    < AWS 계정정보 > Cluster_Name
    RDS INFO
    [ Spec: db.t4g.medium / Instance: 2ea ]
    월 ($)xx 소비중입니다... 빠른 확인 부탁드립니다

관리 시스템의 보완점에 대한 고민

Exception Case 관리

스펙 관리 시스템을 통해 감지된 장비 중, 예외적으로 아래와 같은 케이스가 존재했습니다.

  • 서비스 오픈 예정 장비
  • 지표 상승에 따른 장애 발생률이 높은 주요 서비스 장비
  • 유입 트래픽 상승 예정 장비 (신규 기능 추가 / 서비스 범위 확대 등…)

이에 따라 위 장비들은 알람 대상에서 제외하는 기능이 필요했습니다.
제외된 장비는 이후 상황에 따라 리스트에 추가 / 제거가 필요했고,
그때마다 수동으로 코드 수정하지 않기 위해
RDS 테이블 데이터로 저장하여,
해당 데이터베이스에 리스트 포함 유/무를 체크하는 방식으로 구성했습니다.

def exception_check(aws_account, cluster):
    pymysql.connect(host=[Host], port=[Port], user=[ID], passwd=[P/W], db=[DB], charset='utf8')
    cursor = con.cursor()
    query = "SELECT aws_account, cluster, description FROM [DB].[Table];"
    cursor.execute(query)
    results= cursor.fetchall()
    for row in results:
        exception_list.append(row[1])
        ...

담당팀 확인 프로세스 개선

이후 작업을 하면서 보완이 필요하다고 느껴졌던 부분은 장비별 담당 개발팀과의 커뮤니케이션 프로세스였습니다.
미사용 장비에 대한 제거 작업을 위해서는, 담당 개발팀에 서비스 및 코드에서 미사용 처리된 건지 확인이 필요했는데요.

기존에 구축한 시스템으로는, 특정 장비의 개발 담당팀이 어딘지 확인하기 위해서는 요청 히스토리를 일일이 찾는 수동 커뮤니케이션 과정을 거쳐야 했습니다.
위 과정을 진행하면서 소요되는 시간이 생각보다 크게 다가왔고, 프로세스 개선의 필요성을 느끼게 됐습니다.

AWS Resource Tag 정리

AWS 태그 시스템
출처: AWS 태그 시스템

AWS 내의 리소스 대부분은 Tag 기능이 존재합니다.
Tag 없이는 해당 리소스를 생성/관리하는 인원을 제외하고는 어떤 서비스인지 알기 어려웠는데요.
기존 사내 Tag는 일관된 Rule 없이 사용자별로 자유롭게 생성되어 태그를 활용하기는 어려웠습니다.

실제로 Tag 시스템 활용을 위해선 Rule-base로 정리된 Tag가 있어야
서비스별 명확한 구분이 가능했기 때문에,
기존 Tag 시스템을 일괄 검증 및 정리하는 과정이 선행 작업으로 필요했습니다.

위 프로세스 진행을 위해 1~2개월에 걸쳐 Service / Role 태그를 구분 지어 정리했습니다.
또한 모든 DB 리소스에 두 가지 태그가 반드시 포함됐는지, 생성 시 검증하는 로직을 추가하여
신빙성 있는 태그인지 확인했고, 이를 통해 신뢰성 있는 태그 환경을 준비했습니다.

Tag 기반 담당팀 / 담당자 데이터 매핑

이후 사내 시스템에 리소스별 태그 매핑이 되어 있는 정보망을 활용할 수 있도록 구성을 진행했는데요.

위 매핑을 통해, 특정 클러스터 감지 시 태그 정보를 확인하고, 사내 정보망에 API를 통해 접근하여
알람 발송 시 Slack 메시지에 담당팀, 구성원 정보를 추가했습니다.
이를 통해 담당팀과 비용 관리 알람에 대해 함께 상황을 이해하고, 대응하면서
커뮤니케이션 프로세스를 효율적으로 개선할 수 있었습니다.
50% 이상의 커뮤니케이션 프로세스 비용을 감축시킬 수 있어 많은 도움이 됐습니다.

스펙 조정으로 인한 디스크 I/O 증가

스펙을 내려, 해당 장비에서 사용할 수 있는 가용 메모리 영역이 줄어들게 되면서
디스크 I/O 증가 가능성이 존재합니다.
다행히 스펙 조정의 기준을 여유롭게 (예시) RDS 15% / Redis Memory 30% ... 설정하여
가용 메모리 또한 여유 있게 사용하던 장비들이 대상으로 검출되어 우리 시스템에 위 문제는 발생하지 않았습니다.
하지만 만약 말씀드린 기준보다 설명한 기준보다 엄격하게 관리하려면, 위 사이드 이펙트에 대해서도
고민하여 최적의 기준을 설정할 필요가 있습니다.

글을 마치며

관리 시스템을 통해 사용하지 않거나 고스펙인 장비들을 검출하고, 검출된 장비들을 담당 개발팀과 논의하여 정리하면서 도입 후 2023년 1분기까지 장비별 연간 소모 비용의 약 50%를 절감할 수 있었습니다.

다수 장비를 소수의 DBA가 관리해야 하는 MSA 환경에서
성능, 장애 관련 알람뿐만 아니라, 비용 관리 알람(고스펙 / 미사용) 구성을 통해 안정적이면서도
효율적인 인프라 운영 및 비용 관리를 할 수 있었던 경험이었습니다.

추후 인프라 비용 관리를 계획하시거나 구성 중인 다른 분들께 도움이 됐으면 좋겠습니다.


마지막으로 제가 소속된 클라우드스토리지개발팀에 대해 소개 드리려고 합니다.

클라우드스토리지개발팀은 각 개발팀에 가이드를 제공하여 서비스 성격에 맞는 DBMS를 선택하실 수 있도록 도움을 드리고 있으며,
평소에는 여러 DBMS 운영 및 표준화를 통해 안정적인 서비스 제공을 할 수 있는 운영을 담당하고 있습니다.

또한 위에서 발생하는 문제점이나 비효율을 개선하기 위해, 간단한 코드(Python, JavaScript 등)를 활용한 자동화 스크립트를
개발하고 DB 관련 신규 프로세스를 개발하고 있습니다.