Skip to content

Instantly share code, notes, and snippets.

@taichi
Last active August 10, 2026 06:45
Show Gist options
  • Select an option

  • Save taichi/b2448d20d8f485797c70273ffc804fb3 to your computer and use it in GitHub Desktop.

Select an option

Save taichi/b2448d20d8f485797c70273ffc804fb3 to your computer and use it in GitHub Desktop.
AWS Lambda × distroless nonroot:継承されたWORKDIRによるRuntime.InvalidEntrypointの再現と調査 (gpt-5.6-sol high)

AWS Lambda × distroless nonroot: Runtime.InvalidEntrypoint最小再現

AWS Lambdaがコンテナイメージの実行UIDを独自ユーザーへ置き換えることで、distroless nonrootから継承したWorkingDir=/home/nonrootへ移動できず、ENTRYPOINT開始前に EACCESになる問題の最小再現です。

Runtime.InvalidEntrypoint
fork/exec /app/bootstrap: permission denied

エラーメッセージはENTRYPOINTを指しますが、実際に失敗しているのは、その前に行われる chdir("/home/nonroot")です。詳細な調査結果はREPORT.mdを参照してください。

ファイル

  • Dockerfile.fail: distroless nonrootWORKDIRを継承する失敗ケース
  • Dockerfile.fixed: WORKDIR /appを明示する修正ケース
  • index.ts: Bunで実装した最小のLambdaカスタムランタイム
  • deploy.sh: ECR、IAMロール、Lambda関数の作成・更新
  • invoke.sh: Lambdaの同期呼び出しとログ表示
  • cleanup.sh: 作成したAWSリソースの削除
  • lambda-trust-policy.json: Lambda実行ロールの信頼ポリシー
  • REPORT.md: 原因、対照実験、推奨修正

前提条件

  • AWS CLI v2でログイン済みであること
  • Dockerが起動していること
  • Lambda、ECR、IAMロールを作成・削除できるAWS権限
  • linux/amd64イメージをビルドできること

既定リージョンはAWS CLIの設定値、設定がなければap-northeast-1です。 必要に応じてAWS_PROFILEAWS_REGIONRESOURCE_NAMEを指定できます。

再現

失敗ケースをデプロイします。ローカルDockerでは正常起動しますが、Lambdaでは Runtime.InvalidEntrypointになります。

bash deploy.sh fail
bash invoke.sh

続いて、ベースイメージとバイナリを変えず、WORKDIR /appだけ追加した修正ケースへ更新します。

bash deploy.sh fixed
bash invoke.sh

成功時の応答例です。

{"ok":true,"runtime":"Bun 1.3.14","event":{"source":"lambda-distroless-workdir-repro"}}

検証後は課金を避けるためリソースを削除してください。

bash cleanup.sh

修正内容

FROM gcr.io/distroless/base-debian12:nonroot

WORKDIR /app
COPY --from=builder --chmod=0755 /out/bootstrap /app/bootstrap

ENTRYPOINT ["/app/bootstrap"]

通常のdistrolessタグへ変更する必要はありません。nonrootを維持したまま、実行ステージ自身で アクセス可能な絶対パスのWORKDIRを宣言します。

