Why Shell-Less Matters
A shell (bash, sh, ash, zsh) is a universal interface for arbitrary code execution. If an attacker gains code execution in your application, they can invoke a shell to gain interactive access, install tools, modify files, and pivot to other systems.
The Attack Progression
An attack against a container with a shell progresses through multiple stages. The attacker first exploits an application vulnerability to gain code execution running as the application user. From this initial foothold, the attacker invokes an interactive shell with a command like /bin/bash -i -l. With shell access, the attacker can execute any command. The attacker then pursues several objectives: reading sensitive files such as /etc/passwd or /app/secrets, installing persistence mechanisms like backdoors or rootkits, modifying application code, spawning reverse shells to communicate with a command and control server, and reading secrets from other containers through /proc, and attempting to escape to the host kernel through privilege escalation techniques.
If your container has no shell, this attack fails at the interactive shell invocation step. The attacker can exploit the application vulnerability and gain code execution, but cannot escalate to an interactive shell or install tools. This dramatically limits the damage an attacker can inflict.
The Principle: "Any Shell Invocation at Runtime Is a Confirmed Compromise"
In shell-less containers, if you see a shell process running, it's proof that an attacker has gained code execution. There's no legitimate reason for shells to run — your application is the only process that should execute.
Compliance Benefits
Shell-less containers satisfy requirements across multiple compliance frameworks:
Framework | Requirement | Shell-Less Satisfies |
|---|---|---|
PCI-DSS | "Remove unnecessary software" | ✓ No shells |
CIS Docker Benchmark | "Don't include shell in image" | ✓ Distroless images |
FedRAMP | "Minimize attack surface" | ✓ Fewer binaries |
STIG | "Run containers as non-root" | ✓ Enforced |
HIPAA | "Implement access controls" | ✓ No interactive access |
The Significance of ENTRYPOINT in Shell-Less Containers
This is the CRITICAL concept most teams get wrong.
ENTRYPOINT vs CMD: Shell Form vs Exec Form
There are two ways to specify how a container starts:
Shell Form (Runs Bash/Sh as PID 1)
# ✗ WRONG: Uses shellENTRYPOINT nginxCMD npm startWhen you use shell form (no square brackets), Docker runs it as /bin/sh -c nginx. This design has critical implications. The /bin/sh process becomes PID 1 (the root process), and nginx runs as a child process. The shell becomes part of your container runtime, which is a security problem. Additionally, signals like SIGTERM and SIGKILL do not reach nginx directly.
When Kubernetes needs to shut down the container, it sends a SIGTERM signal to PID 1, which is the shell process. The shell ignores this signal and does not forward it to the child nginx process. Kubernetes then waits 30 seconds for the application to shut down. When the timeout expires, Kubernetes sends SIGKILL to force terminate the process. Because nginx never received the graceful shutdown signal, it crashes without closing connections properly, resulting in data corruption and dropped connections.
Exec Form (Runs Application as PID 1)
# ✓ CORRECT: Uses exec formENTRYPOINT ["nginx"]CMD ["npm", "start"]With exec form (square brackets), Docker runs the command directly as nginx, with no intermediate shell. This means nginx becomes PID 1 directly, and there is no shell process running in your container. Signals go directly to the nginx process, and graceful shutdown works as intended.
When Kubernetes needs to shut down the container, it sends a SIGTERM signal to PID 1, which is nginx itself. Nginx receives the signal immediately and begins its graceful shutdown sequence, closing connections cleanly. Nginx exits on its own without needing Kubernetes to force-kill it. This clean shutdown prevents data corruption and dropped connections.
Why CMD Without ENTRYPOINT Defaults to Shell Invocation
FROM alpine:3.18COPY app /app # If you don't specify ENTRYPOINT, CMD is wrapped in shellCMD ["/app"] # This gets run as:# /bin/sh -c /appEven with square brackets in CMD, if there's no ENTRYPOINT, Docker still wraps it in a shell.
Solution: Always specify ENTRYPOINT explicitly in exec form:
FROM alpine:3.18COPY app /app ENTRYPOINT ["/app"]# No CMD, or:# CMD [] (empty, for clarity)Why cleanimg-init as ENTRYPOINT is the Recommended Pattern
CleanStart applications use cleanimg-init as the entry point because it handles filesystem initialization (creating directories, setting permissions), configuration generation (templates, envvar expansion without shell), signal forwarding (ensuring the actual application receives signals), and health checks (HTTP endpoint built-in, no need for exec probes).
FROM gcr.io/distroless/nodejs18-debian11 COPY --from=builder /app /appCOPY --from=builder /cleanimg-init /cleanimg-init # ENTRYPOINT is cleanimg-init (PID 1)ENTRYPOINT ["/cleanimg-init", "exec", "/nodejs/bin/node", "/app/server.js"]This structure provides the following benefits: cleanimg-init is PID 1 (receives signals), cleanimg-init runs startup logic (mkdir, chown, etc.), cleanimg-init execs the application (replaces itself, application becomes PID 1), and the application receives signals directly.
When the container starts, cleanimg-init becomes PID 1. It then creates the /data directory, execs /app/server.js, and exits. This causes /app/server.js to replace cleanimg-init and become PID 1, so signals go directly to /app/server.js.
Comparison: Different Entry Point Patterns
Pattern | Shell PID 1? | Signal Handling | Startup Logic | Distroless? |
|---|---|---|---|---|
| ✓ Yes | ✗ Broken | None | ✗ No |
| ✓ Yes | ✗ Broken | None | ✗ No |
| ✗ No | ✓ Works | None | ✓ Yes |
| ✗ No | ✓ Works | ✓ Init handles it | ✓ Yes |
Per-Application Shell-Less Patterns
PostgreSQL
PostgreSQL is complex: the official image includes docker-entrypoint.sh which is a shell script. CleanStart replaces this with cleanimg-init.
The Problem with Official PostgreSQL
# Official postgres:16 imageENTRYPOINT ["docker-entrypoint.sh"]# This is a shell script that:# - Initializes data directory# - Sets up replication# - Handles various startup modes# But it requires bash!The official PostgreSQL image has a shell. To use distroless + shell-less, you must handle initialization yourself.
CleanStart PostgreSQL Solution
FROM postgres:16-alpine AS builder # Extract postgres binaries and librariesRUN apk add --no-cache tar && \ tar czf /postgres-runtime.tar.gz \ /usr/local/bin/postgres \ /usr/local/bin/pg_config \ /usr/lib/postgresql # Distroless baseFROM gcr.io/distroless/base-debian11 AS runtime # Copy postgres runtimeCOPY --from=builder /postgres-runtime.tar.gz /RUN tar xzf /postgres-runtime.tar.gz && rm /postgres-runtime.tar.gz # Copy cleanimg-initCOPY --from=builder /cleanimg-init /cleanimg-init # Copy initialization script (shell-less)COPY --chown=999:999 init-db.sh /init-db.sh USER 999 # postgres user # cleanimg-init handles initialization, then execs postgresENTRYPOINT ["/cleanimg-init", \ "exec", \ "postgres"] CMD ["-D", "/var/lib/postgresql/data"]What cleanimg-init Does for PostgreSQL
Instead of a shell script, cleanimg-init handles the following operations: Create /var/lib/postgresql/data if not exists, create /var/run/postgresql if not exists, chown both directories to postgres user, check if data directory initialized (PG_VERSION file exists), if not initialized, call pg_initdb, and exec postgres with provided arguments.
This replaces the entire docker-entrypoint.sh shell script without needing bash.
Kubernetes Manifest (PostgreSQL, Shell-Less)
apiVersion: v1kind: Podmetadata: name: postgres-dbspec: securityContext: readOnlyRootFilesystem: true runAsNonRoot: true runAsUser: 999 fsGroup: 999 containers: - name: postgres image: postgres:16-distroless-cleanimg-init securityContext: allowPrivilegeEscalation: false capabilities: drop: ["ALL"] readOnlyRootFilesystem: true volumeMounts: - name: data mountPath: /var/lib/postgresql/data - name: postgres-run mountPath: /var/run/postgresql - name: tmp mountPath: /tmp # Health check via TCP (no shell) livenessProbe: tcpSocket: port: 5432 initialDelaySeconds: 30 periodSeconds: 10 readinessProbe: tcpSocket: port: 5432 initialDelaySeconds: 5 periodSeconds: 5 volumes: - name: data persistentVolumeClaim: claimName: postgres-data-pvc - name: postgres-run emptyDir: {} - name: tmp emptyDir: sizeLimit: 1GiRedis
Redis is simpler than PostgreSQL. The official image uses docker-entrypoint.sh, but redis-server itself doesn't need initialization.
CleanStart Redis Solution
FROM redis:7-alpine AS builder # Copy redis binariesRUN apk add --no-cache tar && \ tar czf /redis-runtime.tar.gz /usr/local/bin/redis-* FROM gcr.io/distroless/base-debian11 COPY --from=builder /redis-runtime.tar.gz /RUN tar xzf /redis-runtime.tar.gz && rm /redis-runtime.tar.gz # cleanimg-init provides health check endpointCOPY --from=builder /cleanimg-init /cleanimg-init USER 999 # redis user # Direct exec (redis doesn't need complex initialization)ENTRYPOINT ["/cleanimg-init", "exec", "/usr/local/bin/redis-server"] CMD ["--dir", "/data", "--appendonly", "yes"]Health Checks Without Shell
Redis doesn't need complex initialization. Instead of shell-based health checks, use the following approaches: Option 1: TCP socket (Redis listens on 6379), or Option 2: cleanimg-init HTTP health endpoint (if configured in image).
# Option 1: TCP socket (Redis listens on 6379)livenessProbe: tcpSocket: port: 6379 initialDelaySeconds: 10 periodSeconds: 5 # Option 2: cleanimg-init HTTP health endpoint# (if configured in image)livenessProbe: httpGet: path: /health port: 8080 initialDelaySeconds: 10 periodSeconds: 5 # ✗ WRONG: Shell-based probe in shell-less containerlivenessProbe: exec: command: - /bin/sh - -c - redis-cli ping > /dev/null# This fails because /bin/sh doesn't exist!Kafka
Kafka initialization is complex. The official image includes scripts to format storage. CleanStart uses cleanimg-init to handle this without shells.
The Problem: KRaft Initialization
Kafka in KRaft mode requires storage to be formatted:
# Traditional (requires bash)$ /opt/kafka/bin/kafka-storage.sh format -t <cluster-id> -c <config-file>In a shell-less container, you can't run shell scripts.
CleanStart Kafka Solution
FROM confluentinc/cp-kafka:7.5-ubi8 AS builder # Extract Kafka runtimeRUN tar czf /kafka-runtime.tar.gz /opt/kafka FROM gcr.io/distroless/base-debian11 COPY --from=builder /kafka-runtime.tar.gz /RUN tar xzf /kafka-runtime.tar.gz && rm /kafka-runtime.tar.gz # cleanimg-init handles initializationCOPY --from=builder /cleanimg-init /cleanimg-init # Kafka configurationCOPY server.properties /opt/kafka/config/server.properties USER 1000 # kafka user # cleanimg-init:# 1. Format storage if needed# 2. Verify broker ID# 3. Ensure /var/lib/kafka/data exists# 4. exec kafka brokerENTRYPOINT ["/cleanimg-init", "exec", "/opt/kafka/bin/kafka-broker-start.sh"] CMD ["/opt/kafka/config/server.properties"]Kubernetes Manifest (Kafka, Shell-Less)
apiVersion: v1kind: Podmetadata: name: kafka-broker-0spec: securityContext: readOnlyRootFilesystem: true runAsNonRoot: true runAsUser: 1000 containers: - name: kafka image: kafka:7.5-distroless-cleanimg-init securityContext: allowPrivilegeEscalation: false capabilities: drop: ["ALL"] add: ["NET_BIND_SERVICE"] readOnlyRootFilesystem: true volumeMounts: - name: kafka-data mountPath: /var/lib/kafka/data - name: tmp mountPath: /tmp - name: var-run mountPath: /var/run env: - name: KAFKA_DATA_DIR value: /var/lib/kafka/data - name: KAFKA_BROKER_ID value: "0" # Health check via TCP (not shell) livenessProbe: tcpSocket: port: 9092 initialDelaySeconds: 30 periodSeconds: 10 volumes: - name: kafka-data persistentVolumeClaim: claimName: kafka-broker-0-pvc - name: tmp emptyDir: sizeLimit: 10Gi - name: var-run emptyDir: {}Nginx
Nginx needs no complex initialization. The challenge is config validation.
CleanStart Nginx Solution
FROM nginx:alpine AS builder # Copy nginx binariesRUN apk add --no-cache tar && \ tar czf /nginx-runtime.tar.gz \ /usr/sbin/nginx \ /usr/share/nginx FROM gcr.io/distroless/base-debian11 COPY --from=builder /nginx-runtime.tar.gz /RUN tar xzf /nginx-runtime.tar.gz && rm /nginx-runtime.tar.gz COPY --from=builder /cleanimg-init /cleanimg-init # Pre-built nginx config (validated during build)COPY --chown=101:101 nginx.conf /etc/nginx/nginx.conf USER 101 # nginx user # cleanimg-init verifies config, then execs nginxENTRYPOINT ["/cleanimg-init", "exec", "/usr/sbin/nginx"] CMD ["-g", "daemon off;"]Key Point: Config Validation During Build
Instead of validating config at runtime with nginx -t:
# During build, validate the config fileRUN /usr/sbin/nginx -t -c /etc/nginx/nginx.conf # Only if this succeeds, copy the configCOPY nginx.conf /etc/nginx/nginx.conf # At runtime, skip validation (we know it's valid)ENTRYPOINT ["/usr/sbin/nginx"]This ensures your config is valid before deploying.
Python
Python applications need careful handling of configuration file generation without shell substitution.
CleanStart Python Solution (Without Shell Substitution)
FROM python:3.11-slim AS builder WORKDIR /buildCOPY requirements.txt .RUN pip install --target /deps --no-cache-dir -r requirements.txt FROM gcr.io/distroless/python3-debian11 COPY --from=builder /deps /usr/local/lib/python3.11/site-packagesCOPY --from=builder /cleanimg-init /cleanimg-init COPY app.py /app.py USER 1000 # ✗ WRONG: This tries to use a shell# ENTRYPOINT ["sh", "-c", "python -c 'import os; print(os.getenv(\"DEBUG\"))'"] # ✓ CORRECT: Pass arguments directlyENTRYPOINT ["/cleanimg-init", "exec", "/usr/bin/python3"] CMD ["/app.py"]Configuration Without envsubst (Shell Command)
Instead of using envsubst (which invokes a shell), use Python to generate config:
# config_gen.py (run during image build)import osimport json config = { "debug": os.getenv("DEBUG", "false"), "database_url": os.getenv("DATABASE_URL", "localhost"), "port": int(os.getenv("PORT", "8000"))} with open("/app/config.json", "w") as f: json.dump(config, f)Run during build:
COPY config_gen.py /RUN python /config_gen.py # Config is now generated at build timeCOPY config.json /app/config.jsonOr at runtime (without shell):
# app.py startupimport osimport json # Load from environment variables directlyconfig = { "debug": os.getenv("DEBUG", "false").lower() == "true", "database_url": os.getenv("DATABASE_URL"), "port": int(os.getenv("PORT", "8000"))}Node.js
Node.js doesn't need shell scripts for initialization.
CleanStart Node.js Solution
FROM node:18-alpine AS builder WORKDIR /buildCOPY package*.json ./RUN npm ci && npm cache clean --force FROM gcr.io/distroless/nodejs18-debian11 COPY --from=builder /build/node_modules /app/node_modulesCOPY --from=builder /cleanimg-init /cleanimg-init COPY app.js /app.js USER 1000 ENTRYPOINT ["/cleanimg-init", "exec", "/nodejs/bin/node"] CMD ["/app.js"]Health Endpoint (Instead of Shell Probes)
// app.jsconst http = require('http'); const server = http.createServer((req, res) => { if (req.url === '/health') { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ status: 'ok', uptime: process.uptime(), memory: process.memoryUsage() })); return; } // Application logic res.writeHead(200); res.end('Hello, World!');}); server.listen(3000);Kubernetes uses this health endpoint:
livenessProbe: httpGet: path: /health port: 3000 initialDelaySeconds: 10 periodSeconds: 10Java
Java applications are straightforward once you handle the JVM startup.
CleanStart Java Solution
FROM maven:3.9-eclipse-temurin-17 AS builder WORKDIR /buildCOPY . .RUN mvn clean package -DskipTests FROM gcr.io/distroless/java17-debian11 COPY --from=builder /cleanimg-init /cleanimg-initCOPY --from=builder /build/target/app.jar /app.jar USER 1000 ENTRYPOINT ["/cleanimg-init", "exec", "/usr/bin/java"] CMD ["-Xmx512m", "-Xms256m", "-jar", "/app.jar"]Spring Boot Health Endpoint
Spring Boot includes a health endpoint by default:
livenessProbe: httpGet: path: /actuator/health port: 8080 initialDelaySeconds: 30 periodSeconds: 10 readinessProbe: httpGet: path: /actuator/health/readiness port: 8080 initialDelaySeconds: 10 periodSeconds: 5Go and Rust
Go and Rust compiled binaries need minimal initialization.
CleanStart Go Solution
FROM golang:1.20-alpine AS builder WORKDIR /buildCOPY . .RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /build/app . FROM gcr.io/distroless/base-debian11 COPY --from=builder /cleanimg-init /cleanimg-initCOPY --from=builder /build/app /app USER 1000 ENTRYPOINT ["/cleanimg-init", "exec", "/app"]CleanStart Rust Solution
FROM rust:1.70-alpine AS builder WORKDIR /buildCOPY . .RUN cargo build --release FROM gcr.io/distroless/base-debian11 COPY --from=builder /cleanimg-init /cleanimg-initCOPY --from=builder /build/target/release/app /app USER 1000 ENTRYPOINT ["/cleanimg-init", "exec", "/app"]Debugging Without Shell
The biggest concern with shell-less containers: how do you troubleshoot when things break?
Traditional Shell-Based Debugging
# Application crashes in production$ kubectl exec -it pod/myapp -- /bin/bashpod# ls -la /apppod# cat /var/log/app.logpod# grep ERROR /var/log/app.log | headpod# ps auxpod# netstat -anThis is convenient but requires a shell.
Shell-Less Debugging: The New Approach
Without a shell, you need observability-first debugging.
Option 1: Structured Logging to stdout
# Python exampleimport loggingimport jsonfrom datetime import datetime class JSONFormatter(logging.Formatter): def format(self, record): return json.dumps({ "timestamp": datetime.utcnow().isoformat(), "level": record.levelname, "logger": record.name, "message": record.getMessage(), "module": record.module, "function": record.funcName, "line": record.lineno }) # Configure logginghandler = logging.StreamHandler()handler.setFormatter(JSONFormatter())logger = logging.getLogger()logger.addHandler(handler)logger.setLevel(logging.INFO) # Usagetry: process_data()except Exception as e: logger.error(f"Data processing failed: {str(e)}")Query logs through kubectl or your observability platform:
# View structured logskubectl logs pod/myapp | jq '.level == "ERROR"' # Filter by timestampkubectl logs pod/myapp | jq 'select(.timestamp > "2024-03-20T10:00:00")' # View specific loggerkubectl logs pod/myapp | jq '.logger == "database"'Option 2: Kubernetes Ephemeral Debug Container
When you absolutely need to inspect a running container without modifying the image:
# Attach a debug container with toolskubectl debug pod/myapp -it --image=ubuntu:latest # Now you have a shell in a separate container# You can inspect the target container's filesystemroot# ls -la /proc/target-pid/root/approot# cat /proc/target-pid/root/var/log/app.logThis doesn't modify the production container — it attaches a debug sidecar.
Option 3: Prometheus Metrics Endpoint
Export metrics instead of relying on shell commands:
// Node.js example with Prometheus clientconst prometheus = require('prom-client'); const httpDuration = new prometheus.Histogram({ name: 'http_request_duration_seconds', help: 'Duration of HTTP requests in seconds', labelNames: ['method', 'route', 'status_code']}); const errorCounter = new prometheus.Counter({ name: 'application_errors_total', help: 'Total application errors', labelNames: ['error_type', 'severity']}); app.use((req, res, next) => { const start = Date.now(); res.on('finish', () => { const duration = (Date.now() - start) / 1000; httpDuration.labels(req.method, req.route?.path || req.url, res.statusCode).observe(duration); }); next();}); // Prometheus endpointapp.get('/metrics', (req, res) => { res.set('Content-Type', prometheus.register.contentType); res.end(prometheus.register.metrics());});Query metrics:
# Request latency (p95)curl myapp:3000/metrics | grep http_request_duration_seconds # Error ratecurl myapp:3000/metrics | grep application_errors_total # Memory usagecurl myapp:3000/metrics | grep nodejs_heap_size_used_bytesOption 4: Health Endpoint with Detailed Status
# Flask example@app.route('/health')def health(): return { 'status': 'healthy', 'timestamp': datetime.utcnow().isoformat(), 'uptime_seconds': time.time() - start_time, 'memory': { 'used_mb': psutil.Process().memory_info().rss / 1024 / 1024, 'available_mb': psutil.virtual_memory().available / 1024 / 1024 }, 'dependencies': { 'database': check_database_alive(), 'cache': check_redis_alive(), 'queue': check_message_queue_alive() } }Kubernetes monitors this endpoint:
livenessProbe: httpGet: path: /health port: 8000 httpHeaders: - name: Accept value: application/json failureThreshold: 3 periodSeconds: 10If dependencies become unhealthy, the pod is marked as unhealthy and restarted.
Option 5: kubectl cp (Extract Files)
If you need to examine a file inside the container:
# Copy a log file outkubectl cp myapp/pod:/app/debug.log ./debug.log # Copy an entire directorykubectl cp myapp/pod:/var/cache/app ./app-cache # No shell required — kubectl handles the copyCommon Migration Issues
"I Have Shell Scripts in My Entrypoint"
Problem: Your current Dockerfile:
FROM python:3.11COPY start.sh /start.shENTRYPOINT ["/start.sh"]# start.sh contains bash commandsSolution: Rewrite as Python script or use cleanimg-init:
FROM gcr.io/distroless/python3-debian11 # Instead of start.sh, use PythonCOPY startup.py /startup.pyCOPY --from=builder /cleanimg-init /cleanimg-init # cleanimg-init will exec Python startup scriptENTRYPOINT ["/cleanimg-init", "exec", "/usr/bin/python3", "/startup.py"]"I Need to Read Environment Variables in My Config"
Problem: You're using envsubst (a shell utility):
COPY config.template.json /config.template.json # This uses /bin/sh internallyRUN envsubst < /config.template.json > /config.jsonSolution: Use language-native config loading:
# config.pyimport osimport json config = { "database_url": os.getenv("DATABASE_URL"), "debug": os.getenv("DEBUG", "false") == "true", "port": int(os.getenv("PORT", "8000"))} with open("/app/config.json", "w") as f: json.dump(config, f)Or load directly in your application:
import os database_url = os.getenv("DATABASE_URL", "postgres://localhost")debug_mode = os.getenv("DEBUG", "false").lower() == "true""I Need to Wait for a Database"
Problem: You're using a shell script with polling:
#!/bin/bashwhile ! nc -z $DB_HOST $DB_PORT; do sleep 1doneexec python app.pySolution: Use cleanimg-init await builtin:
ENTRYPOINT ["/cleanimg-init", \ "await", "$DB_HOST:$DB_PORT", \ "exec", "/usr/bin/python3", "/app.py"]Or implement health checks in your application and let Kubernetes handle readiness:
@app.route('/ready')def ready(): try: db.connection.ping() return {'status': 'ready'}, 200 except: return {'status': 'not_ready'}, 503readinessProbe: httpGet: path: /ready port: 8000 initialDelaySeconds: 5 periodSeconds: 5Summary: Shell-Less Best Practices
Using exec form ENTRYPOINT is essential—always use ENTRYPOINT ["binary", "arg"] (never ENTRYPOINT binary arg). Use distroless base images instead of Alpine/Debian/Ubuntu. Use cleanimg-init as entry point, which handles initialization without shells. Implement health endpoints with HTTP endpoints that replace shell-based health checks. Structure logging as JSON with logs to stdout that have structured fields for queryability. Export metrics with a Prometheus endpoint instead of ps aux or free -h. Use kubectl debug for troubleshooting with ephemeral debug containers with tools, without modifying production image. Validate config at build time using RUN nginx -t before copying config (don't validate at runtime). Test locally first with docker run --rm -v /tmp:/tmp myapp to verify shell-less behavior. Assume that any shell invocation equals compromise and design your application and infrastructure with this principle.
Shell-less containers combined with read-only filesystems represent the evolution of container security: from "running processes in isolation" to "running applications in confined, hardened environments with zero interactive access points."
Alternative Approaches
Shell-less operations is one hardening strategy. The industry supports multiple approaches: Traditional containers with shell use standard Alpine, Debian, or CentOS base images with bash/sh included. The advantages include familiar tooling, standard debugging practices, and wide compatibility. The disadvantages include attack surface from shell access, easier lateral movement if compromised, and misuse enables unintended mutations. Distroless base images use Google or Chainguard Distroless images which remove all utilities and shells. The advantages include guaranteed zero shell access and smallest possible attack surface. The disadvantages include no runtime debugging tools available, requires all needed functionality pre-built, and steeper learning curve for teams used to shell access. Minimal init systems use systems like tini, dumb-init, or s6-overlay that provide lightweight initialization without full shell functionality. The advantages include lighter than standard shells and enables signal handling and process management. The disadvantages include still requires custom initialization code and less familiar to teams. Init replacement tools use custom initialization binaries (like cleanimg-init) or application-level init functions. The advantages include precisely tailored to your application and can handle application-specific initialization patterns. The disadvantages include requires development effort and debugging must be built into application. Container runtime debugging uses kubectl debug, container introspection tools, or sidecar containers for troubleshooting without modifying the application container. The advantages include keeps production image minimal while enabling debugging. The disadvantages include requires Kubernetes (not applicable to standalone containers) and adds operational complexity.
Each approach trades convenience (shell availability) for security (smaller attack surface). The choice depends on your operational maturity, incident response capabilities, and threat model.
