LY DB tech blog
Published on

Redis에 Audit Log를 추가하기 위한 여정

Authors
  • avatar
    Name
    hyowow
    Twitter
  • avatar
    Name
    liinen
    Twitter

Background

시간이 지남에 따라 기술은 더욱 더 빠른 속도로 발전하며, 취급하게 되는 정보의 양은 기하급수적으로 늘어나고 있습니다. 이러한 상황에서 놓치지 말아야 할 것이 바로 '보안'입니다. 기본적으로 Redis에는 ACL(Access Control List)나 TLS(Transport Layer Security) 등 보안과 관련된 기능을 탑재하고 있습니다. 하지만 보안 관련된 기능이 있음에도 불구하고 언제 어디서 불특정 다수의 공격이 행해질 지는 모르는 일인데요, 이러한 측면에서 '어디서 공격이 이루어졌는지'를 파악하는 것도 중요하다고 생각했습니다. 공격자가 식별되지 않으면 이를 블랙리스트에 등록하는 것 또한 불가능해지고, 그렇다면 계속해서 공격받을 위험이 있습니다.

그렇기 때문에 감사 로그인 Audit Log를 Redis 운영 중에 남기고자 하였고, 관련해서 어떠한 방식으로 문제를 풀어나갔는지를 이야기하려고 합니다.

소스코드 수정을 통한 Logging

먼저 소스코드를 수정하는 것으로 간단히 해결할 수 있다고 생각했습니다. Redis 소스코드 내부에는 클라이언트의 ip 정보를 취득할 수 있을 것이고, 이를 이용하면 원하는 기능을 쉽게 구현할 수 있을 것이라 생각했습니다. 실제로 코드를 살펴보니 VERBOSE 라는 Log Level에서는 접속 성공에 대한 로그를 출력해주는 것을 확인할 수 있었습니다.

// src/socket.c
serverLog(LL_VERBOSE,"Accepted %s:%d", cip, cport);

그럼 "VERBOSE 로 지정해서 Redis를 운영하면 문제가 없는 것 아닌가?" 라고 생각할 수 있지만, 실제로는 몇 가지 문제가 있었습니다.

VERBOSE의 문제점

1. 생각보다 많은 Log Level VERBOSE

Log Level의 경우 기본 값이 NOTICE 입니다. 덕분에 Redis 로그파일을 보면 replication, configuration, failure 등, Redis를 운영하는 데 딱 필요한 만큼 정보가 포함되어 있습니다. 그리고 이러한 로그들은 주로 운영 작업 도중에 발생했기 때문에 보기에 전혀 어려움이 없었습니다.

하지만 VERBOSE의 경우는 달랐습니다. 궁극적으로 원했던 'client 의 ip'를 로그에 남기는 것에는 문제가 없었습니다만, 문제는 'cluster node' 간의 ping 조차도 로그에 남기고 있었습니다.

// src/cluster.c
serverLog(LL_VERBOSE,
    "Handshake: we already know node %.40s (%s), "
    "updating the address if needed.", sender->name, sender->human_nodename);

이로 인해 매 초마다 계속해서 로그가 누적해서 쌓이는 것을 확인할 수 있었고, 이는 생각보다 너무 많은 수치로 기존 대비 로그의 양이 상당히 증가하게 되었습니다. 로그를 확인할 때에는 해당 구문을 제거하고 확인할 수 있지만, 결국 상당수의 로그는 cluster 내부의 ping이기 때문에 로그 분석에 불편함을 주는 것은 확실했습니다.

그나마 긍정적으로 생각할 부분이라면, 인 메모리 데이터베이스인 Redis의 특성 상 서버에는 디스크 공간에 여유가 있고 로그파일의 경우 logrotate를 통해 disk full 이 발생하지 않도록 조절은 가능하다는 점이었습니다. 해당 방식은 특정 규칙에 의해 오래된 데이터가 삭제된다는 문제점이 있지만, 진짜 문제는 다음에 있었습니다.

2. 하나밖에 없는 Redis 로그파일

