BlueByte
exec /docker-entrypoint.sh: exec format errorFixed

Docker: 컨테이너 시작 직후 "exec format error" — 플랫폼이 다른 이미지, 에뮬레이터 없음, 셔뱅 없는 스크립트

작성 Haneul Seo2026년 9월 20일 업데이트12 min

안녕하세요, BlueByte입니다. 노트북에서는 잘 돌던 컨테이너가 서버에서는 시작하자마자 죽고, 로그에는 exec /docker-entrypoint.sh: exec format error 한 줄만 남습니다. 코드가 잘못된 것도, 이미지가 깨진 것도 아닙니다. 호스트 커널이 실행할 수 없는 파일을 건네받았을 뿐입니다. 거의 항상 CPU 아키텍처가 다른 이미지 — Apple 실리콘 Mac이 arm64 이미지를 만들었고 x86_64 서버가 그걸 돌리려 한 경우 — 이고, 가끔은 첫 줄을 잃어버린 스크립트입니다. 오늘은 커널이 실제로 하는 말, 이 오류를 만드는 세 가지 원인, 원인을 가르는 명령, 원인별 해결, 그리고 파이프라인에서 재발을 막는 법까지 하나씩 짚어보겠습니다.

커널이 "exec format error"로 하는 말

이 메시지는 ENOEXEC 의 문구입니다. 파일이 존재하고 실행 권한도 있지만 커널이 어떻게 실행해야 할지 모를 때 execve() 가 돌려주는 오류입니다. 두 종류의 파일이 이를 유발합니다. CPU 와 다른 아키텍처로 컴파일된 ELF 바이너리(x86_64 호스트의 aarch64 바이너리, 또는 그 반대)에 에뮬레이터가 등록돼 있지 않은 경우, 그리고 #! 셔뱅으로 시작하지 않아 넘길 인터프리터가 없는 텍스트 파일입니다. Docker 는 엔진 버전에 따라 몇 가지 형태로 출력합니다:

exec /docker-entrypoint.sh: exec format error
exec /app/server: exec format error
standard_init_linux.go:228: exec user process caused: exec format error

앞의 둘은 현재 runc 이고, 셋째는 오래된 엔진의 출력이며 줄 번호는 버전마다 다릅니다. Kubernetes 에서는 Pod 가 CrashLoopBackOff 로 가고 kubectl logs 에 같은 한 줄이 보이며, kubectl describe pod 는 더 긴 OCI runtime create failed: … exec format error 이벤트 안에 담아 보여줍니다. 그 전에 경고가 먼저 찍힌 경우가 많은데, 이것이 가장 좋은 단서입니다:

WARNING: The requested image's platform (linux/amd64) does not match the detected host platform (linux/arm64/v8) and no specific platform was requested

이 오류를 만드는 세 가지

이미지가 다른 아키텍처로 빌드됐습니다. docker build 는 따로 지정하지 않으면 실행한 머신의 플랫폼으로 이미지를 만듭니다. M 시리즈 Mac 에서 빌드하면 linux/arm64, Intel 노트북이나 GitHub 호스팅 러너에서 빌드하면 linux/amd64 입니다. 이를 레지스트리에 푸시해 다른 종류의 호스트에 배포하면 엔트리포인트 바이너리는 낯선 ELF 파일이 됩니다.

호스트에 에뮬레이터가 등록돼 있지 않습니다. Docker Desktop 은 QEMU 를 함께 제공하므로 arm64 Mac 에서도 amd64 이미지가 (느리게) 뜹니다. 순정 Linux 엔진은 기본적으로 binfmt_misc 에 아무것도 등록돼 있지 않아 같은 이미지가 실패합니다. "내 Mac 에서는 되는데" 와 "서버에서는 죽는데" 가 동시에 참인 이유입니다.

