Building a Custom Environment
This guide walks through building a Fission environment from scratch, modifying an existing one, and adding support for a brand-new language. It is the hands-on companion to the Environments concept guide, which explains what an environment is; here we cover how to build one.
By the end you will understand the runtime HTTP contract every environment must implement, how the optional builder turns source into a deployment package, and the exact files you need to ship a new language.
The reference implementation for every shipped environment lives in the fission/environments repository. The Python, Node.js, Go, and Rust environments there are the best templates to copy from — this guide refers to them throughout.
When you need a custom environment
You need to build or modify an environment when you want to:
- Add a language Fission does not yet ship (the focus of issue #193).
- Pin a different language/runtime version (for example a newer Python or Go base image).
- Bake system libraries, certificates, or shared dependencies into the runtime image.
- Change how source code is compiled or how dependencies are fetched (the builder).
If you only need to use an existing environment, you do not need this page — see Create Environment instead.
How an environment is structured
An environment is one or two container images:
- A runtime container (always required) — an HTTP server that loads your function on demand and then serves invocations.
- A builder container (optional) — compiles source code and gathers dependencies, turning a source archive into a runnable deployment archive.
You only need a builder for languages that compile or fetch dependencies. For interpreted single-file functions, the runtime container alone is enough. See How specialization loads your code for the lifecycle.
The runtime contract
This is the heart of every environment. A runtime image is a generic HTTP server that listens on port 8888 and implements these endpoints:
| Method & path | Purpose |
|---|---|
GET /healthz | Readiness/liveness probe. Return 200 OK once the server is up. |
POST /specialize | v1 protocol. Load the user function from the fixed path /userfunc/user. No request body. |
POST /v2/specialize | v2 protocol. Load the function described by the JSON body {"filepath": "...", "functionName": "..."}. filepath may be a single file or a built package directory. |
| any other path/method | After specialization, route the request to the loaded user function and return its response. |
The lifecycle is deliberately simple:
- A pod starts as a generic, unspecialized server.
Before specialization, any request to
/should return a clear error such asContainer not specialized. - The executor sends exactly one specialize request, which loads the user’s code and caches it in memory.
- From then on the container is warm and serves all invocations for that one function.
sequenceDiagram autonumber participant Fission participant Runtime as Runtime server participant Func as User function Fission->>Runtime: GET /healthz Runtime-->>Fission: 200 OK Fission->>Runtime: POST /v2/specialize Runtime->>Runtime: load & cache code Runtime-->>Fission: 200 specialized Fission->>Runtime: function request Runtime->>Func: invoke cached function Func-->>Runtime: response Runtime-->>Fission: response
A container specializes exactly once. Fission’s pool manager replaces whole pods rather than re-specializing them, so you never need to handle a second specialize call. Implement both/specialize(v1) and/v2/specialize(v2) — v2 is the modern path that supports builders and packaged directories, while v1 keeps single-file snippets working.
How functionName is interpreted
The functionName field in the v2 body is language-specific — it is your environment’s contract with function authors:
- Python —
module.function(loaded withimportlib, e.g.hello.main). - Node.js —
filename.funcname, or a bare filename for the default export. - Go — a symbol exported by the compiled plugin (
.so), looked up withplugin.Lookup. - JVM — a fully-qualified class implementing
io.fission.Function. - Rust — the name of a compiled binary in the deploy package, spawned as a child process.
Pick a convention that is natural for your language and document it in your environment’s README.
Anatomy of a runtime image
The simplest runtime is an interpreted language that loads code in the same process as the server. Python is the canonical example.
Its runtime Dockerfile builds a minimal image whose entrypoint is the server:
FROM python:3.13-alpine
WORKDIR /app
RUN apk add --update --no-cache gcc python3-dev build-base libev-dev libffi-dev bash
COPY requirements.txt /app
RUN pip3 install -r requirements.txt
COPY *.py /app/
ENV PYTHONUNBUFFERED=1
ENTRYPOINT ["python3"]
CMD ["server.py"]
The server.py it runs is a small HTTP server that:
- serves
GET /healthz, - on
POST /v2/specialize, reads{"filepath", "functionName"}, imports the module, and caches the function object, - routes every other request to that cached function.
The v2 loader is the only non-trivial part — it handles both a single file and a built package directory:
def load_v2(specialize_info):
filepath = specialize_info['filepath']
handler = specialize_info['functionName'] # e.g. "hello.main"
moduleName, funcName = handler.rsplit(".", 1)
if os.path.isdir(filepath): # a built package
sys.path.append(filepath)
mod = importlib.import_module(moduleName)
else: # a single source file
mod = import_src(filepath)
return getattr(mod, funcName) # cache this and serve it
Compiled languages follow the same contract but differ in how they load code:
.so).
At specialize time the server calls plugin.Open and plugin.Lookup to resolve the exported symbol, then dispatches requests to it.
The plugin must be built with the exact same Go toolchain as the runtime.The builder contract
A builder is needed when source code must be compiled or its dependencies fetched.
A builder image must provide an executable at /usr/local/bin/build that:
- reads source from the directory in the
SRC_PKGenvironment variable, - produces a deployable artifact in the directory in the
DEPLOY_PKGenvironment variable, - exits
0on success, non-zero on failure.
flowchart TB src(["Source package<br/>$SRC_PKG"]):::store build["/usr/local/bin/build"]:::pod deploy(["Deployment package<br/>$DEPLOY_PKG"]):::store runtime["Runtime container"]:::pod src -->|"<b>1.</b> read source"| build build -->|"<b>2.</b> compile / fetch deps"| deploy deploy -->|"<b>3.</b> loaded by runtime"| runtime classDef pod fill:#e6f7f1,stroke:#11a37f,color:#1f2a43,stroke-dasharray:5 3 classDef store fill:#fff7e0,stroke:#dba514,color:#1f2a43,stroke-dasharray:5 3
The build script is short and language-specific. Examples from the shipped environments:
#!/bin/sh
set -euxo pipefail
if [ -f ${SRC_PKG}/requirements.txt ]; then
pip3 install -r ${SRC_PKG}/requirements.txt -t ${SRC_PKG}
fi
cp -r ${SRC_PKG} ${DEPLOY_PKG}
Dependencies are installed next to the source, then the whole tree is copied so the runtime can add it to sys.path.
#!/bin/sh
cd ${SRC_PKG}
npm install && cp -r ${SRC_PKG} ${DEPLOY_PKG}
npm install populates node_modules/, then the tree (now with dependencies) is copied to the deploy package.
#!/bin/bash
set -eux
# ... place source under GOPATH, init go.mod if needed ...
go build -buildmode=plugin -o ${DEPLOY_PKG} .
Go compiles the source into a plugin written directly into the deploy package.
In your environment repo the build script (often build.sh or defaultBuildCmd) is copied into the builder image and marked executable:
COPY builder/build.sh /usr/local/bin/build
RUN chmod +x /usr/local/bin/build
To understand how the cluster invokes your builder, see the builder pod architecture and Packages and builds.
Modifying an existing environment
The most common change is bumping a base-image or runtime version. Working in a clone of fission/environments:
- Update the base image / version in the environment’s
Dockerfile(or its build args). - Update the matching build args in both the runtime
Makefileand thebuilder/Makefile, and inskaffold.yamlif present. The same pins are duplicated in these places and CI fails if they drift. - Bump the
versionfield in the environment’senvconfig.json. This is what triggers a new image release when the change merges. - Regenerate the catalog with
make update-env-json— never hand-editenvironments.json.
The shared build rules inrules.mkdefaultDOCKER_FLAGSto--push, so a baremakewill try to push toghcr.io. For local builds, override it and target a single platform — see Build and test locally below.
Adding support for a new language
Use the most recently added environment, rust/, as your template — it exercises every pattern.
Create a new top-level directory in the environments repo (for example mylang/) containing:
A runtime server that implements the runtime contract:
GET /healthz,POST /specialize,POST /v2/specialize, and request routing on port8888. Start from the Python or Node.js server — the same-process model is simplest.A runtime
Dockerfilethat installs the language and runs your server (see the Python example above).A builder (only if your language compiles or needs dependency installation):
builder/Dockerfileplus abuild.shcopied to/usr/local/bin/buildthat honorsSRC_PKG/DEPLOY_PKG.An
envconfig.jsondescribing the images for the catalog. For example:[ { "kind": "environment", "name": "MyLang Environment", "image": "mylang-env", "builder": "mylang-builder", "runtimeVersion": "1.0", "version": "0.1.0", "shortDescription": "Fission MyLang environment.", "status": "Beta", "examples": "https://github.com/fission/examples/tree/main/mylang", "readme": "https://github.com/fission/environments/tree/master/mylang" } ]A
Makefilethat includes../rules.mkand declares your image build args (copy fromrust/Makefileandrust/builder/Makefile).Tests — a minimal function fixture plus a local/kind test, mirroring
rust/tests/.
Then:
- Run
make update-env-jsonto add your environment toenvironments.json. - Open a PR against fission/environments.
On merge, CI builds and publishes
ghcr.io/fission/mylang-env(and the builder) at the version inenvconfig.json.
Finally, surface the new language on this website’s Environments catalog by running tools/environments.py to regenerate static/data/environments.json — see the updating-environments-and-examples workflow and tools/README.md.
Build and test locally
Build the runtime (and builder) image into your local Docker daemon — note the overrides that avoid pushing and target a single platform:
cd mylang/
make mylang-env-img DOCKER_FLAGS=--load PLATFORMS=linux/arm64
cd builder/
make mylang-builder-img DOCKER_FLAGS=--load PLATFORMS=linux/arm64
Smoke-test the runtime contract before deploying. Run the image, then exercise the endpoints:
docker run --rm -p 8888:8888 ghcr.io/fission/mylang-env:dev
# in another shell
curl -s localhost:8888/healthz # expect 200
curl -s -XPOST localhost:8888/v2/specialize \
-d '{"filepath":"/path/in/container","functionName":"hello.main"}'
curl -s localhost:8888/ # should run your function
When the image works, load it into your cluster and create an environment from it:
fission env create --name mylang --version 3 \
--image ghcr.io/fission/mylang-env:dev \
--builder ghcr.io/fission/mylang-builder:dev \
--poolsize 2
Then create a function and invoke it to confirm the full path works end to end — see Create Function.
Publish
Once merged to fission/environments, the release workflow detects every unpublished image:version pair from envconfig.json and pushes it to ghcr.io/fission/<image>:<version> plus a latest tag.
Bumping the version in envconfig.json is the single action that cuts a release, and versions are independent per environment.
Related
- Environments — concepts: runtime vs builder, specialization, the versioned interface.
- Create Environment — using
fission env createwith an existing image. - Language environments — per-language setup and entry-point signatures.
- Packages and builds — how the builder turns source into a deployment archive.
- Builder pod — how the cluster runs your builder.
- Environments catalog — all available runtime and builder images.