nginx alpine run shell script

the end goal is to run shell scripts for a given endpoint

to do this we gonna use fcgiwrap which will start dedicated process to execute our scripts

FROM nginx:alpine

RUN apk add fcgiwrap spawn-fcgi

COPY ./05-spawn-fcgi.sh /docker-entrypoint.d/05-spawn-fcgi.sh

to spawn it we adding additiona script to entrypoint

/usr/bin/spawn-fcgi -s /var/run/fcgiwrap.socket -M 766 /usr/bin/fcgiwrap

here is configuration

server {
    listen       80;
    server_name  localhost;
    root   /usr/share/nginx/html;

    location / {
        index  index.html index.htm;
    }

    # added example, any shell script in content root will be executable
    location ~ \.sh$ {
        gzip off;
        fastcgi_pass unix:/var/run/fcgiwrap.socket;
        include /etc/nginx/fastcgi_params;
    }
}

and sample script

#!/bin/sh

echo 'Content-Type: text/plain'
echo ''
echo 'Hello, world!'

be sure to split response body and headers by a single empty line

build

docker build -t cgi .

run

docker run -it --rm -p 8081:80 -v $PWD/demo.sh:/usr/share/nginx/html/demo.sh -v $PWD/default.conf:/etc/nginx/conf.d/default.conf cgi

home page works as usual

curl localhost:8081

and now we can run our scripts

curl http://localhost:8081/demo.sh

note: for everything to work, scripts must be executable, e.g.:

chmod +x 05-spawn-fcgi.sh
chmod +x demo.sh

environment

all request headers and query string passed to our script as environment variables, e.g. following script:

#!/bin/sh

echo 'Content-Type: text/plain'
echo ''
echo 'environment:'
env

and request to http://localhost:8081/demo.sh?foo=bar will print something like:

GATEWAY_INTERFACE=CGI/1.1
DOCUMENT_URI=/demo.sh
HOSTNAME=6aa74906febd
REMOTE_ADDR=172.17.0.1
QUERY_STRING=foo=bar
FCGI_ROLE=RESPONDER
DOCUMENT_ROOT=/usr/share/nginx/html
HTTP_USER_AGENT=Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36
HTTP_HOST=localhost:8081
REQUEST_URI=/demo.sh?foo=bar
SERVER_SOFTWARE=nginx/1.23.3
REQUEST_SCHEME=http
NGINX_VERSION=1.23.3
HTTP_ACCEPT_LANGUAGE=en-US,en;q=0.9,ru;q=0.8,uk;q=0.7
SERVER_PROTOCOL=HTTP/1.1
HTTP_ACCEPT_ENCODING=gzip, deflate, br
REDIRECT_STATUS=200
HTTP_SEC_FETCH_DEST=document
REQUEST_METHOD=GET
SERVER_ADDR=172.17.0.2
PWD=/usr/share/nginx/html
HTTP_SEC_FETCH_SITE=none
SERVER_PORT=80
SCRIPT_NAME=/demo.sh
SERVER_NAME=localhost

status

to control response status you may do something like:

#!/bin/sh

echo 'Status: 404'
echo 'Content-Type: text/plain'
echo ''
echo 'Not found'

post request body

theoretically post request body should be send to stdin but no matter what i have tried it did not work as expected and stdin is empty, even so content length header indicates that there is some body

but at the end it seems like it does not matter because it is even better and easier to pass params as custom headers, e.g.:

#!/bin/sh

echo 'Content-Type: text/plain'
echo ''
echo $HTTP_FOO
echo $HTTP_ACME

and usage:

curl localhost:8081/demo.sh -H 'Foo: Bar' -H 'Acme: 42'

route params

suppose we want to handle /foo/(.+)/bar

server {
    listen       80;
    server_name  localhost;
    root   /usr/share/nginx/html;

    location / {
        index  index.html index.htm;
    }

    # added example, any shell script in content root will be executable
    location ~ \.sh$ {
        gzip off;
        fastcgi_pass unix:/var/run/fcgiwrap.socket;
        include /etc/nginx/fastcgi_params;
    }


    location ~ ^/foo/(.+)/bar$ {
      gzip off;
      fastcgi_pass unix:/var/run/fcgiwrap.socket;
      include /etc/nginx/fastcgi_params;
      fastcgi_param FOOBAR $1;
      fastcgi_param SCRIPT_FILENAME  /usr/share/nginx/html/route.sh;
    }
}

#!/bin/sh

echo 'Content-Type: text/plain'
echo ''
echo "FOOBAR: $FOOBAR"

docker run -it --rm -p 8081:80 -v $PWD/demo.sh:/usr/share/nginx/html/demo.sh -v $PWD/route.sh:/usr/share/nginx/html/route.sh -v $PWD/route.conf:/etc/nginx/conf.d/default.conf cgi

with that in place all commands will work:

curl localhost:8081
# nginx start page
curl localhost:8081/demo.sh
# Hello, world!
curl localhost:8081/foo/helloworld/bar
# FOOBAR: helloworld

json

we can use jq to build json responses, aka

#!/bin/sh

echo 'Content-Type: application/json'
echo ''
jq -n --arg inarr "$(echo -en "hello\nworld\n")" '$inarr | split("\n")'
# echo -e "one\ntwo\nthree" | jq -R . | jq -s '.'

other samples

# jq - produce array of strings
echo -e "one\ntwo\nthree" | jq -R . | jq -s '.'
# [
#   "one",
#   "two",
#   "three"
# ]

# jq - produce json object
jq -n --arg foo "bar" --arg acme "42" '{foo: $foo, acme: $acme}'
# {
#   "foo": "bar",
#   "acme": "42"
# }