엔트리포인트가 셔뱅 없는 스크립트입니다. exec 형식 ENTRYPOINT ["/docker-entrypoint.sh"] 에서 Docker 는 execve() 를 직접 호출하며, 대신 받아줄 셸이 없습니다. 첫 줄이 #!/bin/sh(또는 유사한 것)가 아닌 스크립트는 커널에게 그저 바이트 덩어리입니다. 노트북에서 같은 파일이 되는 건 bash 가 execve 의 ENOEXEC 를 받으면 조용히 자기가 셸 스크립트로 실행해 주기 때문입니다.

먼저 이미지의 플랫폼과 호스트의 플랫폼을 비교하기

실패하는 호스트에서 실행합니다:

uname -m
docker version --format '{{.Server.Os}}/{{.Server.Arch}}'
docker image inspect --format '{{.Os}}/{{.Architecture}}' myorg/app:1.4

arm64 호스트에서 직접 확인해 보면 aarch64, linux/arm64, 그리고 이미지의 플랫폼이 차례로 나옵니다. 셋째 줄이 linux/amd64 라면 그것이 원인입니다. 레지스트리에 실제로 무엇이 있는지는 직접 물어봅니다:

docker buildx imagetools inspect myorg/app:1.4

단일 플랫폼 이미지는 Platform: linux/amd64 한 줄을 출력합니다. 멀티 플랫폼 이미지는 플랫폼마다 항목이 하나씩 있는 Manifests: 블록(linux/amd64, linux/arm64/v8, …)을 출력하고, Docker 는 pull 시점에 맞는 것을 고릅니다. 다음으로 호스트가 에뮬레이션을 할 수 있는지 봅니다:

ls /proc/sys/fs/binfmt_misc/

저희 arm64 서버에서는 python3.12 register status 만 나옵니다. qemu-* 항목이 없으니 amd64 이미지는 여기서 뜰 수 없습니다. 플랫폼이 이미 같다면 대신 엔트리포인트의 첫 바이트를 봅니다:

head -c 16 docker-entrypoint.sh | od -c

# ! / b i n / s h \n 이 나와야 합니다. 출력이 첫 명령으로 바로 시작하면 셔뱅이 빠진 것입니다.

해결 1: 실행할 플랫폼에 맞춰 이미지를 빌드하기

BuildKit 에 대상을 명시하고, 서버가 섞여 있다면 둘 다 푸시합니다:

docker buildx build --platform linux/amd64 -t myorg/app:1.4 --push .
docker buildx build --platform linux/amd64,linux/arm64 -t myorg/app:1.4 --push .

다른 플랫폼을 빌드하려면 에뮬레이션이나 크로스 컴파일이 필요합니다. Docker Desktop 이 없는 Linux CI 러너에서는 QEMU 를 한 번 등록합니다. privileged 로 한 번 도는 컨테이너지만 하는 일은 binfmt_misc 항목을 쓰는 것뿐이라 호스트의 다른 것은 바뀌지 않으니 겁먹지 않아도 됩니다:

docker run --privileged --rm tonistiigi/binfmt --install all

컴파일 언어라면 툴체인 전체를 에뮬레이션으로 돌리는 것보다 크로스 컴파일이 빠릅니다. Docker 의 자동 빌드 인자를 쓰면 Dockerfile 하나로 모든 대상을 처리할 수 있습니다:

FROM --platform=$BUILDPLATFORM golang:1.23 AS build
ARG TARGETOS TARGETARCH
WORKDIR /src
COPY . .
RUN CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -o /out/server .
 
FROM gcr.io/distroless/static-debian12
COPY --from=build /out/server /server
ENTRYPOINT ["/server"]

빌드 스테이지는 빌더에서 네이티브로 돌고, 결과물만 대상 플랫폼용입니다.

해결 2: 다른 플랫폼 이미지를 일부러 돌리기

이미지가 amd64 로만 배포되는데 arm64 노트북을 쓰는 경우가 있습니다. QEMU 가 등록돼 있다면 명시적으로 요청하고 속도 손해를 받아들입니다:

