Skip to Content

17장. 죽고 다시 살아나기 — exit code·restart 정책·latest 태그

--restart=always를 건 컨테이너가 exit 0으로 정상 종료했는데도 12초 동안 여섯 번 다시 떴습니다. 그 정책은 “왜 끝났나”를 안 봅니다. “끝났나”만 봐요.

{/* 삽화 자리: 문 앞에 관리인이 서 있고, 방에서 사람이 나올 때마다 이유를 묻지도 않고 다시 안으로 들여보낸다. 일을 마치고 나온 사람도, 넘어져서 나온 사람도 똑같이 도로 들어간다. 관리인의 손에는 이유를 적는 칸이 없는 명부만 들려 있다. 프롬프트: docs/book/illustration-prompts-container.md */}


배경

16장이 남긴 질문은 “죽으면 누가 다시 띄우나”였습니다.

답부터 말하면 Docker 데몬입니다. compose가 아니에요. 그리고 그 데몬이 보고 판단하는 건 숫자 하나뿐입니다. 종료 코드요.

숫자 하나로 무엇을 판단할 수 있을까요. 그리고 판단할 수 없는 건 뭘까요.

스토리

실무에서 제일 김빠지는 사고가 하나 있습니다. 이미지를 새로 빌드해 올리고, 컨테이너를 재시작하고, 그런데 로그에 옛날 코드가 그대로 도는 거요.

그 사고를 이 장 끝에서 재현할 겁니다. 먼저 숫자부터 봐야 해요.

여는 글부터 137을 봤습니다. 12장에서 143을, 13장에서 또 다른 137을 봤고요. 여러 방식으로 컨테이너를 끝내보면 이렇게 나옵니다.

=== 1. 종료 코드는 어디서 오나 === 정상 종료 exit 0 앱이 1 로 끝냄 exit 1 앱이 42 로 끝냄 exit 42 없는 실행 파일 exit 127 실행 권한 없는 파일 exit 126 잘못된 docker 옵션 exit 125 (컨테이너가 만들어지지도 않는다)

✅ 실측 (Docker Engine 29.8.0 / Docker Desktop 4.91.0 / Apple M1 / macOS 26.6, 2026-09. 전체 소스: docs/book/code/container/ch17/lifecycle.sh)

0·1·42는 앱이 정한 값입니다. exit 42라고 쓰면 42가 나와요.

127·126·125는 앱이 정한 게 아닙니다. 앱이 시작조차 못 했거든요.

핵심

숫자를 세 무리로 나누면

값어디에 찍히나누가 정했나
앱이 고른 값State.ExitCode앱. 0~255 어디든 고를 수 있다
126·127State.ExitCodeDocker 데몬. 런타임이 돌려준 에러를 보고 얹는다
128+NState.ExitCode시그널 N으로 죽었다는 관례
125컨테이너에 안 찍힌다docker 명령 자신의 종료 상태

마지막 줄이 나머지와 성격이 다릅니다. 125는 컨테이너 종료 코드가 아니에요. 컨테이너가 만들어지지도 않았으니까요. docker inspect로는 절대 못 봅니다. 저 실측도 그 줄만 $?로 받았습니다.

126과 127은 앱을 탓하면 안 되는 자리입니다. 127은 CMD에 적은 경로가 이미지 안에 없다는 뜻이에요. 로컬에서 되던 게 컨테이너에서 127로 죽으면 대개 멀티스테이지에서 파일을 안 옮긴 겁니다. 빌드 단계에서 만든 jar를 실행 단계로 COPY 안 한 경우요.

그런데 이 표 전체가 읽는 법이지 증명이 아닙니다.

앱은 아무 숫자나 고를 수 있거든요. exit 127을 내는 앱도, exit 137을 내는 앱도 있습니다. 12장에서 이미 봤죠 — exec form의 143은 시그널에 맞아 죽은 값이 아니었습니다. JVM이 훅을 다 돌리고 스스로 고른 값이었고, 훅에서 halt(7)을 부르면 7이 나왔어요.

그러니 143을 보면 “SIGTERM 쪽 이야기구나”까지가 맞습니다. 확실히 하려면 숫자 말고 다른 걸 봐야 해요. 13장에서 쓴 docker inspect --format '{{.State.OOMKilled}}' 같은 것들이요.

재시작 정책은 무엇에 반응하나

정책 네 가지를 종료 코드 0과 1에 각각 걸어봤습니다.

=== 2. 재시작 정책은 무엇에 반응하나 === --restart=no exit 1 → RestartCount=0 Status=exited --restart=on-failure exit 0 → RestartCount=0 Status=exited --restart=on-failure exit 1 → RestartCount=6 Status=restarting --restart=always exit 0 → RestartCount=6 Status=restarting --restart=on-failure:3 exit 1 → RestartCount=3 Status=exited

