이번 시간에는 Valkey 컨테이너에서 영속화가 돌고 있는 서버가 어떻게 흔들리는지 Docker와 cgroup(컨테이너의 CPU·메모리 사용을 제한하고 세는 리눅스 기능) 통계로 재현하며 배워가는 시간입니다.
예를 들어서 우리가 세션과 작업 큐를 Valkey에 두고, RDB 자동 저장을 켜 둔 채 컨테이너로 운영하고 있다고 해볼게요. maxmemory는 컨테이너 한도보다 넉넉히 낮춰 두었으니 괜찮다고 생각했는데, 쓰기가 몰리던 어느 날 컨테이너가 OOM으로 재시작돼요. 또 다른 날에는 디스크가 가득 찬 뒤로 애플리케이션의 쓰기가 전부 MISCONF 오류로 실패합니다.
저장하는 동안 copy-on-write(COW)로 메모리가 얼마나 늘고 언제 OOM으로 죽는지, 저장이 실패하면 왜 쓰기와 PING까지 거부되는지 차례로 재현해 볼게요. RDB와 AOF가 각각 무엇을 언제 남기는지 궁금하면 Redis 영속화를 다룬 글을 참고하세요.
실습 준비
이 글의 명령은 모두 실습 코드의 valkey-rdb-aof-incidents/ 디렉터리에서 실행합니다.
compose.yaml: 서버vk,vkfull과 부하 도구bench정의env.sh: 셸 함수vcli,fcli,bench,fresh,fillsample.sh:vk안에서 0.5초마다 메모리와 영속화 상태를 찍는 스크립트
이미지는 valkey/valkey:9.1.2를 써요.
vk: 실습 대부분에서 쓰는 서버. CPU 1개, 메모리 한도는VK_MEM(기본 1GB)./data는 Docker named volume입니다.vkfull: 디스크가 가득 차는 실습에만 쓰는 서버./data를 64MB짜리 tmpfs로 올려 쉽게 채울 수 있게 했습니다. CPU 0.5개, 메모리 384MBbench:valkey-benchmark를 돌리는 일회용 컨테이너. CPU 1개, 메모리 256MB
compose.yaml에서 눈여겨보실 설정은 이렇습니다.
memswap_limit을mem_limit과 같게 둡니다. Docker 문서에 따르면mem_limit만 주면 컨테이너가 같은 양의 스왑을 더 쓸 수 있습니다. 그러면 한도를 넘어도 OOM 대신 스왑으로 버티면서 느려지기만 해서, 이 글에서 보려는 장면이 흐려집니다.- 헬스 체크는
PING에PONG이 오는지만 봅니다.
env.sh는 compose 프로젝트 이름을 vkper로 정하고 셸 함수 vcli, fcli, bench, fresh, fill을 정의해요.
vcli와 fcli는 각각 vk, vkfull 컨테이너 안의 valkey-cli를 실행하고, bench는 일회용 bench 컨테이너에서 명령을 실행합니다. fresh는 데이터 볼륨까지 지우고 인자로 받은 설정으로 vk를 새로 띄워요. fill은 키 번호 범위를 받아 그 세 배만큼 1000바이트 값을 SET합니다. 무작위 번호라 일부 번호는 빠지니, 실제 키 수는 범위보다 조금 적어요.
이 함수들은 env.sh에 있고, 터미널에서는 source ./env.sh로 불러 쓰시면 됩니다. 먼저 아무 인자 없이 띄워 영속화 관련 기본값을 볼게요.
source ./env.sh
mkdir -p out
fresh
vcli CONFIG GET save stop-writes-on-bgsave-error no-appendfsync-on-rewrite oom-score-adj oom-score-adj-values disable-thp
disable-thp
yes
no-appendfsync-on-rewrite
no
oom-score-adj
no
oom-score-adj-values
0 200 800
save
3600 1 300 100 60 10000
stop-writes-on-bgsave-error
yes
이미지의 시작 스크립트는 VALKEY_EXTRA_FLAGS 환경 변수 말고는 인자를 붙이지 않아서, 위 값이 Valkey 자체 기본값입니다. 이 글에서 볼 값은 두 가지예요.
save 3600 1 300 100 60 10000: RDB 자동 저장 조건입니다. 설정 파일 없이 띄워도 자동 저장이 켜져 있습니다.stop-writes-on-bgsave-error yes: 마지막 RDB 저장이 실패하면 쓰기를 거부합니다.
이 글의 숫자는 Apple Silicon 노트북의 Docker Desktop 가상 머신에서, 위의 CPU 제한 아래에서 잰 값이에요. 그러니 절댓값보다 조건끼리의 차이로 읽어 주세요.
실습 1: 저장하는 동안 메모리는 두 배까지 늘 수 있습니다
BGSAVE와 AOF 재작성은 메인 스레드가 아니라 fork로 만든 자식 프로세스가 해요. fork 직후 부모와 자식은 같은 물리 메모리 페이지를 함께 가리킵니다.
자식은 저장만 하니 페이지를 거의 바꾸지 않아요. 그런데 부모가 키를 쓰면 그 페이지를 커널이 복사해 부모에게 새 사본을 주고, 자식은 원래 페이지를 저장이 끝날 때까지 쥐고 있습니다. 이것을 copy-on-write(COW)라고 해요. 그래서 저장 중에 쓰기가 많을수록 두 벌이 되는 페이지가 늘어납니다.
키 30만 개 범위에 값 1000바이트를 채워 볼게요. maxmemory는 600MB, 자동 저장은 끕니다(--save '""').
VK_MEM=1g fresh --save '""' --appendonly no --maxmemory 600mb --maxmemory-policy noeviction
fill 300000
vcli DBSIZE
vcli INFO memory | grep -E '^used_memory:|^maxmemory:'
vcli BGSAVE
docker compose logs --no-log-prefix vk | grep -E 'Background saving'
SET key:__rand_int__ __data__: 308430.44 requests per second, p50=1.799 msec
285069
used_memory:308874896
maxmemory:629145600
Background saving started
6:M 12 Oct 2026 02:35:28.769 * Background saving started by pid 115
6:M 12 Oct 2026 02:35:30.334 * Background saving terminated with success
(로그는 저장이 끝난 뒤 봤습니다.) 키 285,069개, used_memory 약 295MB예요.
이 정도 데이터는 저장이 1.6초 만에 끝나서 저장 도중을 관찰하기 어려워요. 그래서 rdb-key-save-delay로 저장을 늦출게요. Valkey 소스(config.c)에 숨은 설정으로 들어 있는 테스트용 설정이고, RDB 저장에서 키 하나를 쓸 때마다 잠깐 쉬게 해요. 이 가상 머신에서는 -15일 때 저장이 20초 남짓 걸렸어요. 운영 서버에서는 쓰지 않는 설정입니다.
vcli CONFIG SET rdb-key-save-delay -15
OK
저장하는 동안의 메모리는 sample.sh로 0.5초마다 찍어요. docker compose exec -T vk sh -s <초> < sample.sh로 vk 컨테이너 안에서 돌리니까, Valkey의 INFO memory persistence와 그 컨테이너의 cgroup 메모리 파일(memory.current, memory.stat)을 같은 줄, 같은 시간에 찍을 수 있어요. 아래 출력에서 볼 열은 이렇습니다(MB).
used:used_memory. Valkey가 할당자(jemalloc)를 통해 할당한 메모리입니다.cow:current_cow_size. 자식 프로세스가 자기 메모리 가운데 부모와 공유가 풀린 양을 재서 부모에게 알려 준 값입니다.perc,rdb: 저장 진행률(current_fork_perc)과rdb_bgsave_in_progress입니다.cur: cgroupmemory.current. 컨테이너 메모리 한도와 비교되는 값입니다.anon,file: cgroupmemory.stat의anon(프로세스의 익명 메모리)과file(페이지 캐시)입니다. RDB 파일을 쓰는 동안 생기는 페이지 캐시도memory.current에 잡히므로, COW를 볼 때는anon을 봅니다.
BGSAVE 직후 같은 키 공간에 덮어쓰기 부하를 걸어 볼게요. 연결 20개, 파이프라인 16으로 SET 300만 번입니다.
docker compose exec -T vk sh -s 60 < sample.sh > cow.txt &
vcli BGSAVE
bench valkey-benchmark -h vk -r 300000 -n 3000000 -c 20 -P 16 -d 1000 -q SET key:__rand_int__ __data__ 2>&1 | tr '\r' '\n' | grep 'per second'
wait
awk '{print $1, $2, $3, $5, $6, $12, $13, $14}' cow.txt | column -t
샘플 가운데 필요한 열과, 부하가 들어온 구간과 저장이 끝난 직후만 남겼어요.
Background saving started
SET key:__rand_int__ __data__: 247851.95 requests per second, p50=0.591 msec
time used cow perc rdb cur anon file
00:42:46.5 294.7 0.0 0.0 0 312 307.8 0.6
00:42:48.0 294.7 0.0 0.0 1 317 310.5 1.2
00:42:54.1 294.7 6.2 25.5 1 325 313.9 5.9
00:42:54.6 295.2 6.2 25.5 1 352 344.0 4.4
00:42:55.2 296.0 6.2 30.9 1 417 404.2 7.0
00:42:55.7 296.7 97.0 35.6 1 464 453.2 5.5
00:42:56.2 297.7 97.0 35.6 1 509 496.7 7.8
00:42:56.8 299.0 182.9 39.9 1 550 538.4 6.6
00:42:57.3 300.3 182.9 39.9 1 583 570.0 8.3
00:42:57.8 303.9 251.4 44.2 1 622 610.5 6.6
00:42:58.4 307.0 251.4 44.2 1 634 620.1 8.4
00:42:58.9 308.3 299.5 48.5 1 633 621.8 6.1
00:42:59.5 309.0 299.5 48.5 1 635 622.6 7.4
00:43:00.0 309.4 300.4 52.4 1 633 623.0 5.1
00:43:14.7 309.9 300.4 95.2 1 636 622.9 8.3
00:43:15.2 309.9 0.0 0.0 0 328 323.2 0.6
bench 컨테이너가 뜨는 데 몇 초가 걸려서 부하는 저장 진행률 25% 근처에서 들어왔고, 그 뒤 6초 만에 COW가 최댓값에 닿았어요.
여기서 눈여겨볼 건 anon과 used_memory의 차이예요. BGSAVE 직전(00:42:46.5)부터 그 시점(00:43:00.0)까지 anon은 307.8MB에서 623.0MB로 315.2MB 늘었는데, used_memory는 294.7MB에서 309.4MB로 14.7MB 늘었을 뿐입니다.
둘의 차이 300.5MB는 같은 시점의 current_cow_size 300.4MB와 같아요. 데이터 전체가 두 벌이 된 셈이죠. 저장이 끝나 자식이 사라지자 anon은 323.2MB로 돌아왔습니다. 같은 실험을 세 번 해서(나머지 두 번은 출력을 싣지 않았어요) anon은 310.9~315.2MB, used_memory는 0~14.7MB 늘었어요.
used_memory는 INFO 문서의 정의대로 "Total number of bytes allocated by Valkey using its allocator"예요. 페이지를 복사하는 건 할당자가 아니라 커널이니 이 값에 들어가지 않고, 따라서 maxmemory와도 비교되지 않아요.
위 출력에서 anon이 먼저 뛰고 cow가 0.5~1초 늦게 따라오는 것도 눈여겨봐 주세요. 자식은 1초에 한 번 정도만 이 값을 재서 보냅니다.
실습 2: maxmemory는 이 메모리를 세지 않습니다
앞 실험의 used_memory가 약 310MB였으니, 컨테이너 한도를 그보다 3분의 1쯤 큰 420MB로 잡고 maxmemory는 330MB로 둘게요. 운영자가 보기에는 maxmemory와 컨테이너 한도 사이에 90MB의 여유가 있는 설정이에요.
자동 저장 조건은 실습 중에 끼어들지 않게 3600 1 하나만 둡니다. 호스트에서는 docker events로 OOM 이벤트를 받아 둬요.
VK_MEM=420m fresh --save '"3600 1"' --appendonly no --maxmemory 330mb --maxmemory-policy noeviction
fill 300000
vcli CONFIG SET rdb-key-save-delay -15
docker events --filter event=oom --filter container=vkper-vk-1 --format '{{.Time}} {{.Action}} {{.Actor.Attributes.name}}' &
docker compose exec -T vk sh -s 50 < sample.sh > oom.txt &
vcli BGSAVE
bench valkey-benchmark -h vk -r 300000 -n 3000000 -c 20 -P 16 -d 1000 -q SET key:__rand_int__ __data__ 2>&1 | tr '\r' '\n' | grep -E 'per second|rror'
Background saving started
1791766296 oom vkper-vk-1
Error: Connection reset by peer
부하 도구의 연결이 끊겼어요. 컨테이너 상태와 샘플의 마지막 줄을 볼게요.
docker compose ps -a vk --format '{{.Service}} {{.Status}}'
docker inspect -f 'OOMKilled={{.State.OOMKilled}} ExitCode={{.State.ExitCode}}' vkper-vk-1
awk '{print $1, $2, $3, $5, $6, $12, $13, $14}' oom.txt | column -t | tail -5
vk Exited (137) 6 seconds ago
OOMKilled=true ExitCode=137
00:51:31.0 295.3 23.1 12.9 1 376 370.4 1.0
00:51:32.0 295.5 23.1 12.9 1 393 385.8 2.9
00:51:32.6 295.7 23.1 13.3 1 407 398.5 4.6
00:51:33.5 295.9 23.1 13.3 1 418 408.6 5.0
00:51:34.4 296.0 23.1 15.1 1 418 413.8 0.4
컨테이너가 종료 코드 137(SIGKILL)로 끝났어요. 마지막 샘플에서 used_memory는 296MB로 maxmemory 330MB 아래였는데, memory.current가 한도 420MB에 닿아 있었습니다.
같은 시점의 current_cow_size는 23.1MB로, 실제로 늘어난 anon 약 100MB를 따라잡지 못했어요.
서버가 죽자 저장하던 자식도 함께 사라졌습니다. 세 번 모두 OOM killer가 고른 건 데이터를 가진 서버 프로세스였어요.
근본 대책은 컨테이너 한도와 maxmemory 사이에 COW가 들어갈 자리를 두는 거예요. 이 실습처럼 저장 중에 데이터 전체를 덮어쓸 수 있는 쓰기 패턴이라면 그 자리는 데이터 크기만큼이 필요합니다.
실습 3: 저장이 실패하면 쓰기가 멈춥니다, MISCONF
더 흔한 원인은 디스크가 가득 차는 거예요. vk를 내리고 /data가 64MB tmpfs인 vkfull을 띄울게요.
10초 동안 키가 하나라도 바뀌면 저장하게 하고, RDB 압축은 끕니다. 운영 기본값은 rdbcompression yes예요. tmpfs에서도 공간이 모자랄 때 write()가 ENOSPC(No space left on device)를 돌려주는 건 실제 디스크와 같습니다.
docker compose rm -sf vk
FULL_ARGS='--save "10 1" --appendonly no --rdbcompression no' docker compose --profile full up -d --wait vkfull
fill 20000 vkfull
fcli DBSIZE
sleep 13
docker compose logs --no-log-prefix vkfull | grep -E 'Background saving|DB saved'
docker compose exec -T vkfull sh -c 'ls; du -m dump.rdb; df -h /data'
SET key:__rand_int__ __data__: 512820.53 requests per second, p50=0.791 msec
18971
7:M 12 Oct 2026 01:13:34.101 * Background saving started by pid 60
60:C 12 Oct 2026 01:13:34.137 * DB saved on disk
7:M 12 Oct 2026 01:13:34.202 * Background saving terminated with success
dump.rdb
19 dump.rdb
Filesystem Size Used Avail Use% Mounted on
tmpfs 64M 19M 46M 29% /data
첫 자동 저장이 성공해 19MB짜리 dump.rdb가 생겼어요.
새 RDB는 임시 파일에 다 쓴 뒤 이름을 바꾸기 때문에, 저장하는 동안에는 기존 파일과 새 파일이 함께 있을 자리가 필요해요. 그래서 32MB짜리 파일을 만들어 남은 공간을 14MB로 줄이고, 키를 하나 써서 다음 자동 저장을 기다려 볼게요.
docker compose exec -T vkfull sh -c 'dd if=/dev/zero of=/data/junk bs=1M count=32 2>&1 | tail -1; df -h /data'
fcli SET trigger 1
sleep 14
docker compose logs --no-log-prefix vkfull | grep -E 'Background saving|Write error' | tail -4
fcli INFO persistence | grep -E 'rdb_last_bgsave_status|rdb_changes_since_last_save'
33554432 bytes (34 MB, 32 MiB) copied, 0.0199207 s, 1.7 GB/s
Filesystem Size Used Avail Use% Mounted on
tmpfs 64M 51M 14M 79% /data
OK
7:M 12 Oct 2026 01:14:16.197 # Background saving error
7:M 12 Oct 2026 01:14:22.098 * Background saving started by pid 278
278:C 12 Oct 2026 01:14:22.157 # Write error while saving DB to the disk(rdbSaveRio): No space left on device
7:M 12 Oct 2026 01:14:22.202 # Background saving error
rdb_changes_since_last_save:1
rdb_last_bgsave_status:err
저장이 실패했고, 6초쯤 뒤 다시 시도해 또 실패했어요. serverCron은 save 조건의 변경 수와 시간이 맞고, 지난번 시도에서 5초(CONFIG_BGSAVE_RETRY_DELAY)가 지났을 때 다시 저장합니다(server.c).
fcli SET k v
fcli GET trigger
fcli PING
MISCONF Valkey is configured to save RDB snapshots, but it's currently unable to persist to disk. Commands that may modify the data set are disabled, because this instance is configured to report errors during writes if RDB snapshotting fails (stop-writes-on-bgsave-error option). Please check the Valkey logs for details about the RDB error.
1
MISCONF Valkey is configured to save RDB snapshots, but it's currently unable to persist to disk. Commands that may modify the data set are disabled, because this instance is configured to report errors during writes if RDB snapshotting fails (stop-writes-on-bgsave-error option). Please check the Valkey logs for details about the RDB error.
SET은 거부되고 GET은 값을 돌려줘요. PING도 MISCONF로 거부됩니다. 그래서 PING으로 살아 있는지 보는 헬스 체크도 실패해요.
docker inspect -f '{{.State.Health.Status}} {{range .State.Health.Log}}{{.ExitCode}} {{end}}' vkper-vkfull-1
unhealthy 1 1 1 1 1
헬스 체크 결과로 컨테이너를 재시작하거나 트래픽에서 빼는 환경이라면, 읽기는 멀쩡히 되는 서버가 빠지는 거예요. 재시작하면 디스크에 마지막으로 저장된 데이터로 돌아가니, 메모리에만 있던 쓰기는 사라집니다.
이 거부는 save 조건이 있을 때만 일어나요. 저장 실패 상태 그대로, 재시작 없이 설정만 바꿔 볼게요.
fcli CONFIG SET save ""
fcli SET k v
fcli INFO persistence | grep rdb_last_bgsave_status
fcli CONFIG SET save "10 1"
fcli SET k v 2>&1 | cut -c1-80
fcli CONFIG SET stop-writes-on-bgsave-error no
fcli SET k v
fcli INFO persistence | grep rdb_last_bgsave_status
fcli CONFIG SET stop-writes-on-bgsave-error yes
fcli SET k v 2>&1 | cut -c1-80
OK
OK
rdb_last_bgsave_status:err
OK
MISCONF Valkey is configured to save RDB snapshots, but it's currently unable to
OK
OK
rdb_last_bgsave_status:err
OK
MISCONF Valkey is configured to save RDB snapshots, but it's currently unable to
save ""로 save 조건을 없애자 저장 상태는 여전히 err인데 SET이 됐어요. save 조건을 되돌리자 다시 거부됐고, stop-writes-on-bgsave-error no로 바꾸자 또 됐습니다. yes로 되돌리면 다시 거부돼요.
급하게 쓰기를 살려야 할 때 쓸 수 있는 방법이지만, 디스크에는 여전히 아무것도 저장되지 않고 있다는 뜻이기도 해요.
정리
범위: Valkey 9.1.2(valkey/valkey:9.1.2), Apple Silicon 노트북의 Docker Desktop 가상 머신, 서버 CPU 1개, 값 1000바이트 키 약 28만 5천 개, 저장은 rdb-key-save-delay -15로 20초 남짓 늘림, 조건마다 3회.
- 저장 중 덮어쓰기 부하에서
used_memory는 0~14.7MB 늘었지만anon은 310.9~315.2MB 늘었고, 그 차이는current_cow_size와 같았어요. 저장 중 데이터 전체를 덮어쓸 수 있으면 컨테이너 한도와maxmemory사이에 데이터 크기만큼 자리를 두세요. - 컨테이너 한도 420MB,
maxmemory330MB에서used_memory가 295~296MB인 채로 OOM이 났고, 기본 설정에서는 세 번 모두 서버 프로세스가 죽어 컨테이너가 137로 끝났어요. 컨테이너가 쓰는 메모리는used_memory가 아니라 cgroup 값으로 보세요. - 디스크가 차서 RDB 저장이 실패하자
SET과PING은MISCONF로 거부되고GET은 됐으며,save ""나stop-writes-on-bgsave-error no로 바꾸면 쓰기가 다시 됐어요.PING만 보는 헬스 체크로 재시작하면 메모리에만 있던 쓰기를 잃는다는 것을 알고 헬스 체크를 정해 주세요. - 이 글에 싣지 않은 추가 측정에서는 공간을 비우자 5.9~8.3초 뒤 자동 저장이 성공하며 쓰기가 저절로 풀렸어요. 재시도는 마지막 성공 저장부터 save 간격을 세니,
save 3600 1만 둔 서버는 원인을 푼 뒤BGSAVE를 직접 보내 주세요.