docker run --rm --platform linux/amd64 myorg/app:1.4

같은 플래그가 Dockerfile(FROM --platform=linux/amd64 …)과 Compose(서비스의 platform: linux/amd64)에도 있고, 환경 변수 DOCKER_DEFAULT_PLATFORM=linux/amd64 는 모든 명령의 기본값으로 만듭니다. Kubernetes 에서는 되길 바라는 대신 실행 가능한 노드에 워크로드를 고정합니다:

nodeSelector:
  kubernetes.io/arch: amd64

해결 3: 엔트리포인트에 셔뱅 넣기

#!/bin/sh 를 맨 첫 줄에 — 위에 빈 줄 없이 — 넣고 다시 빌드하거나, exec 형식에 인터프리터를 직접 적어 커널이 추측할 일을 없앱니다: ENTRYPOINT ["sh", "/docker-entrypoint.sh"].

실제 사례: Mac 빌드, x86 서버, 금요일 배포

한 개발자가 M2 MacBook 에서 평범한 docker build 로 핫픽스를 빌드해 registry/app:1.4 를 푸시했고, Ubuntu VM 은 컨테이너를 exec /app/server: exec format error 크래시 루프로 재시작했습니다. VM 의 uname -m 은 x86_64, 그 태그의 docker image inspect 는 linux/arm64 였습니다. 해결은 같은 커밋에서 --platform linux/amd64,linux/arm64 --push 로 한 번 다시 빌드하는 것이었습니다. imagetools inspect 에 매니페스트 둘이 나열됐고, VM 은 amd64 쪽을 받았으며, 컨테이너는 그대로 떠 있었습니다.

고친 뒤 확인하고, CI 에서 불일치를 막기

호스트에서 docker run --rm myorg/app:1.4 uname -m 은 호스트 자신의 아키텍처를 출력해야 하고, docker ps 는 Restarting 이 아니라 Up 이어야 합니다. 그다음 빌드를 영구히 명시적으로 만듭니다. CI 에서는 러너의 CPU 에 기대지 말고 항상 --platform 을 넘기고, 노드가 섞여 있다면 매니페스트 목록을 발행하며, docker buildx imagetools inspect 를 릴리스 점검에 넣습니다. 스크립트는 .gitattributes 에 *.sh text eol=lf 를 두어 Windows 체크아웃이 첫 줄을 바꾸지 못하게 합니다.

옆집에 있지만 뜻이 다른 오류들

파일이 분명히 있는데 exec /docker-entrypoint.sh: no such file or directory 가 나오면 없는 것은 인터프리터입니다. Alpine 이미지의 #!/bin/bash, 또는 Windows 줄바꿈으로 저장돼 \r 로 끝나는 셔뱅입니다(직접 재현해 보면 #!/bin/sh\r\n 은 ENOEXEC 가 아니라 ENOENT 를 돌려줍니다). permission denied 는 실행 비트가 없는 스크립트입니다. 그리고 WARNING: The requested image's platform … 줄만으로는 치명적이지 않습니다. QEMU 가 있으면 컨테이너는 느릴 뿐 돌아갑니다.

다음에 컨테이너가 첫 명령에서 죽으면 이 순서를 거꾸로 따라가 보세요. uname -m, docker image inspect, binfmt_misc, 그리고 엔트리포인트의 첫 16바이트 — 그리고 서버가 아니라 빌드를 고치면 됩니다.

관련 질문

내 Mac 에서는 이미지가 돌아가는데 서버에서는 exec format error 로 죽습니다. 왜 다른가요?

Docker Desktop 은 QEMU 를 등록해 두므로 다른 아키텍처 이미지도 Mac 에서는 에뮬레이션으로 뜹니다. 순정 Linux 엔진은 기본적으로 /proc/sys/fs/binfmt_misc 에 qemu-* 항목이 없어 같은 이미지가 실패합니다. --platform 으로 서버 플랫폼에 맞춰 빌드하거나, 둘을 모두 담은 매니페스트 목록을 발행하세요.