# jq - produce nested object
jq -n --arg foo "bar" --arg acme "42" '{nested: {foo: $foo, acme: $acme}, hello: "world"}'
# {
#   "nested": {
#     "foo": "bar",
#     "acme": "42"
#   },
#   "hello": "world"
# }

# jq - produce array of objects
for c in $(docker ps -aq)
do
  n=$(docker inspect $c | jq -r ".[0].Name")
  jq -n --arg c $c --arg n $n '{id: $c, name: $n}'
done | jq -n '[inputs | {id: .id, name: .name}]'
# [
#   {
#     "id": "bfca592aaa1d",
#     "name": "/goofy_heisenberg"
#   },
#   {
#     "id": "31fac6dfe385",
#     "name": "/elastic"
#   }
# ]

# jq - tsv with headers
echo -en "id\tname\n1\tone\n2\ttwo\n" | jq -s --slurp --raw-input --raw-output 'split("\n") | .[1:-1] | map(split("\t")) | map({"id": .[0], "name": .[1]})'

# jq - tsv without headers
echo -en "1\tone\n2\ttwo\n" | jq -s --slurp --raw-input --raw-output 'split("\n") | .[0:-1] | map(split("\t")) | map({"id": .[0], "name": .[1]})'

echo -en "1\tone\n2\ttwo" | jq -s --slurp --raw-input --raw-output 'split("\n")| map(split("\t")) | map({"id": .[0], "name": .[1]})'
# [
#   {
#     "id": "1",
#     "name": "one"
#   },
#   {
#     "id": "2",
#     "name": "two"
#   }
# ]

content negotiation

we may get accept header from incomming request and depending on its value do something like

if [[ $HTTP_ACCEPT == *"application/json"* ]]
then
  echo 'Content-Type: application/json'
  echo ''
  echo '{"foo":"bar"}'
else
  echo 'Content-Type: text/plain'
  echo ''
  echo 'foo: bar'
fi

Run FastCGI alone without nginx

Let's pretend that for some reason we wish our fastgci to run alone and utilize our ingress which we have already (sound crazy and overengineered and it definitely is)

To run it locally I did following

Prepare our index.sh script (make sure it is executable)

#!/bin/sh

echo 'Content-Type: text/plain'
echo ''
echo 'Hello, world!'

Start the first container

docker run -it --rm --name=fcgi -v "$PWD/index.sh:/site/index.sh" -w /site alpine sh

Install fastcgi

apk add fcgiwrap spawn-fcgi

Run it

spawn-fcgi -n -p 8080 /usr/bin/fcgiwrap

Notes:

  • -n to run in foreground
  • -p 80 listen to port 80 instead of sockets

Prepare nginx default.conf

server {
    listen 80;
    server_name localhost;

    location / {
        include /etc/nginx/fastcgi_params;
        fastcgi_pass fcgi:8080;
        fastcgi_param SCRIPT_FILENAME /site/index.sh;
    }
}

And run nginx

docker run -it --rm --name=nginx --link=fcgi -v "$PWD/default.conf:/etc/nginx/conf.d/default.conf" -p 8080:80 nginx:alpine

Notes:

  • we are linking previous container so can talk to it
  • SCRIPT_FILENAME should be absolute path to executable script on fcgi side

If everything fine we should be able to

curl localhost:8080

and receive desired hello world message

Running FastCGI with bash scripts behind Nginx Ingress in Kubernetes

With help of docs and previous example it is become as easy as

---
apiVersion: v1
kind: ConfigMap
metadata:
  name: mactemp
data:
  entrypoint.sh: |
    #!/bin/sh
    apk add fcgiwrap spawn-fcgi
    spawn-fcgi -n -p 80 /usr/bin/fcgiwrap
  index.sh: |
    #!/bin/sh
    echo 'Content-Type: text/plain'
    echo ''
    echo 'Hello, world!'
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: mactemp
  labels:
    app: mactemp
spec:
  replicas: 1
  selector:
    matchLabels:
      app: mactemp
  template:
    metadata:
      labels:
        app: mactemp
    spec:
      containers:
        - name: mactemp
          image: alpine
          command: ["/entrypoint.sh"]
          ports:
            - containerPort: 80
          resources:
            limits:
              cpu: 500m
              memory: 128Mi
          volumeMounts:
            - name: mactemp
              mountPath: /entrypoint.sh
              subPath: entrypoint.sh
            - name: mactemp
              mountPath: /scripts/index.sh
              subPath: index.sh
      volumes:
        - name: mactemp
          configMap:
            name: mactemp
            defaultMode: 0777
---
apiVersion: v1
kind: Service
metadata:
  name: mactemp
spec:
  selector:
    app: mactemp
  ports:
    - port: 80
---
apiVersion: v1
kind: ConfigMap
metadata:
  name: mactemp2
data:
  SCRIPT_FILENAME: "/scripts/index.sh"
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: mactemp
  annotations:
    nginx.ingress.kubernetes.io/backend-protocol: "FCGI"
    nginx.ingress.kubernetes.io/fastcgi-index: "index.sh"
    nginx.ingress.kubernetes.io/fastcgi-params-configmap: "mactemp2"
spec:
  ingressClassName: external
  rules:
  - host: mactemp.mac-blog.org.ua
    http:
      paths:
      - backend:
          service:
            name: mactemp
            port:
              number: 80
        path: /
        pathType: ImplementationSpecific
  tls:
  - hosts:
    - mactemp.mac-blog.org.ua
    secretName: mac-blog-wildcard-tls

And now we have our nano service backed by bash :)

The only downside here is that we can not just port forward service or pod, it is not usual HTTP service it is FastCGI