배포 실패 로그를 추적하다 크립토마이너를 발견한 이야기
평범한 배포 실패에서 시작된 하루
Coolify에서 NestJS 백엔드 배포가 실패했습니다. 빌드 타임아웃. 자주 있는 일은 아니지만, 전혀 없는 일도 아닙니다. 코드에 문제가 있나 싶어 로그를 열었는데, 빌드 자체는 정상이었습니다. 단순히 시간이 너무 오래 걸린 것이었습니다.
서버 리소스가 부족한 건 아닌지 확인해봐야겠다는 생각이 들었습니다. SSH로 접속해서 docker stats를 실행했습니다. 그리고 그 숫자를 보고 멈칫했습니다.
docker stats에서 발견한 이상한 숫자
CONTAINER CPU % MEM USAGE / LIMIT MEM %
i8ggkwg4kg4sggcgc8sosc4o-035741765616 123.61% 2.41GiB / 11.65GiB 20.68%
이 컨테이너는 saas-blog-front, 즉 Next.js 프론트엔드 서비스입니다. Next.js 프론트엔드가 CPU 123%, 메모리 2.41GB를 쓰고 있다는 건 상식적으로 말이 되지 않습니다. SSR을 아무리 많이 해도 이 정도 리소스를 점유할 일은 없습니다.
처음엔 메모리 릭을 의심했습니다. Next.js 15에서 뭔가 잘못 설정한 게 있나, 혹은 특정 페이지의 렌더링 루프가 생긴 건 아닌가. 하지만 CPU가 123%라는 건 단순한 메모리 릭으로는 설명하기 어려운 수치였습니다.
docker top으로 프로세스를 들여다보다
컨테이너 안에서 어떤 프로세스가 돌고 있는지 확인하기로 했습니다.
docker top <container_id> -eo user,pid,pcpu,pmem,start,time,args
결과는 이랬습니다.
USER PID %CPU %MEM COMMAND
ubuntu 104489 0.0 0.0 dumb-init -- node packages/saas/blog/server.js
ubuntu 104520 0.0 0.8 next-server (v15.1.3)
ubuntu 1520740 171 19.6 ./XXhAkAOM
dumb-init은 컨테이너의 init 프로세스 매니저이고, next-server는 Next.js 서버입니다. 이 둘은 정상입니다. 문제는 세 번째 줄이었습니다.
./XXhAkAOM — CPU 171%, 메모리 19.6%. 이름부터 정상적인 프로세스가 아니었습니다. 무작위 문자열로 이루어진 바이너리가 컨테이너 안에서 실행되고 있었습니다. 4월 6일부터 실행 중이었고, 누적 실행 시간은 10,519분이었습니다. 약 7일 동안 멈추지 않고 CPU를 갈아먹고 있었다는 뜻입니다.
삭제된 바이너리, 메모리에서만 살아남다
이 프로세스의 실체를 확인하기 위해 /proc 파일시스템을 조사했습니다.
ls -la /proc/1520740/exe
/proc/1520740/exe -> /tmp/XXhAkAOM (deleted)
바이너리의 원본 경로는 /tmp/XXhAkAOM이었지만, 이미 디스크에서 삭제된 상태였습니다. 리눅스에서는 파일이 삭제되어도 해당 파일을 사용 중인 프로세스가 있으면 메모리에서 계속 실행됩니다. 디스크에서는 흔적을 지우고, 프로세스로만 존재하는 겁니다. 포렌식을 어렵게 만드는 전형적인 패턴입니다.
/tmp 디렉토리를 살펴보니 XXKGIJoH라는 또 다른 의심 파일도 발견되었습니다. 이름 패턴이 동일했습니다.
네트워크 연결 — 마이닝 풀 확정
결정적인 증거는 네트워크에서 나왔습니다.
ss -tnp | grep 1520740
ESTAB 172.20.1.9:56892 → 47.239.74.230:33333 users:(("XXhAkAOM",pid=1520740,fd=13))
이 바이너리가 47.239.74.230:33333으로 TCP 연결을 유지하고 있었습니다. 포트 33333은 크립토마이닝 풀에서 흔히 사용하는 포트입니다. 더 이상 추측할 필요가 없었습니다. 크립토마이너였습니다.
누군가 이 컨테이너에 침투해서 크립토마이너를 심었고, 그 바이너리는 7일 넘게 서버의 CPU와 메모리를 갈아먹으며 마이닝 풀에 연결되어 있었습니다. 배포가 타임아웃 난 이유도 이것 때문이었습니다. 마이너가 서버 리소스를 대부분 잡아먹고 있었으니, 새로운 빌드를 돌릴 여유가 없었던 겁니다.
감염 범위 확인
크립토마이너를 발견한 뒤, 가장 먼저 해야 할 일은 감염이 어디까지 퍼졌는지 확인하는 것이었습니다.
다른 컨테이너들을 하나씩 점검했습니다. backend, web, admin, worker, redis — 전부 docker top으로 프로세스를 확인했고, 의심스러운 바이너리는 하나도 없었습니다. CPU와 메모리 사용량도 정상 범위였습니다.
호스트 시스템도 확인했습니다. /tmp 디렉토리에는 수상한 파일이 없었고, crontab에 추가된 스케줄도 없었습니다. 호스트 레벨의 프로세스 목록에서도 이상은 발견되지 않았습니다.
감염은 saas-blog-front 컨테이너 하나에 국한되어 있었습니다. 이 사실이 약간의 안도감을 주기는 했지만, 동시에 의문이 생겼습니다. 왜 이 컨테이너만 뚫렸을까. 다른 컨테이너와 무엇이 달랐을까.
다음 이야기
이 글은 발견까지의 기록입니다. 배포 실패라는 사소한 증상에서 출발해서, 크립토마이너라는 예상하지 못한 원인에 도달하기까지의 과정을 정리했습니다.
하지만 더 중요한 질문이 남아 있습니다. 어떻게 뚫렸는가. 다음 글에서는 이 컨테이너가 감염된 경로를 추적합니다. Next.js의 어떤 취약점이 이용되었는지, 그리고 셀프호스팅 환경에서 흔히 간과하는 보안 포인트가 무엇인지를 살펴볼 예정입니다.
마지막 3편에서는 실제로 취한 대응 조치와, 같은 일이 반복되지 않도록 적용한 보안 강화 사항을 다룰 생각입니다.
관련 글
SaaS 블로그 플랫폼을 Coolify로 배포하며 마주친 실전 문제들
글력(SaaS 블로그 플랫폼)을 Coolify에 처음 배포하면서 겪은 메모리 최적화, OG 이미지 문제, 봇 스캔 방어, Redis 연결, CORS까지. 실전에서 하나씩 해결한 과정을 정리했습니다.
Deep Health Check로 Next.js 배포 안정성 높이기: 롤링 업데이트의 함정을 피하는 방법
Coolify 롤링 업데이트에서 Health Check가 성공해도 실제 페이지 렌더링이 실패하는 문제를 경험했습니다. ESM 모듈 호환성 에러와 정적 렌더링 실패를 해결하고, Deep Health Check를 구현하여 배포 안정성을 개선한 과정을 공유합니다.
Coolify HEALTHCHECK 버그를 발견하고 오픈소스 PR을 올리기까지
NestJS Worker 배포 중 Coolify의 HEALTHCHECK 감지 버그를 발견했습니다. Dockerfile 전체에서 문자열을 검색하는 로직이 multi-stage target을 고려하지 않는 문제였고, 소스 분석부터 PR 제출까지의 과정을 정리했습니다.