arm64 노트북에서 --platform linux/amd64 만 붙이고 넘어가도 되나요?

로컬 테스트라면 됩니다. QEMU 가 등록돼 있으면 컨테이너는 느릴 뿐 돌아가고, Docker 는 플랫폼 불일치 경고를 찍습니다. 배포하는 것이라면 대상에 맞춰 네이티브로 빌드하거나 크로스 컴파일하세요. 에뮬레이션은 편의 기능이지 운영 설정이 아닙니다.

Kubernetes 는 아키텍처를 자동으로 골라 주나요?

태그가 매니페스트 목록일 때만 그렇습니다. 노드의 컨테이너 런타임이 자기 플랫폼에 맞는 항목을 받습니다. 단일 플랫폼 이미지는 모든 노드에서 그대로 받히므로, 혼합 클러스터에서는 두 플랫폼을 모두 발행하거나 kubernetes.io/arch 의 nodeSelector 로 워크로드를 고정하세요.

엔트리포인트 스크립트에 셔뱅이 없는데 노트북에서는 됩니다. 컨테이너에서는 왜 안 되나요?

노트북에서는 bash 에서 실행하는데, bash 는 커널의 ENOEXEC 를 받으면 파일을 자기가 셸 스크립트로 실행합니다. ENTRYPOINT 의 exec 형식은 대신 받아줄 셸 없이 execve() 를 직접 호출하므로 커널의 오류가 그대로 도달합니다. 첫 줄에 #!/bin/sh 를 넣거나 ENTRYPOINT ["sh", "/docker-entrypoint.sh"] 를 쓰세요.

멀티 플랫폼 빌드는 QEMU 와 크로스 컴파일 중 무엇이 좋나요?

Docker 의 멀티 플랫폼 문서는 세 가지 전략을 듭니다. QEMU 에뮬레이션(가장 쉬운 출발), 네이티브 빌더 노드 여러 개, 그리고 BUILDPLATFORM/TARGETARCH 를 쓰는 크로스 컴파일입니다. 에뮬레이션은 모든 빌드 단계를 QEMU 로 돌리므로, 컴파일 언어라면 FROM --platform=$BUILDPLATFORM 스테이지에서 크로스 컴파일하는 쪽이 더 빠르고 CI 에서 유지하기도 단순합니다.

참고 자료

Haneul Seo

Infrastructure engineer · 10+ years running Linux fleets

같은 카테고리 다른 글

detected dubious ownershipFixed

Git: fatal: detected dubious ownership in repository

Git 2.35.2의 CVE-2022-24765 수정 이후, Git은 작업 트리나 .git 디렉터리의 소유자가 명령을 실행한 사용자와 다르면 저장소를 읽지 않습니다. 컨테이너, CI 작업, sudo 세션, 공유 드라이브에서 주로 나타납니다. 저장소가 내 것이어야 한다면 소유권을 바로잡고, 아니라면 global 설정의 safe.directory에 정확한 경로를 추가하세요 — Git은 이 설정을 저장소 자신의 설정에서는 무시합니다.

Git
failed calling webhookFixed

Kubernetes: Internal error occurred: failed calling webhook

admission webhook이 쓰기 요청 앞에 서 있는데 API server가 응답을 받지 못했고, 기본값인 failurePolicy: Fail이 그 침묵을 거부로 바꾼 것입니다. 메시지 끝부분이 곧 진단입니다 — context deadline exceeded는 호출이 도달하지 못한 것, no endpoints available은 떠 있는 게 없는 것, x509 줄은 API server가 webhook 인증서를 신뢰하지 않는 것입니다. 원인마다 해결이 다르고, 어느 것도 매니페스트 문제가 아닙니다.