✅ 실측 (같은 환경, 같은 소스. 12초씩 지켜본 결과입니다)

always에 exit 0을 물린 줄이 이 장의 함정입니다.

정상 종료인데 여섯 번 다시 떴습니다. 할 일을 다 하고 나간 건데 always는 이유를 안 봐요.

배치 잡이나 마이그레이션 컨테이너에 always를 걸면 이렇게 됩니다. 일을 끝내고 나가면 또 띄우고, 또 끝내면 또 띄워요. 같은 일을 무한히 반복합니다.

on-failure에 같은 exit 0을 물리면 정반대입니다. 0은 사고가 아니니 안 띄워요.

정책언제 다시 띄우나
no (기본값)안 띄운다
on-failure[:N]종료 코드가 0이 아닐 때만. :N이면 N번까지
always종료 코드와 무관하게 언제나
unless-stoppedalways와 같되, 손으로 멈춘 건 데몬 재시작 후에도 그대로 둔다

on-failure:3은 3번까지 시도하고 포기했습니다. Status가 restarting이 아니라 exited인 게 그 뜻이에요.

그런데 여기 조건이 하나 숨어 있습니다. “3번”이 무엇의 3번일까요.

on-failure:3은 “연속 3회”가 아닙니다

12초씩 멀쩡히 살다가 죽는 컨테이너로 확인했습니다.

=== 8. on-failure:N 의 카운터는 오래 살아 있으면 리셋되나 === 0초: RestartCount=0 running 12초: RestartCount=1 restarting 24초: RestartCount=1 restarting 36초: RestartCount=2 running 48초: RestartCount=3 running 60초: RestartCount=3 exited 72초: RestartCount=3 exited 손으로 docker start 한 직후: RestartCount=0 running

✅ 실측 (같은 환경, 같은 소스)

오래 살아 있어도 예산이 회복되지 않습니다. 매번 12초씩 정상 가동했는데도 3에서 멈췄어요.

그러니까 :3은 “컨테이너 수명 전체에서 누적 3회”입니다. 하루에 한 번씩 죽는 서비스라면 사흘이면 예산을 다 쓰고, 나흘째에 영영 안 뜹니다. docker ps에는 그냥 Exited로만 보이고요.

예산이 돌아오는 건 마지막 줄뿐입니다. 손으로 docker start를 하면 RestartCount가 0으로 리셋되고 3번이 새로 생겨요.

always와 unless-stopped는 언제 갈리나

둘의 차이는 데몬이 재시작될 때만 드러납니다. 이 기계의 데몬을 재시작할 수는 없으니, 컨테이너 안에 데몬을 한 벌 더 띄워서(dind) 그 안쪽 데몬을 재시작해봤습니다.

=== 10. always 와 unless-stopped 는 데몬이 재시작될 때 갈린다 === 멈추기 전 : /c-always=running /c-unless=running 손으로 stop 뒤: /c-always=exited /c-unless=exited (always 도 스스로 살아나지 않는다) 데몬 재시작 뒤: /c-always=running /c-unless=exited

✅ 실측 (같은 환경, 같은 소스)

손으로 멈추면 always도 안 살아납니다. 가운데 줄이 그겁니다. “always를 걸면 docker stop이 안 먹는다”는 흔한 오해인데, 그렇지 않아요.

갈리는 건 마지막 줄입니다. 데몬이 다시 뜰 때 always는 멈춰 있던 컨테이너까지 같이 띄우고, unless-stopped는 “사람이 멈춰둔 거니까” 하고 그대로 둡니다.

기계를 재부팅했을 때 내가 어제 꺼둔 컨테이너가 혼자 살아나 있는 게 싫다면 unless-stopped입니다.

계속 죽으면 점점 뜸하게 띄웁니다

바로 죽는 컨테이너에 always를 걸고, docker events로 재시작 시각을 그대로 받아봤습니다.

=== 3. 재시작 간격은 일정한가 (바로 죽는 컨테이너) === 0번째 start: 기동 후 0.00초 1번째 start: 기동 후 0.17초 직전 start 로부터 0.17초 2번째 start: 기동 후 0.42초 직전 start 로부터 0.25초 3번째 start: 기동 후 0.92초 직전 start 로부터 0.50초 4번째 start: 기동 후 1.79초 직전 start 로부터 0.87초 5번째 start: 기동 후 3.46초 직전 start 로부터 1.67초 6번째 start: 기동 후 6.73초 직전 start 로부터 3.27초 7번째 start: 기동 후 13.19초 직전 start 로부터 6.47초 8번째 start: 기동 후 26.06초 직전 start 로부터 12.87초 9번째 start: 기동 후 51.73초 직전 start 로부터 25.67초 RestartCount=10 Status=restarting

