Redis Lua 스크립팅의 최근 개선 사항
Lua 스크립팅은 Redis가 이미 상당히 널리 사용되던 시기에 추가된 기능 가운데 아마도 가장 성공적인 기능일 것입니다. 따라서 사용자들이 정말 원하는 몇 가지 사항이 스크립팅과 관련되어 있다는 것도 놀랍지 않습니다. 다음 두 기능은 지난 2년 동안 여러 차례 제안되었고, 몇 주 전 Redis 개발자 회의에서도 많은 사람이 저에게 둘 중 하나에 집중해 보라고 권했습니다.
- Redis Lua 스크립트를 위한 제대로 된 디버거.
- Lua 스크립트 자체를 평소처럼 복제하는 대신, 스크립트의 효과를 구체화하는 쓰기 명령 집합으로 복제하고 AOF에도 저장하는 기능.
두 번째 기능은 단순히 스크립트를 어떻게 복제할지에 관한 문제가 아닙니다. 뒤에서 살펴보겠지만 Lua 스크립팅으로 할 수 있는 일에도 영향을 줍니다.
런던에서 돌아온 뒤 저는 두 기능을 모두 구현했습니다. 이 글에서는 두 기능을 모두 소개하면서, 독자에게 흥미로울 수 있는 설계와 구현 측면에 관한 몇 가지 힌트도 함께 설명하겠습니다.
제대로 된 Lua 디버거
Lua 스크립팅은 처음에는 정말 간단한 스크립트를 작성하기 위한 기능으로 구상되었습니다. 예를 들면 키가 존재하면 어떤 작업을 수행하는 식입니다. Redis에 가능한 모든 명령 변형을 넣어 덩치를 키우지 않기 위해 몇 줄의 코드를 사용하는 것이었죠. 물론 사용자들은 이를 훨씬 더 다양하게 활용했고, 쿼드트리 구현부터 까다로운 의미론을 갖춘 완전한 기능의 메시징 시스템까지 복잡한 스크립트를 작성하기 시작했습니다. Lua 스크립팅은 Redis를 프로그래밍 가능하게 만들며, 프로그래머는 대개 프로그래밍 가능한 것을 그냥 지나치지 못합니다. 모든 Lua 스크립트가 같은 인터프리터로 실행되고 캐시되므로 매우 빠르다는 점도 도움이 됩니다. 대부분의 경우 Lua 스크립팅을 사용하면 기능 면에서도 초당 처리량 면에서도 Redis 인스턴스로 훨씬 더 많은 일을 할 수 있습니다. 따라서 오늘날에는 복잡한 스크립트가 충분히 제자리를 차지하고 있습니다. 스크립팅 기능이 처음에는 매우 냉담한 반응을 받았던 때, 즉 ‘데이터베이스에 스크립트처럼 동적인 것을 보낸다고?’ 하던 시기에서 불과 몇 년 만에 대중적인 사용으로, 다시 복잡한 스크립트를 작성하는 단계로 발전한 셈입니다.
하지만 간단한 스크립트를 작성하는 일과 복잡한 스크립트를 작성하는 일은 완전히 다릅니다. 프로그램은 커질수록 복잡도가 기하급수적으로 증가하며, 코드가 10줄에서 200줄로 늘어나는 것만으로도 이를 체감할 수 있습니다. 간단한 스크립트라면 몇 가지 변형을 시도하고 데이터 세트에 미치는 영향을 관찰하거나 중간중간 로그 명령을 몇 개 넣는 식의 무식한 방법으로도 디버깅할 수 있습니다. 그러나 복잡한 스크립트는 디버거 없이는 고생할 수밖에 없습니다.
최근 제 동료 Itamar Haber는 복잡한 스크립트를 작성하는 데 많은 시간을 썼습니다. 그러다 어느 시점에 Lua 디버그 라이브러리를 사용해 Redis Lua 스크립팅용 디버거 비슷한 것을 만들기도 했습니다. 하지만 샌드박싱 문제 때문에 이제는 디버그 라이브러리를 스크립트에 노출하지 않으므로 이 디버거는 더 이상 작동하지 않습니다. 그리고 일반적으로 Redis 디버거에 필요한 것은 대화형 원격 디버거입니다. 서버와 함께 작동하면서 좋은 디버깅 경험을 제공할 수 있는 제대로 된 클라이언트가 있어야 하죠. 디버깅 자체도 이미 충분히 어려운 작업이므로 견고한 도구는 정말 필수입니다. 이를 실현하는 유일한 방법은 Redis 자체에 제대로 된 디버깅 지원을 추가하는 것이었습니다.
그래서 런던에서 돌아온 뒤 Itamar와 저는 디버거가 유용하고 과거에 비해 실질적으로 개선된 도구가 되려면 사용자에게 무엇을 제공해야 할지 이야기하기 시작했습니다. Redis 생태계 밖에 이미 존재하는 Lua 디버거를 지원하는 방안도 논의했습니다. 하지만 모든 요소를 Redis와 잘 작동하도록 특별히 설계할 때 사용자 경험이 향상된다고 저는 강하게 믿습니다. 결국 디버거를 처음부터 직접 작성하기로 했습니다. 한 가지 분명한 점이 있었습니다. Redis에 연결해 디버깅 세션을 시작하고, 스크립트가 Redis 데이터 세트에 어떤 작업을 수행하는지 잘 관찰할 수 있는 원격 디버거가 필요했습니다. 개인적으로 특히 신경 쓴 부분은 물론 색상이 적용된 출력이었습니다 ;-) 디버깅을 재미있는 경험으로 만들고 싶었고, 학습 곡선도 아주 가파르지 않게 만들고 싶었습니다. 이 둘은 서로 관련되어 있으니까요.
디버거의 작동 방식을 블로그 글로 보여 주는 것도 분명 가능하지만, Courier 글꼴로 글을 쓰는 저 같은 순수주의자라도 가끔은 동영상에 의지하게 됩니다. 그래서 여기 Redis 디버거의 주요 기능을 보여 주는 조금 긴 동영상이 있습니다. 이른 아침에 촬영해서 처음에는 약간 우울한 목소리로 말하지만, 몇 분 지나 커피가 돌고 나면 좀 더 기분 좋아진 제 모습을 볼 수 있을 겁니다.
(힌트: 대화형 세션의 글자를 읽을 수 있는 수준으로 보려면 동영상을 전체 화면으로 시청하세요. 글자가 보일 만큼 충분히 고화질입니다.)
동영상을 보는 데 관심이 없다면 Lua 디버거로 무엇을 할 수 있는지 간단히 정리한 내용이 디버거의 도움말 화면 자체에 나와 있습니다.
$ ./redis-cli --ldb --eval /tmp/script.lua
Lua debugging session started, please use:
quit -- End the session.
restart -- Restart the script in debug mode again.
help -- Show Lua script debugging commands.
* Stopped at 1, stop reason = step over
-> 1 local src = KEYS[1]
lua debugger> help
Redis Lua debugger help:
[h]elp Show this help.
[s]tep Run current line and stop again.
[n]ext Alias for step.
[c]continue Run till next breakpoint.
[l]list List source code around current line.
[l]list [line] List source code around [line].
line = 0 means: current position.
[l]list [line] [ctx] In this form [ctx] specifies how many lines
to show before/after [line].
[w]hole List all source code. Alias for 'list 1 1000000'.
[p]rint Show all the local variables.
[p]rint <var> Show the value of the specified variable.
Can also show global vars KEYS and ARGV.
[b]reak Show all breakpoints.
[b]reak <line> Add a breakpoint to the specified line.
[b]reak -<line> Remove breakpoint from the specified line.
[b]reak 0 Remove all breakpoints.
[t]race Show a backtrace.
[e]eval <code> Execute some Lua code (in a different callframe).
[r]edis <cmd> Execute a Redis command.
[m]axlen [len] Trim logged Redis replies and Lua var dumps to len.
Specifying zero as <len> means unlimited.
[a]abort Stop the execution of the script. In sync
mode dataset changes will be retained.
Debugger functions you can call from Lua scripts:
redis.debug() Produce logs in the debugger console.
redis.breakpoint() Stop execution like if there was a breakpoing.
in the next line of code.
lua debugger>어떻게 작동하는가?
디버거 전체는 상당 부분 자체적으로 완결된 코드 블록이며, 1,300줄의 코드로 구성되어 있습니다. 대부분은 scripting.c 안에 있고, 디버거의 클라이언트 역할을 하는 CLI 특수 모드를 구현하기 위해 일부가 redis-cli.c 안에 들어 있습니다. 앞서 말했듯이 서버와 클라이언트로 구성된 원격 디버거입니다.
Lua C API에는 꽤 유용한 디버깅 인터페이스가 있습니다. 그 자체가 디버거는 아니지만 디버거를 작성하는 데 필요한 기본 기능을 제공합니다. 그러나 Redis의 맥락에서 디버거를 작성하는 일은 독립 실행형 Lua 디버거를 작성하는 것보다 조금 더 까다로웠습니다. 스크립트를 디버깅하려면 스크립트 실행 중에 콜백이 실행되어야 합니다. 그런데 Redis가 스크립트를 실행할 때는 클라이언트 명령인 EVAL의 실행 맥락에 있습니다. 이 상태에서 블로킹되어 있는데 어떻게 I/O를 처리할 수 있을까요? Redis 서버에는 무슨 일이 일어날까요? 디버깅은 운영 서버가 아니라 개발 서버에서 해야 하더라도 인스턴스 전체를 완전히 멈춰 세우는 것은 좋은 생각이 아닐 수 있습니다. 다른 개발자가 그 인스턴스를 사용하고 싶을 수도 있고, 스크립트를 디버깅하는 개발자 본인이 새로운 병렬 디버깅 세션을 만들고 싶을 수도 있습니다. 마지막으로, 디버깅 중 스크립트가 데이터 세트에 가한 변경을 되돌려 같은 Redis 데이터 세트에서 동일한 스크립트를 계속 반복해서 테스트하려면 어떻게 해야 할까요? 디버깅에서는 결정성이 무엇보다 중요합니다.
따라서 겉보기에는 복잡한 구현이 필요했습니다. 아니면 크게 편법을 쓰고, 코드와 복잡성을 훨씬 줄이면서도 제가 원했던 이점의 90%를 제공하는 이상한 해결책을 찾아야 했습니다. 그 기묘한 해결책은 다음과 같았습니다.
- 디버깅 세션이 시작되면 Redis를
fork()합니다. - 클라이언트 파일 디스크립터를 가져와 디버깅 세션이 활성화된 동안 직접 블로킹 I/O를 수행합니다.
- Redis 프로토콜을 사용하되, 몇 줄의 코드로 구현할 수 있는 아주 단순한 부분집합만 사용합니다. 따라서 Redis 이벤트 루프에 다시 진입하지 않습니다. I/O는 디버거의 일부로 처리됩니다.
400줄의 코드를 작성하고 나니 기본 기능이 모두 작동했습니다. 나머지는 기능을 추가하고 버그와 예외 상황을 수정하는 일일 뿐이었습니다.
이 방식으로 필요한 것을 모두 얻을 수 있었습니다. 각 디버깅 세션이 별도의 프로세스에서 실행되므로 서버가 블로킹되지 않습니다. EVAL 호출 도중에 이벤트 루프에 다시 진입할 필요도 없고, 롤백도 자동으로 얻을 수 있습니다.
다만 데이터 세트의 변경 사항을 보존하면서 정말 무언가를 디버깅해야 하는 경우를 위해 서버를 블로킹하는 동기 모드도 제공됩니다. 이 모드는 거의 사용되지 않을 것 같지만, 포크하지 않고 마지막에 클라이언트 정리 작업을 처리하면 추가할 수 있었으므로 이 모드도 넣었습니다.
여기에 더해 Lua의 ‘라인’ 훅을 사용해 스텝 실행과 중단점을 구현하고 나머지 기능도 모두 추가할 수 있었습니다. 디버거가 Redis 자체에 통합되어 있으므로 모든 Redis 호출을 가로채 사용자에게 무슨 일이 일어나고 있는지 보여 주는 것도 간단했습니다. I/O 모델 역시 단순합니다. 사용자 입력을 읽고 출력 내용을 버퍼에 덧붙이면 됩니다. 디버거가 어떤 지점에서 멈출 때마다 출력은 ‘상태 응답’ 배열로 클라이언트에 플러시됩니다. 각 줄의 접두사는 redis-cli가 어떤 색상을 적용해야 하는지 알려 줍니다.
이 설계 덕분에 디버거는 이틀 만에 작동했고, 나흘 만에 완성되었습니다. 게다가 이 설계 덕분에 완전히 자체적으로 동작하는 코드를 작성할 수 있었고, 디버거가 Redis의 나머지 부분과 상호작용하는 지점도 거의 없습니다. 따라서 12월에 Redis 3.2와 함께 릴리스할 수 있게 됩니다.
거의 공짜로 디버거를 훨씬 강력하게 만드는 간단한 방법은 새로운 Redis Lua 호출 두 가지, redis.breakpoint()와 redis.debug()를 추가하는 것이었습니다. 각각 디버거 안에서 중단점을 시뮬레이션하거나 Lua 객체를 디버거 콘솔에 기록하는 기능입니다. 이를 이용하면 흥미로운 일이 발생할 때만 작동하는 중단점을 추가할 수 있습니다.
if some_odd_condition then redis.breakpoint() end이 기능은 디버거에 넣을 수 있는 복잡한 기능을 상당 부분 효과적으로 대신합니다. 물론 정적 중단점, 상태 관찰 기능 등 일반적인 기능도 디버거 안에서 직접 사용할 수 있습니다.
복잡한 스크립트를 작성하는 사용자들이 이 기능을 어떻게 생각할지 매우 궁금합니다! 두고 보죠.
스크립트 효과 복제
스크립트의 효과만 복제하는 것이 왜 흥미로운지 이해하기 전에, 기본적으로 스크립트 자체를 복제해 슬레이브에서 다시 실행하는 방식이 왜 최선의 선택으로 여겨졌고 여전히 기본값인지부터 이해하는 편이 좋습니다. 핵심은 마스터와 슬레이브 사이의 대역폭, 그리고 일반적으로 슬레이브가 마스터를 따라갈 수 있는 능력, 즉 지연이 너무 커지지 않은 상태로 동기화를 유지하고 뒤처지지 않는 능력입니다.
다음과 같은 작은 Redis Lua 스크립트를 생각해 봅시다.
local i;
for i=0,1000000 do
redis.call(“lpush”,KEYS[1],ARGV[1])
end이 스크립트는 지정한 리스트에 원소 100만 개를 추가하며, 제 테스트 환경에서는 0.75초 만에 실행됩니다. 몇 바이트밖에 되지 않고 서버 내부에서 실행되므로, 스크립트 실행 결과로 생기는 100만 개의 명령이 아니라 이 스크립트 자체를 복제하는 것이 매우 합리적입니다.
정반대인 스크립트도 있습니다. 반대쪽 극단에는 리스트에 저장된 정수 원소 100만 개의 평균을 계산한 뒤 그 결과를 어떤 키에 SET으로 저장하는 스크립트가 있습니다.
이 스크립트의 효과는 단순히 다음과 같을 수 있습니다. SET somekey 94.29
하지만 실제 실행에는 2초 정도의 계산이 필요할 수 있습니다. 이 경우에는 결과 명령을 복제하는 편이 훨씬 낫습니다. 그러나 스크립트를 복제하는 것과 효과를 복제하는 것에는 차이가 있습니다. 사용 사례에 따라 둘 중 하나가 최적일 수도, 비효율적일 수도 있지만, 스크립트 복제는 비효율적인 경우에도 항상 작동합니다. 복제 링크가 막대한 양의 데이터를 전송해야 하는 상황도 만들지 않고, 슬레이브가 마스터보다 훨씬 더 많은 작업을 해야 하는 상황도 만들지 않습니다. 지금까지 Redis가 스크립트를 있는 그대로 복제해 온 이유가 바로 이것입니다.
하지만 아마도 가장 흥미로운 부분은 이것이 단순히 효율성의 문제가 아니라는 점입니다. 스크립트를 복제하려면 각 스크립트가 순수 함수여야 합니다. 같은 초기 데이터 세트에서 실행된 스크립트는 항상 같은 결과를 내야 합니다. 이 요구 사항 때문에 사용자는 예를 들어 TIME이나 SRANDMEMBER 명령을 사용하는 스크립트를 작성할 수 없습니다. Redis는 이런 위험한 상황을 감지하고 첫 번째 쓰기 명령이 호출되려는 순간 스크립트를 중단합니다.
그러나 현재 시간, 난수 또는 무작위 요소를 사용하는 스크립트가 필요한 사용 사례는 많습니다. 스크립트의 효과를 복제하면 이 제한도 해결됩니다.
결국 지난 몇 달 동안 Redis 내부를 리팩터링한 덕분에 스크립트 효과 복제를 선택적으로 활성화하는 기능을 구현할 수 있었습니다. 스크립트 시작 부분에서 다음 명령을 호출하기만 하면 됩니다.
redis.replicate_commands()
… do something with the script …스크립트는 쓰기 명령 집합으로만 복제됩니다. 실제로 replicate_commands()를 첫 번째 명령으로 호출할 필요는 없습니다. 쓰기 작업을 수행하기 전에 호출하기만 하면 됩니다. 따라서 Lua 스크립트가 처리할 작업을 먼저 확인하고 적절한 복제 모델을 선택하도록 만들 수도 있습니다. replicate_commands()를 호출할 때 이미 쓰기 작업이 수행되었다면 false를 반환하고 일반적인 전체 스크립트 복제를 사용합니다. 따라서 잘못 사용하더라도 이 명령 때문에 스크립트 실행 자체가 막히는 일은 없습니다.
하지만 더 고급이고 어쩌면 위험할 수도 있는 기능을 추가하고 싶은 유혹을 뿌리치지는 못했습니다. 저는 Redis Labs의 동료 Yossi Gottleib과 함께 이 기능을 설계했는데, 그는 선택한 명령을 복제 스트림에서 제외할 수 있는 위험한 기능에 대한 매우 설득력 있는 사용 사례를 제시했습니다.
아이디어는 스크립트가 다음과 같은 작업을 수행할 수 있다는 것입니다.
- 임시 값을 쓰는 특정 명령을 호출합니다. 머릿속으로 이해하기 쉽도록 집합 간 교집합을 계산한다고 생각해 봅시다.
- 집계 작업을 수행합니다.
- 스크립트의 효과로 작은 결과를 저장합니다.
- 임시 값을 폐기합니다.
위와 같은 패턴에는 정당한 사용 사례가 몇 가지 있습니다. 그런데 당연히 AOF와 슬레이브에 임시 쓰기 작업까지 복제하고 싶지는 않을 것입니다. ‘3’ 단계만 복제하고 싶겠죠. 그래서 결국 스크립트 효과 복제가 활성화된 경우, 용감한 사용자가 다음 API를 사용해 무엇을 복제하고 무엇을 복제하지 않을지 선택할 수 있도록 하기로 했습니다.
redis.set_repl(redis.REPL_ALL); -- The default
redis.set_repl(redis.REPL_NONE); -- No replication at all
redis.set_repl(redis.REPL_AOF); -- Just AOF replication
redis.set_repl(redis.REPL_SLAVE); -- Just slaves replication잘못 사용할 여지는 많지만, 비전문 사용자가 이 기능을 건드릴 가능성은 매우 낮습니다. 반면 강력한 도구를 어떻게 써야 하는지 아는 사람에게는 도움이 될 수 있습니다.
출시 일정
두 기능 모두 2015년 12월 중순 RC로 출시될 Redis 3.2에서 사용할 수 있습니다. Redis 3.2에는 사용자에게 공개되는 API 수준의 흥미로운 새 기능이 많이 포함될 예정입니다. 그동안 운영과 내부 성숙도에 더 집중했던 시기를 거친 뒤, 많은 사용자가 이를 요청했습니다.
더 알고 싶은 내용이 있거나 궁금한 점이 있다면 댓글로 자유롭게 질문해 주세요.
Hacker News 스레드는 여기 있습니다. https://news.ycombinator.com/item?id=10594236
글을 무작위로 읽기