ElastiCacheでメモリ一斉開放はどのくらいCPU使用率が上がるのか検証してみた

2020.05.29

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

こんにちは(U・ω・U)
ElastiCacheおじさんを目指して日々鍛錬を続ける深澤です。

まだ見習いです。

さて皆さんElastiCacheでメモリがモリモリになったご経験はありますでしょうか。運用をご経験された方には分かるかもしれませんがキャッシュでメモリ使用量がモリモリ成長していくのはとっても恐怖ですよね!!さて今回はそんなモリモリになったメモリを一斉に開放した時、ノードへの負荷がどうなるのか実際に検証してみました。

環境

ElastiCache for Redisに対してやってきます。ノードタイプですが、今回はcache.r4.largeを選択しました!搭載メモリは12.3GBですね。

ノードにデータを投入するには自作pythonスクリプトを使いました。まだまだ改善は多いのですが今回の検証にはこれで十分でした。

import os
import time
import logging
import redis
import random
import string
from retrying import retry


class StressTestForRedis:
    def __init__(self, host: str, port=6379, db=0):
        self.redis_client = redis.StrictRedis(
            connection_pool=redis.ConnectionPool(host=host, port=port, db=db)
        )
        logging.basicConfig(
            level=logging.INFO, format='%(asctime)s %(funcName)s %(levelname)s :%(message)s')
        self.logger = logging.getLogger(__name__)
        self.logger.setLevel(logging.DEBUG)

    def _chk_error(e):
        logger = logging.getLogger(__name__)
        if isinstance(e, redis.exceptions.ConnectionError):
            logger.error('Connection refused. Try to reconnect.')
            return True
        elif isinstance(e, redis.exceptions.ReadOnlyError):
            logger.error(
                'Can not write against a read only replica. Try to reconnect.')
            return True
        else:
            logger.error('Unexpected error type: {}'.format(type(e)))
            logger.error('Error message: {}'.format(e))
            return False

    @retry(stop_max_delay=5000, retry_on_exception=_chk_error)
    def _r_set(self, key, value):
        self.logger.info('set start')
        self.logger.debug(self.redis_client.set(key, value))

    @retry(stop_max_delay=5000, retry_on_exception=_chk_error)
    def _r_get(self, key):
        self.logger.info('get start')
        self.logger.debug(self.redis_client.get(key))

    @retry(stop_max_delay=5000, retry_on_exception=_chk_error)
    def _r_delete(self, key):
        self.logger.info('delete start')
        self.logger.debug(self.redis_client.delete(key))

    @retry(stop_max_delay=5000, retry_on_exception=_chk_error)
    def _r_lpushx(self, key, value):
        self.logger.info('lpush start')
        self.logger.debug(self.redis_client.lpush(key, value))

    def repeat_item_save(self, count: int, ttl: int = 0, byte_per_key: int = 5):
        self.logger.info('repeat_item_save start')
        i = 0
        while i < count:
            key_name = self._randnum()
            self._r_set(key=key_name,
                        value=self._randstr(byte_per_key))
            if(0 < ttl):
                self.logger.debug('set ttl to key : {}'.format(key_name))
                self.logger.debug(self.redis_client.expire(
                    name=key_name, time=ttl))

            i += 1

    def flushdb(self):
        '''
        Delete all keys in the current database.
        '''
        self.logger.info('flushdb start')
        self.logger.debug(self.redis_client.flushdb(asynchronous=False))

    def flushall(self):
        '''
        Delete all keys in all databases on the current host.
        '''
        self.logger.info('flushall start')
        self.logger.debug(self.redis_client.flushall(asynchronous=False))

    def search(self, pt: str):
        '''
        Returns a list of keys matching pattern(pt)
        '''
        self.logger.info('search start')
        return self.redis_client.keys(pattern=pt)

    def create_simple_num_list(self, list_len: int = 10, ttl: int = 0, value_min: int = 0, value_max: int = 10000):
        self.logger.info('create_list_key start')
        i = 0
        key_name = self._randstr(10)
        self.logger.info('Key name is : {}'.format(key_name))
        while i < list_len:
            self._r_lpushx(key=key_name, value=random.randint(
                value_min, value_max))

            i += 1

        if(0 < ttl):
            self.logger.debug('set ttl to key : {}'.format(key_name))
            self.logger.debug(self.redis_client.expire(
                name=key_name, time=ttl))

        return key_name

    def sort(self, key: str, desc: bool = False):
        self.logger.info('sort start')
        return self.redis_client.sort(name=key, desc=desc)

    def _randstr(self, len: int = 10) -> str:
        return ''.join([random.choice(string.ascii_lowercase) for i in range(len)])

    def _randnum(self, len: int = 10) -> int:
        return ''.join([random.choice(string.digits) for i in range(len)])