비정상적인 접근을 확인하기 위해서 로그파일을 들여다 볼 때에는 특정 시간, 특정 동작에 대해 필터링해 확인하면 불필요한 내용이 포함된 다량의 로그라도 전혀 문제가 없습니다. 하지만 운영 작업 중에도 지속적으로 로그가 발생하는 점이 큰 문제로 다가왔습니다. replication을 새롭게 맺거나, cluster 구성을 변경하거나 하는 과정에서는 보통 Redis 로그파일을 지속 관찰하며 작업에 착수합니다. 하지만 VERBOSE라면 '특정 작업에 반응하여 로그를 남긴다'는 느낌보다는 '매 초마다 상태를 확인하여 로그를 남긴다'는 느낌을 받게 되었습니다. Redis에서 지정되어 있는 로그에 대한 config는 'logfile' 뿐이었고, 현재로서는 모두 동일한 로그파일에 기록할 수 밖에 없었습니다.

코드 레벨의 수정

위에서 언급된 문제를 해소하기 위해 몇 가지 방안을 고민해보았고, Redis 소스코드에 적용해보았습니다.

1. Log Level의 세분화

원하는 바를 이루기 위해서는 먼저 Log Level을 세분화해서 Audit Log에 맞는 로그만을 수집할 필요가 있었습니다. 기존의 VERBOSE를 그대로 사용하기에는 부담되는 부분이 있었기 때문에, 이를 세분화하고자 했습니다. VERBOSE로 지정되면 그 순간 로그의 양이 몇 백배 이상으로 증가하기 때문에, VERBOSE와 NOTICE 사이에 'AUDIT'이라는 새로운 Log Level을 만들었습니다. 

// src/server.h

/* ASIS (redis) Log Levels */
#define LL_DEBUG 0
#define LL_VERBOSE 1
#define LL_NOTICE 2
#define LL_WARNING 3
#define LL_NOTHING 4
#define LL_RAW (1<<10) /* Modifier to log without timestamp */
 
/* TOBE (patch) Log Levels */
#define LL_DEBUG 0
#define LL_VERBOSE 1
#define LL_AUDIT 2
#define LL_NOTICE 3
#define LL_WARNING 4
#define LL_NOTHING 5
#define LL_RAW (1<<10) /* Modifier to log without timestamp */

이제 기존에 VERBOSE로 지정되어 있던 Log Level을 AUDIT으로 변경할 필요도 있었는데요, 'client connection'의 연관성의 유무로 이를 분류했습니다. 아래는 그 예시입니다.

// src/networking.c
serverLog(LL_AUDIT,
    "Accepted client connection in error state: %s (addr=%s laddr=%s)",
    connGetLastError(conn), addr, laddr);
serverLog(LL_AUDIT,
    "Error writing to client: %s", connGetLastError(c->conn));
serverLog(LL_AUDIT, "Reading from client: %s",connGetLastError(c->conn));
serverLog(LL_AUDIT, "Client closed connection %s", info);
 
// src/timeout.c
serverLog(LL_AUDIT,"Closing idle client");

2. 로그파일의 분리

하나의 로그파일에 누적되는 것이 관리나 작업 차원에서 어렵다고 생각되었고, 이는 분리하는 것으로 쉽게 해결할 수 있었습니다. 특별할 것도 없었습니다.

새로운 변수 audit_logfile 를 선언해주고

// src/server.h
char *logfile;                  /* Path of log file */
char *audit_logfile;            /* Path of audit log file*/

Redis Config에서 해당 로그파일의 경로를 지정해줄 수 있도록 하며

// src/config.c
createStringConfig("logfile", NULL, IMMUTABLE_CONFIG, ALLOW_EMPTY_STRING, server.logfile, "", NULL, NULL),
createStringConfig("audit-logfile", NULL, IMMUTABLE_CONFIG, ALLOW_EMPTY_STRING, server.audit_logfile, "", NULL, NULL),

특정 Log Level 이하의 로그는 새로운 로그파일에 쓰기만 하면 되는 것이었습니다.

// src/server.c
fp = log_to_stdout ? stdout :
                     ((level == server.verbosity && server.verbosity == LL_VERBOSE)
                         ? fopen(server.audit_logfile,"a")
                         : fopen(server.logfile,"a"));

