서버 보안 점검 7편: 보존 증거와 커널·LSM 로그로 Docker 시작 실패 추적하기
세 줄 요약
격리 복원용 컨테이너는PHYSICAL_START에서 실패했고, 준비 상태 대기에는 도달하지 못했으며, 사후 Docker 로그도 확보하지 못했습니다.
보존 증거 8종과 제한된 Docker·containerd·커널·LSM·코어덤프 저널을 순서대로 확인했지만, 최종 분류 stdout이 남아 있지 않아 정확한 원인은 확정하지 않습니다.
후속 V4 계측 재현은 승인되고 준비됐으나 실제 실행 결과는 확인되지 않으며, 이 글은 Ghost·MySQL 자체 점검에 들어가기 직전에서 끝납니다.
이전 편: 서버 보안 점검 6편 — Ghost·MySQL 백업 복원 검증과 Docker 실패 포렌식
이전 편에서는 백업 파일의 무결성 확인과 실제 복원 성공을 구분하고, 운영 환경과 분리된 검증 과정에서 발생한 Docker 시작 실패를 읽기 전용으로 조사하는 기본 원칙을 살펴봤습니다. 이번 편은 그 실패 뒤에 이어진 포렌식 전체 흐름을 하나로 연결합니다.
문제 상황은 단순합니다. 격리된 물리 복원용 데이터베이스 컨테이너를 생성했지만 시작 단계에서 실패했습니다. 실패 처리기는 임시 컨테이너와 작업 공간을 정리했고, 운영 환경과 입력 자료가 바뀌지 않았다는 결과를 남겼습니다. 그러나 컨테이너가 제거된 뒤에는 같은 런타임 객체를 다시 조사할 수 없습니다.
이때 흔히 하는 실수는 두 가지입니다.
- 남아 있는 오류 문구 하나를 근본 원인으로 확정합니다.
- 로그에서 특정 단서를 찾지 못했다는 이유로 그 원인이 아니라고 결론 내립니다.
둘 다 위험합니다. 첫 번째는 전달용 래퍼 오류와 실제 원인을 혼동하고, 두 번째는 보존 기간·로깅 드라이버·조회 상한·소스 누락을 무시합니다. 올바른 절차는 정확한 실패 실행을 고정하고, 보존 증거를 검증한 뒤, 필요한 범위만 제한된 저널로 확장하고, 확인하지 못한 것은 끝까지 미확인으로 남기는 것입니다.
먼저 구분할 것: 확인값, 감사 설계, 추론
이 글에는 성격이 다른 정보가 함께 나옵니다. 이를 섞지 않기 위해 다음 네 층으로 구분합니다.
| 구분 | 이 글에서의 의미 | 예시 |
|---|---|---|
| 실제 확인값 | 당시 공개 출력으로 보존된 고정 상태 | PHYSICAL_START, RUNTIME_STATE_ERROR, DOCKER_LOGS_UNAVAILABLE |
| 감사 설계 | 스크립트가 검사하도록 구현된 규칙 | 증거 8종, 고정 원인 가족 5종, 시간·용량 상한 |
| 대화 흐름상 추론 | 후속 단계로 이동한 사실이 시사하는 해석 | 고정 가족이 강하게 관찰되지 않아 V4가 필요했을 가능성 |
| 미확인 | 실제 stdout이나 완료 증거가 없어 말할 수 없는 값 | 최종 enum, 가족별 건수, V4 실행 결과, 정확한 근본 원인 |
코드에 어떤 결과 분기가 존재한다는 사실은 실제 실행에서 그 분기가 선택됐다는 증거가 아닙니다. 마찬가지로 후속 단계가 승인됐다는 사실도 앞 단계의 정확한 최종 출력값을 대신하지 못합니다.
전체 조사 흐름
이번 포렌식은 다음 순서로 범위를 넓혔습니다.
- abrupt-exit 흔적 고정: 실패 실행과 정리 결과가 연결되는지 확인합니다.
- physical-start 분류: 실패 단계와 세부 단계를 고정 enum으로 분리합니다.
- OCI 런타임 심층 분류: 중첩 오류를 래퍼·작업·조건·세부 축으로 나눕니다.
- 보존 증거 8종 소진: 시작 관련 필수 자료 전체에서 추가 고정 원인 계열을 찾습니다.
- Docker·containerd 제한 저널 감사 v1: 실패 시간 주변의 데몬·런타임 기록을 제한적으로 연결합니다.
- 의미 기반 제한 저널 감사 v2: 원시 JSON 순서가 아닌 감사에 필요한 의미 투영을 비교합니다.
- 커널·LSM·systemd-coredump 최종 감사: AppArmor, SELinux, seccomp, OOM, 런타임 보조 프로세스 충돌을 고정 가족으로 검사합니다.
- V4 계측 재현 준비: 기존 증거로 좁히지 못한 지점을 다음 재현에서 직접 보존하도록 설계합니다.
1~7단계는 운영 서비스와 데이터를 바꾸지 않는 읽기 전용 감사입니다. 8단계의 V4는 격리된 임시 컨테이너를 실제로 한 번 생성·시작하는 계측 재현이므로 별도 승인이 필요한 제한적 변경 작업입니다.
1부. 보존 증거로 Docker 시작 실패를 재구성합니다
실행 중 정보가 아니라 보존 증거를 사용한 이유
컨테이너가 정리된 뒤 docker inspect를 다시 실행해도 실패 당시의 같은 객체는 존재하지 않습니다. Docker 컨테이너의 writable layer에만 있던 자료도 컨테이너 제거와 함께 사라질 수 있습니다. 반면 실패 처리기가 별도 비공개 증거 영역에 남긴 상태 스냅샷, 표준 오류, 로그 조회 결과는 당시 관찰을 제한적으로 재검토할 수 있게 해 줍니다.
그러나 “파일이 남아 있다”는 사실만으로 곧바로 신뢰할 수는 없습니다. 다음 위험이 있기 때문입니다.
- 다른 실행의 증거가 섞였을 수 있습니다.
- 완료되지 않은 부분 트랜잭션이 최신 자료처럼 보일 수 있습니다.
- 심볼릭 링크가 증거 루트 바깥을 가리킬 수 있습니다.
- 매니페스트와 실제 파일 목록이 다를 수 있습니다.
- 분석 중 자료가 바뀌어 두 번의 읽기가 서로 다른 실행을 볼 수 있습니다.
- 체크섬은 맞지만 누가 만든 증거인지 인증되지 않았을 수 있습니다.
그래서 내용 분류보다 증거 경계 검증이 먼저입니다.
fail-closed로 정확한 실행 하나만 선택합니다
분류기는 최신으로 보이는 폴더를 무조건 고르지 않습니다. 검증기 잠금의 유형·소유권·권한과 열린 파일 디스크립터의 동일성을 먼저 확인하고, 실행 중인 검증기가 없을 때만 후속 분석으로 넘어갑니다.
그다음 완료된 증거 후보를 찾되 다음 조건을 적용합니다.
- 후보 이름과 디렉터리 유형이 허용된 형식과 일치해야 합니다.
- 예상 소유자·권한·링크 수 경계를 만족해야 합니다.
- 완료 마커와 비공개 증거 매니페스트가 있어야 합니다.
- 매니페스트의 파일 목록과 실제 인벤토리가 정확히 일치해야 합니다.
- 최신 후보가 정확히 하나여야 합니다.
후보가 없거나 최신 시각이 같은 후보가 둘 이상이면 임의로 하나를 선택하지 않습니다. 분석을 계속하는 대신 REQUIRES_REVIEW 계열로 중단합니다. 이는 불편해 보이지만, 서로 다른 실패 실행을 합쳐 가짜 원인을 만드는 것보다 안전합니다.
필수 보존 증거 8종
선택된 실행에서는 시작 실패를 생산한 필수 자료 8종을 역할별로 검증했습니다. 실제 내부 경로, 컨테이너 이름, 실행 ID, 이미지 해시, 원문 오류는 공개하지 않습니다.
| 증거 묶음 | 수량 | 확인 목적 |
|---|---|---|
| 컨테이너 생성·시작 표준 오류 | 2 | 생성과 시작 중 어느 단계에서 오류가 전달됐는지 구분합니다. |
| 연속된 컨테이너 상태 스냅샷 | 2 | 실패 직후 두 상태 관찰이 구조적으로 일치하는지 확인합니다. |
| 소유권·상태 조회 진단 표준 오류 | 2 | 보조 진단 자체의 실패나 별도 오류 혼입을 구분합니다. |
| Docker 로그 조회의 표준 출력·표준 오류 | 2 | 실제 컨테이너 로그 확보 여부와 로그 조회 실패를 구분합니다. |
“8종이 모두 존재하고 체크섬이 맞다”는 것은 분석 입력의 연속성을 강화합니다. 그러나 아래 세 등식은 성립하지 않습니다.
증거 8종 완비 ≠ 근본 원인 규명
체크섬 일치 ≠ 디지털 서명으로 진본 인증
두 번 동일 ≠ 원자적 스냅샷
체크섬 바인딩은 파일이 매니페스트에 기록된 내용과 같은지를 확인합니다. 별도 신뢰 키로 서명하지 않았다면 누가 만들었는지까지 인증하지 않습니다. 두 번 읽은 공개용 투영이 같아도 두 읽기 사이 모든 순간의 파일 상태가 고정됐다고 증명할 수 없습니다.
실제로 확인된 실패값
보존된 출력에서 다음 값은 실제로 확인됐습니다.
status=failed
fixed_failure_stage_code=PHYSICAL_DATABASE_RESTORE
fixed_failure_step_code=PHYSICAL_START
mysql_wait_observation_class=NOT_RUN
physical_failure_runtime_state_class=RUNTIME_STATE_ERROR
physical_failure_log_capture_class=DOCKER_LOGS_UNAVAILABLE
test_container_cleanup_complete=true
private_scratch_cleanup_complete=true
live_boundary_unchanged=true
input_boundary_unchanged=true
exact_root_cause_certified=false
이 결과는 꽤 많은 것을 알려 주지만, 그보다 더 많은 것을 말해 주지는 않습니다.
PHYSICAL_DATABASE_RESTORE는 격리된 물리 복원 단계에서 실패했다는 뜻입니다.PHYSICAL_START는 컨테이너 준비 상태 확인이나 데이터베이스 질의보다 앞선 시작 단계에서 실패했다는 뜻입니다.NOT_RUN은 대기 로직이 실패한 것이 아니라 시작 실패 때문에 실행되지 않았다는 뜻입니다.RUNTIME_STATE_ERROR는 Docker가 보존한 런타임 상태 오류가 있었다는 뜻이며, 세부 원인이 확정됐다는 뜻은 아닙니다.DOCKER_LOGS_UNAVAILABLE은 이번 증거 경계에서 컨테이너 로그를 확보하지 못했다는 뜻입니다.exact_root_cause_certified=false는 이 결과만으로 근본 원인을 판결하지 않았다는 뜻입니다.
특히 DOCKER_LOGS_UNAVAILABLE을 “오류 로그가 없었다”로 바꾸면 안 됩니다. docker logs가 보여 주는 것은 기본적으로 컨테이너 프로세스가 표준 출력과 표준 오류로 보낸 내용입니다. 애플리케이션이 파일에만 기록했거나, 사용한 로깅 드라이버가 읽기를 지원하지 않거나, 로그가 순환 삭제됐거나, 시작 전에 프로세스가 충분한 출력을 남기지 못했다면 사후 조회에서 유용한 자료를 얻지 못할 수 있습니다.
정확한 표현은 다음과 같습니다.
이번 보존 증거에서 Docker 로그를 확보하지 못했습니다. 이는 로그 대상 사건이 없었다는 증명이 아닙니다.
abrupt-exit에서 physical-start까지 단계별로 좁힙니다
첫 번째 읽기 전용 포렌식은 비정상 종료 뒤 남은 실패 요약, 비공개 증거 트랜잭션, 정리 결과를 연결합니다. 이 단계의 목적은 원인을 찾는 것이 아니라 어느 실패 실행을 보고 있는가를 고정하는 것입니다.
다음 physical-start 분류기는 실패 요약을 고정된 축으로 나눕니다. 복원 전체가 실패했다는 넓은 결과에서 PHYSICAL_DATABASE_RESTORE와 PHYSICAL_START를 분리함으로써, 준비 상태 대기·인증·스키마·애플리케이션 단계에 아직 도달하지 않았음을 명확히 합니다.
이 구분이 중요한 이유는 원인 후보를 잘못 확장하지 않기 위해서입니다. 시작 자체가 실패했는데 데이터베이스 쿼리 결과나 Ghost 애플리케이션 동작을 원인으로 추정하면 조사 순서가 뒤집힙니다.
OCI 심층 분류는 원문 오류를 고정 축으로 바꿉니다
Docker와 OCI 런타임 오류는 여러 래퍼가 중첩된 긴 문자열로 전달될 수 있습니다. 심층 분류기는 원문을 그대로 출력하지 않고 다음과 같은 축으로 나눕니다.
| 축 | 구분하려는 질문 |
|---|---|
| 래퍼 체인 | Docker 데몬, containerd, shim, OCI 런타임 가운데 어느 계층을 거쳐 전달됐습니까? |
| 작업 | 생성, 프로세스 시작, 실행 파일 호출, 제어 평면 통신 중 어디와 관련됩니까? |
| 조건 | 권한, 부재, 읽기 전용 파일시스템, 리소스 부족, 스토리지 I/O 같은 고정 조건이 있습니까? |
| 세부 정보 | 런타임 오류 조회 실패, 실행 인자 제한, OOM 보고 같은 추가 단서가 있습니까? |
| 종료 외피 | 신호, 충돌, 비영 종료, 불투명한 중첩 오류 중 무엇입니까? |
이 방식은 전달용 문구를 원인처럼 오해하지 않게 해 줍니다. 하지만 이번 실행의 최종 OCI 세부 enum stdout은 보존되지 않았습니다. 따라서 권한, 스토리지, LSM, 리소스 또는 특정 런타임 보조 프로세스 원인이 실제로 관찰됐다고 쓰지 않습니다.
증거 8종 소진 분류가 답하려는 질문
마지막 정적 분류기는 시작 표준 오류 하나만 보지 않고 필수 자료 8종을 함께 대조합니다.
- 두 상태 스냅샷이 구조적으로 같은지 확인합니다.
- 시작 표준 오류가 보존된 상태 오류를 정확히 전달하는지 확인합니다.
- 생성 단계 표준 오류에 별도 고정 원인이 있는지 확인합니다.
- 소유권·상태 조회 진단이 새로운 오류 계열을 추가하는지 확인합니다.
- 로그 조회 표준 출력·오류가 단순 비어 있음인지, 드라이버 읽기 불가인지, 로그 경로 문제인지 구분합니다.
- 서로 충돌하는 고정 원인 가족을 하나로 합치지 않습니다.
설계상 최종 상태는 다음 부류 가운데 하나가 될 수 있습니다.
- 추가 고정 원인 가족이 관찰됐습니다.
- 둘 이상의 원인 가족이 충돌하거나 함께 나타났습니다.
- 런타임 보조 프로세스가 종료됐지만 중첩 원인은 불투명합니다.
- 선택한 8종에서 추가 고정 원인을 찾지 못했습니다.
그러나 실제 실행이 어느 최종 enum으로 끝났는지 stdout이 남아 있지 않습니다. 코드에 가능한 분기가 존재한다는 이유로 하나를 골라서는 안 됩니다.
기록상 unchanged와 독립 재검증을 구분합니다
실패 처리기는 테스트 컨테이너와 비공개 임시 공간 정리가 완료됐고, 운영·입력 경계가 바뀌지 않았다는 상태를 남겼습니다. 후속 분류기는 이 기록과 증거 묶음의 연속성을 읽었습니다.
하지만 후속 읽기 전용 분류기가 운영 환경 전체를 다시 조사해 live_boundary_unchanged와 input_boundary_unchanged를 독립적으로 증명한 것은 아닙니다. 따라서 글에서는 다음처럼 표현해야 정확합니다.
- 올바른 표현: “실패 처리기에 운영·입력 경계 unchanged가 기록됐고 후속 증거 묶음과 연결됐습니다.”
- 과한 표현: “후속 포렌식이 운영 서버 전체의 무변경을 다시 증명했습니다.”
1부의 실제 확인과 미확인
| 항목 | 판단 | 해석 범위 |
|---|---|---|
| 실행 상태 | 확인됨: failed |
복원 검증이 성공 완료되지 않았습니다. |
| 실패 단계 | 확인됨: PHYSICAL_DATABASE_RESTORE |
격리된 물리 복원 단계입니다. |
| 실패 세부 단계 | 확인됨: PHYSICAL_START |
준비 상태 대기 전 컨테이너 시작 단계입니다. |
| 대기 관찰 | 확인됨: NOT_RUN |
시작 실패 뒤 대기 로직은 실행되지 않았습니다. |
| 런타임 상태 | 확인됨: RUNTIME_STATE_ERROR |
런타임 상태 오류가 기록됐지만 세부 원인은 미확정입니다. |
| 컨테이너 로그 | 확인됨: DOCKER_LOGS_UNAVAILABLE |
이번 경계에서 로그를 확보하지 못했습니다. |
| 테스트 정리 | 기록상 완료 | 테스트 컨테이너와 비공개 임시 공간 정리가 기록됐습니다. |
| 운영·입력 경계 | 기록상 unchanged | 후속 단계에서 운영 전체를 독립 재검증한 값은 아닙니다. |
| 증거 연속성 | 확인됨 | 매니페스트·인벤토리·체크섬과 두 읽기 투영이 일치했습니다. |
| 증거의 진본성 | 미확인 | 체크섬은 디지털 서명이 아닙니다. |
| 최종 8종 분류 enum | 미확인 | 실제 stdout이 보존되지 않았습니다. |
| 정확한 근본 원인 | 미확인 | exact_root_cause_certified=false입니다. |
2부. Docker·containerd 제한 저널로 범위를 확장합니다
보존 증거 8종만으로 정확한 중첩 원인을 확정하지 못하면, 다음 범위는 실패 당시 호스트에 남은 런타임 저널입니다. 이 단계에서도 운영 컨테이너를 다시 시작하거나 실시간 로그를 읽지 않습니다. 실패 실행과 연결되는 제한된 과거 저널만 조회합니다.
Docker 데몬 로그와 컨테이너 로그는 다릅니다
systemd 기반 Linux에서는 Docker 데몬 로그를 docker.service 저널에서 확인할 수 있습니다. containerd도 자체 서비스 저널과 런타임 보조 프로세스 기록을 남길 수 있습니다.
그러나 이것을 컨테이너 애플리케이션 로그와 같다고 보면 안 됩니다.
- Docker 데몬 저널은 컨테이너 수명주기와 런타임 제어 평면의 기록입니다.
docker logs는 컨테이너 프로세스의 표준 출력·표준 오류를 읽는 인터페이스입니다.- 컨테이너가
journald로깅 드라이버를 실제로 사용한 경우에만 해당 출력이 journal에 전달될 수 있습니다. - 기본 로깅 드라이버, 로테이션, non-blocking 버퍼, 저장 정책에 따라 수집 범위가 달라질 수 있습니다.
따라서 Docker·containerd 저널에서 시작 요청과 연결된 기록을 찾더라도 애플리케이션 내부 오류를 직접 봤다고 표현하지 않습니다.
제한 저널 감사 v1: 시간·소스·자원을 고정합니다
1차 감사는 보존된 컨테이너 생성 시각과 비공개 실패 증거의 기록 시각을 기준으로 시간 창을 만들고, Docker·containerd 관련 허용 소스만 조회하도록 설계했습니다.
실제 설계 상한은 다음과 같습니다.
| 경계 | 값 | 목적 |
|---|---|---|
| 기준 전 여유 | 120초 | 시각 기록과 저널 전달의 작은 차이를 흡수합니다. |
| 기준 후 여유 | 120초 | 실패 처리 직후 기록까지 포함합니다. |
| 최대 전체 시간 폭 | 1,800초 | 잘못된 기준으로 과도한 저널을 읽는 것을 막습니다. |
| 정착 여유 | 후속 v2부터 30초 | 아직 기록 중인 시간대를 읽지 않도록 합니다. |
| 최대 총 바이트 | 16 MiB | 메모리·노출 범위를 제한합니다. |
| 최대 한 레코드 크기 | 256 KiB | 비정상적으로 큰 행을 거부합니다. |
| 최대 레코드 수 | 8,192개 | 과도한 입력과 처리 시간을 제한합니다. |
| 최대 벽시계 실행시간 | 20초 | 멈춘 조회나 예상 밖 지연을 중단합니다. |
이 숫자는 모든 서버에 그대로 적용할 보편 권장값이 아닙니다. 당시 감사에서 자원 사용과 민감 정보 노출을 제한하기 위해 선택한 안전 상한입니다. 다른 환경에서는 저널 발생량과 보존 정책을 고려해 별도로 설계해야 합니다.
시간 기준으로 실행 폴더 이름의 로컬 시각을 사용하지 않은 점도 중요합니다. 폴더 이름은 생성 과정이나 시간대 설정에 따라 실제 사건 시각과 어긋날 수 있습니다. 컨테이너의 보존된 UTC 생성 시각과 필수 실패 자료의 최신 기록 시각을 함께 사용해야 합니다.
상한에 걸리면 0건이 아니라 캡처 불완전입니다
바이트, 행, 레코드, 실행시간 상한 가운데 하나라도 넘으면 부분 결과를 “고정 가족 미관찰”로 바꾸면 안 됩니다. 전체 범위를 읽지 못했기 때문입니다.
if byte_cap_reached
or line_cap_reached
or record_cap_reached
or wall_clock_timeout:
suppress_partial_counts = true
classification = CAPTURE_INCOMPLETE
automatic_action_authorized = false
부분 집계를 숨기는 이유는 일부에서 0건이 나온 숫자가 전체 결과처럼 소비되는 것을 막기 위해서입니다. 안전 상한은 로그를 덜 읽기 위한 편의 기능이 아니라 재현성·자원 사용·민감 정보 노출을 동시에 제한하는 보안 경계입니다.
제한 저널 감사 v2: 원시 순서 대신 의미를 비교합니다
1차 구현처럼 원시 JSON 객체의 필드 순서와 저널 레코드 순서까지 완전히 같아야 한다고 요구하면, 내용이 같아도 두 번의 조회가 불안정하다고 오판할 수 있습니다. JSON 필드 순서는 의미가 없고 저널 반환 순서도 동일 의미 안에서 달라질 수 있기 때문입니다.
v2는 다음과 같이 비교 기준을 바꿨습니다.
- 감사에 필요한 필드만 선택합니다.
- JSON 값을 정규화해 필드 순서 차이를 제거합니다.
- 레코드 집합은 순서와 무관한 다중집합으로 비교합니다.
- 중복 레코드의 개수는 정보이므로 보존합니다.
- 커서, 불필요한 필드, 원시 메시지 순서의 동일성은 요구하지 않습니다.
- 정규화한 투영과 보존 증거 바인딩을 두 번 읽어 비교합니다.
두 번의 투영이 다르면 분류를 강행하지 않습니다. 하지만 두 번 같아도 저널이 사건 당시부터 빠짐없이 보존됐거나 암호학적으로 진본이라는 뜻은 아닙니다.
Docker events와 저널의 보존 한계
Docker 이벤트는 create, start, die, oom, destroy 같은 수명주기 상관분석에 유용합니다. 그러나 Docker 공식 문서상 과거 이벤트 조회는 최근 256개라는 경계가 있습니다. --since, --until, 필터를 사용해도 이미 그 창 밖으로 밀린 기록을 복구할 수 없습니다.
systemd journal도 완전한 영구 기록소가 아닙니다.
- volatile journal은 재부팅 뒤 사라질 수 있습니다.
- 저장 용량 상한과 여유 공간 정책에 따라 오래된 기록이 순환 삭제될 수 있습니다.
- 최대 보존 기간이 설정될 수 있습니다.
- rate limit을 넘은 메시지가 버려질 수 있습니다.
- 부팅이 바뀌었는데 올바른 boot 범위를 고르지 않으면 다른 범위만 읽을 수 있습니다.
그래서 “Docker 이벤트와 journal에서 못 찾았습니다”는 다음처럼 써야 합니다.
선택한 보존 창과 저널 소스, 조회 상한 안에서 관련 기록을 확인하지 못했습니다.
다음 표현은 피해야 합니다.
해당 사건은 발생하지 않았습니다.
3부. 커널·LSM·systemd-coredump 최종 감사를 진행합니다
Docker·containerd 저널만으로 원인을 확정하지 못하면 하부 계층을 확인해야 합니다. 컨테이너 프로세스가 충분한 로그를 남기기 전에 커널 보안 정책이나 OOM 처리, 런타임 보조 프로세스 충돌로 시작이 끊겼을 수 있기 때문입니다.
이 단계는 Ghost 애플리케이션이나 MySQL 데이터·설정·계정을 읽기 전에 수행하는 마지막 호스트·런타임 포렌식입니다.
최종 감사가 찾는 다섯 가지 고정 원인 가족
무제한 키워드 검색은 우연히 비슷한 메시지를 원인처럼 만들기 쉽습니다. 최종 감사는 다음 다섯 가족만 고정 분류합니다.
| 고정 가족 | 무엇을 찾는가 | 관찰 시 시사점 | 관찰만으로 증명하지 못하는 것 |
|---|---|---|---|
| AppArmor 집행 거부·종료 | DENIED, KILLED 같은 AppArmor 감사 신호 |
프로필 집행이 관련 작업을 막았을 가능성 | 해당 거부가 시작 실패의 유일한 원인이라는 사실 |
| SELinux AVC 거부 | audit 또는 커널 소스의 AVC denial | SELinux 정책 거부가 같은 실행 또는 시간대에 있었을 가능성 | SELinux가 실제 활성·enforcing이었고 그 사건이 정확 원인이라는 사실 |
seccomp 감사·SIGSYS |
seccomp 차단 신호 또는 coredump의 SIGSYS |
허용되지 않은 시스템 호출이나 필터 관련 종료 가능성 | seccomp 기록이 없으면 차단도 없었다는 결론 |
| 커널 OOM kill | 커널·cgroup 메모리 부족 종료 신호 | 메모리 압박으로 프로세스가 종료됐을 가능성 | 어떤 프로세스가 대상이었는지, 시작 실패와 인과관계가 있는지 |
| 런타임 보조 프로세스 충돌·코어덤프 | runc, containerd shim, containerd, dockerd 계열 충돌 |
런타임 제어 계층의 비정상 종료 가능성 | 충돌이 복원 실패의 최초 원인이었다는 사실 |
AppArmor와 SELinux는 모두 Linux Security Module 프레임워크를 사용하지만, 실제 활성 모듈은 배포판과 호스트 설정에 따라 다릅니다. 두 정책이 모두 집행 중이라고 가정하면 안 됩니다. AppArmor 모듈이 로드돼 있어도 해당 작업이 unconfined일 수 있고, SELinux 기록을 찾지 못해도 SELinux가 비활성인지, 기록만 없는지 별도 확인이 필요합니다.
seccomp도 같은 주의가 필요합니다. Docker 기본 seccomp 프로필은 차단된 시스템 호출에 오류를 반환할 수 있지만, 모든 거부가 반드시 journal에 명시적 로그로 남는 구성은 아닙니다. 그러므로 “seccomp 신호 미관찰”은 “seccomp 무관”의 증명이 아닙니다.
소스 허용 목록이 문자열 검색보다 먼저입니다
저널 메시지에 apparmor, oom, runc라는 문자열이 있다고 해서 모두 신뢰할 수 있는 커널·감사 기록은 아닙니다. 애플리케이션도 같은 단어를 로그에 쓸 수 있습니다.
최종 감사는 먼저 레코드의 전송 계층과 구조화 필드를 검사해 다음 범주만 허용하도록 설계했습니다.
- 커널 전송 계층의 레코드
- audit 전송 계층 또는 정확히 허용한 audit 출처
- systemd-coredump 서비스 출처
그 뒤에만 메시지와 제한된 구조화 필드를 다섯 고정 가족으로 분류합니다.
여기서 journalctl의 조회 선택자와 실제 출처 증명도 구분해야 합니다. 특정 identifier나 message ID로 검색했다는 사실은 그 레코드가 정말 신뢰한 커널·audit 프로세스에서 왔다는 보장이 아닙니다. 조회 선택자는 범위를 좁히는 수단이고, 출처 허용은 레코드 자체의 구조화 메타데이터로 다시 확인해야 합니다.
정확 실행 연관과 시간상 연관을 분리합니다
같은 시간대에 보안 이벤트가 있었다는 사실과 실패 실행의 정확한 식별자가 연결됐다는 사실은 증거 강도가 다릅니다.
| 상관관계 | 일반화한 기준 | 해석 |
|---|---|---|
| 정확 실행 연관 | 전체 실행 식별자, 정확한 임시 컨테이너 이름 또는 고정 실행 레이블이 허용 소스와 연결됩니다. | 해당 실행과 직접 연결된 강한 증거지만 근본 원인 증명은 아닙니다. |
| 시간상 연관 | 고정 가족 사건이 제한 시간 창에 있지만 정확한 실행 식별 연결은 없습니다. | 조사 우선순위를 높이는 주변 증거이며 실행 귀속은 보류합니다. |
| 보조 일치 | 짧은 식별자 조각, 정규화한 오류 문구, 시간 근접만 일치합니다. | 충돌 가능성이 있어 정확 귀속으로 승격하지 않습니다. |
| 미관찰 | 선택한 시간·소스·상한 안에서 고정 가족을 찾지 못했습니다. | 사건 부재가 아니라 제한된 음성 관찰입니다. |
| 캡처 불완전 | 시간·크기·레코드·파싱 경계를 모두 충족하지 못했습니다. | 분류 자체를 중단하며 부분 결과를 사용하지 않습니다. |
정확 실행 연관이 있어도 인과관계는 별도입니다. 예를 들어 정확한 컨테이너 실행과 연결된 AppArmor 거부가 보인다면 중요한 단서지만, 그 거부가 최초 원인인지, 후속 오류인지, 다른 원인이 함께 있었는지 추가 검토가 필요합니다.
시간상 연관은 더 조심해야 합니다. 같은 시간 창에 OOM 기록이 있었다는 사실만으로 실패한 컨테이너가 OOM 때문에 시작하지 못했다고 말할 수 없습니다. 대상 프로세스·cgroup·전체 실행 식별을 연결하는 증거가 필요합니다.
systemd-coredump는 메타데이터도 민감합니다
systemd-coredump는 충돌한 프로세스의 메타데이터와 코어 이미지를 보존할 수 있습니다. 코어 이미지는 프로세스 메모리를 포함하므로 비밀번호, 토큰, 요청 본문, 환경변수 같은 민감 정보가 섞일 수 있습니다.
이번 감사의 목적은 코어 이미지를 열거나 역추적을 공개하는 것이 아닙니다. 허용된 systemd-coredump 출처에서 런타임 보조 프로세스의 충돌 여부와 신호 분류에 필요한 제한 정보만 메모리에서 확인하도록 설계했습니다.
공개 출력에서 제외한 정보는 다음과 같습니다.
- 실제 실행·컨테이너·부팅 식별자
- 프로세스 실행 경로와 cgroup 원문
- 저널 시각, 커서, 메시지 원문
- 코어덤프 내용과 역추적
- 파일 해시, 크기, 내부 작업 경로
- 계정, 도메인, IP, 포트, 환경변수와 비밀값
“원문을 출력하지 않았다”와 “원문을 읽지 않았다”도 다릅니다. 고정 enum으로 분류하려면 제한된 원문을 메모리에서 읽을 수 있지만, 공개 결과에는 안전한 집계와 분류만 남깁니다.
음성 관찰이 증명하지 못하는 것
최종 감사에서 다섯 가족이 보이지 않더라도 다음을 자동으로 결론 내릴 수 없습니다.
- AppArmor 또는 SELinux 정책 거부가 없었습니다.
- seccomp가 어떤 시스템 호출도 차단하지 않았습니다.
- OOM이나 cgroup 메모리 압박이 없었습니다.
- 런타임 보조 프로세스가 충돌하지 않았습니다.
- 관련 기록이 사건 당시부터 완전하게 보존됐습니다.
- 모든 소스가 journald로 전달됐습니다.
- coredump 저장 정책이 사건 당시 활성화돼 있었습니다.
journald 저장 공간, 보존 기간, 재부팅, rate limit, 소스 라우팅, coredump 정책 가운데 하나만 달라도 기록이 남지 않을 수 있습니다. 따라서 음성 결과의 정확한 표현은 항상 선택한 보존 자료와 제한된 저널 범위에서 관찰되지 않음입니다.
실제 최종 감사 결과는 어디까지 말할 수 있나
최종 감사 스크립트의 설계와 가능한 분기는 보존돼 있습니다. 그러나 실제 실행의 마지막 stdout 전체, 가족별 건수, 최종 상관관계 enum은 보존되지 않았습니다.
| 구분 | 확인 가능한 내용 | 말하면 안 되는 내용 |
|---|---|---|
| 확인됨 | 보존 실패 실행을 기준으로 Docker·containerd 제한 저널을 거쳐 커널·LSM·코어덤프 감사 단계까지 진행했습니다. | 가족별 실제 관찰 건수와 최종 enum을 임의로 선택하면 안 됩니다. |
| 확인됨 | 감사기는 AppArmor, SELinux, seccomp·SIGSYS, OOM, 런타임 충돌 다섯 가족을 고정 분류하도록 설계됐습니다. |
설계된 가족이 실제로 관찰됐다고 쓰면 안 됩니다. |
| 확인됨 | 모든 결과 분기에서 자동 복원 재시도·정리·완화 조치를 승인하지 않았습니다. | 감사 완료를 자동 수정 승인으로 해석하면 안 됩니다. |
| 대화 흐름상 추론 | 최종 감사 뒤 V4 계측 재현이 명시적으로 승인됐습니다. 선택한 보존·저널 증거에서 강한 고정 가족을 얻지 못했을 가능성을 시사합니다. | “다섯 가족 모두 0건으로 확정됐다”고 쓰면 안 됩니다. |
| 미확인 | 실제 journal_query_state, 레코드 수, 정확·시간상 가족 enum, 가족별 건수 |
코드에 정의된 기본값을 실행 결과로 대체하면 안 됩니다. |
| 미확인 | 정확한 시작 실패 원인 | exact_root_cause=false를 유지해야 합니다. |
후속 대화에서 V4 재현이 승인된 흐름은 최종 감사가 “선택한 보존·제한 저널 증거를 소진했으므로 계측 재현에 별도 승인이 필요하다”는 분기로 끝났을 가능성을 높입니다. 하지만 이는 대화 흐름에 따른 추론입니다. stdout이 없으므로 최종 고정 가족 미관찰 enum이나 0건 집계를 실제값으로 확정하지 않습니다.
RC=2는 취약점 발견이나 스크립트 고장을 뜻하지 않습니다
일반적인 셸 사용 경험 때문에 0이 아닌 종료 코드를 모두 오류로 해석하기 쉽습니다. 그러나 이 감사 체계에서 RC=2는 자동 조치를 막기 위한 운영자 검토 필요 상태로 사용할 수 있습니다.
다음처럼 서로 다른 경우가 모두 검토 필요로 끝날 수 있습니다.
- 정확한 증거 대상이나 시간 경계를 하나로 고르지 못했습니다.
- 두 번 읽은 공개 투영이 안정적이지 않습니다.
- 저널 캡처가 상한 또는 시간 제한 때문에 불완전합니다.
- 고정 가족이 관찰돼 정확·시간상 연관을 사람이 검토해야 합니다.
- 제한 범위에서 고정 가족을 찾지 못해 V4 계측 재현에 별도 승인이 필요합니다.
따라서 종료 코드만 보고 “보안 문제가 발견됐다”, “스크립트가 고장 났다”, “같은 명령을 자동 재시도하면 된다”고 결론 내릴 수 없습니다. 결과 enum과 다음 행동 코드를 함께 봐야 합니다.
공통 정책은 명확합니다.
restore_retry_authorized=false
cleanup_authorized=false
remediation_authorized=false
분류와 변경 승인을 분리하지 않으면, 원인을 확인하기도 전에 증거를 없애거나 운영 경계를 바꿀 수 있습니다.
V4 계측 재현은 승인됐지만 실행 결과는 없습니다
기존 증거가 소진됐을 때 올바른 다음 단계는 같은 복원을 무작정 반복하는 것이 아닙니다. 실패 순간에 필요한 증거를 남길 수 있도록 관찰 지점을 먼저 추가해야 합니다.
준비된 V4 계측 재현은 다음 원칙을 갖습니다.
- 운영 대상과 분리된 임시 실행만 한 번 생성·시작합니다.
- 시작 전, 요청 직후, 실패 직후, 정리 직전 상태를 구분해 기록합니다.
- watchdog으로 비정상 대기와 전체 실행시간을 제한합니다.
- Docker·containerd·커널·LSM·coredump 자료를 같은 실행 경계에 연결합니다.
- 원문 증거는 비공개 영역에 제한하고 공개 출력에는 고정 enum만 남깁니다.
- 성공과 실패 모두에서 임시 자원 정리와 운영·입력 경계를 확인합니다.
- 사전 조건이 맞지 않으면 컨테이너를 만들기 전에 거부합니다.
이 단계는 실제 임시 컨테이너 생성·시작이 포함되므로 앞선 읽기 전용 감사와 다릅니다. 그래서 명시적인 V4 재현 승인이 별도로 이뤄졌습니다.
다만 보존 자료에서 확인되는 마지막 상태는 승인과 스크립트 준비입니다. 실제 V4 실행 stdout, 완료 마커, 새로 관찰된 고정 가족, 정확한 원인 결과는 확인되지 않습니다. 따라서 다음 표현은 사용하지 않습니다.
- “V4 재현에 성공했습니다.”
- “V4에서 같은 오류가 다시 발생했습니다.”
- “V4로 원인을 찾았습니다.”
- “V4 결과에 따라 수정했습니다.”
정확한 표현은 다음과 같습니다.
V4 계측 재현은 승인되고 준비됐지만, 실제 실행 결과는 보존 자료에서 확인되지 않습니다.
다른 서버에 적용할 때의 판정 계약
이 절차를 재사용하려면 명령부터 복사하기보다 입력·상한·출력·권한 계약을 먼저 고정하는 편이 안전합니다.
input:
retained_failure_transaction: exactly_one
manifest_and_inventory: exact_match
required_start_evidence: eight_payloads
time_anchor: retained_evidence_only
journal_scope:
runtime_sources: [docker, containerd]
final_sources: [kernel, audit, systemd_coredump]
fixed_families: [apparmor, selinux, seccomp_sigsys, kernel_oom, runtime_crash]
bounds:
time_window: fixed_maximum
total_bytes: fixed_maximum
line_bytes: fixed_maximum
record_count: fixed_maximum
wall_clock: fixed_timeout
output:
raw_records: never_publish
exact_correlation: fixed_enum
temporal_correlation: fixed_enum
incomplete_partial_counts: suppress
exact_root_cause_without_proof: false
automatic_retry_or_remediation: false
실제 journalctl을 사용할 때는 --since와 --until을 모두 지정하고, 필요한 unit 또는 kernel 범위를 분리해야 합니다. JSON 출력도 안전하다고 가정하면 안 됩니다. 요청한 출력 필드와 별개로 부팅 식별자·커서·정확 시각이 포함될 수 있으므로 원문을 게시하지 않고 별도 분류기에서 집계·익명화해야 합니다.
결과별 다음 행동
| 결과 | 다음 행동 | 금지할 자동화 |
|---|---|---|
| 정확 실행 고정 가족 관찰 | 원문 비공개 증거와 대상 식별 연결을 사람이 검토합니다. | 즉시 설정 변경, 권한 완화, 재시도 |
| 시간상 고정 가족 관찰 | 대상 프로세스·cgroup·실행 식별 연결을 추가 확인합니다. | 해당 가족을 원인으로 확정 |
| 선택 범위에서 미관찰 | 보존 한계를 기록하고 필요하면 승인된 계측 재현을 설계합니다. | “문제 없음” 판정 |
| 캡처 불완전 | 시간·소스·자원 상한과 조회 오류를 먼저 해결합니다. | 부분 0건을 전체 결과로 사용 |
| 두 읽기 불안정 | 변화하는 증거 경계를 검토하고 분류를 중단합니다. | 안정될 때까지 무한 자동 재시도 |
| V4 승인 | 사전 조건·격리·증거 보존·정리 계약을 재확인합니다. | 실행 결과가 나오기 전 원인 확정 또는 수정 |
통합 재점검 체크리스트
보존 증거 경계
- [ ] 검증기 잠금이 해제돼 있고 잠금 파일·열린 FD의 동일성을 확인했습니다.
- [ ] 완료된 최신 증거 후보가 정확히 하나이며, 없거나 둘 이상이면 중단합니다.
- [ ] 완료 마커, 매니페스트, 허용 파일 인벤토리가 정확히 일치합니다.
- [ ] 심볼릭 링크, 비정상 하드링크, 예상 밖 파일, 과도한 파일 크기를 거부합니다.
- [ ] 생성·시작 오류, 상태 스냅샷, 보조 진단, 로그 조회 결과 8종을 역할별로 분리합니다.
- [ ] 체크섬 연속성을 디지털 서명이나 진본 인증으로 과장하지 않습니다.
- [ ] 두 번 같은 투영을 원자적 스냅샷으로 표현하지 않습니다.
결과 해석
- [ ]
NOT_RUN과 대기 실패를 구분합니다. - [ ]
DOCKER_LOGS_UNAVAILABLE과 오류 미발생을 구분합니다. - [ ] 기록된 cleanup·live·input unchanged와 현재 독립 재검증을 구분합니다.
- [ ] 코드에 가능한 최종 enum을 실제 stdout 대신 사용하지 않습니다.
- [ ] 최종 enum이 보존되지 않았다면 미확인으로 남깁니다.
- [ ] 긍정 단서도 정확한 근본 원인 증명으로 승격하지 않습니다.
제한 저널
- [ ] Docker·containerd 데몬 저널과 컨테이너 애플리케이션 로그를 구분합니다.
- [ ] 실패 실행의 보존 증거에서 시간 기준을 가져옵니다.
- [ ] 조회 전후 여유, 최대 시간 폭, 정착 시간을 고정합니다.
- [ ] 전체 바이트, 한 행, 레코드 수, 벽시계 실행시간 상한을 적용합니다.
- [ ] 상한 도달·파싱 실패·시간 초과 시 부분 집계를 숨기고 불완전으로 판정합니다.
- [ ] 커널·audit·systemd-coredump 소스 허용 목록을 먼저 검증합니다.
- [ ] 조회 identifier나 message ID를 출처 증명으로 사용하지 않습니다.
- [ ] 정확 실행 연관, 시간상 연관, 보조 일치를 별도 결과로 남깁니다.
- [ ] 미관찰을 사건 부재나 정책 무관의 증명으로 표현하지 않습니다.
공개와 변경 승인
- [ ] 원문 오류, 저널 메시지, 경로, 실행·컨테이너·부팅 ID, 커서, 시각, 해시를 공개하지 않습니다.
- [ ] 코어 이미지와 역추적, 계정·도메인·IP·포트·비밀값을 공개하지 않습니다.
- [ ]
RC=2를 일반 실패 자동 재시도 큐에 넣지 않습니다. - [ ] 분류 결과와 복원 재시도·정리·완화 권한을 분리합니다.
- [ ] V4 승인, 스크립트 준비, 실제 실행 완료, 원인 확정을 각각 구분합니다.
최종 판단
이번 포렌식에서 실제로 확정한 내용은 다음과 같습니다.
failure_stage=PHYSICAL_DATABASE_RESTORE
failure_step=PHYSICAL_START
readiness_wait=NOT_RUN
runtime_state=RUNTIME_STATE_ERROR
docker_logs=UNAVAILABLE_WITHIN_RETAINED_EVIDENCE
cleanup_and_recorded_boundaries=completed_or_unchanged
exact_root_cause=not_certified
보존 증거 8종의 구성과 연속성은 확인됐습니다. Docker·containerd 제한 저널과 커널·LSM·systemd-coredump를 연결하는 감사 설계도 확인됐습니다. 그러나 최종 8종 분류 enum, 최종 저널 상관관계 enum, 가족별 실제 건수와 stdout은 보존되지 않았습니다.
후속 V4 계측 재현이 승인된 흐름상, 선택한 보존 증거와 제한 저널에서 강한 추가 고정 가족을 찾지 못했을 가능성은 높습니다. 하지만 이는 추론일 뿐 “AppArmor·SELinux·seccomp·OOM·런타임 충돌이 없었다”는 확정 판정이 아닙니다.
V4는 승인되고 준비됐지만 실제 실행 결과가 없습니다. 따라서 정확한 Docker 시작 실패 원인은 여전히 미확정입니다. 이 지점에서 기존 하부 계층 증거를 소진했고, 다음 조사로 넘어가려면 계측 재현 결과 또는 별도의 애플리케이션 계층 점검이 필요합니다.
이 글은 요청 범위에 따라 Ghost·MySQL 애플리케이션 로그, 설정, 계정, 데이터, 비밀값을 확인하기 직전에서 종료합니다. 하부 계층 포렌식이 끝났다는 사실을 Ghost·MySQL 자체 보안 점검이나 전체 복구 검증의 완료로 해석하면 안 됩니다.
공식 문서
Docker·OCI·containerd
- Docker system events — 컨테이너 수명주기 이벤트와 최근 256개 이벤트 보존 경계를 설명합니다.
- docker container inspect — 컨테이너 상태와 설정 메타데이터 조회 방법을 설명합니다.
- Docker logging —
docker logs와 컨테이너 표준 출력·표준 오류의 관계를 설명합니다. - Configure logging drivers — 로깅 드라이버, non-blocking 버퍼, 로그 가용성 차이를 설명합니다.
- Docker daemon logs — systemd 환경에서 Docker 데몬 로그를 확인하는 방법을 설명합니다.
- Docker journald logging driver — journald 드라이버 사용 시 컨테이너 로그 메타데이터를 설명합니다.
- containerd Runtime v2 — containerd shim과 런타임 v2의 구조를 설명합니다.
- Open Container Initiative Runtime Specification — OCI 컨테이너 런타임 구성과 수명주기의 표준 근거입니다.
Linux·systemd 보안 감사
- Linux Security Module 사용법 — LSM 프레임워크와 활성 모듈 확인 근거입니다.
- Linux 커널 AppArmor 문서 — AppArmor 정책과 unconfined 상태를 설명합니다.
- Docker AppArmor 보안 프로필 — Docker 기본 AppArmor 프로필과 감사 신호 확인 방법을 설명합니다.
- Linux 커널 seccomp 필터 — seccomp BPF 동작과 각 반환 동작을 설명합니다.
- Docker seccomp 프로필 — Docker 기본 seccomp 허용 목록과 기본 차단 동작을 설명합니다.
- Docker 리소스 제약과 OOM — 컨테이너 메모리 제약과 커널 OOM 처리의 관계를 설명합니다.
- Linux cgroup v2 — cgroup 단위 OOM·OOM kill 이벤트의 의미를 설명합니다.
- journalctl — 시간·부팅·unit·kernel 범위와 출력 제한 방법을 설명합니다.
- systemd-journald — journald의 수집원과 volatile 저장 특성을 설명합니다.
- journald.conf — 저장 용량, 보존 기간, rate limit 정책을 설명합니다.
- systemd-coredump — 충돌 메타데이터와 코어 이미지 저장 방식을 설명합니다.
- coredumpctl — 저장된 코어덤프 메타데이터의 제한 조회 방법을 설명합니다.
- coredump.conf — 코어덤프 저장·처리 정책을 설명합니다.