#!/usr/bin/env bash
set -euo pipefail
export AWS_PAGER=""
AWS_REGION="${AWS_REGION:-${AWS_DEFAULT_REGION:-}}"
RESOURCE_NAME="${RESOURCE_NAME:-lambda-distroless-workdir-repro}"
if [[ -z "$AWS_REGION" ]]; then
AWS_REGION="$(aws configure get region)"
fi
if [[ -z "$AWS_REGION" ]]; then
AWS_REGION="ap-northeast-1"
fi
if aws lambda get-function \
--region "$AWS_REGION" \
--function-name "$RESOURCE_NAME" >/dev/null 2>&1; then
aws lambda delete-function \
--region "$AWS_REGION" \
--function-name "$RESOURCE_NAME"
fi
if aws ecr describe-repositories \
--region "$AWS_REGION" \
--repository-names "$RESOURCE_NAME" >/dev/null 2>&1; then
aws ecr delete-repository \
--region "$AWS_REGION" \
--repository-name "$RESOURCE_NAME" \
--force
fi
if aws iam get-role --role-name "$RESOURCE_NAME" >/dev/null 2>&1; then
aws iam detach-role-policy \
--role-name "$RESOURCE_NAME" \
--policy-arn arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
aws iam delete-role --role-name "$RESOURCE_NAME"
fi
printf 'Deleted Lambda, ECR repository, and IAM role named %s.\n' "$RESOURCE_NAME"
#!/usr/bin/env bash
set -euo pipefail
export AWS_PAGER=""
VARIANT="${1:-fail}"
BUN_VERSION="${BUN_VERSION:-1.3.14}"
AWS_REGION="${AWS_REGION:-${AWS_DEFAULT_REGION:-}}"
RESOURCE_NAME="${RESOURCE_NAME:-lambda-distroless-workdir-repro}"
case "$VARIANT" in
fail) DOCKERFILE="Dockerfile.fail" ;;
fixed) DOCKERFILE="Dockerfile.fixed" ;;
*)
printf 'Unknown variant: %s (expected fail or fixed)\n' "$VARIANT" >&2
exit 2
;;
esac
if [[ -z "$AWS_REGION" ]]; then
AWS_REGION="$(aws configure get region)"
fi
if [[ -z "$AWS_REGION" ]]; then
AWS_REGION="ap-northeast-1"
fi
AWS_ACCOUNT_ID="$(aws sts get-caller-identity --query Account --output text)"
IMAGE_TAG="bun-${BUN_VERSION}-${VARIANT}"
IMAGE_URI="${AWS_ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com/${RESOURCE_NAME}:${IMAGE_TAG}"
ROLE_ARN="arn:aws:iam::${AWS_ACCOUNT_ID}:role/${RESOURCE_NAME}"
if ! aws ecr describe-repositories \
--region "$AWS_REGION" \
--repository-names "$RESOURCE_NAME" >/dev/null 2>&1; then
aws ecr create-repository \
--region "$AWS_REGION" \
--repository-name "$RESOURCE_NAME" \
--image-scanning-configuration scanOnPush=true \
--tags Key=purpose,Value=lambda-distroless-workdir-repro >/dev/null
fi
aws ecr get-login-password --region "$AWS_REGION" |
docker login \
--username AWS \
--password-stdin "${AWS_ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com"
docker build \
--platform linux/amd64 \
--provenance=false \
--file "$DOCKERFILE" \
--build-arg "BUN_VERSION=${BUN_VERSION}" \
--tag "$IMAGE_URI" \
.
printf 'Local Docker result:\n'
docker run --rm --platform linux/amd64 "$IMAGE_URI"
docker push "$IMAGE_URI"
if ! aws iam get-role --role-name "$RESOURCE_NAME" >/dev/null 2>&1; then
aws iam create-role \
--role-name "$RESOURCE_NAME" \
--assume-role-policy-document file://lambda-trust-policy.json \
--tags Key=purpose,Value=lambda-distroless-workdir-repro >/dev/null
aws iam wait role-exists --role-name "$RESOURCE_NAME"
# IAM may report the role before Lambda can assume it.
sleep 10
fi
aws iam attach-role-policy \
--role-name "$RESOURCE_NAME" \
--policy-arn arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
if aws lambda get-function \
--region "$AWS_REGION" \
--function-name "$RESOURCE_NAME" >/dev/null 2>&1; then
aws lambda update-function-code \
--region "$AWS_REGION" \
--function-name "$RESOURCE_NAME" \
--image-uri "$IMAGE_URI" >/dev/null
aws lambda wait function-updated-v2 \
--region "$AWS_REGION" \
--function-name "$RESOURCE_NAME"
else
aws lambda create-function \
--region "$AWS_REGION" \
--function-name "$RESOURCE_NAME" \
--package-type Image \
--code "ImageUri=${IMAGE_URI}" \
--role "$ROLE_ARN" \
--architectures x86_64 \
--memory-size 256 \
--timeout 30 \
--tags purpose=lambda-distroless-workdir-repro >/dev/null
aws lambda wait function-active-v2 \
--region "$AWS_REGION" \
--function-name "$RESOURCE_NAME"
fi
printf 'Deployed %s variant to %s in %s.\n' "$VARIANT" "$RESOURCE_NAME" "$AWS_REGION"
printf 'Run: bash invoke.sh\n'
ARG BUN_VERSION=1.3.14
FROM oven/bun:${BUN_VERSION} AS builder
WORKDIR /src
COPY index.ts .
RUN bun build \
--target=bun \
--compile \
--minify \
--sourcemap \
--bytecode \
--outfile /out/bootstrap \
./index.ts
FROM gcr.io/distroless/base-debian12:nonroot
# Intentionally inherits WorkingDir=/home/nonroot from the base image.
COPY --from=builder --chmod=0755 /out/bootstrap /app/bootstrap
ENTRYPOINT ["/app/bootstrap"]
ARG BUN_VERSION=1.3.14
FROM oven/bun:${BUN_VERSION} AS builder
WORKDIR /src
COPY index.ts .
RUN bun build \
--target=bun \
--compile \
--minify \
--sourcemap \
--bytecode \
--outfile /out/bootstrap \
./index.ts
FROM gcr.io/distroless/base-debian12:nonroot
# Lambda replaces the image user, so do not inherit the private nonroot home.
WORKDIR /app
COPY --from=builder --chmod=0755 /out/bootstrap /app/bootstrap
ENTRYPOINT ["/app/bootstrap"]
type LambdaEvent = Record<string, unknown>;
async function lambdaRuntimeLoop(runtimeApi: string): Promise<never> {
const runtimeBaseUrl = `http://${runtimeApi}/2018-06-01/runtime`;
while (true) {
const invocation = await fetch(`${runtimeBaseUrl}/invocation/next`);
const requestId = invocation.headers.get("Lambda-Runtime-Aws-Request-Id");
if (!requestId) {
throw new Error("Lambda Runtime API response did not include a request ID");
}
const event = (await invocation.json()) as LambdaEvent;
const response = {
ok: true,
runtime: `Bun ${Bun.version}`,
event,
};
await fetch(`${runtimeBaseUrl}/invocation/${requestId}/response`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(response),
});
}
}
const runtimeApi = process.env.AWS_LAMBDA_RUNTIME_API;
if (runtimeApi) {
lambdaRuntimeLoop(runtimeApi);
} else {
console.log(
JSON.stringify({
ok: true,
runtime: `Bun ${Bun.version}`,
message: "The compiled binary started outside Lambda.",
}),
);
}
#!/usr/bin/env bash
set -euo pipefail
export AWS_PAGER=""
AWS_REGION="${AWS_REGION:-${AWS_DEFAULT_REGION:-}}"
RESOURCE_NAME="${RESOURCE_NAME:-lambda-distroless-workdir-repro}"
RESPONSE_FILE="${RESPONSE_FILE:-invoke-response.json}"
if [[ -z "$AWS_REGION" ]]; then
AWS_REGION="$(aws configure get region)"
fi
if [[ -z "$AWS_REGION" ]]; then
AWS_REGION="ap-northeast-1"
fi
aws lambda invoke \
--region "$AWS_REGION" \
--function-name "$RESOURCE_NAME" \
--cli-binary-format raw-in-base64-out \
--payload '{"source":"lambda-distroless-workdir-repro"}' \
--log-type Tail \
--query '{StatusCode:StatusCode,FunctionError:FunctionError}' \
--output json \
"$RESPONSE_FILE"
printf 'Lambda response:\n'
while IFS= read -r line || [[ -n "$line" ]]; do
printf '%s\n' "$line"
done < "$RESPONSE_FILE"
printf '\nRecent CloudWatch logs:\n'
sleep 3
aws logs tail "/aws/lambda/${RESOURCE_NAME}" \
--region "$AWS_REGION" \
--since 5m \
--format short
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Service": "lambda.amazonaws.com"
},
"Action": "sts:AssumeRole"
}
]
}

