이번 시간에는 Redis에 쓰기를 계속 보내다가 프로세스를 SIGKILL로 죽여 보면서, RDB와 AOF 설정마다 데이터가 얼마나 남는지 Docker의 Redis와 Python 스크립트로 측정하며 배워가는 시간입니다.
예를 들어서 로그인 세션을 Redis에 저장하던 서비스에서 Redis 컨테이너가 재시작됐다고 해볼게요. 다시 올라온 Redis에는 세션 키가 남아 있었지만, 재시작 직전 몇 분 사이에 로그인한 사용자들은 다시 로그인해야 했어요.
왜 그럴까요? 설정 파일 없이 띄운 Redis는 기본값으로 RDB 스냅숏을 만들고, 스냅숏 사이에 들어온 쓰기는 디스크에 없기 때문이에요.
Redis는 데이터를 메모리에 두지만, 디스크에 남겨 재시작 뒤 되살리는 방법이 두 가지 있어요.
- RDB: 특정 시점의 데이터 전체를 파일 하나로 저장합니다.
- AOF(Append Only File): 쓰기 명령을 파일 끝에 계속 덧붙입니다.
실습 준비와 기본값
이 글의 명령은 모두 실습 코드의 redis-persistence/ 디렉터리에서 실행합니다.
compose.yaml: Redis 8.10과 Python 컨테이너 정의writer.py: 번호를 하나씩 올려SET하고 마지막으로 응답받은 번호를 찍는 스크립트check.py: 재시작한 Redis에 남은 키 수와 가장 큰 번호를 찾는 스크립트
Redis 컨테이너와, 쓰기 스크립트를 돌릴 Python 컨테이너를 띄울게요(compose.yaml).
Redis 설정은 REDIS_OPTS 환경 변수로 받아 command에 붙여요. 재시작해도 설정이 그대로 남아야 하니까 CONFIG SET 대신 실행 인자로 줍니다. --enable-debug-command local은 이 글에서는 쓰지 않아요.
services:
redis:
image: redis:8.10.2
command: redis-server --enable-debug-command local ${REDIS_OPTS:-}
ports:
- "7340:6379"
volumes:
- data:/data
mem_limit: 1g
app:
image: python:3.12-slim
command: sh -c "pip install -q --root-user-action=ignore redis==6.4.0 && sleep infinity"
volumes:
- .:/app
working_dir: /app
mem_limit: 256m
volumes:
data:
compose 프로젝트 이름은 rper로 정하고 자주 쓰는 명령을 셸 함수로 만들어 놓을게요.
fresh: 데이터 볼륨을 지우고 인자로 받은 설정으로 Redis를 새로 띄워요.
아래 블록은 파일로 만들지 말고 실습 디렉터리에서 연 터미널에 그대로 붙여 넣어 주세요. 이후 명령은 모두 이 터미널에서 실행하고, 터미널을 새로 열면 다시 붙여 넣으시면 됩니다.
export COMPOSE_PROJECT_NAME=rper
rcli() { docker compose exec -T redis redis-cli --no-raw "$@"; }
py() { docker compose exec -T app python "$@"; }
fresh() {
docker compose rm -sf redis
docker volume rm -f rper_data
REDIS_OPTS="$*" docker compose up -d --wait redis
docker compose up -d app
}
먼저 아무 설정 없이 띄운 상태의 영속화 관련 값을 볼게요(발췌).
fresh
rcli CONFIG GET save appendonly appendfsync aof-use-rdb-preamble auto-aof-rewrite-percentage auto-aof-rewrite-min-size aof-load-truncated appenddirname dir appendfilename dbfilename
3) "save"
4) "3600 1 300 100 60 10000"
5) "appendfsync"
6) "everysec"
11) "appendonly"
12) "no"
공식 이미지의 시작 스크립트(docker-entrypoint.sh)는 이미지에 든 모듈을 불러오는 --loadmodule 말고는 영속화 인자를 붙이지 않아요. 그러니 이 값들이 Redis 자체 기본값이라고 보시면 됩니다.
save 3600 1 300 100 60 10000: RDB 저장 조건이에요. 3600초 동안 1번 이상, 300초 동안 100번 이상, 60초 동안 1만 번 이상 바뀌면 저장합니다.appendonly no: AOF는 꺼져 있어요.appendfsync everysec: AOF를 켜면 1초마다 fsync해요.
손실을 재려면 쓰기를 멈추지 않고 계속 보내다가 프로세스를 죽여야 해요.
writer.py는 seq:1, seq:2처럼 번호를 하나씩 올려 SET하고, 응답을 받은 번호만 기억해요. 연결이 끊기면 마지막으로 응답받은 번호를 출력하고 끝납니다.
redis-py 6.4.0은 연결 오류가 나면 기본으로 세 번까지 다시 보내요. 그래서 재시도한 쓰기가 섞이지 않게 꺼 두었습니다.
check.py는 재시작한 Redis에 남은 키 수와, 남아 있는 가장 큰 번호를 이진 탐색으로 찾아요.
실습 1: RDB, 스냅숏 사이의 쓰기는 사라집니다
RDB는 fork로 만든 자식 프로세스가 그 순간의 메모리 내용을 dump.rdb로 저장해요. 저장 조건(save)을 만족하거나 BGSAVE를 실행하면 저장합니다.
결과를 분명히 보려고 여기서는 자동 저장은 끄고(--save ""), 쓰기 도중에 BGSAVE를 한 번만 실행한 뒤 5초 후 프로세스를 죽이는 방식으로 가볼게요. DBSIZE와 BGSAVE는 같은 연결로 연달아 보내 저장 직전의 키 수를 봅니다.
fresh --save '""' --appendonly no
py writer.py &
sleep 3
printf 'DBSIZE\nBGSAVE\n' | rcli
sleep 5
rcli INFO persistence | grep -E 'rdb_changes_since_last_save|rdb_last_bgsave_status'
docker compose kill -s SIGKILL redis
wait
docker compose start redis
py check.py
(integer) 70081
Background saving started
rdb_changes_since_last_save:100619
rdb_last_bgsave_status:ok
마지막으로 응답받은 번호: 210330 (ConnectionError)
남은 키 수: 70081, 가장 큰 번호: 70081
rdb_changes_since_last_save는 마지막 저장 뒤 바뀐 키 수예요. 이 값을 확인한 뒤에도 쓰기는 계속됐고, writer는 seq:210330까지 응답을 받았어요.
그런데 재시작한 Redis에는 BGSAVE 시점의 70,081개만 남았어요.
같은 실험을 세 번 했어요.
- 1회차: 저장 직전
DBSIZE70,249, 저장된 키 70,251, 마지막 응답 179,914, 재시작 뒤 70,251 - 2회차: 저장 직전 70,081, 저장 70,081, 마지막 응답 210,330, 재시작 뒤 70,081
- 3회차: 저장 직전 68,414, 저장 68,415, 마지막 응답 207,014, 재시작 뒤 68,415
세 번 모두 재시작 뒤 남은 키는 BGSAVE가 저장한 키 수와 정확히 같았고, 응답까지 받은 쓰기 가운데 11만~14만 건이 사라졌어요.
DBSIZE와 저장된 수가 한두 개 다른 건 두 명령 사이에 writer의 쓰기가 끼어들었기 때문이에요.
기본 저장 조건이라면 쓰기가 많을 때 60초마다 저장해요. 그러니 프로세스가 죽으면 최대 1분 가까운 쓰기를 잃을 수 있어요. 쓰기가 드물면 간격이 300초, 3600초로 늘어납니다.
실습 2: AOF, 쓰기를 파일에 덧붙입니다
AOF는 데이터를 바꾸는 명령을 받을 때마다 그 명령을 파일 끝에 덧붙이고, 재시작할 때 파일의 명령을 처음부터 다시 실행해 데이터를 만들어요.
이때 "파일에 썼다"는 말은 두 단계로 나뉘어요.
write(): 데이터를 커널에 넘겨요. 커널은 이걸 메모리의 페이지 캐시에 두고 나중에 디스크에 기록합니다. 페이지 캐시는 Redis 프로세스가 아니라 운영체제의 메모리라서, Redis 프로세스가 죽어도 남아 있다가 디스크에 기록돼요.fsync(): 페이지 캐시에 있는 그 파일의 데이터를 디스크에 기록하고, 기록이 끝날 때까지 기다려요. 이게 끝나야 OS가 멈추거나 전원이 꺼져도 남습니다.
appendfsync는 이 가운데 fsync를 언제 할지 정해요.
always: 명령을 AOF에 덧붙일 때마다 fsync하고, 끝난 뒤에 응답을 보내요. 여러 클라이언트가 동시에 쓰면 한 번의 fsync로 묶어 처리합니다.everysec(기본값): 1초에 한 번 백그라운드 스레드가 fsync해요.no: fsync를 직접 하지 않고 커널이 알아서 기록하게 둬요. 공식 문서에 따르면 리눅스에서는 보통 30초 안팎마다 기록되지만 커널 설정에 따라 다릅니다.
write()는 세 설정 모두 평소에는 응답을 보내기 전에 해요. Redis 소스(aof.c)의 flushAppendOnlyFile 주석은 클라이언트에 응답하기 전에 AOF를 써야 하므로 이벤트 루프로 돌아가기 직전에 버퍼를 기록한다고 적고 있어요.
그렇다면 프로세스만 죽는 경우에는 설정과 관계없이 응답받은 쓰기가 남아야겠죠. 설정마다 세 번씩 확인해 볼게요.
fresh --appendonly yes --appendfsync always --save '""'
py writer.py &
sleep 5
docker compose kill -s SIGKILL redis
wait
docker compose start redis
py check.py
--appendfsync만 everysec, no로 바꿔 같은 순서로 실행한 결과예요.
# always
마지막으로 응답받은 번호: 14889 (ConnectionError)
남은 키 수: 14890, 가장 큰 번호: 14890
마지막으로 응답받은 번호: 14910 (ConnectionError)
남은 키 수: 14911, 가장 큰 번호: 14911
마지막으로 응답받은 번호: 17395 (ConnectionError)
남은 키 수: 17395, 가장 큰 번호: 17395
# everysec
마지막으로 응답받은 번호: 93578 (ConnectionError)
남은 키 수: 93579, 가장 큰 번호: 93579
마지막으로 응답받은 번호: 95116 (ConnectionError)
남은 키 수: 95116, 가장 큰 번호: 95116
마지막으로 응답받은 번호: 93736 (ConnectionError)
남은 키 수: 93736, 가장 큰 번호: 93736
# no
마지막으로 응답받은 번호: 91601 (ConnectionError)
남은 키 수: 91601, 가장 큰 번호: 91601
마지막으로 응답받은 번호: 94365 (ConnectionError)
남은 키 수: 94365, 가장 큰 번호: 94365
마지막으로 응답받은 번호: 93481 (ConnectionError)
남은 키 수: 93481, 가장 큰 번호: 93481
아홉 번 모두 응답받은 쓰기는 하나도 사라지지 않았어요.
남은 키가 하나 더 많은 경우는, Redis가 실행하고 AOF에 썼지만 응답이 writer에 닿기 전에 프로세스가 죽은 마지막 한 건이에요.
appendfsync no에서도 결과가 같았던 건 데이터가 이미 커널 페이지 캐시에 있었기 때문이에요. Docker의 SIGKILL은 Redis 프로세스만 죽이고, 그 아래의 리눅스 커널(Docker Desktop의 가상 머신)은 그대로 돌고 있거든요.
그러니 "everysec면 Redis가 죽을 때 1초를 잃는다"는 말은 프로세스 장애에는 맞지 않아요. 1초는 OS가 멈추거나 전원이 꺼져 페이지 캐시가 사라질 때의 기준이에요.
이 경우는 Docker Desktop에서 안전하게 재현할 수 없어서 공식 문서를 근거로 정리할게요.
always: 응답을 받은 쓰기는 디스크에 기록된 상태예요.everysec: 마지막 fsync 이후의 쓰기, 보통 1초 안팎을 잃을 수 있어요.no: 커널이 아직 디스크에 기록하지 않은 만큼, 리눅스 기본 설정에서는 수십 초를 잃을 수 있어요.
설정 사이의 차이는 처리량에서 드러나요. 같은 5초 동안 writer가 받은 응답이 always는 1만 5천~1만 7천 건, everysec와 no는 9만 1천~9만 5천 건이었어요.
MySQL에서 같은 선택을 하는 innodb_flush_log_at_trx_commit이 더 궁금하시면 Binlog, Redo log, Undo log를 다룬 글을 참고해 주세요.
재시작 뒤 빈 캐시로 시작하면 요청이 DB로 몰리는데, 캐시가 비어 있을 때 읽기가 어떻게 흐르는지 더 궁금하시면 Cache Aside 패턴을 다룬 글을 참고해 주세요.
영속화 없이 재시작한 Redis에서 분산 락 키가 사라지는 경우가 더 궁금하시면 Redis 분산 락을 다룬 글을 참고해 주세요.
정리
범위: redis:8.10.2 컨테이너, redis-py 6.4.0, 노트북 Docker Desktop, 장애는 Redis 프로세스 SIGKILL(OS·전원 장애는 재현하지 않음).
- 기본 설정(RDB만)에서 쓰기 도중 프로세스를 죽이자 재시작 뒤 키는
BGSAVE가 저장한 수와 같았고, 응답까지 받은 쓰기 11만~14만 건이 세 번 모두 사라졌어요. 재시작 뒤 잃으면 안 되는 데이터에는 RDB만 쓰지 마세요. - AOF를 켜면
appendfsync가always,everysec,no중 무엇이든 아홉 번 모두 응답받은 쓰기가 하나도 사라지지 않았습니다.appendfsync는 프로세스 장애가 아니라 OS·전원 장애 때 잃는 범위와 처리량을 보고 고르세요. - 같은 5초 동안 writer가 받은 응답은
always가 1만 5천~1만 7천 건으로,everysec와no의 9만 1천~9만 5천 건보다 훨씬 적었어요. 한 건도 잃으면 안 되는 값이 아니라면 기본값everysec를 두세요. - 이 글에 싣지 않은 추가 측정에서는 RDB와 AOF를 모두 켜면 재시작 때 AOF만 읽었고, RDB만 쓰던 서버를 설정만 바꿔 AOF로 재시작하자
dump.rdb가 있는데도 키가 0개였어요. 운영 중인 서버는CONFIG SET appendonly yes로 AOF를 먼저 만든 뒤 설정 파일을 고치세요.