블로그 구축기 세 번째 글이다. 지난 글에서 마이그레이션 문제를 정리했는데, 이번에는 데이터베이스 연결 쪽에서 생긴 일이다. 코드는 한 줄도 바꾸지 않았는데 MySQL을 재시작하자 사이트가 통째로 멈췄다.

증상: 모든 페이지가 500

보안·운영 단계를 작업하던 중 개발용 MySQL 컨테이너를 재시작했다. 그 뒤로 블로그의 모든 페이지가 500 오류를 냈다. 사이드바에 카테고리 목록이 있어서 모든 페이지가 DB를 읽는다. 그래서 DB 연결이 안 되면 화면 하나도 열리지 않는다.

로그에 남은 예외는 이랬다.

Retrieval of the RSA public key is not enabled for insecure connections.

당시 연결 문자열은 다음과 같았다. 같은 PC 안의 Docker 컨테이너에 붙는 개발 환경이라 TLS를 꺼 두었다.

server=127.0.0.1;port=3310;database=addsoft_blog;uid=addsoft_blog;pwd=...;charset=utf8mb4;SslMode=Disabled

이상한 점은 재시작 전까지는 이 설정으로 아무 문제 없이 돌았다는 것이다.

원인: caching_sha2_password의 캐시

MySQL 8.0부터 기본 인증 플러그인은 caching_sha2_password다. 이름에 "caching"이 들어 있는 데는 이유가 있다. 인증 경로가 두 가지다.

  1. 빠른 인증: 서버 메모리에 이 계정의 인증 정보가 캐시돼 있으면, 비밀번호를 직접 주고받지 않고 간단한 확인만으로 통과한다. 비암호화 연결이어도 된다.
  2. 전체 인증: 캐시가 없으면 클라이언트가 비밀번호를 서버로 보내야 한다. 이때 비밀번호가 평문으로 흐르지 않도록 둘 중 하나가 필요하다.
    • TLS로 암호화된 연결
    • 서버의 RSA 공개키로 비밀번호를 암호화해서 전송

이 캐시는 메모리에만 있어서 MySQL을 재시작하면 비워진다. 재시작 뒤 첫 접속은 반드시 전체 인증을 거친다.

우리 앱은 TLS를 껐고(SslMode=Disabled), 서버에서 RSA 공개키를 받아 오는 것도 허용하지 않았다. 그러니 전체 인증을 할 방법이 없어 커넥터가 접속을 포기한 것이다. 위의 오류 메시지가 바로 그 뜻이다.

그럼 재시작 전에는 왜 됐을까. 캐시가 이미 채워져 있었기 때문이다. 어떤 클라이언트든 한 번 안전한 경로로 전체 인증에 성공하면 캐시가 채워진다. 예를 들어 컨테이너 안에서 mysql 클라이언트로 접속하는 경우다. 아마 개발 DB를 준비하면서 그렇게 접속한 적이 있었을 것이다. 그 뒤로는 앱의 비암호화 연결도 빠른 인증으로 통과했다. 이 문제가 고약한 이유가 여기 있다. 평소에는 멀쩡하다가 DB를 재시작한 직후에만 터진다. 운영 서버였다면 윈도우 업데이트로 서버가 재부팅된 새벽에 사이트가 내려갔을 것이다.

해결책 세 가지 비교

방법 내용 판단
SslMode=Required TLS로 연결한다. 전체 인증도 암호화된 채널로 처리된다 채택
AllowPublicKeyRetrieval=true 비암호화 연결에서 서버의 RSA 공개키를 받아 와 비밀번호를 암호화한다 사용 안 함
mysql_native_password로 계정 변경 예전 인증 방식으로 되돌린다 사용 안 함

AllowPublicKeyRetrieval=true는 오류 메시지를 검색하면 가장 먼저 나오는 답이다. 설정 한 줄이면 당장 해결되지만 공개키를 검증 없이 받아 온다. 중간에서 누군가 가짜 공개키를 건네면 비밀번호를 빼앗길 수 있다. 커넥터가 이 옵션을 기본으로 꺼 둔 이유다.

mysql_native_password로 되돌리는 방법은 미래가 없다. MySQL 8.4에서는 이 플러그인이 기본으로 꺼져 있고, 9.0에서는 아예 제거됐다. 우리 운영 서버는 MySQL 9라서 선택지 자체가 없었다.

남은 답은 TLS다. MySQL 8 이상은 설치할 때 자체 서명 인증서를 자동으로 만들어 두므로, 서버 쪽 설정 없이 연결 문자열만 바꾸면 된다.

...;charset=utf8mb4;SslMode=Required

Required는 암호화만 강제하고 인증서가 믿을 만한지는 검사하지 않는다. 이 블로그는 앱과 DB가 같은 서버에 있어서 루프백(127.0.0.1)으로만 연결하므로 이 정도로 충분하다고 판단했다. DB가 다른 서버에 있다면 인증서까지 검증하는 VerifyCA나 VerifyFull을 쓰는 것이 맞다.

확인

바꾼 뒤에는 일부러 MySQL 컨테이너를 다시 재시작했다. 그리고 사이트가 따로 손대지 않아도 정상으로 돌아오는 것을 확인했다. 재시작 직후 첫 연결이 TLS로 전체 인증을 통과한 것이다.

docker restart blog-mysql
# 페이지 새로고침 → 정상

이후 운영 DB(MySQL 9)도 처음부터 SslMode=Required로 연결했다. 같은 날 ASP.NET Core로 전환하면서 커넥터가 MySql.Data에서 MySql.EntityFrameworkCore로 바뀌었지만, 같은 Oracle 커넥터 계열이라 옵션 이름도 동작도 같다.

정리하며

  • "재시작해도 괜찮은가"를 테스트 목록에 넣는다. DB, 앱풀, 서버 재부팅 뒤에 사이트가 스스로 돌아오는지 확인해야 한다. 이번 문제는 재시작을 해 보지 않았다면 운영에서 처음 만났을 것이다.
  • 검색 결과의 첫 답을 바로 쓰지 않는다. AllowPublicKeyRetrieval=true는 증상을 없애 주지만, 그 옵션이 왜 기본으로 꺼져 있는지 생각해 보면 답이 달라진다.
  • 로컬 연결이라도 TLS를 켜 두는 편이 낫다. 이번 같은 문제를 피할 수 있고, 개발과 운영의 연결 설정이 같아지는 장점도 있다.

이 일은 배포 문서의 장애 대응 표 첫 줄에 올라가 있다. 증상, 원인, 조치를 한 줄로 남겨 두었다.

다음 글에서는 배포 스크립트의 오류 출력에 배포 비밀번호가 평문으로 찍힌 사고를 다룬다.