mLog 명판이 있는 비행선 기관실에서 경고 계기와 재시작 레버를 따로 살펴보는 흰색 3D 유령 기관사

Docker unhealthy인데 자동 재시작 안 될 때: healthcheck와 restart 정책 구분

서버·인프라 Docker 2026년 9월 22일

docker compose ps에는 컨테이너가 실행 중이라고 나오는데 뒤에 unhealthy가 붙는다. restart: unless-stopped도 설정했다. 그런데 재시작은 일어나지 않는다. 설정이 무시된 것일까?

일반 Docker Engine에서 Compose로 실행한 컨테이너라면 먼저 건강 상태와 프로세스 종료를 구분해야 한다. healthcheck는 검사 결과를 기록하고, 컨테이너 재시작 정책은 종료 등에 대한 동작을 정한다. 주 프로세스가 살아 있는 상태에서 검사만 실패했다고 restart 정책이 자동 복구를 수행하는 것은 아니다. 이는 Docker의 두 기능 설명을 함께 적용한 결론이다. Docker HEALTHCHECK, 재시작 정책

이 글은 2026-09-22 공식 문서를 기준으로 한 진단 가이드다. Swarm 서비스·Kubernetes·별도 자동 복구 도구의 동작은 범위 밖이다. 작성 환경에는 Docker CLI가 없어 실제 컨테이너에서 명령을 실행하지 않았다. 아래 명령과 설명은 운영 장애를 해결한 실측 기록이 아니다.

1. 네 가지 설정을 같은 기능으로 보지 않는다

설정·명령 담당하는 일
healthcheck 컨테이너 안에서 검사 명령을 실행해 건강 상태를 기록
서비스의 restart: unless-stopped 컨테이너 종료 등에 대한 런타임 재시작 정책
depends_on의 condition: service_healthy 의존 서비스의 건강 상태를 확인한 뒤 종속 서비스를 시작
depends_on의 restart: true 명시적인 Compose 의존 서비스 갱신·재시작에 따른 종속 서비스 재시작

마지막 두 항목은 실행 중 장애를 계속 감시하는 감시자가 아니다. 특히 depends_on.restart는 런타임이 컨테이너 종료 후 자동으로 다시 시작하는 경우를 포함하지 않는다. 이름이 같은 restart라도 YAML 위치를 함께 읽어야 한다. Compose services, 시작 순서

다음은 위치를 비교하기 위한 설정 조각이며 배포 가능한 전체 파일은 아니다. 기존 이미지·DB·healthcheck 설정을 이 코드로 대체하지 않는다.

services:
  app:
    restart: unless-stopped
    depends_on:
      db:
        condition: service_healthy
        restart: true

DB가 나중에 응답하지 않는 상황에는 앱의 재연결 처리와 장애 알림이 별도로 필요하다. 시작 시 한 번 준비 상태를 확인했다는 사실로 이후의 연결 성공을 보장할 수 없다.

2. 재시작 전에 정확한 컨테이너부터 확인한다

먼저 기존 배포와 같은 Docker context, Compose 파일, 프로젝트를 선택한다. -f, -p, --env-file 및 여러 override 파일을 쓰고 있었다면 같은 인자를 유지한다. 아래 조회도 해당 프로젝트를 가리키는 디렉터리·인자를 확인한 뒤 실행한다.

docker context show
docker compose version
docker compose ps -a

-a를 붙여 종료된 컨테이너도 함께 확인한다. 서비스에 인스턴스가 여러 개면 모두 같은 상태라고 가정하지 말고 장애가 난 컨테이너 ID를 고른다. Compose ps

다음 값은 반드시 방금 확인한 실제 ID로 바꾼다. YOUR_CONTAINER_ID는 예시 자리표시자다.

CID='YOUR_CONTAINER_ID'
docker inspect --type container --format \
  'status={{.State.Status}} running={{.State.Running}} restarting={{.State.Restarting}} health={{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' \
  "$CID"
docker inspect --type container --format \
  'policy={{.HostConfig.RestartPolicy.Name}} restartCount={{.RestartCount}} started={{.State.StartedAt}}' \
  "$CID"

전체 inspect JSON 대신 필요한 필드만 출력한다. health=none은 건강함을 증명하는 값이 아니라 이 조회에서 Health 정보가 없다는 뜻이다. 재시작 횟수 한 번의 값만 보고 변화가 있었다고 판단하지 말고 시간과 컨테이너 ID를 함께 기록한다. docker inspect

3. 검사 실패와 앱 장애를 분리한다

건강 상태 검사의 기본 흐름은 starting에서 검사 성공 시 healthy, 연속 실패가 기준에 이르면 unhealthy다. 검사 명령은 컨테이너 안에서 실행되므로, 호스트에서 성공한 명령이 그대로 성공한다는 보장은 없다. Docker HEALTHCHECK

실패 출력이 필요하면 권한 있는 터미널에서 다음을 확인한다. 출력에는 URL·응답 본문·토큰 등이 포함될 수 있다. 외부 공유 전에 가린다.