후기

가볍게 생각한 소스코드의 수정이었지만 영향 범위가 넓었고 고려된 부분도 많았습니다. 결과적으로 만족스러운 작업이었다고 생각합니다.

opensource contribution 시도

Log Level의 분리와 새로운 config의 추가 등, 충분히 매력적인 변화라고 생각했기 때문에 이를 오픈소스에도 제안해보았습니다. 해당 시점에서 이미 Redis는 opensource가 아니게 되었기 때문에 Valkey에 기여하고자 했는데요, 상당히 큰 변화이기 때문에 issue부터 작성을 하고, 이해를 위해 간단한 코드 수정을 Pull Request에도 작성해보았습니다.

https://github.com/valkey-io/valkey/issues/260

얼마 되지 않아 회신을 받을 수 있었고, 메인테이너들도 생각보다 좋은 반응을 보여주었습니다. 특히 Log Level의 분할에 대해서 긍정적인 모습을 보여주었습니다. 더 나아가 기존 Redis의 로그는 정형화 되어 있지 않고 상당히 '자유 형식' 이었기 때문에 전체적인 개선을 하고 싶다는 생각까지 들을 수 있었습니다.
다만 아쉽게도 현재로서 우선순위가 떨어지는 것은 맞아보였고, 당장에 받아들여지기 힘들다는 부분에 대해서는 납득했습니다. 향후 개선에 대해서 여지를 남겨두었기 때문에 해당 issue는 close하지 않은 채로 남겨두기로 했습니다. :)

단점: 유지보수의 어려움

기존에도 특정 버전의 Redis는 상위 버전의 버그 픽스를 반영하기 위해 패치파일을 적용한 상태로 빌드 및 운영되고 있었는데요, 그렇기 때문에 이번 소스코드 수정도 문제 없이 잘 소화해낼 수 있을 것이라고 생각했습니다. 하지만 버그 픽스의 경우 한 두개의 파일만 변경하면 되는 상황이었기 때문에 큰 문제로 다가오지 않았는데, 이번 audit log의 추가는 10개 이상의 파일을 수정하게 되면서 문제가 되었습니다. 그리고 영향범위가 많다는 것은, 새로운 버전의 Redis가 릴리즈 되었을 때 conflict이 많이 발생한다는 의미이기도 합니다.

이미 눈치 채셨겠지만 '소스코드 수정을 통한 Logging'은 최종적으로 채택되지 못했습니다. 현재 사용되는 버전, 향후 추가될 모든 메이저/마이너 버전에 대해 conflict을 하나씩 확인해가며 본 패치를 하나씩 적용하는 것이 현실적으로 어렵다는 결론에 도달했기 때문에, 저희는 두 번째 방법인 'Redis Module을 이용한 Logging'에 도전했습니다.

Redis Module을 이용한 Logging

Redis의 동작에 대해서 변화를 주는 방법에는 앞서 언급한 소스코드의 수정도 있지만, 자체적으로 지원하는 모듈(Redis Module, 이하 모듈)을 이용하는 방법도 있습니다. 모듈의 경우 별도의 빌드를 통해 '.so'확장자를 가지는 모듈 파일을 생성해 이용해야만 했는데요, 처음에는 굉장히 번거로운 일이라고 생각했습니다. 하지만 반대로 생각해보면 소스코드 전체가 아닌 모듈에 대해서만, 그것도 수정사항이 있을 경우에만 빌드가 필요한 것이기 때문에 오히려 유지보수에 편리하다는 것을 느끼게 되었습니다.

기존에 Redis를 운영하는 데에 있어서 별도의 툴을 구축해서 사용한 경험은 많지만 모듈을 이용한 적은 없었는데요, 덕분에 이 기회에 모듈 자체에 대해서도 연구할 기회가 생겼습니다.
모듈이라는 것이 비교적 최근에 추가된 기능이라 그런지, API는 직관적인 이름을 가지고 있고 문서의 정리가 잘 되어 있어 이를 업무에 적용하는 데에는 큰 어려움이 없었습니다.

Access Log의 취득

가장 먼저 진행한 것은 소스코드의 변경에서도 수행했던 접속하는 클라이언트의 정보 취득이었습니다.

