Writing an AWS Lambda Custom Runtime in Bash: Runtime API and Handler Loop
AI generated
$_
#!/
Bash · AWS Lambda · Serverless · Runtime API
AWS Lambda Custom Runtime in Bash
Runtime API, bootstrap file and handler loop for a minimal Bash runtime

AWS Lambda does not force a specific programming language. Through the Runtime API, any executable file, including a plain Bash script, can act as a full Lambda runtime. Understanding how the bootstrap file fetches events, calls the actual handler and reports the result back makes it possible to run small glue scripts in Lambda without any interpreter overhead.

18 min read Runtime API · bootstrap · curl AWS Lambda · Serverless

1. What a custom runtime is and why Bash qualifies

AWS Lambda ships officially supported runtimes for Node.js, Python, Java, Go, Ruby and .NET, but also opens up the execution environment for custom runtimes. A custom runtime is ultimately just an executable file named bootstrap that Lambda invokes when a container starts, and which is responsible for the entire lifecycle of the execution context, including fetching events and handling errors.

Because bootstrap only needs to be executable, a Bash script with the right shebang line qualifies as a full runtime too, as long as the execution image ships basic tools like curl and jq. For simple glue tasks that mostly orchestrate shell commands anyway, that eliminates the detour through a Python or Node interpreter entirely, and cold starts stay minimal since no language runtime overhead needs to load.

2. Runtime API basics: /next, /response and /error

The Lambda Runtime API is a simple HTTP server reachable locally inside the execution context under the environment variable AWS_LAMBDA_RUNTIME_API. A runtime calls GET /2018-06-01/runtime/invocation/next to wait for the next event, with the call blocking until an event actually arrives, which maps directly to a blocking curl call in Bash.

After processing, the runtime reports the result via POST /2018-06-01/runtime/invocation/{requestId}/response, or through the corresponding /error endpoint on failure. The request ID lives in a response header of the /next call and must be extracted by the runtime itself from the headers, which in Bash typically happens via curl -D followed by parsing with grep or sed.

3. Writing a minimal runtime loop in Bash

The core of the custom runtime is an infinite loop that fetches exactly one event per iteration, processes it and reports the result back. This loop runs for the entire lifetime of the Lambda execution context, so several consecutive invocations reuse the same container and therefore the same Bash process, which avoids cold starts after the first execution.

It is important to catch every error inside the loop instead of letting the entire runtime crash on a single failed event. A set -e across the whole loop would be counterproductive here, because a single failure would then terminate the entire container instead of just reporting that one invocation as failed and continuing with the next.


#!/usr/bin/env bash
set -uo pipefail

readonly RUNTIME_API="$AWS_LAMBDA_RUNTIME_API"
readonly BASE_URL="http://${RUNTIME_API}/2018-06-01/runtime"

while true; do
  # Fetch the next event, extract the request ID from the header
  headers_file=$(mktemp)
  event=$(curl -sS -D "$headers_file" "${BASE_URL}/invocation/next")
  request_id=$(grep -Fi "Lambda-Runtime-Aws-Request-Id" "$headers_file" | tr -d '\r' | cut -d' ' -f2)
  rm -f "$headers_file"

  # Run the handler, catch failures instead of aborting the runtime
  if response=$(./handler.sh "$event" 2>&1); then
    curl -sS -X POST "${BASE_URL}/invocation/${request_id}/response" -d "$response" > /dev/null
  else
    curl -sS -X POST "${BASE_URL}/invocation/${request_id}/error" \
      -d "{\"errorMessage\": \"handler failed\", \"errorType\": \"HandlerError\"}" > /dev/null
  fi
done

4. Separating the handler script from the bootstrap file

It pays off to move the actual business logic into a separate handler script while bootstrap stays responsible only for Runtime API communication. That keeps both files manageable and allows testing the handler independently of the runtime loop logic, simply by calling it with a sample JSON argument.

The handler receives the raw event JSON as an argument or via standard input and has to extract the needed fields itself with jq, since Bash has no native JSON parsing. The return value goes through standard output as valid JSON, which the bootstrap loop forwards unchanged to the /response endpoint.


#!/usr/bin/env bash
# handler.sh -- actual business logic, independently testable
set -euo pipefail

event="$1"
name=$(echo "$event" | jq -r '.name // "World"')

echo "{\"message\": \"Hello, ${name}!\"}"

5. Building the package and deploying it as a Lambda function

A custom runtime gets uploaded as a completely normal deployment package that contains at least the executable bootstrap file and the handler script. Both files need execute permissions before being packed into the ZIP archive, because Lambda carries the permissions over from the archive, and a missing execute bit only shows up as a fairly unhelpful error on the very first invocation.

When creating the function through the AWS CLI, the runtime is specified as provided.al2 or provided.al2023, the minimal Amazon Linux base image without any preinstalled language runtime. If that base image lacks needed tools like jq, they can be added through an extra Lambda layer instead of copying them into every single function package.


#!/usr/bin/env bash
set -euo pipefail

chmod +x bootstrap handler.sh
zip -j function.zip bootstrap handler.sh

aws lambda create-function \
  --function-name mironsoft-bash-glue \
  --runtime provided.al2023 \
  --handler bootstrap \
  --zip-file fileb://function.zip \
  --role arn:aws:iam::123456789012:role/lambda-bash-runtime-role \
  --timeout 10 \
  --memory-size 128

6. Error handling and timeouts in the runtime loop

