Silverstripe & Nginx

Silverstripe is the CMS engine of choice in NZ. The government has backed it for a number of years, which is pretty awesome for a number of reasons.

There are many choices for running it, the old fall back of Apache and mod_php is so alluring given that it just works. However is can be a pain in the but when it comes to scaling or running in constrained environments.

Where I work we have settled into a pattern of running smaller sites in a Kubernetes cluster, the background for this is that we needed a place to run smaller sites in a performant way without paying through the nose for hosting that doesn't allow us to manage anything.

Recently I've been struggling with the memory usage of these sites. How can a small site require so much memory when it's not really doing that much, and why does that memory not get reclaimed over time?

Apache in pre-fork with mod_php is not really the best combination for memeory utilisation especially with http/2 in the mix on a site with gazillions of static resources. "... Sure I'll handle 40 concurrent requests for large images, just let me allocate memory for the PHP runtime too...""

So it's time to fall back to the tried and tested Nginx with PHP-FPM. Sure it's not supported out of the box by Silverstripe, but it's what Silverstripe Cloud uses under the coverers to serve all those juicy sites.

There are some caveats:

But if we're using Kubernetes then we can address the second caveat by having a Pod with 2 Containers.

The first caveat is an annoyance, but we also need to make sure that when Silverstripe does attempt to create these files that it is possible. Secondly we need to ensure that they are not served on request. And thirdly if a new feature is added that requires something in a .htaccess rule that it is replicated into nginx config.

With those out of the way lets get started...

Building the application container

I'm going to assume that we have an example Silverstripe application already created and that it works...

So lets build our container image, using a multi-stage build. In this example if you just run a docker build . then you would end up with a container which includes PHP-FPM and updates from the php target and nothing else.

However if it is built using docker build --target build-prod ., then the resulting container will have; the themes from the node layer, PHP-FPM and updates from the php layer, and finally the installed and exposed application from the build-prod layer.

By building in this way the final container will be smaller than the sum of its parts.

# Build webpack
FROM node:18 AS node

WORKDIR /static

COPY package.json package-lock.json webpack.mix.js .
COPY /themes ./themes

RUN npm ci && npm run prod

# Apply updates and settings
FROM brettt89/silverstripe-web:8.1-fpm AS php

# Configure and harden php
RUN cp /usr/local/etc/php/php.ini-production /usr/local/etc/php/php.ini && \
    sed -i -e 's/^\(expose_php\).*$/\1 = Off/' \
           -e 's/^\(memory_limit\).*$/\1 = 128M/' \
           -e 's/^\(max_execution_time\).*$/\1 = 120/' \
           -e 's/^\(max_input_vars\).*$/\1 = 2000/' \
           -e 's/^\(upload_max_filesize\).*$/\1 = 10M/' \
           -e 's/^\(post_max_size\).*$/\1 = 10M/' \
           -e 's/^\(session\.cookie_samesite\).*/\1 = "Strict"/' \
   /usr/local/etc/php/php.ini

RUN apt-get update && \
    apt-get upgrade -y && \
    apt-get install -y curl git gzip mariadb-client zip && \
    apt-get clean

# Install latest composer for PHP dependencies
RUN cd /tmp && \
    curl -sS https://getcomposer.org/composer-2.phar > composer.phar && \
    chmod +x composer.phar && \
    mv composer.phar /usr/local/bin/composer

# Build Composer App as non-root
FROM php AS php-build

USER www-data

WORKDIR /var/www/html

COPY composer.json composer.lock .
COPY --chown=www-data:www-data app ./app
COPY --chown=www-data:www-data public ./public
COPY --chown=www-data:www-data --from=node /static/themes ./themes
RUN composer install && composer vendor-expose

#
# Build a production container
# use `--target deploy-prod`
#
FROM php AS deploy-prod

COPY --from=php-build /var/www/html /var/www/html

#
# Run this by default when no build target specified
#
FROM php as deploy-dev

Now that we have our application in a container we can move onto setting up Nginx to serve the static content and act as a reverse proxy to PHP-FPM.

Nginx config

NB: you need to put this behind another service which terminates TLS or use the original which demonstrates using lets encrypt