이왕 모듈을 이용하는 김에 더욱 다양한 모듈을 사용해보고자 ACL 정보도 함께 받아오는 방향으로 진행했습니다. 앞서 진행한 '소스코드 수정을 통한 Logging'에서는 접속자의 ip만 취득했는데, 이번에는 단순히 공격자의 ip를 취득할 뿐만 아니라 어느 ACL이 취약한 상태인지도 함께 확인할 수 있는 장점 또한 갖게 되었습니다.

모듈 초기화

Redis 모듈은 고유의 라이프사이클이 존재합니다. 모듈 개발을 할 때에는 필수적으로 RedisModule_OnLoad 라는 함수가 선언되어야 하고, 이 함수가 모듈의 시작을 의미합니다.
모듈로서는 main 함수라고 볼 수 있는데요, 정상적으로 종료된 경우에는 'REDISMODULE_OK'를, 비정상적인 종료 시에는 'REDISMODULE_ERR'를 반환하도록 구현하면 됩니다.

int RedisModule_OnLoad(RedisModuleCtx *ctx, RedisModuleString **argv, int argc) {
    // if err; return REDISMODULE_ERR;
    return REDISMODULE_OK;
}

클라이언트의 정보 취득

모듈의 기반을 만들었으니 본격적으로 클라이언트의 정보를 취득하고자 했고, 이는 크게 어렵지 않았습니다. 저희는 두 가지 모듈 API를 이용했는데요, RedisModule_GetClientInfoById 와 RedisModule_GetClientUserNameById 입니다.

먼저 RedisModule_GetClientInfoById 는 현재 접속 중인 client_id를 바탕으로 전반적인 클라이언트 정보를 RedisModuleClientInfo 변수에 저장합니다. 해당 변수는 사전에 정의되어 있는 구조체로서, 아래와 같은 값을 가지고 있습니다. 저희는 ip 주소만을 이용했는데요, 다른 값들도 함께 포함하고 있는 구조체이기 때문에 필요에 따라서는 함께 사용할 수 있습니다.

uint64_t flags;         // REDISMODULE_CLIENTINFO_FLAG_*
uint64_t id;            // Client ID
char addr[46];          // IPv4 or IPv6 address.
uint16_t port;          // TCP port.
uint16_t db;            // Selected DB.

다음으로는 RedisModule_GetClientUserNameById 입니다. ById로 끝나는 것을 보면 알 수 있듯, 위와 마찬가지로 현재 접속 중인 client_id를 바탕으로 사용자 이름을 가져오는 API입니다.
이 때 문자열을 반환하는 것이 아니라 RedisModuleString 이라는 구조체를 반환하기 때문에, RedisModule_StringPtrLen 이라는 별도의 API를 통해 문자열로 변경해주었습니다. 두 모듈 API에 대한 사용법은 아래와 같습니다.

// client info 가져오기. client_info.addr 를 통해 ip 주소를 확인할 수 있습니다.
RedisModuleClientInfo client_info;
RedisModule_GetClientInfoById(&client_info, client_id);
 
// username 가져오기. username은 ACL의 사용자 이름을 의미합니다.
RedisModuleString *username = RedisModule_GetClientUserNameById(ctx, client_id);
const char *user = RedisModule_StringPtrLen(username, NULL);

이벤트 등록

클라이언트의 정보를 취득하는 방법에 대해 파악했으니, 이제 이를 적절한 시점에 수행하면 완료입니다. 모듈에는 특정 이벤트에 반응하여 지정한 함수를 수행하도록 하는 기능이 API도 준비가 되어 있었습니다.

먼저 이벤트를 살펴보니 클라이언트의 상태가 변경될 때의 이벤트인 RedisModuleEvent_ClientChange 가 존재했고, 특정 이벤트에 반응하도록 하는 RedisModule_SubscribeToServerEvent API가 존재했습니다.
결과적으로 클라이언트의 상태가 변경될 때, 별도의 함수를 수행하도록 구성할 수 있었으며 그 함수로서는 위에서 알아본 '클라이언트의 정보 취득'을 수행하도록 했습니다.