Because the bootstrap loop is responsible for the entire execution lifetime of a container, it has to distinguish between a failure in the actual business logic and a failure in the runtime communication itself. A failed handler call gets reported cleanly through the /error endpoint and the loop keeps running, while a failed curl call against the Runtime API itself usually indicates a deeper problem and should terminate the container.

Lambda enforces its own timeout independently of the runtime, killing the container once the configured timeout value is exceeded, regardless of what the bootstrap loop is doing at that moment. Inside the runtime it still pays off to enforce a somewhat shorter timeout for the handler call, so a properly formatted error message reaches /error instead of Lambda cutting off execution with a generic timeout message.

7. Performance: cold start and interpreter overhead compared

A Bash Lambda starts without the overhead of initializing a Python or Node runtime, because the shell itself is already part of the minimal base image as a process. For very simple tasks that mostly consist of calling external programs, such as aws s3 cp or curl, that shows up as measurably shorter cold start times, since no additional interpreter needs to load.

Once more complex logic enters the picture though, such as nested JSON parsing, error objects with stack traces, or state management across multiple invocations, the advantage reverses: Bash scripts relying on many external process calls like jq, awk and curl spawn a new subprocess per call, which is slower for compute-heavy logic than the same logic running in a native Python runtime with in-process libraries.

8. When a Bash Lambda makes sense: glue scripts and simple automation

A Bash Lambda works great for small glue scripts that react to an S3 event or an EventBridge trigger by running a handful of CLI commands, for instance copying a file between two S3 buckets, kicking off a backup command, or running a simple health-check script periodically. Such tasks are almost entirely shell commands anyway, so a native language runtime brings little real value while adding cold-start overhead.

It is also attractive as a throwaway tool for one-time migrations, or for teams that already maintain extensive Bash tooling libraries for their deployment pipelines, since existing scripts can be reused in Lambda almost unchanged instead of being ported to another language just to run them serverless.

9. When a Bash Lambda does not make sense

As soon as a function processes complex data structures, needs extensive error handling with typed exceptions, or relies on a rich SDK for other AWS services, Bash quickly becomes a burden. Missing native JSON handling, no real data structures beyond arrays, and the need to delegate practically every piece of logic to external processes like jq make the code fragile and hard to test once it grows past a few dozen lines.

For production-critical business logic, teams without deep Bash experience, or functions maintained jointly by many developers, an official runtime like Python or Node is almost always the better choice, because tooling, test frameworks and the error messages of the AWS SDKs are considerably more mature there than what can be recreated in Bash with curl and jq.

Criterion Bash custom runtime Native runtime (Python/Node) Recommendation
Cold start Minimal, no interpreter startup Interpreter and SDK initialization Bash for simple glue scripts
JSON handling Only via external jq Natively built in Native runtime for heavy JSON logic
Error handling Manual through /error endpoint Try/except with stack traces Native runtime for critical logic
AWS SDK access Only through AWS CLI calls Fully featured SDK (boto3 etc.) Native runtime for many AWS services
Team maintainability Low without Bash experience High, widely known language Native runtime in a team context

Mironsoft

Shell automation, DevOps tooling and deployment infrastructure

Shell scripts that hold up in production?

We review existing Bash scripts, spot fragile patterns and replace them with robust Bash patterns: complete error handling, logging and safe parallelization for your deployment stack.

Code Review

ShellCheck analysis and manual review for critical Bash pattern violations.

Refactoring

Retrofitting error handling, logging and safe file operations.

CI Integration

Wiring ShellCheck and BATS into pipelines and building regression tests.

10. Summary

AWS Lambda Custom Runtime in Bash: The Essentials at a Glance

Core idea

A custom runtime is just an executable bootstrap file that fetches events and reports results through the Runtime API.

Core loop

while true with curl against /invocation/next, handler call, result to /response or failure to /error.

Deployment

Make bootstrap and the handler executable, upload as a ZIP, choose runtime provided.al2023.

Where it fits

Good for glue scripts without complex logic, unsuitable for data-heavy, team-owned business logic.

11. FAQ: AWS Lambda Custom Runtime in Bash: The Essentials at a Glance

1What is an AWS Lambda custom runtime?
An executable file named bootstrap that Lambda calls when a container starts, and which is itself responsible for fetching events, calling the handler and reporting results through the Runtime API.
2Why does Bash even qualify as a Lambda runtime?
Because bootstrap only needs to be executable. A Bash script with a shebang line satisfies that, as long as curl and jq are available in the execution image.
3How do I fetch the next event?
With a blocking GET call against /2018-06-01/runtime/invocation/next at the address stored in AWS_LAMBDA_RUNTIME_API. The call waits until an event arrives.
4How do I report the result back?
Via POST to /2018-06-01/runtime/invocation/{requestId}/response with the JSON result as the body, or to the corresponding /error endpoint on failure.
5How do I parse JSON in Bash for the handler?
With jq, since Bash has no native JSON parsing. jq -r '.fieldname' extracts individual fields as text from the event JSON.
6Which runtime do I specify at deployment?
provided.al2 or provided.al2023, the minimal Amazon Linux base image without a preinstalled language runtime, matching custom runtimes.
7What happens if the handler fails?
The bootstrap loop catches the failure, reports it through the /error endpoint and keeps running for the next invocation instead of terminating the whole container.
8Is a Bash Lambda faster at cold start?
For simple tasks yes, since no interpreter needs to load. With complex logic involving many external process calls, that advantage can disappear again.
9What if tools like jq are missing from the base image?
They can be added through an extra Lambda layer instead of copying them into every single function package.
10When should I use Python or Node instead of Bash?
With complex data structures, extensive error handling, heavy use of AWS SDKs, or when the function is jointly maintained by a larger team.