pythonのバージョンは3.8.2です。スクリプトを動かす環境にはFargateのRunTaskを採用しました!

一斉開放の方法

今回は以下の2パターンで検証しました。

  1. TTLを設定して一斉解放
  2. FLUSHALLコマンドでアイテムを一斉削除

両者とも大凡7GBくらいのデータを放り込んで一斉に開放してみました。1.は同じTTLのキーをほぼ同じタイミングで投入し、TTLのタイミングで一斉に開放させる。2.は 1.と同じデータが投入できたらFLUSHALLコマンド(Redisに保存されている全データを開放)を実行してみるというパターンでそれぞれ検証してみます!

環境構築

まずはECRを作成します。

$ aws ecr create-repository --repository-name redis_stress_test

続いてECSクラスタを作成します。

$ aws ecs create-cluster --cluster-name sample-fargate-cluster

コンテナをビルドします。

$ docker build -t 12345678910.dkr.ecr.ap-northeast-1.amazonaws.com/redis_stress_test:latest .

なおDockerfileは次のようにしました。

FROM python:3.8.2-alpine
COPY redis_stress_test_module.py /app/
COPY controller.py /app/
WORKDIR /app
RUN pip install redis retrying

タスクを登録します。

$ aws ecs register-task-definition --cli-input-json file://task_definition.json

なおタスク定義は次のようにしました。

{
    "family": "redis-stress-test",
    "networkMode": "awsvpc",
    "containerDefinitions": [
        {
            "name": "redis-stress-test",
            "image": "12345678910.dkr.ecr.ap-northeast-1.amazonaws.com/redis_stress_test:lates",
            "memory": 8192,
            "cpu": 1024,
            "workingDirectory": "/app",
            "logConfiguration": {
                "logDriver": "awslogs",
                "options": {
                    "awslogs-group": "fargate-runtask-app-logs",
                    "awslogs-region": "ap-northeast-1",
                    "awslogs-stream-prefix": "redis-stress-test"
                }
            }
        }
    ],
    "requiresCompatibilities": [
        "FARGATE"
    ],
    "cpu": "1024",
    "memory": "8192",
    "executionRoleArn": "arn:aws:iam::12345678910:role/ecsTaskExecutionRole"
}

ECRにログインします。

$ aws ecr get-login-password | docker login --username AWS --password-stdin https://12345678910.dkr.ecr.ap-northeast-1.amazonaws.com

ECRへPushします。

$ docker push 12345678910.dkr.ecr.ap-northeast-1.amazonaws.com/redis_stress_test:latest

さてタスクの準備ができました。ではElastiCacheを構築しましょう!ノードは1台あれば十分なので次のようにします。

$ aws elasticache create-cache-cluster \
    --cache-cluster-id stress-test-sample \
    --cache-node-type cache.r4.large \
    --engine redis \
    --port 6379 \
    --engine-version 5.0.6 \
    --num-cache-nodes 1 \
    --cache-subnet-group-name subnet-group \
    --security-group-ids sg-XXXXXXXXXXXXXXX

これを次のように実行します。

$ aws ecs run-task \
    --task-definition redis-stress-test:1 \
    --cluster practice-fargate-cluster \
    --network-configuration file://network_configuration.json \
    --launch-type FARGATE \
    --overrides file://container_overrides.json \
    --platform-version 1.4.0 \
    --count 1

ネットワークはPublicにしました。count数を調整すれば負荷をかける並列数が調整できます。

実戦!!

さて同じくらいデータを入れて一斉開放してみました。

TTL

FLUSHALL

両者とも良い感じにストーンっと落ちてますね!!ではこの時のCPU使用率をみてみましょう!!!!

TTL

FLUSHALL

両者をまとめてみましょう!

CPU使用率(%)
TTLによる一斉開放 0.3
FLUASHALLによる一斉開放 0.1

はい、TTLによる一斉開放の勝利です。あまり上がりませんでしたね。

最後に

いかがでしたでしょうか!メモリ一斉解放って怖いイメージがありますがCPU使用率的には全然問題なかったですね。TTLは全然怖くないのでAWSのキャッシュ戦略にもありますがほぼ必ずと言って良いほど全てのキーにTTLを設定するのはマストだと思います。

またFLUASHALLも大丈夫でしたが全てのキーが消失してしまうのでこれを使用する場面は限られていると思います。FLUASHALLが必要な場面では、FLUASHDBもしくはノードそのものの再起動も検討してみてください。

以上、深澤(@shun_quartet)でした!