RedisModuleEvent_ClientChange 에는 연결이 생성되고 끊어지는 것에 대한 'subevent' 도 함께 반환되기 때문에, 이를 이용해 접속 시기를 명확히 확인할 수 있었습니다.

// RedisModuleEvent_ClientChange 발생 시, CallbackFunction 수행
RedisModule_SubscribeToServerEvent(ctx, RedisModuleEvent_ClientChange, CallbackFunction);
 
void CallbackFunction(RedisModuleCtx *ctx, RedisModuleEvent event, uint64_t subevent, void *data) {
    // subevent
    // REDISMODULE_SUBEVENT_CLIENT_CHANGE_CONNECTED
    // REDISMODULE_SUBEVENT_CLIENT_CHANGE_DISCONNECTED
}

결과

모듈에는 Redis 로그파일에 작성하도록 도와주는 API인 RedisModule_Log 가 존재합니다. 하지만 저희는 로그파일의 분리를 희망했고, 여기서 모듈의 장점이 또 하나 등장합니다.
바로 C언어를 기반으로 하고 있기 때문에, 모듈 API만 사용 가능한 것이 아니라 C의 각종 헤더파일을 참조하며 사용하는 것이이 가능하다는 점입니다.

위에서 알아본 모듈을 조합해서 코드를 작성했으며, fopen을 통해 별도의 로그파일에 작성하도록 테스트를 진행했습니다. 물론, 결과는 성공적이었습니다.

[2024-06-24 11:41:04] [connected]    Address: 127.0.0.1, User: default
[2024-06-24 11:41:06] [disconnected] Address: 127.0.0.1, User: default

다만 저희는 여기서 만족하지 못하고 더 완벽한 결과를 위해 마지막 한 발짝 더 나아가기로 했습니다.

Audit Log

최초에는 접속처를 분류할 수 있게 되면 공격 감지가 충분할 것이라고 생각했습니다. 하지만 진행을 하다보니 이는 단순한 'Access Log'로서 어느 동작을 수행했는지는 전혀 모르는 일이었습니다.
그렇기 때문에 모듈을 이용해 사용자와 접속처를 취득하는 것에 그치지 않고, 어떠한 동작이 수행되었는지 까지 확인할 수 있는 진정한 의미의 'Audit Log'를 남기고자 했습니다.

Redis 명령어에 필터 추가

위에서 이미 클라이언트 정보는 수집을 완료했기 때문에, 남은 것은 '명령어'에 대한 정보입니다.

이번에는 특정 이벤트를 감시하는 형태가 아닌, 명령어 자체에 대한 감시를 하고자 RedisModule_RegisterCommandFilter 를 이용했습니다. 공식 문서에 의한 사용법은 아래와 같습니다.

RedisModuleCommandFilter *RedisModule_RegisterCommandFilter(RedisModuleCtx *ctx,
                                                            RedisModuleCommandFilterFunc callback,
                                                            int flags);

여기서 'CommandFilter'라는 개념은, 명령어가 실행되기 전에 수행할 사전 작업 정도로 이해하면 될 것 같습니다.
즉, RedisModule_RegisterCommandFilter를 통해 어떠한 명령어가 수행되던 간에 그 이전에 저희가 지정하는 callback 함수를 수행하도록 합니다.

Redis 명령어에 대한 분석

이제 명령어의 인입에 대해 별도의 함수를 수행하도록 준비를 마쳤으니, 해당 명령어가 어떤 동작을 수행하려고 한 것인지 분석하고자 합니다.

먼저 CallbackFunction의 구조에 대해 확인해보겠습니다. 기본적으로 명령어에 대한 정보가 포함된 RedisModuleCommandFilterCtx 라는 ctx를 포함한 상태입니다.

void CallbackFunction(RedisModuleCommandFilterCtx *filterCtx) {
    // ...
}

그럼 이제 해당 ctx를 이용하여 명령어를 분석해보겠습니다. RedisModule_CommandFilterArgGet 라는 API는, 인입된 명령어에 대해서 지정한 순번의 명령어를 반환해줍니다. 아래는 'SET A 1'을 수행했을 때, 각각의 API의 결과가 갖는 값입니다.