docker inspect --type container --format \
  '{{if .State.Health}}{{range .State.Health.Log}}{{.End}} exit={{.ExitCode}} {{printf "%q" .Output}}{{println}}{{end}}{{else}}no healthcheck{{end}}' \
  "$CID"

최근 검사 기록은 영구 감사 로그가 아니다. 출력이 없다고 예전 실패가 없었다고 단정하지 않는다. 다음 질문으로 원인을 좁힌다.

  • 검사에 사용한 실행 파일이 이미지 안에 있는가? 도구가 없는 오류와 서비스가 응답하지 않는 오류를 구분한다.
  • 검사 주소·포트·경로가 이 컨테이너 기준으로 맞는가? 호스트에 게시한 포트와 컨테이너 내부 포트를 혼동하지 않았는가?
  • 인증이 필요한 경로를 무인 검사하고 있지는 않은가? 민감한 인증값을 명령 문자열에 직접 붙이지 않았는가?
  • DB 장애 때문에 검사에 실패했는가, 앱 자신의 기능에 문제가 있는가?

DB 서비스명 해석 오류라면 Ghost ENOTFOUND db 진단으로 분기한다. 내부 검사는 성공하지만 외부에서 502가 발생한다면 NPM 502 계층별 진단을 참고한다. 한 검사 결과로 프록시·TLS·사용자 기능까지 모두 정상이라고 결론 내리지 않는다.

앱 로그도 범위를 제한해 읽는다. app은 실제 서비스명으로 바꾼다.

docker compose logs --since 10m --tail 100 app

이 명령은 원인을 고치지 않고 로그만 조회한다. 앱 로그에도 개인정보·비밀값이 있을 수 있으며, 설정된 로깅 방식에 따라 필요한 로그가 여기에 없을 수도 있다. Compose logs

3-1. 오류가 보이는데 검사 결과는 성공인 경우: HTTP 코드와 종료 코드

검사 출력에 오류가 있어도 마지막 종료 코드가 성공이면 상태 판정이 예상과 달라질 수 있다. Docker는 건강 검사 명령의 종료 코드 0을 성공, 1을 실패로 정의하고 2는 예약해 두었다. 출력 문자열에 “error”가 있는지 검사하는 방식이 아니다. Docker HEALTHCHECK

2026-09-29 로컬 검증: Python HTTP 수신기에 200·302·503 응답 경로를 만들고 curl 8.5.0, Bash 5.2.21 및 이 환경의 sh로 아래 종료값을 확인했다. Docker CLI가 없어 컨테이너의 healthy/unhealthy 전환과 Compose 반영은 실행하지 않았다.

검사 방식실측 종료 코드놓치는 조건
503에 curl 실행, --fail 없음0HTTP 오류 응답도 전송 자체는 성공할 수 있다.
503에 curl --fail 실행22실패를 감지했다. Docker용 스크립트는 실패를 1로 정규화할 수 있다.
503에 curl --fail … | cat 실행, Bash 기본 파이프라인0마지막 cat의 성공이 앞선 curl 실패를 가렸다.
503에 curl --fail … || exit 1 실행1실패를 보존했다.
302에 curl --fail 실행, 리디렉션 추적 없음0--fail만으로 정확한 200을 요구하지는 않는다.
200만 허용하는 명시적 비교200은 0, 302·503은 1Bash와 sh 모두 세 가지 응답을 구분했다.

--fail은 HTTP 400 이상을 curl 오류로 처리하는 옵션이며, 이 실험의 503에서 종료 코드 22를 반환했다. curl 공식 매뉴얼 Bash는 pipefail을 켜지 않으면 보통 파이프라인 마지막 명령의 종료값을 사용한다. Bash Pipelines 컨테이너에 Bash가 있다는 보장은 없으므로, 검사 하나를 위해 지원 여부를 모르는 set -o pipefail만 추가하기보다 불필요한 파이프를 없애는 편이 단순하다.

검사가 정확히 200을 요구한다면

아래는 기존 서비스의 healthcheck.test 안에서 쓸 수 있는 설정 조각이다. 이미지에 curl과 셸이 있고, http://127.0.0.1:8080/health가 실제로 200을 반환하는 건강 검사 경로라고 가정한다. 이 경로를 Ghost 또는 모든 앱이 제공한다는 뜻은 아니다. 자신의 컨테이너 내부 포트·경로로 바꾸고 기존 검사 주기·시작 유예 설정을 유지한다. 2초는 실습값이며 운영 권장값이 아니다.