調査レポート: Lambdaとdistroless nonrootWORKDIR非互換

  • 調査日: 2026-08-10
  • 対象: AWS Lambdaコンテナイメージ
  • 症状: Runtime.InvalidEntrypoint / fork/exec <entrypoint>: permission denied
  • 検証アーキテクチャ: linux/amd64イメージ、x86_64 Lambda

結論

根本原因はENTRYPOINTの実行ビットやELF形式ではなく、distroless nonrootから継承した WorkingDir=/home/nonrootと、Lambdaが割り当てる実行UIDの不一致です。

検証したdistrolessイメージの設定と権限は次のとおりでした。

User:       65532
WorkingDir: /home/nonroot

drwx------ 700 65532:65532 /home/nonroot

通常のDockerはイメージのUID 65532を使うため正常に起動します。一方、Lambdaはイメージの USERをそのまま使わず、Lambda独自の最小権限ユーザーを割り当てます。実験時にはUID 993、 GID 990でしたが、この値が常に同じであることを前提にしてはいけません。

割り当てられたユーザーはmode 0700、UID 65532所有の/home/nonrootを検索できないため、 ENTRYPOINTを実行する前のchdir("/home/nonroot")EACCESになります。

なぜENTRYPOINTのエラーに見えるのか

概念的な起動順序は次のとおりです。