✅ 실측 (같은 환경, 같은 소스)

마지막 열을 세로로 읽어보세요. 0.17 → 0.25 → 0.50 → 0.87 → 1.67 → 3.27 → 6.47 → 12.87 → 25.67.

매번 두 배씩 벌어집니다. 이걸 백오프라고 합니다.

Docker는 100밀리초에서 시작해 실패할 때마다 간격을 두 배로 늘리고, 1분에서 멈춥니다. 위 실측이 9번째에서 25.67초였으니 두 번만 더 가면 상한에 닿아요.

📄 문서 기반 (moby 소스의 restartmanager: 초기 100ms, 배수 2, 상한 1분). 상한까지는 안 재봤습니다.

백오프를 모르면 오진하기 쉽습니다. 컨테이너가 계속 죽는데 로그가 한동안 안 늘어나면 “멈춘 것 같은데” 싶거든요. 멈춘 게 아니라 다음 시도를 기다리는 중입니다. 5부에서 만날 CrashLoopBackOff가 이 이름을 그대로 씁니다.

Docker 문서에는 “재시작 정책은 컨테이너가 10초 이상 떠 있어야 발동한다”는 문장이 있습니다. 그런데 위 실측의 컨테이너는 0.1초도 못 살고 죽는데 열 번 다시 떴어요. 실제 구현에서 그 10초는 “정책 발동 조건”이 아니라 “벌어진 간격을 원래대로 되돌리는 조건”입니다. 여는 글에서 본 문서-실측 불일치가 여기서 한 번 더 나옵니다.

그리고 하나 더. 재시작이 반복되는 동안 Status는 restarting입니다. running도 exited도 아니에요. 이 상태를 모니터링에서 놓치면 “죽지도 살지도 않은” 컨테이너가 조용히 남습니다.

재시작은 고치는 게 아닙니다

재시작 정책은 같은 컨테이너를 다시 시작하는 겁니다.

설정이 틀려서 죽는 앱은 다시 띄워도 또 죽어요. DB가 없어서 죽는 앱도 마찬가지고요. 재시작이 도움이 되는 건 일시적인 실패뿐입니다.

Docker도 그걸 압니다. 없는 실행 파일을 always로 띄워봤어요.

=== 9. 시작 자체가 실패하면 always 도 루프에 안 들어간다 === ExitCode=127 Status=created RestartCount=0

✅ 실측 (같은 환경, 같은 소스)

RestartCount가 0입니다. always인데 한 번도 다시 안 띄웠어요. Status도 exited가 아니라 created고요.

시작 자체가 실패한 컨테이너는 재시작 루프에 아예 안 들어갑니다. 몇 번을 띄워도 같은 자리에서 죽을 걸 아니까요.

그리고 15장에서 본 것도 여기서 다시 확인됩니다. 재시작 정책은 종료 코드에 반응하지 건강 상태에 반응하지 않아요. unhealthy인 채로 도는 컨테이너는 이 정책의 관심 밖입니다.

latest는 ‘최신’이 아닙니다

여기서 밟는 지뢰가 하나 더 있습니다. 스토리에서 예고한 그거요. 재시작은 됐는데 코드가 안 바뀌는 사고.

v1을 담은 이미지를 latest로, v2를 담은 이미지를 v2로 태그했습니다.

=== 4. latest 는 '최신'이 아니라 그냥 기본 태그다 === ch17demo:latest CMD=echo v1; sleep 300 ch17demo:v2 CMD=echo v2; sleep 300 ch17demo CMD=echo v1; sleep 300 (태그를 생략하면 latest 를 찾는다. 그 latest 안에는 v1 이 들어 있다)

✅ 실측 (같은 환경, 같은 소스)

latest가 v2보다 오래된 걸 담고 있습니다. 그래도 아무도 안 말려요.

latest는 Docker가 특별 취급하는 이름이 아닙니다. 태그를 생략했을 때 쓰는 기본 이름이에요. 4장에서 이미지가 이름이 아니라 다이제스트로 식별된다는 걸 봤죠. 태그는 그 다이제스트에 붙이는 포스트잇이고, latest는 그중 이름이 하나 정해져 있을 뿐입니다.

포스트잇이니까 언제든 다른 이미지에 옮겨 붙일 수 있습니다. 그래서 같은 app:latest가 어제와 오늘 다른 이미지를 가리킬 수 있어요.

