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