// SET A 1
RedisModule_CommandFilterArgGet(filter, 0); // SET
RedisModule_CommandFilterArgGet(filter, 1); // A
RedisModule_CommandFilterArgGet(filter, 2); // 1

아 물론, 클라이언트의 유저명을 가져올 때처럼 해당 API는 문자열이 아닌 RedisModuleString을 반환합니다. 이 역시 로그파일에 출력하기 위해서는 문자열로의 변환이 필요합니다.
아래는 명령어의 모든 인자가 아닌 명령어 그 자체에 대해서만 취득하는 예시입니다.

RedisModuleString *argv0 = RedisModule_CommandFilterArgGet(filter, 0);
const char *command = RedisModule_StringPtrLen(argv0, &len);

결과

CommandFilter에 대한 코드를 적용하고 나면 아래와 같이 어떠한 명령어를 수행했는지 확인할 수 있습니다.

[2024-06-24 14:23:05] command executed: AUTH
[2024-06-24 14:23:07] command executed: SET
[2024-06-24 14:23:11] command executed: GET

위의 로그는 단순히 명령어 그 자체만 취득한 결과입니다. 하지만 위에서 살펴보았듯, RedisModule_CommandFilterArgGet API를 통해 명령어 그 자체뿐만 아니라 해당 명령어의 모든 인자를 취득할 수 있습니다.
비로소 저희는 클라이언트의 접속 정보와 함께 해당 유저의 액션까지, 언제든 요구사항에 맞추어 Audit Log를 커스텀할 수 있는 준비가 되었습니다:)

후기

소스코드의 변경을 통한 로깅 구현과 비교했을 때, 확실한 장점들이 있었습니다.

1. 명확한 영향 범위의 파악

소스코드에서 수정을 할 때에는 그 영향 범위가 어디까지 미치는지 명확하게 확인하기 어렵습니다. 그리고 만약 특정 상황에서 에러가 발생하게 될 경우 이는 Redis 프로세스 전체에 영향을 주게 됩니다.
하지만 모듈이라면, 기본적으로는 API를 바탕으로 수행되기 때문에 검증된 구조 하에 더욱 안전하게 Redis를 커스텀하는 것이 가능합니다.

예를 들어, 특정 명령어에 대해서 추가적인 동작을 수행하고 싶을 때에는 위에서 알아본 CommandFilter를 이용하여 간단하게 구현이 가능합니다.
아래는 SET 명령어에만 반응하여 무언가 동작을 추가하도록 하는 예제인데요, 이 처럼 명령어 자체에는 영향을 주지 않으면서 기능을 덧붙이는 것이 가능합니다.

RedisModuleString *argv0 = RedisModule_CommandFilterArgGet(filter, 0);
const char *command = RedisModule_StringPtrLen(argv0, &len);
 
if (strcasecmp(command, "SET") == 0) {
    // ...
}

이러한 장점은 후술할 '유지보수 및 기능추가의 용이함'으로 이어지게 됩니다.

2. 유지보수의 및 기능추가의 용이함

Redis 소스코드와는 별도인 모듈을 사용하는 것이기 때문에 유지보수함에 문제가 없었습니다. 심지어 특정 버전에서 추가된 기능을 제외한다면, 현재 운영하고 있는 모든 Redis 환경에 동일한 모듈 파일을 적용하는 것이 가능했습니다.
단순한 수정뿐만 아니라 기능 추가 자체에 대해서도 굉장히 편리했습니다. 감사 로그는 취급하는 데이터라던지, 보안의 강화 등 여러 요인에 의해 요구사항이 달라질 수도 있습니다. 소스코드의 수정을 통한 기능 개발이라면 이러한 요구사항이 변경될 때마다 반영하고 빌드하여, 모든 Redis 환경에 재배포 및 재기동하는 과정이 필요합니다.
하지만 모듈을 이용한다면 해당하는 소스코드만 빌드하고, MODULE LOAD 및 UNLOAD 커맨드를 통해 서비스 중인 Redis에 적용하는 것이 가능합니다. 모듈은 소스코드만큼 자유도가 높지는 않지만 C 라이브러리를 자유롭게 사용하는 것이 가능하고, 동적으로 관리할 수 있다는 것이 가장 큰 장점으로 다가왔습니다.