재시작한 컨테이너는 옛 이미지를 쥐고 있습니다

latest로 컨테이너를 띄워놓고, 그사이 latest를 v2로 갈아 끼운 다음 재시작해봤습니다.

=== 5. 재시작으로 다시 뜬 컨테이너는 새 이미지를 쓰나 === 띄운 직후 이미지 ID : sha256:8da2bd96a04c latest 를 v2 로 갈아끼움 docker restart 후 로그 : v1 컨테이너가 말하는 이미지: ch17demo:latest 컨테이너가 쥔 이미지 ID : sha256:8da2bd96a04c 지금 latest 의 이미지 ID: sha256:67b0f4dc5aee

✅ 실측 (같은 환경, 같은 소스. ID 값 자체는 기계마다 다릅니다 — 볼 건 위아래 두 ID가 서로 다르다는 것)

여전히 v1입니다.

컨테이너는 태그 문자열을 그대로 들고 있습니다. 가운데 세 줄이 그거예요. docker inspect도 docker ps도 계속 ch17demo:latest라고 보여줍니다. “inspect 해보니 latest 쓰네, 그럼 최신이겠지” 하고 넘어가게 되죠.

그런데 시작할 때 그 태그를 다시 해석하지는 않습니다. 컨테이너가 실제로 쓰는 건 만들어질 때 붙든 이미지 ID고, 그 ID는 안 바뀝니다. 지금 latest가 어디를 가리키든 상관없어요.

“이미지를 새로 올렸는데 재시작해도 옛날 코드가 돈다”는 사고가 여기서 납니다. 재시작 정책으로 알아서 다시 뜨는 컨테이너도 마찬가지고요. 몇 번을 다시 떠도 같은 이미지입니다.

compose에서도 같은지 확인했습니다.

=== 6. compose 는 restart 와 up 이 다르다 === 처음 로그 : v1 compose restart 후 : v1 compose up -d 후 : v2

✅ 실측 (같은 환경, 같은 소스)

restart는 같은 컨테이너를 껐다 켭니다. up -d는 설정과 이미지를 다시 보고, 달라졌으면 컨테이너를 새로 만듭니다. 그래서 up -d에서만 v2가 나옵니다.

새 이미지를 반영하려면 컨테이너를 다시 만들어야 합니다. 껐다 켜는 걸로는 안 돼요.

여기에 함정이 하나 더 붙어 있습니다. 위 실측은 docker tag로 로컬 이미지를 바꿔치기한 경우예요. 실무에서 흔한 건 CI가 레지스트리에 새 이미지를 올린 상황인데, 그때 up -d는 같은 태그의 이미지가 로컬에 있으면 새로 당겨오지 않습니다. docker compose pull을 먼저 해야 해요. 안 그러면 “up -d 했는데도 옛날 코드가 돈다”는 2차 사고를 그대로 밟습니다.

그래서 태그 대신 무엇을 쓰나

=== 7. 태그 대신 다이제스트로 고정하면 === postgres:16-alpine → postgres@sha256:3c5c8892d184f738f4fe282d14ddaa613a38f00f4189d2d94725ebe6f2909ddb

✅ 실측 (같은 환경, 같은 소스)

앞에서 본 이미지 ID와는 다른 값입니다. 이미지 ID는 내 기계 안에서만 쓰는 번호고, @sha256:으로 적는 다이제스트는 레지스트리가 보관하는 매니페스트의 지문이에요. 둘 다 내용에서 나오지만 같은 값은 아닙니다.

다이제스트는 내용에서 계산된 값이라 옮겨 붙일 수 없습니다. image: postgres@sha256:3c5c...라고 적으면 6개월 뒤에도 같은 이미지가 뜹니다.

다만 읽기 어렵고, 올릴 때마다 손으로 바꿔야 해요. 그래서 실무에서는 대개 중간을 씁니다. 사람이 읽을 수 있는 불변 태그를 쓰는 거죠. 커밋 해시나 빌드 번호를 태그로 붙이고(app:a1b2c3d), 그 태그는 다시 안 옮깁니다.

그래서 어떻게 잡나

상황하면 좋은 것
오래 도는 서비스unless-stopped. 손으로 멈춘 건 멈춘 채로 둔다
배치·마이그레이션 컨테이너always를 걸지 않기. 끝난 걸 사고로 보고 또 띄운다
설정 오류로 죽는 앱재시작은 답이 아니다. 몇 번을 띄워도 같은 자리에서 죽는다
컨테이너가 멈춘 것 같을 때Status=restarting과 RestartCount를 보기. 간격이 최대 1분까지 벌어진다
배포용 이미지 태그latest 말고 커밋 해시 같은 불변 태그. 또는 다이제스트
새 이미지를 반영할 때restart 말고 compose pull && compose up -d. 컨테이너를 다시 만들어야 한다
on-failure:N을 쓸 때N은 누적 횟수다. 오래 살아도 안 회복된다

