서버 보안 점검 7편: 보존 증거와 커널·LSM 로그로 Docker 시작 실패 추적하기
핵심 요약
격리된 MySQL 복원용 컨테이너가 시작 단계에서 실패했습니다. 보존 자료로 실패 위치와 정리 결과는 확인했지만, 정확한 원인은 끝내 확정하지 못했습니다. 이 글에서는 당시 확인한 사실과 다른 서버에서도 활용할 수 있는 로그 확인 순서를 구분해 정리합니다.
이전 편: Ghost·MySQL 백업 복원 검증과 Docker 실패 포렌식에서는 백업을 실제로 복원하는 과정에서 Docker 시작 오류를 만났습니다. 실패 처리기가 임시 컨테이너를 정리한 뒤에는 같은 컨테이너에 다시 docker inspect를 실행할 수 없었습니다. 남은 자료를 먼저 연결하고, 실패 시각 주변의 호스트 기록을 확인해야 했습니다.
여기서 얻은 실무적인 교훈은 시작 실패, 준비 상태 확인 실패, 애플리케이션 오류를 먼저 나누어야 한다는 것입니다. MySQL 준비 상태를 기다리는 단계에도 도달하지 못했다면, DB 비밀번호나 Ghost 설정을 바꾸기 전에 컨테이너 시작을 막은 계층부터 살펴봐야 합니다.
아래의 실제 상태값은 당시 보존된 기록입니다. 별도로 제시한 조회 명령은 Docker와 systemd 공식 문서를 바탕으로 구성한 일반 진단 예제이며, 당시 실행 명령 원문이나 새로 검증한 운영 결과를 뜻하지 않습니다. Ubuntu처럼 systemd로 Docker를 운영하는 Linux 호스트를 기준으로 하며, Docker Desktop·rootless Docker·다른 배포판은 서비스명과 로그 위치가 달라질 수 있습니다.
1. 먼저 확인한 것은 원인이 아니라 실패한 단계였습니다
복원 검증의 공개 출력에는 다음 상태가 남았습니다. 이 이름들은 당시 점검 스크립트가 정의한 분류값이며 Docker 자체의 공통 오류 코드가 아닙니다.
| 항목 | 보존된 값 | 해석 |
|---|---|---|
| 실행 결과 | status=failed |
복원 검증이 성공 완료되지 않았습니다. |
| 실패 단계 | PHYSICAL_DATABASE_RESTORE |
격리된 물리 복원 과정에서 실패했습니다. |
| 실패 위치 | PHYSICAL_START |
컨테이너 시작 단계에서 멈췄습니다. |
| MySQL 준비 상태 대기 | NOT_RUN |
대기 검사가 실행되기 전 실패했습니다. |
| 런타임 상태 | RUNTIME_STATE_ERROR |
런타임 상태 오류가 기록됐습니다. |
| Docker 로그 수집 | DOCKER_LOGS_UNAVAILABLE |
해당 증거 묶음에서 컨테이너 로그를 확보하지 못했습니다. |
| 임시 컨테이너·작업 공간 | 정리 완료로 기록 | 실패 처리기가 정리 완료 상태를 남겼습니다. |
| 운영·입력 자료 경계 | unchanged로 기록 |
실패 처리기의 기록이며, 이후 운영 전체를 독립 재검사한 결과는 아닙니다. |
| 정확한 원인 | exact_root_cause_certified=false |
근본 원인은 미확정입니다. |
이 표에서 가장 유용한 값은 PHYSICAL_START와 NOT_RUN의 조합입니다. “MySQL이 준비되지 않았다”는 넓은 표현 대신, 준비 상태 확인 전에 컨테이너 시작이 실패했다고 범위를 좁힐 수 있습니다.
RUNTIME_STATE_ERROR만으로 파일 권한, 디스크, 메모리, AppArmor 중 하나를 원인으로 고를 수는 없습니다. Docker가 전달하는 오류는 daemon, containerd, shim, OCI runtime 등 여러 계층의 메시지를 포함할 수 있습니다. 겉부분의 실행 실패 문구와 그 안에 담긴 구체적인 조건을 분리해서 읽어야 합니다.
또한 DOCKER_LOGS_UNAVAILABLE은 오류가 없었다는 뜻이 아닙니다. 프로세스가 로그를 남기기 전에 시작에 실패했거나, 로깅 드라이버·저장 방식 때문에 조회하지 못했을 가능성을 남겨 둬야 합니다. Docker 컨테이너 로그 문서는 docker logs와 컨테이너의 표준 출력·표준 오류 관계를 설명합니다.
2. 삭제된 컨테이너 대신 보존 자료 8종을 연결했습니다
당시에는 시작 요청 전후의 자료를 별도 작업 공간에 보존했습니다. 분석 대상은 다음 네 묶음, 총 8종이었습니다.
| 자료 묶음 | 수량 | 확인하려는 내용 |
|---|---|---|
| 컨테이너 생성·시작의 표준 오류 | 2 | 생성과 시작 중 어느 요청에서 실패했는지 |
| 연속된 컨테이너 상태 스냅샷 | 2 | 실패 직후 상태가 어떻게 기록됐는지 |
| 소유권·상태 조회 진단의 표준 오류 | 2 | 진단 명령의 실패가 원래 오류와 섞였는지 |
| Docker 로그 조회의 표준 출력·표준 오류 | 2 | 로그 내용이 없는 것과 조회에 실패한 것을 구분할 수 있는지 |
자료를 읽기 전에 같은 실패 실행에서 만들어진 것인지 확인했습니다. 완료 마커, 파일 목록, 매니페스트, 체크섬을 대조했고, 예상하지 않은 파일과 링크가 섞이지 않는지 점검했습니다. 최신 폴더처럼 보인다는 이유만으로 선택하면 실패한 실행과 이후 재시도의 자료가 섞일 수 있기 때문입니다.
실제 운영에서도 폴더를 여러 개 만드는 것보다 한 실행의 연결 관계를 남기는 편이 중요합니다. 예를 들어 생성 명령은 성공했는데 시작 명령이 실패했다면, 두 명령의 종료 코드와 stderr를 별도로 보관해야 합니다. 뒤이어 실행한 로그 조회가 실패한 경우에도 그 오류를 최초 시작 오류와 구분할 수 있어야 합니다.
체크섬은 보관한 파일이 기록된 내용과 같은지 확인하는 수단입니다. 파일 작성자의 신원이나 모든 기록의 진실성까지 보증하지는 않습니다. 두 상태 스냅샷이 같다는 관찰 역시 그 사이에 아무 변화도 없었다는 증명으로 확대하지 않았습니다.
이 단계에서 보존 자료의 구성과 연속성은 확인했지만, 마지막 세부 분류 출력은 남아 있지 않았습니다. 따라서 “8종의 자료를 확보했다”는 사실과 “8종을 분석해 원인을 찾아냈다”는 결론을 구분했습니다.
3. 컨테이너 로그와 호스트 로그를 나누어 봅니다
Docker 문제를 조사할 때 모든 로그를 같은 것으로 보면 확인 순서가 꼬입니다. 다음 표처럼 각 기록이 답하는 질문을 나누면 필요한 범위를 줄일 수 있습니다.
| 기록 | 주로 확인하는 내용 | 읽을 때의 한계 |
|---|---|---|
| 컨테이너 상태·생성/시작 오류 | 어떤 요청과 단계에서 실패했는지 | 제거된 동일 객체는 다시 조회할 수 없습니다. |
docker logs |
컨테이너 프로세스의 출력 | 호스트의 모든 런타임 오류를 모아 주지 않습니다. |
| Docker 데몬 저널 | 컨테이너 요청 처리와 데몬의 오류 | MySQL 내부 동작을 직접 증명하지 않습니다. |
| containerd 저널 | 런타임과 프로세스 관리 관련 기록 | 설치 방식에 따라 서비스·출처가 다릅니다. |
| 커널·audit 기록 | 보안 정책 거부, 메모리 부족 등 | 기록을 남기는 설정과 보존 범위를 확인해야 합니다. |
| systemd-coredump 기록 | 저장된 충돌 메타데이터 | 모든 충돌을 반드시 기록하는 것은 아닙니다. |
systemd 기반 Docker 호스트의 데몬 로그는 일반적으로 docker.service에서 확인합니다. 반면 컨테이너가 journald 로깅 드라이버를 사용하도록 구성돼 있지 않다면 컨테이너의 출력이 같은 저널에 들어 있다고 가정해서는 안 됩니다. Docker 데몬 로그 안내와 로깅 드라이버 안내를 함께 확인할 부분입니다.
이번 사례는 컨테이너를 정리한 뒤의 조사였습니다. 현재 운영 중인 다른 MySQL 컨테이너를 조회해 얻은 정상 상태를 과거 실패의 증거로 쓰지 않았습니다. 이름이 같아도 재생성된 컨테이너는 다른 실행이므로, 사건 시각과 전체 식별자가 연결되는 기록을 우선해야 합니다.
이 원칙은 로그 보관에도 적용됩니다. 장애 당시의 상태를 남기려면 문제가 재현된 직후의 생성·시작 stderr, 상태, 관련 시각을 수집해야 합니다. 일단 제거한 다음 나중에 필요한 값을 채우는 방식에는 한계가 있습니다.
4. 같은 시각과 부팅을 기준으로 저널을 조회합니다
아래 예제는 운영자가 자신의 호스트에서 과거 기록을 확인하는 읽기 전용 조회입니다. 출력에는 내부 경로·식별자·계정 정보가 포함될 수 있으므로 원문은 운영자 로컬에서 확인하고, 공유할 때 필요한 부분만 가립니다.
먼저 남아 있는 부팅 목록에서 장애 시각을 포함하는 부팅을 고릅니다.
sudo journalctl --list-boots --no-pager
BOOT_ID에는 목록의 전체 부팅 ID를 넣습니다. START_TIME과 END_TIME에는 사건 앞뒤의 좁은 구간을 YYYY-MM-DD HH:MM:SS UTC 형식으로 지정합니다. 아래 빈 값을 채운 뒤 다음 조회를 실행합니다.
BOOT_ID=''
START_TIME=''
END_TIME=''
: "${BOOT_ID:?장애 시각에 해당하는 전체 부팅 ID를 입력하세요}"
: "${START_TIME:?조회 시작 UTC 시각을 입력하세요}"
: "${END_TIME:?조회 종료 UTC 시각을 입력하세요}"
처음에는 장애 전후 몇 분처럼 짧은 구간으로 시작합니다. 종료 시각이 시작 시각보다 뒤이며 현재보다 과거인지 확인합니다. 폴더 이름에 붙은 로컬 시각만 사용하면 시간대 차이 때문에 다른 구간을 조회할 수 있으므로, 보존된 상태의 시각과 맞춰야 합니다.
Docker와 containerd가 각각 systemd 서비스로 운영되는 호스트에서는 다음과 같이 조회할 수 있습니다. 실제 서비스명이 다르면 해당 이름으로 바꿉니다.
sudo journalctl \
--boot="$BOOT_ID" \
--unit=docker.service \
--unit=containerd.service \
--since="$START_TIME" \
--until="$END_TIME" \
--no-pager \
--output=short-iso
커널 기록도 같은 부팅과 시각에 맞춥니다.
sudo journalctl \
--boot="$BOOT_ID" \
--dmesg \
--since="$START_TIME" \
--until="$END_TIME" \
--no-pager \
--output=short-iso
--dmesg(-k)는 부팅을 따로 지정하지 않으면 현재 부팅으로 제한됩니다. 장애 이후 재부팅했다면 --since만 과거로 바꿔도 필요한 커널 기록을 못 찾을 수 있습니다. 그래서 위 예제는 부팅 ID를 명시했습니다. 옵션의 의미는 Ubuntu 24.04의 journalctl 문서에서 확인할 수 있습니다.
이 두 명령은 조회 방법을 보여 주는 예제입니다. 원래 감사기의 출처 검증, 바이트 제한, 시간 초과 처리까지 구현한 자동 감사 스크립트는 아닙니다. 출력이 지나치게 많으면 시각과 서비스 범위를 다시 좁히고, 일부만 읽은 상태에서 해당 구간 전체에 이상이 없다고 판정하지 않습니다.
읽기 권한 오류가 발생하거나 해당 부팅이 목록에 없으면 조회 실패·자료 부재부터 기록해야 합니다. 명령이 실패했는데 화면에 내용이 없다는 이유로 “관련 오류 0건”으로 옮기는 일이 가장 흔한 오판 중 하나입니다.
5. 커널과 런타임에서 확인할 다섯 가지 원인 계열
당시 최종 감사기는 다음 다섯 계열의 신호를 구분하도록 설계했습니다. 이 목록은 찾으려던 후보이며, 다섯 가지가 실제로 발견됐다는 뜻은 아닙니다.
| 원인 계열 | 확인할 신호 | 추가로 연결할 정보 |
|---|---|---|
| AppArmor 거부·종료 | 정책 거부 또는 종료 감사 기록 | 적용 프로필, 거부한 작업, 대상 프로세스 |
| SELinux AVC 거부 | AVC denial 기록 | 실제 집행 상태, 대상과 거부된 접근 |
| seccomp 관련 종료 | 감사 기록, SIGSYS 등 |
대상 프로세스와 신호·필터 정보 |
| 메모리 부족 종료 | 커널 또는 cgroup OOM 기록 | 종료된 프로세스, 해당 cgroup, 사건 시각 |
| 런타임 프로세스 충돌 | runc·shim·containerd·dockerd 관련 충돌 | 실행 파일, 신호, 부팅과 실패 실행의 관계 |
AppArmor 로그에 DENIED가 있다고 해서 복원 실패 원인이 즉시 확정되는 것은 아닙니다. 해당 프로필이 어떤 프로세스에 적용됐고, 거부된 작업이 컨테이너 시작 요청과 연결되는지 확인해야 합니다. 현재 프로필 상태를 확인해야 하는 상황이라면 Docker 문서에 나온 sudo aa-status를 참고할 수 있지만, 현재 결과를 과거 상태와 동일하게 취급해서는 안 됩니다. Docker AppArmor 진단 문서
seccomp는 동작에 따라 프로세스를 종료하거나 시스템 호출에 오류를 반환할 수 있습니다. 따라서 관련 메시지가 없다는 이유만으로 seccomp 가능성을 모두 제외할 수 없습니다. SIGSYS 역시 신호 하나만 보고 판단하기보다 메타데이터와 대상 실행을 연결해야 합니다. Linux 커널의 seccomp 반환 동작
systemd-coredump를 사용하는 호스트에서는 같은 시간·부팅의 충돌 목록을 다음처럼 확인할 수 있습니다.
sudo coredumpctl \
--since="$START_TIME" \
--until="$END_TIME" \
--no-pager \
list "_BOOT_ID=$BOOT_ID"
목록에서는 런타임 관련 프로세스와 신호부터 확인합니다. list는 코어 메모리를 추출하는 dump와 다릅니다. 목록에는 실행 파일 경로와 식별 정보가 포함되므로 그대로 공개하지 않습니다. 메타데이터는 남아 있는데 코어 파일은 이미 삭제된 경우도 있습니다. coredumpctl 공식 배포 문서
같은 시간대에 OOM이 보였더라도 대상이 다른 서비스라면 복원 컨테이너의 실패 원인으로 쓸 수 없습니다. 마찬가지로 애플리케이션 메시지에 apparmor라는 단어가 등장한 것과 실제 커널·audit 기록은 다릅니다. 시각, 부팅, 프로세스, cgroup, 컨테이너 식별 연결을 함께 확인해야 합니다.
6. 로그가 없을 때는 수집 범위부터 점검합니다
이번 감사 설계에는 실패 기준 시각 전후 각 120초의 여유, 최대 전체 구간 1,800초, 총 16 MiB, 레코드당 256 KiB, 최대 8,192개 레코드, 실행시간 20초라는 제한을 두었습니다. 후속 설계에서는 기록이 아직 추가되는 구간을 피하기 위한 30초의 여유도 포함했습니다. 이는 당시 선택한 처리 상한이며 모든 서버의 권장값은 아닙니다.
이런 상한은 조회가 예상보다 커지는 것을 막지만 결과 해석에도 영향을 줍니다. 수집 도중 제한에 걸렸다면 결론은 “관련 신호 없음”이 아니라 수집 불완전입니다. 출력 앞부분을 head로 잘랐거나 최근 몇 줄만 읽었을 때도 같은 구분이 필요합니다.
보존 범위 자체에도 한계가 있습니다. Docker의 과거 이벤트 조회는 최근 256개 이벤트로 제한됩니다. docker events에 --since와 --until을 붙여도 보존 범위 밖으로 사라진 이벤트를 되살릴 수는 없습니다. Docker events 공식 문서
systemd journal은 저장 방식, 용량, 보존 기간과 rate limit에 따라 남아 있는 기록이 달라집니다. 휘발성 저장소만 사용했다면 재부팅 이전 기록이 없을 수 있고, 영구 저장이라도 모든 과거 메시지가 유지되는 것은 아닙니다. journald 저장·보존 설정
당시 후속 감사에서는 같은 자료를 두 번 읽었을 때 의미가 같은지도 비교하도록 바꿨습니다. JSON 필드 순서 차이를 실제 상태 변화로 오판하지 않도록 필요한 필드를 정규화하고, 레코드는 중복 개수를 보존해 비교하는 방식입니다. 다만 두 번 같다는 사실도 과거 자료가 처음부터 빠짐없이 남았다는 보장은 아닙니다.
독자가 결과를 남길 때에는 다음 네 상태만 분명히 구분해도 도움이 됩니다.
| 확인 결과 | 기록할 내용 |
|---|---|
| 대상 실행과 직접 연결된 신호가 있음 | 신호 종류와 식별 연결 근거, 추가 확인할 원인 |
| 같은 시간대에만 신호가 있음 | 주변 단서이며 해당 실행과의 연결은 미확인 |
| 정한 범위에서 신호를 못 찾음 | 조회한 시각·부팅·소스와 보존 한계 |
| 오류·권한·상한 때문에 다 못 읽음 | 수집 불완전 또는 조회 실패, 해결할 수집 문제 |
이렇게 기록하면 다음 사람이 “0건”이라는 숫자만 받아 정상 판정으로 확대하는 일을 줄일 수 있습니다.
7. 이번 조사의 최종 결과와 남은 한계
보존된 자료로 확정한 것은 물리 복원용 컨테이너가 시작 단계에서 실패했고, MySQL 준비 상태 대기는 실행되지 않았으며, 해당 증거에서 Docker 로그를 확보하지 못했다는 사실입니다. 임시 자원 정리와 운영·입력 경계의 무변경도 실패 처리기에 기록돼 있었습니다.
반면 다음 값은 확인되지 않았습니다.
- 보존 자료 8종을 대조한 마지막 세부 분류 출력
- Docker·containerd·커널·LSM 감사의 최종 원인 계열별 건수
- 특정 커널 신호와 실패 실행의 최종 상관관계 판정
- 정확한 컨테이너 시작 실패 원인
후속 V4 계측 재현은 준비되고 실행 승인을 받은 상태까지 확인됩니다. 그러나 실제 실행 출력이나 완료 기록은 확인되지 않아, 재현 성공·원인 발견·수정 완료로 정리할 수 없습니다. 감사 스크립트에 특정 판정 분기가 있다는 사실도 그 분기가 실제로 실행됐다는 증거를 대신하지 않습니다.
원인을 찾지 못한 기록도 의미는 있습니다. 이번에는 DB 접속이나 Ghost 오류를 확인하기 전에 런타임 시작 단계가 막혔다는 점을 구분했고, 다음 조사에서 어떤 자료가 빠지면 안 되는지 알게 됐습니다. 다만 이 결과를 서버 전체의 안전성 평가나 백업 복원 성공으로 표현하지 않습니다.
8. 다음 장애에서 바로 남겨야 할 것
같은 문제가 다시 생겼을 때 조사 가능성을 높이려면 임시 컨테이너를 정리하기 전에 실패 직후 자료를 남기는 순서가 필요합니다. 보존 대상은 크게 다섯 가지입니다.
- 생성·시작 요청 각각의 종료 코드와 표준 오류
- 실패 직후 상태와 해당 실행의 식별 정보
- 시작 전후 UTC 시각과 호스트 부팅 ID
- 컨테이너 출력, 데몬 저널, 커널 기록의 확보 여부
- 정리 결과와 운영 서비스에 대한 영향 확인값
관찰 기록은 다음처럼 작성할 수 있습니다. 빈칸을 채우기 위해 결과를 추정하지 않고, 확인하지 못한 값은 그대로 미확인으로 둡니다.
사건: 격리 복원용 컨테이너 시작 실패
생성 요청: 확인값 / 미확인
시작 요청: 확인값 / 미확인
준비 상태 검사: 실행됨 / 실행되지 않음 / 미확인
컨테이너 로그: 확보 / 조회 실패 / 보존 자료 없음
데몬·커널 기록: 확보한 소스와 시간 범위
실행 연결: 직접 연결 / 시간상 일치 / 연결 미확인
수집 상태: 완료 / 일부 누락 / 조회 실패
원인: 확정 근거 또는 미확정
다음 행동: 필요한 추가 자료 한 가지
조치도 발견한 신호에 맞춰 좁힙니다. 대상과 연결된 정책 거부가 있으면 해당 프로필과 작업을 확인하고, OOM이 연결되면 당시 메모리 제한과 종료 대상을 확인합니다. 기록을 읽지 못한 경우에는 수집 문제부터 해결합니다. 원인 정보가 부족한 상태에서 권한이나 보안 정책을 일괄 완화하면 무엇이 문제였는지 판단하기 더 어려워집니다.
이 글에서 가져갈 절차는 실패 단계 확인 → 같은 실행의 자료 연결 → 같은 부팅·시각의 로그 대조 → 근거에 맞는 결론 기록입니다. 로그가 없다는 사실과 오류가 없다는 결론을 구분하면, 조사 범위를 불필요하게 넓히지 않고 다음에 필요한 자료를 정할 수 있습니다.
후속 작업에서는 이 실패를 해결된 것으로 처리하지 않은 채 정상 운영 상태 점검으로 방향을 바꿨습니다. 그 과정과 실제 MySQL 업그레이드 결과는 서버 보안 점검 8편: Ghost·MySQL 일반 보안 점검 재시작과 업그레이드 실전기에 정리했습니다.