여담) new config: enable-module-command (redis 7.0~)

생각해보니 redis 7 버전부터는 MODULE 커맨드에 대해서 제한하는 config parameter가 추가되었습니다. 의외의 복병으로서 작용했는데요...

PR#9920 에서 추가되어 Redis 7.0 부터 도입된 'enable-module-command'는 보안 등의 이유로 MODULE 커맨드를 입력할 수 있는 client를 제한하는 기능입니다. 이는 127.0.0.1, 즉 local host에서만 허용하도록 하는 'local'을 기본 값으로 채택하고 있으며, 보안이라는 이유에 맞게 저희도 동일하게 기본 값인 'local'을 채택하기로 결정했습니다.

다행히도 이는 '커맨드의 실행'만을 막고 있기 때문에 기동 시 Redis Config에 명시적으로 loadmodule 을 지정해준다면 문제 없이 동작할 수 있었습니다.

# cat ${REDIS_CONFIG} | grep module
enable-module-command local        # set default as local
loadmodule audit_module_v1.so      # set default loadmodule with fixed path

그런데 이 경우 외부 호스트에서의 접근이라면 모듈 파일을 불러오는 'MODULE LOAD'와 기존 모듈 파일을 해제하는 'MODULE UNLOAD' 역시 불가능하기 때문에 저희가 작성한 모듈 파일에 변경사항이 있을 경우 업데이트가 어려웠습니다. 설정을 바꾸고 서버를 재기동해야하나 고민하다가, 답은 의외로 간단한 곳에 있다는 것을 깨달았습니다. 바로 운영 중에 자주 사용하는 'ansible' 입니다. ansible의 경우 호스트에 접근해서 명령을 수행하도록 하는 것과, 일괄적으로 여러 서버에 대해서 작업을 수행하는 것이 가능했기 때문에 이 경우에 딱 맞는 해결책이었습니다.

- name: COPY new module file
  ansible.builtin.copy:
    src: "{{ new_module }}"
    dest: "{{ path }}/{{ new_module }}"
    mode: 0755
 
- name: UNLOAD old module file
  ansible.builtin.shell:
    cmd: "{{ redis_cli }} MODULE UNLOAD {{ module_name }}"
 
- name: LOAD new module file
  ansible.builtin.shell:
    cmd: "{{ redis_cli }} MODULE LOAD {{ path }}/{{ new_module }}"

간단한 스크립트를 작성해 테스트해본 결과, 성공적으로 모듈을 업데이트해서 결과적으로 모든 Redis 버전에 대해 저희가 바라던 모든 것을 성취할 수 있었습니다!

# before running ansible script
redis-cli MODULE LIST
1) 1) "name"
   2) "cmdmonitor"
   3) "ver"
   4) (integer) 1
   5) "path"
   6) "audit_module_v1.so"
   7) "args"
   8) (empty array)
 
# after running ansible script
redis-cli MODULE LIST
1) 1) "name"
   2) "cmdmonitor"
   3) "ver"
   4) (integer) 1
   5) "path"
   6) "audit_module_v2.so"
   7) "args"
   8) (empty array)

마치며..

여러 시행 착오는 있었지만, 기능의 구현이라는 궁극적인 목표를 달성했습니다. 또한 이러한 결과 뿐만 아니라 그 과정에서 체득한 Redis 소스코드의 분석과 모듈의 사용법 자체도 예상 외의 상당한 수확이었습니다. 기능을 파악하다보니 Audit Log 의 구현 이외에도 추가적으로 구현할만한 내용들이 떠올라 상당히 흥미로운 작업이었습니다.

저희가 시도하는 방법이 언제나 정답인 것은 아니고 하나의 솔루션이라고만 봐주시면 감사하겠습니다. 다음에 시간이 된다면 Redis 소스코드나 모듈에 의존해 직접적으로 로그를 남기는 것 이외에도, 네트워크 패킷 캡쳐를 통해 간접적으로 로그를 남기는 프로젝트에도 도전해보고 싶습니다.