정리

  • 종료 코드는 네 종류입니다. 앱이 고른 값(0~255 어디든), Docker 데몬이 얹는 126·127, 시그널 관례인 128+N, 그리고 컨테이너에 찍히지도 않는 125(docker 명령 자신의 종료 상태).
  • 다만 이 분류 전체가 읽는 법이지 증명이 아닙니다. 앱은 아무 숫자나 고를 수 있어요. 12장의 143도 JVM이 스스로 고른 값이었습니다.
  • on-failure는 exit 0을 사고로 안 봅니다. always는 봅니다. 실측에서 exit 0인 컨테이너를 12초 동안 여섯 번 다시 띄웠어요.
  • 그래서 배치 잡에 always를 걸면 같은 일을 무한히 반복합니다.
  • on-failure:3은 3번까지만 시도하고 exited로 끝냅니다.
  • on-failure:N의 N은 누적 횟수입니다. 매번 12초씩 멀쩡히 살아도 안 회복돼요. 예산이 돌아오는 건 손으로 docker start 할 때뿐입니다.
  • always와 unless-stopped는 데몬 재시작 때 갈립니다. dind로 재봤더니 멈춰둔 컨테이너를 always는 다시 띄웠고 unless-stopped는 그대로 뒀어요. 손으로 멈췄을 때는 always도 안 살아납니다.
  • 계속 죽으면 재시작 간격이 두 배씩 벌어집니다(백오프). 실측에서 0.17 → 0.25 → 0.50 → … → 25.67초였어요. 5부의 CrashLoopBackOff가 같은 이름입니다.
  • 그동안 Status는 restarting입니다. running도 exited도 아니라 모니터링에서 놓치기 쉬워요.
  • 재시작은 고치는 게 아닙니다. 시작 자체가 실패한 컨테이너는 always여도 루프에 안 들어갑니다. 실측에서 RestartCount=0, Status=created였어요.
  • latest는 ‘최신’이 아니라 태그를 생략했을 때 쓰는 기본 이름입니다. 실측에서 latest가 v2보다 오래된 이미지를 가리켰어요.
  • 컨테이너는 태그 문자열을 들고는 있지만 시작할 때 다시 해석하지 않습니다. docker inspect는 계속 ch17demo:latest라고 보여주는데, 실제로 쓰는 건 처음 붙든 이미지 ID예요. 그래서 재시작해도 v1이 나왔습니다.
  • 새 이미지를 반영하려면 컨테이너를 다시 만들어야 합니다. compose에서 restart는 v1, up -d는 v2였어요. 레지스트리에서 받아오는 경우라면 pull이 먼저입니다.
  • 태그 대신 다이제스트를 쓰면 고정됩니다. 실무에서는 커밋 해시처럼 다시 안 옮기는 태그를 쓰는 쪽이 현실적이고요.

생각해볼 질문

여기까지가 4부입니다.

11장에서 앱 하나를 이미지로 담았고, 16장에서 DB와 이름으로 묶었고, 이 장에서 죽으면 다시 뜨게 했습니다. 그러면서 두 가지를 알게 됐어요. 정책은 왜 죽었는지를 묻지 않는다는 것, 그리고 다시 떠도 같은 이미지라는 것.

두 번째가 다음 부의 문을 엽니다.

새 코드를 반영하려면 컨테이너를 다시 만들어야 합니다. 다시 만드는 동안 그 컨테이너는 요청을 못 받고요. 한 대에서는 그게 그냥 잠깐의 다운타임입니다.

그럼 열 대에서는요. 요청을 받는 중에, 컨테이너를 하나씩 새로 만들면서, 끊기지 않게 하려면 어떻게 할까요. 손으로 순서를 지켜가며?

전제가 하나 더 무너집니다. 재시작 정책을 집행하는 건 그 기계 위의 Docker 데몬이에요. 기계가 죽으면 아무도 안 띄웁니다. 정책을 집행할 쪽이 같이 죽었으니까요. 16장의 라운드로빈 DNS도 한 대 안에서만 유효했고요. 앱을 셋으로 늘려도 한 기계에 있으면 같이 죽습니다.

기계 여러 대로 넘어가면 무엇이 필요해질까요.

▶ 18장. 한 대로 안 될 때 — 오케스트레이션, Pod와 Deployment

Last updated on