So I found the original of this config in the (Silverstripe forum)[https://forum.silverstripe.org/t/nginx-webserver-configuration/2246] I have removed a chunk of the config relating to TLS & Let's Encrypt as in use this will be behind an Ingress Controller in Kubernetes which will handle that part.

worker_processes  auto;
events {
  worker_connections  1024;
}
http {
  include      mime.types;
  default_type application/octet-stream;
  sendfile     on;

  keepalive_timeout  65s;

  # Let PHP manage this
  client_max_body_size 0;

  server_tokens off;

  server {
    server_name _;
    root /var/www/html/public;

    error_page 404 /assets/error-404.html;
    error_page 500 /assets/error-500.html;
    error_page 502 /assets/error-502.html;
    error_page 503 /assets/error-503.html;

    listen      80;
    listen [::]:80;

    location / {
      try_files $uri /index.php?$query_string;
    }

    location ~ /\.(htaccess|method)$ {
      return 403;
    }

    location ~ ^/assets/.protected/ {
      return 403;
    }

    location ~ ^/assets/.*\.(?i:css|js|ace|arc|arj|asf|au|avi|bmp|bz2|cab|cda|csv|dmg|doc|docx|dotx|flv|gif|gpx|gz|hqx|ico|jpeg|jpg|kml|m4a|m4v|mid|midi|mkv|mov|mp3|mp4|mpa|mpeg|mpg|ogg|ogv|pages|pcx|pdf|png|pps|ppt|pptx|potx|ra|ram|rm|rtf|sit|sitx|tar|tgz|tif|tiff|txt|wav|webm|wma|wmv|xls|xlsx|xltx|zip|zipx)$ {
      sendfile on;
      try_files $uri /index.php?$query_string;
    }

    location ~ ^/assets/error-\d\d\d\.html$ {
      try_files $uri =404;
    }

    location ~ ^/assets/ {
      return 404;
    }

    location /index.php {
      fastcgi_buffer_size 32k;
      fastcgi_busy_buffers_size 64k;
      fastcgi_buffers 4 32k;
      fastcgi_keep_conn on;
      fastcgi_pass   site:9000;
      fastcgi_index  index.php;
      fastcgi_param  SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
      include        fastcgi_params;
    }
  }
}

The key items of the config are that the root path matches the pathing in the app container rather than the default of Nginx.

Next is that all requests are tested using try_files for the URI, this means that any static files that match will be served from Nginx. If they don't match then the request is passed to /index.php (more on the later).

Then there are a set of access controls for files and locations that should not be served. And how to handle various error conditions.

Finally we get to the reverse proxy where requests are passed to the upstream listed at fastcgi_pass.

Testing in docker compose

Before we start to put this into a Kubernetes environment it can be useful to test locally using docker compose. Firstly this provides a means for developers who maybe using this locally. Secondly it removes the number of components and config used in Kubernetes that can slow the initial stage of developing the architecture.

version: '3.8'
services:

  nginx:
    container_name: project-nginx
    image: nginx:1.25-alpine
    ports:
      - 80
    volumes:
      - ./nginx.conf:/etc/nginx/nginx.conf:ro
      - .:/var/www/html:ro
    depends_on:
      - site
    networks:
      - proxy

  site:
    container_name: project-site
    image: site
    build: .
    working_dir: /var/www/html
    ports:
      - 9000
    volumes:
      - .:/var/www/html
    depends_on:
      - project-database
    networks:
      - proxy

  project-database:
    container_name: project-database
    image: mariadb:10.11
    environment:
      - MYSQL_ALLOW_EMPTY_PASSWORD=yes
    volumes:
      - db-data-project:/var/lib/mysql
    command:
      - --bind-address=0.0.0.0
    ports:
      - 13306:3306
    networks:
      - proxy

volumes:
  db-data-project:
networks:
  proxy:
    external: true

So the networks: block here provides a link to another piece of developer local infrastructre that we use to facilitate getting our applications up with a signed certificate to replicate a production like environment quite closely. See (docker-traefik)[http://github.com/mediasuitenz/docker-traefik]

This config assumes that you have built the front-end JS and CSS. That you have run composer install && composer vendor-expose. That you are able to enter into the site container and execute vendor/bin/sake dev/build.

All being well you should be able to goto https://nginx-project.localhost.direct/ and view the CMS.

Moving to Kubernetes

So now onto moving the application into a Kubernetes cluster. There are a number of things that we need to consider as we will be building production images so we will have built the front-end JS and CSS into the image, and that the templates will be in /public directory in the PHP-FPM container. So these will not be available directly to the Nginx container, meaning that we will have to make them available to it.

Secondly we will need to share the /public/_graphql between PHP-FPM and Nginx as this is updated via a few different paths and needs to be available to both.

Pod config

Note that this should be a Deployment, and the assets Volume should be something more persistent and shared.

apiVersion: v1
kind: Pod
metadata:
  name: project
spec:
  initContainers:
  - name: public-init
    image: site:latest
    command:
      - cp
      - -pR
      - --dereference
      - public/
      - /
    volumeMounts:
    - name: public-volume
      mountPath: /public
  containers:
  - name: nginx
    image: nginx:1.25-alpine
    ports:
    - name: http
      containerPort: 80
    volumeMounts:
    - name: public-volume
      mountPath: /var/www/html/public
    - name: graphql-volume
      mountPath: /var/www/html/public/_grapql
      readOnly: true
    - name: assets-volume
      mountPath: /var/www/html/public/assets
      readOnly: true
    - name: nginx-conf-volume
      mountPath: /etc/nginx/nginx.conf
      subPath: nginx.conf
      readOnly: true
  - name: site
    image: site:latest
    envFrom:
    - configMapRef:
        name: site-config
    - secretRef:
        name: site-config
    volumeMounts:
    - name: graphql-volume
      mountPath: /var/www/html/public/_grphql
    - name: assets-volume
      mountPath: /var/www/html/public/assets
  volumes:
  - name: public-volume
    emptyDir: {}
  - name: graphql-volume
    emptyDir: {}
  - name: assets-volume
    emptyDir: {}
  - name: nginx-conf-volume
    configMap:
      name: nginx-config