healthcheck:
  test:
    - CMD-SHELL
    - >-
      code=$$(curl -sS --max-time 2 -o /dev/null -w '%{http_code}'
      http://127.0.0.1:8080/health) || exit 1;
      [ "$$code" = 200 ]

CMD-SHELL이므로 변수 대입과 조건식을 컨테이너 셸이 해석한다. CMD 배열에 ||나 $code를 문자열로 넣는 것과 다르다. Compose healthcheck Compose YAML의 $$는 Compose 단계의 변수 치환을 피하고 실제 검사 셸에 $를 전달하기 위한 것이다. 터미널에서 직접 시험할 때는 $ 하나를 사용한다. Compose 변수 치환

이 조건식은 본문이 비어 있는 200도 통과시킨다. 서비스가 본문 필드까지 약속한다면 별도의 파싱 검사가 필요하다. 반대로 실제 건강 경로의 계약이 204라면 200 전용 검사를 붙이는 것은 오탐이다. 로그인 화면으로의 302를 건강 상태로 볼 것인지도 먼저 정한다. 건강 검사의 성공 기준은 앱이 제공하는 경로의 계약과 일치해야 한다.

이번 실험은 검사 명령의 반환값을 확인한 것이다. 이 설정 조각의 Compose 파싱·이미지 내 도구 존재·실제 앱 응답·컨테이너 재생성은 별도 검증이 필요하다. 반영 후에는 2~3절의 제한된 inspect 조회로 최근 종료값을 확인하고 실제 사용자 기능도 다시 확인한다. TCP 연결과 HTTP 오류 구분처럼, 한 단계의 성공을 전체 기능 성공으로 넓혀 해석하지 않는다.

4. 유예 시간은 장애를 숨기는 설정이 아니다

초기화가 오래 걸리는 앱은 시작 직후의 검사 실패와 평상시 장애를 구분해야 한다. start_period 안의 실패는 재시도 실패 횟수에 포함되지 않지만, 그 안에 한 번 성공하면 이후 연속 실패가 집계된다. timeout은 검사 한 번의 제한이고 retries는 연속 실패 기준이다. Docker HEALTHCHECK

따라서 고정된 “몇 초면 반드시 unhealthy”라는 계산보다 실제 시작 시간과 검사 기록을 본다. 임계값을 무작정 늘리거나 검사를 끄면 화면은 조용해져도 장애 원인은 남는다. 조정이 필요하면 정상 기동 시간, 외부 의존성 지연, 허용 가능한 탐지 지연을 근거로 결정한다.

5. 수정한 검사 설정은 재시작만으로 반영하지 않는다

Compose의 healthcheck를 수정했다면 파일 변경과 실행 컨테이너의 설정 반영은 별개다. docker compose restart는 Compose 설정 변경을 적용하는 명령이 아니다. 관련 차이는 환경변수 변경과 재생성 글에서 다뤘다. Docker restart

적용 전에 대상 서비스의 중단 영향, 데이터 저장 위치, 백업과 되돌릴 설정을 확인한다. 반영이 승인된 경우 기존 배포 인자를 유지한 docker compose up -d app처럼 범위를 제한할 수 있다. up은 변경된 구성에 따라 컨테이너를 재생성할 수 있으므로 단순 조회나 무중단 명령이 아니다. Docker up

수정 후에는 새 컨테이너 ID를 다시 확인해 2~3절을 반복하고, 실제 사용자 요청도 점검한다. 이전 컨테이너 ID를 계속 조회하면 새 상태를 보지 못한다. up --wait 역시 실행·건강 상태를 기다리는 옵션이지, 이후 장애를 영구 감시하는 복구 서비스는 아니다.

6. 자동 복구가 필요하면 조건과 권한부터 설계한다

자동 복구 도구를 추가하는 선택은 가능하지만, 모든 unhealthy 컨테이너를 무조건 재시작하는 정책은 이 글에서 권하지 않는다. 다음은 운영 설계 제안이지 Docker의 기본 보장 사항이 아니다.

  1. 재시작 대상 서비스를 명시적으로 제한한다.
  2. 한 번의 실패가 아니라 지속 실패와 실제 기능 장애를 함께 확인한다.
  3. 재시도 상한·대기 시간·알림·사람의 개입 조건을 둔다.
  4. DB 장애처럼 재시작으로 해결되지 않는 원인은 별도 경로로 처리한다.
  5. 유지보수 중 의도적으로 중지한 서비스를 다시 살리지 않도록 한다.

특히 복구 도구에 Docker 소켓 접근을 주는 것은 단순 모니터링 권한 부여로 취급할 일이 아니다. 제어 권한과 신뢰 경계를 먼저 검토한다. Docker 소켓 보호, Portainer와 Docker 소켓 보안

증거를 남기기 전에 전체 스택을 내리거나 볼륨을 지우지 않는다. 검사를 항상 성공시키는 임시 우회도 장애 해결로 기록하지 않는다. 원인을 고친 뒤 건강 상태와 사용자 기능을 따로 확인하는 편이 다음 장애 때도 재사용할 수 있는 절차다.

결론

unhealthy는 상태 신호이고, restart는 복구 동작을 결정하는 별도 정책이다. 신호가 있다고 동작까지 자동으로 연결된 것은 아니다. 먼저 대상 컨테이너 → 검사 실패 이유 → 실제 기능 영향 → 최소 범위 수정 순서로 확인하자. 그 다음에야 자동 복구가 필요한지, 어떤 조건과 권한으로 실행할지 결정할 수 있다.

태그

mLog

웹 개발과 서버 운영 과정에서 마주친 문제와 해결 과정을 기록합니다. 직접 확인한 설정과 실행 결과를 함께 정리해, 비슷한 문제를 겪는 분들이 참고할 수 있도록 합니다.