Writing a Service Kubernetes Can Run
Kubernetes runs your container as a disposable process: config from the environment, logs to stdout, no local state.
const port = Number(process.env.PORT ?? 8080);Kubernetes starts and stops copies of your service for you. A container is your code and its dependencies packaged into one image. A pod is one running copy of that image, and a replica is one pod among several identical ones. Any replica can be deleted at any moment: during a deploy, or when the machine it sits on is taken out of service. Three habits make a service that survives that.
Config from the environment
An environment variable is a named string the operating system hands to a process (one running program) when it starts. Node puts them on process.env, where every value is a string. Kubernetes sets them per deployment, so one image runs in staging and in production unchanged. Read them once at startup and stop with an error if a required one is missing.
Logs to stdout
stdout is the output stream a process writes to. console.log writes there, console.error writes to stderr, and Kubernetes collects both streams, which is what kubectl logs prints. Write one JSON object per line and open no log files: a file inside a container goes away with the pod.
No local state
Uploads, sessions and caches written to the container filesystem are lost on restart and invisible to the other replicas. They belong in object storage, a database, or a cache every replica shares.
The same thing, written two ways
const { PORT } = process.env; and const PORT = process.env.PORT; are the same statement. The defaults are not: const { PORT = '8080' } = process.env fills in only when the variable is absent, while process.env.PORT || '8080' also fills in when it is set to an empty string.
Example
When to use it
- Promoting one built image from staging to production by changing only its environment variables.
- Making kubectl logs deploy/api show structured lines a log search tool can filter.
- Moving uploaded files and sessions out of the container before scaling past one replica.
- Giving a pod a reason to crash at startup when a required credential was never set.
More examples
The environment the Deployment hands the process
The same image is configured per environment by this block alone, which is what makes one build promotable from staging to production. fieldRef pulls a value from the pod's own definition, so each replica can log which pod it is without being told.
# Part of a Deployment. Each replica starts with exactly these variables.
spec:
template:
spec:
containers:
- name: api
image: registry.example.com/api:1.4.2
env:
- name: PORT
value: "8080" # quoted: env values are strings
- name: LOG_LEVEL
value: "info"
- name: POD_NAME # useful in every log line
valueFrom:
fieldRef:
fieldPath: metadata.nameFail at startup, not at the first request
A pod that exits immediately with a clear message is visible in kubectl logs and in the pod's restart count. A pod that starts happily and then throws on the first request that touches the database looks healthy for as long as nobody uses it, and the cause shows up far from the mistake.
// config.js — imported once, at the top of the entry file.
const required = ['DATABASE_URL', 'SESSION_SECRET'];
const missing = required.filter((key) => !process.env[key]);
if (missing.length > 0) {
console.error(JSON.stringify({ level: 'fatal', msg: 'missing config', missing }));
process.exitCode = 1;
throw new Error('startup aborted');
}
export const config = Object.freeze({
port: Number(process.env.PORT ?? 8080),
databaseUrl: process.env.DATABASE_URL,
sessionSecret: process.env.SESSION_SECRET,
logLevel: process.env.LOG_LEVEL ?? 'info'
});Moving state out of the container
This is the change that has to happen before a service can run more than one replica. The same reasoning applies to in-memory sessions and to an in-process cache: both are per-replica, so a user whose next request is balanced onto a different pod appears logged out or sees stale data.
// Before: works on one replica, breaks on two.
// Replica A writes the file; a later request lands on replica B, which has
// never seen it, and a restart of A loses it entirely.
await writeFile(`/app/uploads/${id}.png`, bytes);
// After: the bytes go somewhere every replica can read, and the row that
// records them goes in the database.
await storage.put(`uploads/${id}.png`, bytes);
await db.query('insert into uploads (id, key) values ($1, $2)', [id, `uploads/${id}.png`]);
Discussion