Kubernetes
1205Fixed

MySQL: ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction

다른 트랜잭션이 쥐고 있는 행 잠금을 innodb_lock_wait_timeout 만큼 기다리다 포기한 것입니다. sys.innodb_lock_waits 가 막고 있는 세션을 지목하고 KILL 문까지 만들어 주며, blocking_query 가 NULL 이면 블로커가 열린 트랜잭션 위에서 놀고 있다는 뜻입니다. 재시도 로직이 가장 자주 틀리는 지점은 따로 있습니다. 기본값에서는 타임아웃된 문장 하나만 롤백되므로, 트랜잭션은 그대로 열린 채 앞서 잡은 잠금을 전부 쥐고 있습니다.

MySQL
MISCONFFixed

Redis: MISCONF Redis is configured to save RDB snapshots, but it's currently unable to persist to disk

읽기는 되는데 모든 쓰기가 거부됩니다. 마지막 백그라운드 저장이 실패했고 stop-writes-on-bgsave-error 기본값이 yes 이기 때문입니다. 진짜 원인은 로그에 적혀 있습니다 — 디스크 공간 부족, redis 사용자가 쓸 수 없는 dir, rename 시점의 read-only 마운트, 또는 fork 가 Cannot allocate memory 로 실패하는 경우입니다. 원인을 고치고 BGSAVE 한 번만 돌리면 재시작 없이 쓰기가 돌아옵니다. rdb_last_bgsave_status 가 err 에서 ok 로 바뀝니다. stop-writes-on-bgsave-error no 는 쓰기를 즉시 되살리지만 스냅샷은 여전히 실패한 상태로 두므로, 해결이 아니라 의도한 맞교환으로 다뤄야 합니다.

Redis
FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memoryFixed

Node.js: FATAL ERROR: Reached heap limit — JavaScript heap out of memory (종료 코드 134)

V8 힙에는 시스템 메모리와 Node 릴리스에서 유도되는 자기만의 천장이 있고, 가진 RAM 보다 훨씬 낮은 경우가 흔합니다. 빌드나 서버가 거기 닿으면 V8 은 FATAL ERROR: Reached heap limit 과 종료 코드 134 로 스스로 중단합니다. v8.getHeapStatistics().heap_size_limit 으로 실제 한도를 읽은 뒤, 큰 작업은 --max-old-space-size(MiB)나 NODE_OPTIONS 로 올리고, 컨테이너 안에서는 cgroup 한도보다 낮게 잡고, 오래 도는 프로세스의 누수는 --heapsnapshot-near-heap-limit 으로 잡으세요. FATAL ERROR 줄 없이 137 로 끝나면 컨테이너 kill 이지 이 오류가 아닙니다.

Node.js
curl: (60) SSL certificate problem: unable to get local issuer certificateFixed

curl: (60) SSL certificate problem: unable to get local issuer certificate

curl이 서버가 보낸 인증서 체인을 따라가다가, 자기가 읽는 CA 저장소에 발급자가 없는 인증서에 닿아 종료 코드 60으로 연결을 거부한 것입니다. 발급자가 없는 이유는 넷 중 하나입니다. 서버가 중간 인증서 없이 리프만 보내거나(브라우저는 스스로 받아 와서 가려 줍니다), TLS 검사 프록시가 컨테이너·러너가 신뢰하지 않는 회사 CA로 사이트를 다시 서명했거나, curl이 생각과 다른 CA 번들(CURL_CA_BUNDLE, SSL_CERT_FILE, 벤더 curl)을 읽고 있거나, ca-certificates 패키지가 너무 오래된 경우입니다. curl -v로 어느 저장소를 썼는지, openssl s_client로 서버가 무엇을 보냈는지 보고 고리가 빠진 쪽을 고친 뒤 -w '%{ssl_verify_result}'로 확인합니다.

curl