Lambdaが独自UIDを設定
        ↓
chdir(OCI WorkingDir)
        ↓
execve(OCI Entrypoint)

作業ディレクトリへの移動で失敗しても、Lambdaはプロセス起動全体の失敗を次のように報告します。

fork/exec /app/bootstrap: permission denied

このため、ENTRYPOINTのmode、所有者、ELFインタープリタなどに原因があるように見えますが、 実際にはENTRYPOINTへ到達していません。

対照実験

distroless debugのroot版とnonroot版は、検証時点で全rootfsレイヤーが一致していました。 意味のあるOCI設定差だったUserWorkingDirを独立して変更しました。

OCI User OCI WorkingDir Lambda結果
65532 /home/nonroot 失敗: ENTRYPOINT開始前にEACCES
0 /home/nonroot 失敗: ENTRYPOINT開始前にEACCES
0 / 成功
65532 / 成功
65532 /app 成功

成否を決めていたのはOCI UserではなくWorkingDirでした。

WORKDIR /appについては、診断用shellを介さず、Bunバイナリを直接ENTRYPOINTにした構成でも Lambdaから正常応答を確認しました。

否定した仮説

仮説 根拠
Bunコンパイラの回帰 静的Cランチャーとdistroless内のBusyBoxも同じ条件で開始不能
実行ビット欠落 ENTRYPOINTはmode 0755でaccess(X_OK)も成功
ELFまたは動的リンカの問題 ELF検査、mmap(PROT_EXEC)、成功ケースのexecve()で正常
ルートファイルシステムのnoexec mount情報にnoexecなし
distrolessのrootfs内容 root版とnonroot版のrootfsレイヤーが一致
USER 65532そのもの USER 65532のままWORKDIR /appにすると成功

推奨修正

実行ステージで、Lambdaの最小権限ユーザーが検索可能な絶対パスを明示します。

FROM gcr.io/distroless/base-debian12:nonroot

WORKDIR /app
COPY --from=builder --chmod=0755 /out/bootstrap /app/bootstrap

ENTRYPOINT ["/app/bootstrap"]

/appは通常root所有でもmode 0755なら移動と読み取り・実行が可能です。Lambdaのルート ファイルシステムはread-onlyなので、アプリケーションが書き込みを必要とする場合は/tmpを使います。

通常のdistrolessタグへ変更しても、そのタグのWorkingDir=/によって起動する可能性があります。 ただし、DockerなどLambda以外の環境ではUID 0で起動するため、nonrootを維持してWORKDIRだけ 明示する方が安全です。

一般化した知見

マルチステージDockerfileでは、実行可能イメージとなる各ステージが、そのステージ自身で 絶対パスのWORKDIRを宣言するべきです。ベースイメージの暗黙的なWorkingDirへ依存すると、 ベースイメージの更新や実行環境によるUID置換で動作が変わります。

Lintルールにする場合は、次の条件が適切です。

ENTRYPOINTまたはCMDを持つ実行ステージは、そのステージ自身で絶対パスのWORKDIRを 宣言しなければならない。

Docker Build checksやHadolintの標準ルールは、相対WORKDIRなどを検出しますが、今回のように WORKDIR自体を省略してベースイメージから継承するケースは検出しません。TrivyのDockerfile カスタムチェックなどで、ステージ単位のポリシーとして実装できます。

参考資料

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment