서버 보안 점검 8편: Ghost·MySQL 일반 보안 점검 재시작과 업그레이드 실전기
세 줄 요약
백업 복원 실패를 추적하던 심층 포렌식을 멈추고, 정상 운영 기준선부터 다시 세웠습니다.
Docker 소켓 권한 오류와 MySQL 와일드카드 계정을 침해 징후로 오판하지 않도록 관찰값과 권한 범위를 분리했고, 검증된 콜드백업을 만든 뒤 MySQL 8.0.41에서 8.4.11로 전환했습니다.
실제 중단 시간은 25초였고 업그레이드·기동 검증 구간에서 정의한 MySQL·Ghost 오류 패턴 0건, 재시작 0, 내부 HTTP 200, 외부 HTTPS 200을 확인한 뒤 Nginx Proxy Manager(NPM) 보안 헤더까지 검증했습니다.
이전 편: 서버 보안 점검 7편 — 보존 증거와 커널·LSM 로그로 Docker 시작 실패 추적하기
이번 점검은 새로운 의심 흔적에서 시작하지 않았습니다. 오히려 조사가 너무 깊어지고 있다는 사실에서 시작했습니다.
6편에서는 운영과 분리된 환경에서 Ghost·MySQL 백업 복원을 검증하려 했지만, 물리 복원용 Docker 컨테이너가 시작 단계에서 실패했습니다. 7편에서는 보존 증거와 Docker·containerd·커널·LSM 로그까지 범위를 넓혔지만 정확한 원인은 확정하지 못했습니다. 후속 V4 계측 재현도 준비됐지만 실제 실행 결과가 없는 상태였습니다.
여기서 방향을 바꿨습니다.
정상 운영 중인 서비스에 더 깊은 포렌식을 계속 적용하기 전에, 현재 Ghost·MySQL·프록시가 어떤 상태라면 정상으로 볼 것인지부터 다시 정의하기로 했습니다. 포렌식이 틀렸다는 뜻은 아닙니다. 다만 기준선 없이 조사 범위만 넓히면 정상적인 경고도 사고처럼 보이고, 반대로 정말 중요한 이상 징후를 놓칠 수 있습니다.
그래서 V3·V4 흐름을 성공으로 포장하거나 억지로 마무리하지 않았습니다. 그 단계는 멈추고 다음 원칙으로 일반 보안 점검을 처음부터 다시 시작했습니다.
- 읽기 전용 확인을 먼저 합니다.
- 명령이 정상 실행됐는지와 보안 상태가 정상인지를 분리합니다.
- 관찰값 하나만으로 침해를 판정하지 않습니다.
- 변경 전에 복구 수단과 실패 경로를 확인합니다.
- 컨테이너 내부뿐 아니라 실제 외부 HTTPS 응답까지 검증합니다.
- 이상이 있을 때만 포렌식 범위를 다시 넓힙니다.
2026년 9월 업데이트
이 글의 실제 성공 기록은 MySQL 8.0.41에서 8.4.11로 전환한 시점의 결과입니다. 다만 2026년 8월 18일 MySQL Server Docker 이미지 전용 보안 패치 8.4.12가 공개됐습니다. 현재 운영 이미지가 아직 8.4.11 기반이라면 8.4.12 이상 이미지를 적용하고 같은 검증을 다시 통과하기 전까지 현재 기준선은needs_review로 봐야 합니다. 아래 버전은 과거 실행 기록이며, 새 작업에는 현재 8.4 LTS 이미지와 검증한 다이제스트를 사용해야 합니다.latest태그도 최신 LTS를 뜻한다고 가정하면 안 됩니다.
확인, 실패, 보류를 먼저 구분했습니다
보안 점검에서 가장 위험한 실수는 명령의 실패를 곧바로 취약점으로 번역하는 것입니다. 반대도 마찬가지입니다. 명령이 종료 코드 0으로 끝났다고 시스템이 안전한 것은 아닙니다.
이번 점검에서는 결과를 다음처럼 구분했습니다.
| 상태 | 의미 | 예시 |
|---|---|---|
verified |
정한 통과 조건과 증거를 모두 충족했습니다. | 버전, 로그, 재시작, 내부·외부 응답이 모두 정상입니다. |
measurement_failed |
측정 명령 자체가 유효하게 실행되지 않았습니다. | Docker API 권한이 없어 필요한 값을 읽지 못했습니다. |
failed |
유효한 검사를 실행했지만 통과 조건을 만족하지 못했습니다. | 업그레이드 완료 마커가 없거나 정의한 오류 패턴이 발견됐습니다. |
not_run |
선행 단계에서 중단돼 검사를 시작하지 못했습니다. | DB가 기동되지 않아 Ghost 연결 검사를 하지 못했습니다. |
needs_review |
값은 읽었지만 배치 구조나 예외 조건을 더 봐야 합니다. | require_secure_transport=0, 와일드카드 호스트 계정이 있습니다. |
accepted_exception |
보안 영향이 없거나 제한된 예외로 근거와 함께 수용했습니다. | 404 응답에서 사용자 정의 진단 헤더만 빠졌습니다. |
이 구분 덕분에 “못 읽은 값”을 위험값으로 오해하지 않았고, “경고가 있었다”는 사실도 서비스 실패와 분리할 수 있었습니다.
전체 점검 절차
이번에 다시 설계한 순서는 다음과 같습니다. measurement_failed, failed 또는 업그레이드에 영향을 주는 미해결 needs_review가 있으면 변경을 중단합니다. 비차단 needs_review는 완화 조건과 위험 수용 근거를 기록한 경우에만 다음 단계로 진행합니다.
| 단계 | 확인 대상 | 통과 기준 |
|---|---|---|
| 1 | Docker·Compose 실행 상태 | 대상 컨테이너가 정확히 하나씩 존재하고, 실행·OOM·재시작 상태가 정상입니다. |
| 2 | 포트·네트워크·권한 경계 | Ghost와 MySQL이 호스트에 직접 공개되지 않고, Docker 소켓·host network·privileged 권한이 없습니다. |
| 3 | MySQL 계정과 권한 | 익명·원격 관리자·전역·시스템·위임·역할·프록시 권한 이상이 없습니다. |
| 4 | MySQL 보안 설정 | 파일 적재·파일 입출력·이름 해석·TLS·로그 설정을 배치 구조와 함께 판정합니다. |
| 5 | 변경 전 백업 게이트 | 정상 종료한 전체 데이터 디렉터리 백업과 목록·비교·SHA-256 검증이 통과합니다. |
| 6 | MySQL 8.4 전환 | 버전·업그레이드 완료 로그·스키마·오류·재시작·OOM을 확인합니다. |
| 7 | Ghost 연결과 기능 | DB 연결, Ghost 내부 응답, 실제 외부 HTTPS 응답이 모두 200입니다. |
| 8 | NPM 보안 헤더 | 설정 백업, nginx -t, CSP 단계 전환, 홈·관리자·404·공개 파일을 검증합니다. |
| 9 | 최종 판정 | 성공·실패·예외·기술 부채를 분리하고 재현 가능한 기록을 남깁니다. |
1. Docker·Compose 기준선부터 읽었습니다
첫 단계에서는 어떤 것도 재시작하거나 재생성하지 않았습니다. Compose가 읽는 실제 모델과 실행 중인 컨테이너가 같은지, 중복 컨테이너가 없는지, 최근 재시작과 OOM 종료가 없는지부터 확인했습니다.
sudo docker compose config -q
sudo docker compose config --services
sudo docker compose ps -a
config -q는 YAML 문법만 보는 명령이 아닙니다. 환경변수와 Compose 파일을 반영한 최종 모델을 해석할 수 있는지 확인하는 첫 관문입니다. 이후에는 docker inspect로 실제 컨테이너의 Compose 프로젝트·서비스 라벨, 이미지, 재시작 횟수, OOM 여부, 마운트와 네트워크를 비교했습니다.
container_name='ghost'
sudo docker inspect \
--format 'state={{.State.Status}} restarts={{.RestartCount}} oom={{.State.OOMKilled}}' \
"$container_name"
이번 환경에서 Ghost와 MySQL은 실행 중이었고, OOM 종료가 없었으며, 불필요한 재시작 증가도 없었습니다. 이미지 문자열만 비교하지 않고 실제 이미지 ID와 Compose 모델도 함께 봤습니다. 운영 중인 컨테이너와 현재 Compose 파일이 서로 다른 상태라면, 이후 재생성 때 예상하지 못한 설정이 적용될 수 있기 때문입니다.
첫 번째 실패: Docker 소켓 권한 오류
최초 권한 점검은 다음 오류로 멈췄습니다.
permission denied while trying to connect to the Docker API
at unix:///var/run/docker.sock
처음 보이는 단어만 보면 Docker 보안 문제처럼 느껴집니다. 하지만 이 출력이 증명하는 것은 점검을 실행한 사용자에게 Docker API를 읽을 권한이 없었다는 사실뿐입니다. 컨테이너가 취약하다거나 침해됐다는 증거가 아닙니다.
그래서 이 실행의 보안 결과는 failed가 아니라 measurement_failed, 즉 측정 실패로 분류했습니다. 같은 읽기 전용 점검을 sudo 경계 안에서 다시 실행한 뒤에야 실제 권한값을 판정했습니다.
이 경험에서 얻은 첫 번째 원칙은 단순합니다.
보안 판정 전에 측정이 유효했는지 확인해야 합니다.
Docker 그룹에 사용자를 무조건 추가하는 것도 해법으로 삼지 않았습니다. Docker 데몬 접근은 사실상 높은 호스트 권한과 연결될 수 있으므로, 일상 계정의 상시 권한을 넓히기보다 필요한 점검만 명시적으로 sudo로 실행했습니다.
Compose 경고는 장애와 분리했습니다
점검 중 다음 경고도 반복해서 나타났습니다.
the attribute `version` is obsolete
networks.default: external.name is deprecated
둘 다 정리해야 할 기술 부채입니다. 그러나 이번 실행에서 docker compose config -q가 통과했고, 서비스와 네트워크 모델이 정상적으로 해석됐으며, 실제 컨테이너도 정상 동작했습니다. 따라서 이 경고를 보안 사고나 업그레이드 실패로 분류하지 않았습니다.
경고를 무시한 것이 아니라 다음 유지보수 항목으로 분리했습니다. 자세한 정리 방법은 Docker Compose version obsolete와 external.name deprecated 경고 해결하기에서 다룹니다.
2. 노출 포트와 컨테이너 권한 경계를 확인했습니다
컨테이너 내부 설정이 좋아도 호스트에 불필요한 포트가 공개돼 있으면 공격 표면이 달라집니다. 그래서 MySQL 변수보다 먼저 실제 네트워크 노출을 확인했습니다.
sudo docker ps --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}'
sudo ss -lntp
확인 결과는 다음과 같았습니다.
- MySQL 3306은 호스트의 비루프백 주소에 공개되지 않았습니다.
- Ghost 2368도 외부에 직접 공개되지 않았습니다.
- 공개 요청은 Nginx Proxy Manager를 거쳐 Ghost로 전달됐습니다.
- NPM 관리자 포트는 루프백 주소에만 바인딩돼 있었습니다.
- Ghost와 MySQL 컨테이너에 Docker 소켓 마운트가 없었습니다.
privileged, host network, host PID, 장치 전달도 없었습니다.- 데이터베이스는 필요한 애플리케이션과만 같은 내부 네트워크에서 통신했습니다.
이 결과는 “내부 네트워크니까 무조건 안전하다”는 뜻이 아닙니다. 다만 require_secure_transport, 와일드카드 호스트 계정 같은 값을 해석할 때 실제 공격 가능 경계를 결정하는 중요한 조건입니다.
3. MySQL 와일드카드 계정 하나를 침해로 오판할 뻔했습니다
MySQL 계정 점검에서 잠기지 않은 와일드카드 호스트 계정이 하나 확인됐습니다.
wildcard_unlocked_accounts=1
'사용자'@'%'의 %는 여러 호스트에서 일치할 수 있으므로 좋은 신호는 아닙니다. 하지만 계정이 존재한다는 사실과 그 계정이 강한 권한을 가졌다는 사실은 다릅니다. 처음에는 숫자 1만 보고 외부 접근이나 침해 가능성부터 떠올렸지만, 권한 범위를 분해해 다시 확인했습니다.
확인된 범위
- 잠기지 않은 와일드카드 계정:
1개 - 접근하는 비시스템 스키마:
1개
확인되지 않은 고위험 권한 — 아래 항목은 모두 0건
- 정적 전역 권한 행
- 동적 전역 권한 행
- 애플리케이션 스키마 밖 권한 행
- 시스템 스키마 권한 행
WITH GRANT OPTION가능 행- 역할·기본 역할 할당 행
- 프록시 권한 행
- 원격
root계정
결과적으로 이 계정은 Ghost 애플리케이션 스키마 하나에 필요한 범위만 가지고 있었고, 글로벌·동적·시스템·권한 위임·역할·프록시 권한은 없었습니다. 3306도 호스트에 공개되지 않았습니다. 따라서 “침해 계정”으로 판정하지 않고, 호스트 범위는 넓지만 권한은 제한된 애플리케이션 계정으로 분류했습니다.
이후 확인에서도 애플리케이션 스키마 밖 실제 권한은 0이었습니다. 호스트 패턴을 더 좁히는 작업은 별도의 하드닝 과제로 남길 수 있지만, 그 작업을 하기 전에는 Ghost 연결 경로와 복구 계정을 검증해야 합니다.
특히 root@'%'처럼 원격 관리자 계정이 발견된다면 이야기가 달라집니다. 정확한 사용자·호스트 조합, 로컬 관리자와 비상 복구 경로를 확인한 뒤 먼저 ALTER USER 'root'@'%' ACCOUNT LOCK으로 잠그고 영향을 관찰해야 합니다. 충분히 검증한 뒤에만 DROP USER 'root'@'%'를 고려하며, mysql.user를 직접 UPDATE하거나 DELETE해서는 안 됩니다.
공개 글에는 계정명과 원문 권한을 그대로 싣지 않았습니다
점검 출력에는 계정·호스트·권한뿐 아니라 환경변수, 비밀 파일 경로와 내부 네트워크 정보가 포함될 수 있습니다. 특히 docker inspect와 docker compose config 전체 출력은 비밀값을 노출할 수 있으므로 접근을 제한해 보관하고, 공개 전 반드시 값을 가렸습니다. 이 글에는 계정명, 비밀번호, 전체 SHOW GRANTS, 내부 네트워크명, 실제 백업 경로를 싣지 않습니다. 조회 명령이라고 출력까지 공개에 안전한 것은 아닙니다.
4. MySQL 변수는 값 하나가 아니라 배치 구조와 함께 판단했습니다
일반 보안 기준선에서 확인한 핵심 값은 다음과 같습니다.
| 설정 | 관찰값 | 이번 환경의 해석 |
|---|---|---|
local_infile |
0 |
클라이언트 로컬 파일 적재가 비활성화돼 있습니다. |
secure_file_priv |
/var/lib/mysql-files/ |
서버 파일 입출력이 전용 경로로 제한됩니다. 앱 계정의 FILE 권한도 별도로 확인했습니다. |
skip_name_resolve |
1 |
계정 확인에 DNS 호스트명을 사용하지 않습니다. 실제 IP·호스트 계정 구성이 맞아야 합니다. |
general_log |
0 |
상시 전체 쿼리 로그를 끈 상태입니다. 필요할 때 제한적으로 사용해야 합니다. |
performance_schema |
1 |
연결과 실행 상태를 진단할 수 있습니다. |
log_bin |
ON(점검 출력에서는 binary_log=1) |
바이너리 로그가 활성화돼 있습니다. 시점 복구에 사용하려면 전체 백업과 보존·외부 보관 정책이 필요하며 로그 자체에도 민감한 데이터가 포함될 수 있습니다. |
tls_version |
TLSv1.2,TLSv1.3 |
서버가 허용하는 TLS 버전입니다. 현재 세션이 TLS라는 증거와는 다릅니다. |
require_secure_transport |
0 |
비암호화 TCP를 서버 차원에서 거부하지 않습니다. 네트워크 노출과 실제 연결 경로를 함께 봤습니다. |
가장 해석이 필요한 값은 require_secure_transport=0이었습니다. MySQL 3306은 호스트에 공개되지 않았고 Ghost와 DB는 제한된 Compose 내부 네트워크에서 통신했습니다. 그래서 즉시 외부 노출 취약점으로 판정하지는 않았습니다.
다만 내부 네트워크와 TLS는 같은 보호가 아닙니다. 호스트에 3306이 없다는 사실은 공격 표면을 줄이는 완화 조건일 뿐, 컨테이너 간 트래픽을 암호화하지는 않습니다. 따라서 이 항목은 needs_review로 남기고, Ghost의 TLS 연결 호환성과 같은 네트워크에 접근할 수 있는 컨테이너를 확인한 뒤 require_secure_transport=ON 전환을 검토했습니다.
또한 tls_version=TLSv1.2,TLSv1.3은 허용 프로토콜 범위일 뿐, 현재 Ghost 세션이 실제 TLS를 사용했다는 뜻이 아닙니다. 변수 이름만 보고 보안 효과를 과장하지 않는 것이 중요합니다.
5. 변경 전에 콜드백업 게이트를 통과시켰습니다
버전 전환보다 먼저 만든 것은 업그레이드 명령이 아니라 중단 가능한 백업 게이트였습니다. 이 게이트의 목적은 “백업 파일 하나가 생겼다”가 아니라, 업그레이드에 들어가도 되는 상태인지 증명하는 것입니다.
온라인 상태에서 먼저 확인한 항목
서비스를 멈추기 전에 다음을 모두 확인했습니다.
- MySQL 실제 버전이 8.0.41인지 확인했습니다.
- Ghost 실제 버전과 내부 HTTP, 외부 HTTPS가 정상인지 확인했습니다.
- 준비된 XA 트랜잭션이 0인지 확인했습니다.
- Ghost 스키마의 테이블 수를 기록했습니다.
- 실행 중인 컨테이너와 Compose 모델·이미지·마운트가 일치하는지 확인했습니다.
- 변경 전 Compose 파일, 환경 파일 존재 여부, 컨테이너 메타데이터와 체크섬을 보관했습니다.
- 백업 경로가 MySQL 데이터 디렉터리 밖인지 확인했습니다.
- 이번 배치의 자체 안전 기준으로 데이터 크기의 두 배와 추가 여유 공간을 확보했습니다. 이는 MySQL의 보편적인 공식 산식이 아니라 이 환경에서 정한 보수적 기준입니다.
- 목표 이미지는 미리 내려받고 버전·아키텍처·다이제스트를 확인했습니다.
MySQL 공식 문서는 util.checkForServerUpgrade()와 mysqlcheck --all-databases --check-upgrade 등의 사전 검사를 안내합니다. 보존된 이번 실행 기록에는 이 공식 검사 자체의 통과 결과가 없으므로 실행한 것으로 서술하지 않습니다. 당시 자동 게이트는 배치·XA·스키마·이미지·복구 경계를 추가로 확인한 자체 안전장치였으며, 독자는 업그레이드 전에 공식 검사도 별도로 통과해야 합니다.
중단 구간에서는 Ghost를 먼저 멈췄습니다
데이터베이스를 먼저 끄면 Ghost가 실패한 쓰기나 재연결을 반복할 수 있습니다. 그래서 순서를 다음처럼 고정했습니다.
- Ghost를 정상 중지합니다.
XA RECOVER를 다시 확인해 준비된 XA 트랜잭션이 0인지 검증합니다.innodb_fast_shutdown=0을 적용하고 실제 값을 재확인합니다.- MySQL을 정상 중지하고 exit code, OOM 여부,
Shutdown complete로그를 확인합니다. - 전체 MySQL 데이터 디렉터리를 메타데이터와 함께 아카이브합니다.
- 아카이브 파일은 원본 데이터 디렉터리 밖에 만들고, 구성원 이름에는 절대경로나
..가 없으며 예상한mysql/트리만 포함됐는지 확인합니다. ibdata1과mysql/시스템 디렉터리의 존재를 확인합니다.tar --compare로 원본과 아카이브를 비교합니다.- SHA-256을 생성하고 즉시
sha256sum --check로 다시 검증합니다.
# 개념을 보여 주기 위한 축약 예시입니다. 실제 경로와 서비스명에 맞게 바꿔야 합니다.
sudo docker compose config -q
sudo docker compose stop -t 60 ghost
# MySQL 내부에서 XA와 innodb_fast_shutdown 값을 재확인한 뒤
sudo docker compose stop -t 300 db
sudo install -d -m 0700 /path/to/backup-parent
backup_dir="$(sudo mktemp -d /path/to/backup-parent/mysql-preupgrade.XXXXXX)"
sudo tar --create --file="$backup_dir/mysql-datadir.tar" \
--numeric-owner --acls --xattrs \
--directory=/path/to/mysql-parent mysql
sudo tar --compare --file="$backup_dir/mysql-datadir.tar" \
--numeric-owner --acls --xattrs \
--directory=/path/to/mysql-parent
sudo sha256sum "$backup_dir/mysql-datadir.tar" \
| sudo tee "$backup_dir/mysql-datadir.tar.sha256" >/dev/null
sudo sha256sum --check "$backup_dir/mysql-datadir.tar.sha256"
sudo chmod 0600 \
"$backup_dir/mysql-datadir.tar" \
"$backup_dir/mysql-datadir.tar.sha256"
실행 중인 MySQL 데이터 디렉터리를 위 명령만 복사해 백업하면 안 됩니다. 실제 절차에서는 Ghost 쓰기를 먼저 멈추고 MySQL을 정상 종료한 뒤 전체 데이터 파일과 필요한 설정·메타데이터를 함께 보존했습니다. 데이터 디렉터리·환경 파일·바이너리 로그에는 개인정보와 자격증명이 포함될 수 있으므로 웹 루트 밖의 접근 제한 저장소에 암호화해 보관해야 합니다. 암호화된 테이블스페이스를 사용한다면 keyring 구성과 키 자료도 별도로 보존하고 복원 시험에 포함해야 합니다.
최종 전환에 사용한 콜드백업 아카이브는 310,118,400바이트였고 SHA-256 검증과 서비스 복구 뒤 재검증을 모두 통과했습니다. 실제 서버 경로와 전체 해시값은 공개하지 않습니다.
tar --compare와 SHA-256 통과는 아카이브가 캡처 시점의 원본과 일치하고 이후 바뀌지 않았다는 증거입니다. 실제 복원 성공 증거는 아닙니다. 백업 무결성, 복원 가능성, 애플리케이션 복구 성공은 서로 다른 단계이며, 이 구분은 5편과 6편에서 자세히 다뤘습니다.
백업 게이트의 성공이 운영상 혼란을 만들기도 했습니다
첫 설계에서는 콜드백업 게이트와 실제 버전 전환을 두 단계로 분리했습니다. 백업 게이트는 의도대로 성공했고, 안전을 위해 Ghost와 MySQL을 멈춘 상태로 끝났습니다.
그 순간 화면만 보면 장애처럼 보입니다. “그냥 다시 올리면 되는 것 아닌가?”라는 판단을 하기 쉽습니다. 하지만 먼저 확인해야 할 것은 서비스가 내려갔다는 사실이 아니라 어느 단계까지 완료됐는가였습니다.
- MySQL 정상 종료가 확인됐습니까?
- 아카이브 생성과 비교가 끝났습니까?
- SHA 검증이 끝났습니까?
- 목표 이미지가 데이터 디렉터리를 한 번이라도 열었습니까?
- Compose 파일은 이전 이미지와 새 이미지 가운데 어느 쪽을 가리킵니까?
목표 MySQL이 데이터 디렉터리를 열기 전과 후는 롤백 의미가 완전히 다릅니다. 이 상태를 확인하지 않고 up부터 실행하면 복구 경계를 스스로 흐릴 수 있습니다.
이 경험을 계기로 온라인 사전 점검, 제한된 중단, 콜드백업, 이미지 전환, MySQL 검증, Ghost 복구, 외부 HTTPS 확인을 중단 단계가 명확한 하나의 전환 절차로 합쳤습니다. 최종 성공은 이 통합 절차에서 나왔습니다.
6. MySQL 8.0.41에서 8.4.11로 전환했습니다
이번 출발점인 MySQL 8.0.41에서 8.4 LTS로의 전환은 공식 지원 경로입니다. 하지만 지원된다는 말이 현재 데이터와 플러그인, 인증 방식, 애플리케이션이 자동으로 호환된다는 뜻은 아닙니다. 다른 8.0 패치 버전은 공식 업그레이드 경로와 Upgrade Checker 결과를 다시 확인해야 합니다. 그래서 버전 전환도 단순한 이미지 문자열 교체가 아니라 여러 관문으로 나눴습니다.
latest 대신 검증한 이미지 다이제스트를 고정했습니다
Compose에는 검증한 MySQL 이미지 다이제스트를 고정했습니다. 태그가 같은데 이미지 내용이 바뀌는 상황을 피하고, 사전 점검한 바이트와 실제 실행 바이트를 연결하기 위해서입니다.
services:
db:
image: mysql@sha256:<검증한-다이제스트>
MySQL Docker 문서에서 태그를 생략한 latest는 최신 LTS를 뜻한다고 보장되지 않습니다. 운영에서는 현재 8.4 LTS의 정확한 버전이나 검증한 다이제스트를 사용하고, 보안 패치가 나오면 새 다이제스트를 다시 검증해야 합니다.
실제 전환 순서
- 기존 Compose 모델과 새 후보 모델을 각각 정규화했습니다.
- DB 이미지 이외의 모델 해시가 같은지 확인했습니다.
- 원본 Compose와 후보 파일의 SHA-256을 기록했습니다.
- Ghost를 멈추고 MySQL을 정상 종료했습니다.
- 검증된 콜드백업을 만든 뒤에만 후보 Compose를 원자적으로 설치했습니다.
- MySQL 8.4.11만 먼저
--no-deps --force-recreate --pull never로 시작했습니다. - 재시작 0, OOM false,
SELECT 1, 버전, 스키마 테이블 수를 확인했습니다. - 업그레이드 시작·완료 로그가 있고, 검증 스크립트가 정의한 치명 오류 패턴이 0건인지 확인했습니다.
- 그 뒤에만 Ghost를 시작했습니다.
- Ghost 내부 HTTP 200, 애플리케이션 DB 연결, 기동 구간의 Ghost 오류 패턴 0건을 확인했습니다.
- 마지막으로 실제 외부 HTTPS 200을 확인했습니다.
- 사이트가 돌아온 뒤 콜드백업 SHA-256을 다시 검증했습니다.
실패 시 자동으로 8.0을 다시 물리지 않도록 했습니다
실패 처리도 시작 단계에 따라 다르게 설계했습니다.
- 새 MySQL을 아직 시작하지 않았다면 이전 Compose를 복원하고 기존 컨테이너를 다시 올릴 수 있습니다.
- 새 MySQL 8.4가 데이터 디렉터리를 열었지만 검증에 실패했다면 8.0 이미지를 같은 데이터 디렉터리에 자동 연결하지 않습니다.
- 이 경우에는 검증된 업그레이드 전 8.0 콜드백업과 8.0 이미지를 함께 복원해야 합니다.
MySQL 8.4에서 8.0으로의 인플레이스 다운그레이드는 지원되지 않습니다. 업그레이드 전 스냅샷을 복원하는 것은 다운그레이드가 아니라 변경 전 상태 복구입니다. 둘을 같은 말로 쓰면 실제 장애에서 잘못된 명령을 선택할 수 있습니다.
실제 최종 결과
| 항목 | 결과 |
|---|---|
| 이전 MySQL 버전 | 8.0.41 |
| 전환 MySQL 버전 | 8.4.11 |
| 실제 서비스 중단 시간 | 25초 |
| 콜드백업 크기 | 310,118,400바이트 |
| 백업 SHA-256 검증 | 전환 전·복구 후 통과 |
| DB 상태 | running |
| DB 재시작 횟수 | 0 |
| DB OOM 종료 | false |
| DB 업그레이드 완료 마커 | 확인 |
| DB 기동·업그레이드 구간의 정의된 치명 오류 패턴 | 0건 |
| Ghost 버전 | 6.61.0 |
| Ghost 상태 | running |
| Ghost 기동 구간의 정의된 오류 패턴 | 0건 |
| Ghost 내부 응답 | HTTP 200 |
| 실제 외부 응답 | HTTPS 200 |
| 최종 전환 검증 | pass |
가장 중요한 값은 running 하나가 아닙니다. 컨테이너는 실행 중이어도 반복 재시작 중일 수 있고, DB는 준비됐어도 Ghost 마이그레이션이나 인증이 실패할 수 있습니다. 이번 성공 판정은 버전, 업그레이드 로그, 스키마, 애플리케이션 연결, 정의한 오류 패턴, 재시작, OOM, 내부 응답, 외부 응답을 모두 통과한 결과입니다. 오류 패턴 0건은 해당 검증 구간과 정규식 범위의 결과이지 모든 과거 로그에 오류가 한 번도 없었다는 뜻은 아닙니다.
7. Ghost가 DB에 연결되지 않을 때는 재설치보다 경로를 먼저 봅니다
이번 점검을 재구성하면서 Ghost·MySQL 복구에서 자주 생기는 두 가지 함정도 다시 확인했습니다.
DB 호스트와 포트를 분리합니다
Ghost의 DB 호스트에는 Compose 서비스 이름을, 포트에는 숫자를 따로 지정해야 합니다.
environment:
database__connection__host: db
database__connection__port: 3306
호스트 값에 db:3306을 통째로 넣으면 런타임은 그것을 하나의 호스트명으로 해석해 다음과 같은 오류를 낼 수 있습니다.
getaddrinfo ENOTFOUND db:3306
이때 비밀번호를 바꾸거나 볼륨을 지우기 전에 서비스 이름, 공통 네트워크, 내부 DNS를 확인합니다.
sudo docker compose ps -a
sudo docker compose config -q
sudo docker compose up -d db
sudo docker compose run --rm --no-deps --pull never \
--entrypoint getent ghost hosts db
Compose 서비스명 db는 같은 Docker 네트워크 안에서 제공되는 DNS 이름입니다. 따라서 호스트 셸이 아니라 같은 네트워크의 컨테이너에서 확인해야 합니다. 위 명령은 의존 서비스를 시작하지 않고 임시 진단 컨테이너를 만든 뒤 자동 제거합니다. 실행 중인 Ghost가 정상이라면 docker compose exec ghost getent hosts db로 읽기만 해도 됩니다. 이미지에 getent가 없다면 Node.js의 dns.lookup()처럼 해당 컨테이너에 이미 있는 런타임으로 같은 이름을 확인할 수 있습니다.
환경변수 변경은 restart만으로 적용되지 않습니다
Compose 환경값을 바꾼 뒤 docker compose restart ghost만 실행하면 기존 컨테이너 정의가 그대로 남습니다. 새 환경을 적용하려면 Ghost 컨테이너를 재생성해야 합니다.
sudo docker compose up -d --no-deps --force-recreate ghost
여기서도 docker compose down -v는 복구 명령으로 사용하지 않았습니다. -v는 데이터베이스나 콘텐츠 볼륨을 제거할 수 있으므로, 단순 연결 오류에서 사용할 이유가 없습니다.
TLS는 Nginx Proxy Manager에서 종료하고 내부 Ghost로 전달했습니다. Ghost의 공개 url은 실제 https:// 주소와 일치시켰고, 프록시는 Host, X-Forwarded-For, X-Forwarded-Proto를 올바르게 전달했습니다. 특히 X-Forwarded-Proto=https가 빠지면 Ghost가 HTTPS를 인식하지 못해 리다이렉트 루프가 생길 수 있습니다.
8. Nginx Proxy Manager 보안 헤더를 단계적으로 적용했습니다
MySQL·Ghost 기준선과 업그레이드 검증이 끝난 뒤 프록시 응답을 보강했습니다. 데이터베이스 변경과 프록시 변경을 같은 순간에 섞지 않은 이유는, 문제가 생겼을 때 어느 변경이 원인인지 분리하기 위해서입니다.
적용 순서
- 기존 NPM 호스트 고급 설정을 백업했습니다.
- 같은 헤더 블록이 이미 있는지 마커와 중복 개수를 확인했습니다.
- 후보 설정을 넣은 뒤
nginx -t를 통과시켰습니다. X-Content-Type-Options,Referrer-Policy, 프레임 보호, HSTS를 검증했습니다.- CSP는 먼저
Content-Security-Policy-Report-Only로 배치했습니다. - 보존된 검증 기록으로 홈,
/ghost/, 존재하지 않는 경로와ads.txt의 실제 GET 응답을 확인했습니다. - 실제로 기록이 남은 경로를 기준으로 필요한 출처를 조정한 뒤 같은 정책을 강제
Content-Security-Policy로 전환했습니다. - 게시글·이미지 업로드·검색·회원 기능·외부 임베드는 별도의 브라우저 회귀검사가 끝난 경우에만 통과로 기록합니다.
curl -sS -D - -o /dev/null https://example.com/
curl -sS -D - -o /dev/null https://example.com/ghost/
curl -sS -D - -o /dev/null https://example.com/security-header-not-found
NGINX의 add_header는 기본적으로 일부 2xx·3xx 응답에만 붙습니다. 404와 5xx에도 적용하려면 always가 필요합니다. 또한 하위 location에서 add_header를 하나라도 다시 선언하면 상위 헤더 상속이 끊길 수 있습니다. add_header_inherit merge는 NGINX 1.29.3부터 제공되므로, NPM에 포함된 NGINX 버전을 확인하지 않고 이 문법을 사용하면 안 됩니다.
HSTS는 되돌리기 어려운 브라우저 상태를 만들 수 있으므로 모든 하위 도메인의 HTTPS를 확인하기 전에 includeSubDomains나 preload를 성급하게 넣지 않았습니다. CSP도 오류가 보일 때마다 'unsafe-inline'이나 'unsafe-eval'을 추가하는 방식으로 해결하지 않았습니다. Report-Only 위반은 브라우저 개발자 콘솔이나 구성된 보고 엔드포인트에서 확인해야 하며, 헤더가 존재한다는 사실만으로 위반을 관찰했다고 볼 수 없습니다. 실제로 기록이 남은 경로만 검증 완료로 분류한 뒤 강제 모드로 옮겼습니다.
최종 프록시 검증 결과
| 확인 항목 | 결과 | 판정 |
|---|---|---|
nginx -t |
syntax/test successful | 통과 |
| 홈페이지 | 200, 진단 헤더 1회 | 통과 |
| Ghost 관리자 경로 | 200, 진단 헤더 1회 | 통과 |
| 존재하지 않는 경로 | 404 | 정상 |
404의 X-Served-By |
0회 | 비보안 진단 헤더 예외로 수용 |
ads.txt |
200 | 통과 |
| NPM 재시작 횟수 | 0 | 통과 |
| 설정 마커 개수 | 1 | 중복 없음 |
Strict-Transport-Security |
HTTPS 응답에서 확인 | 통과 |
X-Content-Type-Options |
nosniff |
통과 |
Referrer-Policy |
정책 헤더 확인 | 통과 |
X-Frame-Options |
SAMEORIGIN |
통과 |
| CSP 모드 | enforced | 설정 적용 확인·전체 기능 판정 needs_review |
404에서 X-Served-By가 빠졌다고 전체 헤더 적용이 실패한 것은 아닙니다. 이 헤더는 사용자 정의 진단용이며 핵심 보안 헤더가 아닙니다. 다른 location, 업스트림 또는 캐시 처리 때문에 상속이 달라졌을 가능성으로 기록했으며, 설정과 로그를 확인하기 전에는 원인을 단정하지 않았습니다. 또한 X-Served-By 값에 컨테이너명·호스트명·내부 IP·인스턴스 ID가 들어간다면 외부에 노출하지 말고 진단이 끝난 뒤 제거해야 합니다. HSTS·CSP·nosniff 같은 실제 보안 헤더는 별도로 확인했습니다.
ads.txt 설정 방법 자체는 Ghost ads.txt 404를 Nginx Proxy Manager에서 해결하기에서 다루고 있으므로, 이번 글에서는 외부 응답 검증 결과만 포함했습니다.
9. 성공과 실패를 다시 표로 정리했습니다
이번 점검에서 가장 유용했던 것은 성공 명령보다 처음 해석과 최종 판정의 차이였습니다.
| 경험 | 다시 확인한 결과 | 교훈 |
|---|---|---|
| Docker 소켓 권한 오류를 보안 문제로 의심 | 점검 사용자 권한 때문에 측정이 실패했습니다. | 측정 유효성을 먼저 확인합니다. |
| 와일드카드 계정 1개를 외부 침해 계정으로 의심 | 앱 스키마 하나만 접근하고 전역·시스템·위임 권한은 0이었습니다. | 계정 패턴과 실제 권한을 분리합니다. |
| 콜드백업 뒤 서비스 중지를 장애로 의심 | 백업 게이트가 성공하고 의도적으로 멈춘 상태였습니다. | 재기동 전에 완료 단계를 확정합니다. |
| 업그레이드 실패 때 이전 이미지를 다시 실행하려는 판단 | 8.4가 데이터를 연 뒤에는 8.0 자동 재연결을 금지했습니다. | 업그레이드 전 콜드백업과 이전 이미지를 함께 복원합니다. |
| Compose 경고를 배포 실패로 의심 | 모델·서비스는 정상이고 문법의 기술 부채였습니다. | 경고, 장애, 보안 위험을 구분합니다. |
| 404 진단 헤더 누락을 전체 헤더 실패로 의심 | 비보안 사용자 정의 헤더만 누락됐습니다. | 보안 영향과 라우팅 단서를 분리합니다. |
| 컨테이너 기동을 MySQL 전환 성공으로 판단 | 로그·스키마·Ghost·HTTPS까지 확인해야 성공이었습니다. | 종단 간 성공 조건을 미리 정의합니다. |
10. 작업 종료 당시의 기준선 판정과 발행 시점 상태
MySQL 8.4.11 전환과 Ghost 가용성 검증은 통과했습니다. 그러나 require_secure_transport=0, 공식 Upgrade Checker 실행 증거 부재, CSP 전체 기능 회귀검사 미확인이 남아 있어 작업 종료 당시 보안 기준선 판정은 조건부 통과였습니다. 이는 호스트 OS 패치, SSH, 방화벽, 사용자·sudo 권한, 지속성 흔적, 전체 이미지 취약점까지 포함한 서버 전체 보안 감사 결과도 아닙니다.
- Ghost·MySQL 컨테이너는 실행 중이며 OOM과 비정상 재시작이 없습니다.
- MySQL과 Ghost 포트는 호스트의 외부 주소에 직접 공개되지 않았습니다.
- Docker 소켓, privileged, host network·PID 같은 강한 권한이 없습니다.
- 익명 계정과 원격 관리자 계정이 없고, 애플리케이션 스키마 밖 권한과 전역·시스템·위임 권한은 0입니다.
local_infile=0, 제한된secure_file_priv,skip_name_resolve=1, TLS 1.2·1.3 허용 상태를 확인했습니다.- 정상 종료한 전체 데이터 디렉터리 콜드백업과 SHA-256 재검증을 완료했습니다.
- MySQL 8.4.11 업그레이드 로그, 스키마, 검증 구간의 정의된 치명 오류 패턴 0건, 재시작 0을 확인했습니다.
- Ghost 6.61.0 내부 응답과 실제 외부 HTTPS가 모두 200이었습니다. 이는 가용성 확인값이며 올바른 콘텐츠, 관리자 로그인, 글 읽기·쓰기, 회원 기능까지 증명하지는 않습니다.
- NPM 설정 검사, 핵심 보안 헤더, CSP 강제 모드와 주요 경로를 확인했습니다. 다만 관리자 로그인·글 저장·이미지 업로드·회원 기능까지 포함한 전체 브라우저 회귀검사는
needs_review입니다.
하지만 발행 시점에는 Docker 이미지용 보안 패치인 MySQL 8.4.12가 이미 공개됐습니다. 현재 운영 이미지가 8.4.11이라면 **발행 시점 상태는 needs_review**입니다. 8.4.12 이상 현재 보안 패치를 적용하고 같은 사후 검증을 다시 통과하기 전까지 8.4.11을 현재 보안 기준선이라고 부르지 않습니다.
다음 항목은 유지보수 목록으로 남았습니다.
- 발행 전 우선 조치: MySQL Server Docker 이미지 8.4.12 이상 현재 보안 패치를 적용하고 새 다이제스트를 고정한 뒤 전체 사후 검증 재실행
- Ghost와의 호환성을 확인한 뒤
require_secure_transport=ON검토 - 애플리케이션 계정의 호스트 범위 축소 가능성 검토
- Compose의 obsolete/deprecated 문법 정리
- CSP 위반 보고와 Ghost 테마·코드 삽입 변경 후 재검증
- 백업의 별도 매체 보관과 정기 복원 훈련
기준선 통과와 후속 하드닝 목록이 동시에 존재할 수 있습니다. 위험값을 숨기지 않으면서도, 제한된 예외를 곧바로 침해로 과장하지 않는 것이 이번 재시작의 핵심이었습니다.
재사용할 최종 체크리스트
변경 전
- [ ] Compose 최종 모델이 해석되는가?
- [ ] 대상 컨테이너가 정확히 하나씩 존재하는가?
- [ ] 실행 상태, 재시작 횟수, OOM 여부가 정상인가?
- [ ] 이미지 태그가 아니라 실제 이미지 ID·다이제스트를 확인했는가?
- [ ] MySQL 3306과 Ghost 2368이 외부에 직접 노출되지 않았는가?
- [ ] privileged, host network·PID, Docker 소켓 마운트가 없는가?
- [ ] 익명·원격 관리자·전역·동적·위임·역할·프록시 권한을 확인했는가?
- [ ] 공식 업그레이드 호환성 검사와 XA 상태를 확인했는가?
- [ ] 현재 Ghost 내부 응답과 외부 HTTPS가 정상인가?
- [ ] 백업 공간과 복구 경로가 준비됐는가?
변경 중
- [ ] Ghost를 먼저 멈추고 MySQL을 정상 종료했는가?
- [ ]
innodb_fast_shutdown=0, exit code 0, OOM false, 종료 로그를 확인했는가? - [ ] 전체 데이터 디렉터리와 설정·메타데이터를 보존했는가?
- [ ] 아카이브 목록, 핵심 파일,
tar --compare, SHA-256을 확인했는가? - [ ] Compose 변경이 DB 이미지 한 항목으로 제한됐는가?
- [ ] MySQL을 먼저 기동하고 검증한 뒤 Ghost를 시작했는가?
- [ ] 실패 단계에 따라 복구 경로를 다르게 적용하는가?
- [ ] 볼륨을 삭제하는
down -v를 사용하지 않았는가?
변경 후
- [ ] 실제 MySQL·Ghost 버전을 확인했는가?
- [ ] 업그레이드 시작·완료 로그와 정의한 치명 오류 패턴 0건을 확인했는가?
- [ ] 스키마·테이블 수와 애플리케이션 DB 연결을 확인했는가?
- [ ] DB·Ghost 재시작 0과 OOM false를 확인했는가?
- [ ] Ghost 내부 HTTP와 실제 외부 HTTPS를 모두 확인했는가?
- [ ] 사이트 복구 뒤 백업 SHA-256을 다시 검증했는가?
- [ ] 실제 중단 시간을 기록했는가?
프록시와 외부 응답
- [ ] NPM 기존 설정을 백업했는가?
- [ ] 헤더 블록과 마커가 중복되지 않았는가?
- [ ]
nginx -t가 통과했는가? - [ ] CSP를 Report-Only에서 기능 검증한 뒤 강제 모드로 옮겼는가?
- [ ] 홈, 관리자, 404, 공개 파일을 실제 GET으로 확인했는가?
- [ ] 핵심 보안 헤더와 사용자 정의 진단 헤더를 구분했는가?
- [ ] NPM 재시작 횟수와 오류 로그가 정상인가?
마치며
이번 점검의 가장 큰 성과는 의심스러운 흔적을 하나 더 찾은 것이 아닙니다. 무엇을 정상으로 볼지 다시 정의한 것입니다.
백업 복원 실패와 Docker 시작 오류를 끝까지 추적하는 과정은 의미가 있었습니다. 그러나 정상 기준선 없이 포렌식만 깊어지면 실패하지 않은 단계까지 실패처럼 보일 수 있습니다. 반대로 기준선이 있으면 Docker 권한 오류는 측정 실패로, 와일드카드 계정은 제한된 앱 계정으로, Compose 경고는 기술 부채로, 404 진단 헤더 누락은 비보안 예외로 정확히 분리할 수 있습니다.
그리고 실제 변경은 더 단순해졌습니다. 온라인에서 확인할 것은 모두 먼저 확인하고, 중단 구간에서는 정상 종료·백업·전환·복구를 한 흐름으로 묶고, 마지막에는 사용자가 접속하는 HTTPS까지 확인했습니다. 그 결과 MySQL 8.0.41에서 8.4.11로의 전환을 25초 중단으로 끝내고 Ghost 서비스까지 정상 복구할 수 있었습니다.
보안 점검은 깊을수록 좋은 것이 아니라, 정상과 이상을 구분할 증거가 충분할수록 좋습니다. 다음 이상 징후가 나타나면 그때 다시 필요한 계층만 깊게 보겠습니다.
공식 문서
- MySQL 8.4 지원 업그레이드 경로
- MySQL 업그레이드 권장 절차
- MySQL 업그레이드 사전 조건
- MySQL InnoDB 콜드백업
- MySQL 다운그레이드 제한
- MySQL 8.4.12 릴리스 노트
- MySQL Docker 이미지 사용 안내
- MySQL 계정명과 호스트 패턴
- MySQL 보안 지침
- Ghost HTTPS 프록시와 리다이렉트 루프
- Ghost Configuration
- NGINX
ngx_http_headers_module - NGINX HSTS 가이드
- MDN Content Security Policy 가이드
- MDN CSP Report-Only