Redis 보안 점검표를 받으면 손이 빨라진다.
bind를 좁히고, 위험한 명령을 막고, 파일 권한을 600으로 바꾼다. 체크박스가 하나씩 초록색이 된다.
그리고 애플리케이션이 멈춘다.
보안팀은 문을 잘 잠갔다고 말하고, 개발팀은 열쇠가 안 맞는다고 말한다. Redis는 아무 말 없이 6379번 문 뒤에 앉아 있다.
이 장면이 낯설지 않다면 자물쇠만 샀기 때문이야.
하드닝에는 자물쇠와 영수증이 함께 필요하다. 무엇을 잠갔는지가 자물쇠라면, 의도한 손님은 들어오고 엉뚱한 손님은 막혔으며 재시작 뒤에도 그대로라는 증거가 영수증이다.
오늘 이야기는 Redis 입문도 아니고, Lua 스크립트나 프록시 호환성 이야기도 아니다.
핵심은 하나다.
Redis 하드닝은 설정값 다섯 개를 모으는 일이 아니라, 다섯 자물쇠마다 세 장의 영수증을 붙이는 일이다.
다섯 자물쇠는 서로 다른 문을 잠근다
먼저 다섯 자물쇠를 한 줄에 놓아보자.
[1. bind] 어느 출입문을 열까?
[2. replica] 복사본에 직접 써도 될까?
[3. CONFIG] 관리자 공구함을 누가 열까?
[4. /data 750] 데이터 창고에 누가 들어갈까?
[5. conf 600] 운영 매뉴얼을 누가 읽고 바꿀까?
다섯 개 모두 보안과 관련 있지만 보호하는 경계가 다르다.
이걸 한 덩어리로 보면 “다 잠갔으니 안전하다”는 착각이 생긴다. 현관문을 잠갔다고 창고 열쇠와 관리자 공구함까지 자동으로 잠기는 건 아니잖아.
반대도 마찬가지다.
설정 파일을 600으로 만들었다고 Redis 포트가 외부에서 안 보이는 것도 아니다.
첫 번째 자물쇠: bind는 경비원 명단이 아니다
가장 흔한 오해부터 보자.
애플리케이션 주소가 10.10.1.20, Redis 서버 주소가 10.10.2.10이라고 해보자.
이때 이런 설정을 쓰고 싶어진다.
bind 10.10.1.20
“이 주소에서 오는 애플리케이션만 받겠다”는 뜻처럼 보인다.
하지만 아니다.
bind는 Redis 서버가 자기 몸에 달린 어느 네트워크 인터페이스에서 들을지 정한다. 위 설정에 들어갈 후보는 클라이언트 주소가 아니라 Redis 서버가 실제로 소유한 주소다.
목적지가 여는 문
App 10.10.1.20 ───────────────▶ Redis 10.10.2.10:6379
▲ ▲
│ │
firewall source allowlist bind
"누가 올 수 있나?" "내 어느 문에서 들을까?"
손님 명단은 방화벽이나 보안 그룹이 관리한다.
허용: App 10.10.1.20/32 -> Redis 10.10.2.10:6379
거부: 그 밖의 source -> Redis 10.10.2.10:6379
bind 0.0.0.0은 모든 IPv4 문에서 듣겠다는 뜻이다. 방화벽이 단단하면 운영할 수도 있지만, 그 자체가 접근 허용 목록은 아니다.
반대로 bind 127.0.0.1은 아주 안전해 보이지만 다른 호스트나 다른 컨테이너에서 오는 정상 애플리케이션까지 밀어낼 수 있다.
보안 설정이 서비스 중단 스위치가 되는 순간이다.
여기서 “둘 다 같은 서버에 있는데?”라는 반론이 나온다.
Docker에서는 같은 아파트 건물에 산다고 같은 현관을 쓰는 게 아니다. 컨테이너마다 network namespace가 다르면 localhost도 각자의 집을 가리킨다.
예를 들어 Redis는 host network를 쓰고 애플리케이션은 bridge network를 쓴다고 해보자.
같은 Docker host
├─ Redis container: host network
│ └─ 127.0.0.1 = Docker host의 loopback
└─ App container: bridge network
└─ 127.0.0.1 = App container 자신
이 구성에서 Redis를 bind 127.0.0.1로만 제한하면 App은 Redis에 도착하지 못한다. 같은 서버라는 사실보다 어느 network namespace에서 어느 목적지 주소로 연결하는가가 더 중요하다.
App이 host.docker.internal 같은 host gateway 이름으로 접속한다면 먼저 App 안에서 실제 주소를 확인한다.
docker exec <app-container> getent hosts host.docker.internal
Redis는 그 이름이 가리키는 host 소유 주소에서 수신해야 한다. 반대로 App container IP를 Redis bind에 넣으면 안 된다. 그 주소는 “허용할 손님”이지 Redis가 소유한 “열 수 있는 문”이 아니기 때문이다.
방화벽도 추측으로 설정하면 안 된다. Redis host에서 보이는 source는 App container IP일 수도 있고, Docker bridge나 NAT를 거친 주소일 수도 있다. 연결 시점에 실제 source를 관찰하고, container recreate 뒤에도 정책이 유효한지 확인해야 한다.
그러니 bind를 바꿀 때는 성공과 실패를 한 쌍으로 확인해야 해.
# 허용된 애플리케이션 위치에서
redis-cli -h 10.10.2.10 -p 6379 PING
# 허용되지 않은 별도 위치에서도 같은 접속을 시도
redis-cli -h 10.10.2.10 -p 6379 PING
첫 번째는 PONG, 두 번째는 연결 거부나 시간 초과가 나와야 한다.
둘 다 성공하면 bind가 틀렸다기보다 경비원 명단이 비어 있을 가능성이 크다.
두 번째 자물쇠: replica는 표지판이 아니라 행동으로 확인한다
Replica는 원본을 따라가는 복사본이다.
도서관으로 치면 열람용 사본에 가깝다.
Primary : 원본 데스크 ── 쓰기 ──▶ 복제
Replica : 열람 데스크 ── 읽기 가능, 직접 쓰기 거부
설정은 단순하다.
replica-read-only yes
하지만 설정값만 읽고 “완료”라고 쓰면 영수증이 부족하다.
먼저 그 서버가 실제 replica인지 확인해야 한다. Standalone 서버에 이 값이 있어도 당장 막을 직접 쓰기 경로가 없다.
redis-cli -h <replica-host> INFO replication
역할과 primary 연결 상태를 본다. 그다음 원본에서 쓴 값이 replica에 도착하는지 확인하고, 마지막으로 replica에 직접 쓰기를 시도한다.
redis-cli -h <replica-host> SET hardening:test blocked
의도한 결과는 성공이 아니다.
READONLY You can't write against a read only replica.
이 오류는 장애가 아니라 합격 도장이다.
단, Sentinel이나 Cluster처럼 연결 토폴로지가 복잡하면 애플리케이션이 어느 노드로 읽기와 쓰기를 보내는지도 따로 봐야 한다. “read-only가 켜져 있다”와 “클라이언트가 올바른 노드로 간다”는 다른 문제다.
세 번째 자물쇠: 공구함 하나를 잠갔는데 부엌까지 잠그지 마라
CONFIG는 실행 중인 Redis 설정을 조회하거나 바꾸는 관리자 공구함이다.
일반 애플리케이션이 이 공구함을 쓸 이유가 없다면 막는 선택을 할 수 있다.
rename-command CONFIG ""
문제는 점검표에 “위험 명령 제한”이라고 적혀 있다는 이유로 이런 명령까지 한꺼번에 막을 때 생긴다.
EVAL SCRIPT PUBLISH CLIENT INFO CLUSTER
이름이 무서워 보인다고 역할까지 같은 건 아니다.
어떤 라이브러리는 연결하면서 서버 정보를 읽는다. 어떤 프레임워크는 락을 위해 스크립트를 쓴다. 어떤 애플리케이션은 Pub/Sub를 사용한다.
관리자 공구함을 잠그려다가 냄비, 가스레인지, 냉장고까지 봉인할 수 있는 셈이다.
그래서 범위를 좁혀야 한다.
1. 점검이 실제로 요구하는 명령을 확정한다.
2. 우선 CONFIG만 제한한다.
3. 사용하는 클라이언트 라이브러리로 smoke test를 한다.
4. 기능 성공과 관찰 정보 저하를 따로 기록한다.
Smoke test는 PING 하나로 끝내면 안 된다.
연결
├─ PING
├─ String / Hash / Set / Sorted Set 읽기·쓰기
├─ 만료 시간
├─ 실제 사용한다면 Lua script
├─ 실제 사용한다면 Pub/Sub
└─ 연결 풀 재사용과 재연결
여기서 재미있는 경계가 나온다.
CONFIG를 막아도 주요 데이터 작업은 멀쩡할 수 있다. 대신 클라이언트가 보여주는 데이터베이스 개수나 서버 설정 같은 metadata가 비거나 줄어들 수 있다.
이건 “완전 무영향”도 아니고 “서비스 장애”도 아니다.
기능은 통과했지만 관찰 정보는 저하됐다고 정확히 적어야 한다.
그 문장이 나중에 엄청난 시간을 아껴준다. 모니터링 값 하나가 사라졌을 때 모두가 장애라고 뛰어오지 않아도 되니까.
네 번째 자물쇠: /data는 750, 그런데 볼륨이 덮어쓸 수 있다
Redis 데이터 디렉터리는 창고다.
일반적인 목표는 실행 사용자와 그룹만 접근하게 하고, 그 밖의 사용자는 막는 것이다.
/data
owner: redis
mode : 750
7 owner 읽기 + 쓰기 + 진입
5 group 읽기 + 진입
0 others 접근 불가
이미지에서 이렇게 만들 수 있다.
USER root
RUN mkdir -p /data \
&& chown redis:redis /data \
&& chmod 750 /data
여기서 “이미지 빌드 성공”은 첫 장짜리 영수증일 뿐이다.
운영에서 /data에 호스트 디렉터리나 볼륨을 마운트하면 이미지 안의 권한이 가려질 수 있다. 새 창고를 통째로 가져다 놓으면서 예전에 붙인 자물쇠도 함께 치워버리는 셈이다.
실행 중인 컨테이너에서 실제 값을 봐야 한다.
docker exec <redis-container> \
stat -c '%a %U %G %n' /data
예상 결과는 대략 이렇다.
750 redis redis /data
그 상태에서 Redis가 실제로 값을 쓰고 읽는지도 확인해야 한다. 권한 숫자가 예뻐도 실행 사용자가 쓰지 못하면 창고 문을 용접한 것과 같다.
다섯 번째 자물쇠: 스캐너가 찾은 파일이 진짜 운영 매뉴얼인가
보안 스캐너가 redis.conf 권한이 느슨하다고 알려줬다고 해보자.
급한 마음에 검색 결과에 나온 파일을 바로 chmod 600으로 바꾸면 어떨까?
먼저 물어야 할 질문이 있다.
그 파일을 지금 Redis가 실제로 읽고 있나?
컨테이너 환경에는 오래된 이미지 레이어, 과거 컨테이너의 흔적, 별도 마운트, 사용하지 않는 샘플 설정이 함께 보일 수 있다.
스캐너가 찾은 redis.conf
│
├─ 현재 프로세스가 읽는 파일? ← 조치 대상
├─ 예전 컨테이너의 흔적?
├─ 이미지 안의 미사용 샘플?
└─ 호스트에 설치된 다른 Redis 설정?
파일 이름이 같다고 역할까지 같은 건 아니다.
프로세스 명령줄, 컨테이너 실행 명령, 마운트 정보를 함께 확인해 실제 설정 경로를 찾는다.
ps -ef | grep '[r]edis-server'
docker inspect <redis-container>
실제 사용 파일을 확인했다면 목표를 분명히 한다.
redis.conf
owner: redis
mode : 600
600은 owner만 읽고 쓸 수 있다는 뜻이다.
COPY --chown=redis:redis --chmod=600 redis.conf \
/usr/local/etc/redis/redis.conf
그리고 Redis가 그 파일을 명시적으로 읽어 기동하게 한다.
redis-server /usr/local/etc/redis/redis.conf
중요한 건 특정 경로를 외우는 게 아니다.
스캐너의 발견과 런타임의 진실을 연결하는 것이다.
이제 세 장의 영수증을 챙기자
다섯 자물쇠를 달았다면 각 자물쇠마다 세 장의 영수증이 필요하다.
┌─────────────────────────────────────────────┐
│ 영수증 1: 연결 │
│ 허용된 경로는 성공하고 비허용 경로는 실패했나? │
├─────────────────────────────────────────────┤
│ 영수증 2: 행동 │
│ 읽기·쓰기·복제·명령 제한이 의도대로 움직이나? │
├─────────────────────────────────────────────┤
│ 영수증 3: 생존 │
│ restart와 recreate 뒤에도 그대로 남아 있나? │
└─────────────────────────────────────────────┘
첫 번째는 네트워크 영수증이다.
정상 애플리케이션은 연결되고, 허용하지 않은 출발지는 막혀야 한다. 성공만 확인하면 문이 너무 넓게 열린 걸 놓친다. 실패만 확인하면 정상 손님까지 쫓아낸 걸 놓친다.
두 번째는 행동 영수증이다.
Replica 직접 쓰기는 READONLY로 거부돼야 한다. CONFIG는 실패해야 하지만 애플리케이션이 실제로 사용하는 자료 작업은 성공해야 한다. /data는 750이어야 하지만 Redis 쓰기도 성공해야 한다.
세 번째는 생존 영수증이다.
현재 컨테이너에서 chmod
↓
restart에서는 우연히 유지
↓
container recreate
↓
원래대로 복귀! ← 하드닝 실패
컨테이너 안에서 한 번 실행한 chmod나 임시 설정은 컨테이너 재생성 뒤 사라질 수 있다.
그러니 최소한 이 순서를 밟자.
hardened image 또는 영구 mount 준비
→ 최초 기동
→ client smoke test
→ restart
→ 권한·설정·기능 재확인
→ container recreate
→ 권한·설정·기능 재확인
Restart는 같은 집에서 전등을 껐다 켜는 일이다.
Recreate는 집을 허물고 설계도로 다시 짓는 일이다.
하드닝은 두 번째 시험까지 통과해야 한다.
실전에서는 한 번에 잠그지 않는다
운영 변경 순서도 중요하다.
1. 현재 상태를 기록한다.
2. 되돌릴 이미지와 설정을 준비한다.
3. 비운영 또는 canary에서 자물쇠를 하나씩 건다.
4. 자물쇠마다 세 장의 영수증을 모은다.
5. 운영에 적용하고 같은 검증을 반복한다.
기록에는 최소한 이런 내용이 들어가면 좋다.
- 이미지 태그와 digest
- 현재
bind,protected-mode,replica-read-only - 실제 수신 주소와 포트
- standalone, primary, replica 중 현재 역할
/data의 owner와 mode- Redis가 실제로 읽는 설정 파일 경로와 권한
- 방화벽 변경 전후 정책
- 사용하는 클라이언트 라이브러리와 smoke test 결과
- rollback 이미지와 설정
Rollback도 보안을 포기하는 버튼이면 안 된다.
급하다고 6379를 전부 열거나 인증을 빼거나 chmod 777을 쓰면, 장애를 고치는 동안 더 큰 사고의 문을 열 수 있다.
이전의 검증된 이미지와 설정으로 돌아가고, 정상 연결과 차단 경계를 다시 확인하는 편이 안전하다.
좋은 하드닝 보고서는 숫자보다 문장으로 강하다
나쁜 보고서는 이렇게 끝난다.
bind 적용 완료
read-only 적용 완료
CONFIG 차단 완료
750 완료
600 완료
설정값은 많지만 서비스가 살아 있는지, 공격 경로가 막혔는지, 다음 재배포에도 남는지 알 수 없다.
좋은 보고서는 이렇게 말한다.
허용된 애플리케이션은 PING과 주요 자료 작업에 성공했다.
비허용 출발지의 6379 연결은 차단됐다.
Replica 직접 쓰기는 READONLY로 거부됐다.
CONFIG는 차단됐고 주요 기능은 통과했지만 일부 metadata는 저하됐다.
/data 750과 실제 redis.conf 600은 restart와 recreate 뒤에도 유지됐다.
스캐너가 찾은 파일과 Redis가 실제로 읽는 파일이 일치함을 확인했다.
이건 체크리스트가 아니라 설명 가능한 보안이다.
자물쇠는 공격자를 막는다.
영수증은 우리가 정상 사용자를 같이 가둬버리지 않았다는 걸 증명한다.
다음에 Redis 보안 점검표를 받으면 설정부터 바꾸지 말고 종이에 두 칸을 먼저 그려봐.
무엇을 잠그나? | 무엇으로 증명하나?
그리고 다섯 자물쇠 옆에 세 장의 영수증을 붙여라.
그러면 “보안을 잠갔더니 서비스도 잠겼다”는 결말 대신, 더 좋은 결말을 만들 수 있다.
문은 좁아졌고, 서비스는 계속 움직이며, 다음 재배포 뒤에도 그대로다.