phpdockersymfonynginxssi

NGINX SSI with Symfony and PHP-FPM


I need your help with an application that use as technology stack :

I would like to split the page in different parts and cache some of them because are quite slow to be generated.

So I try to use SSI ( Server Side Inclue ) as is explaned in the documentation : https://symfony.com/doc/current/http_cache/ssi.html

This is the configuration of my dockers :

NGINX :

FROM nginx:1.19.2
COPY docker-compose/nginx /
ADD docker-compose/nginx/nginx.conf /etc/nginx/nginx.conf
ADD docker-compose/nginx/symfony.dev.conf /etc/nginx/conf.d/default.conf

and the configuration files :

nginx.conf

user www-data;
worker_processes 4;
pid /run/nginx.pid;

events {
  worker_connections  2048;
  multi_accept on;
  use epoll;
}

http {
  server_tokens on;
  sendfile on;
  tcp_nopush on;
  tcp_nodelay on;
  keepalive_timeout 15;
  types_hash_max_size 2048;
  include /etc/nginx/mime.types;
  default_type application/octet-stream;
  access_log on;
  error_log on;
  access_log /dev/stdout;
  error_log /dev/stdout;
  gzip on;
  gzip_disable "msie6";
  include /etc/nginx/conf.d/*.conf;
  include /etc/nginx/sites-enabled/*;
  open_file_cache max=300;
  client_body_temp_path /tmp 1 2;
  client_body_buffer_size 256k;
  client_body_in_file_only off;
}

symfony.dev.conf

proxy_cache_path  /tmp/nginx levels=1:2   keys_zone=default:10m;

server {
    listen 80;
    root /var/www/html/symfony/public;
    client_max_body_size 40M;

    location = /health {
        return 200 "healthy\n";
    }

    location = /ping {
        return 200 "pong\n";
    }

    location / {
        ssi on;
        try_files $uri /index.php$is_args$args;
    }

    location ~ ^/index\.php(/|$) {
        ssi on;
        fastcgi_pass php-fpm:9000;
        fastcgi_split_path_info ^(.+\.php)(/.*)$;
        include fastcgi_params;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
        fastcgi_param DOCUMENT_ROOT $document_root;
        fastcgi_param REQUEST_METHOD  $request_method;
        fastcgi_param CONTENT_TYPE    $content_type;
        fastcgi_param CONTENT_LENGTH  $content_length;
        fastcgi_read_timeout 300;
        internal;
    }

    location ~ \.php$ {
        return 404;
    }

    location /status {
        access_log off;
        include fastcgi_params;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
        fastcgi_pass php-fpm:9000;
        fastcgi_index status.html;
    }

    error_log /var/log/nginx/error.log;
    access_log /var/log/nginx/access.log;
}

As you can see I enable the SSI on the webserver.

Moreover I add this in the configuration of the framework (like doc) :

framework:
    ssi: { enabled: true }
    fragments: { path: /_fragment }

In the template / controller I follow the doc :

template

    {{ render_ssi(controller('App\\Controller\\Pages\\HomeController::xxxx')) }}

controller

    public function xxxx() {
        sleep(2);
        $response = $this->render('pages/home/xxxx.html.twig', [
        ]);
        $response->setSharedMaxAge(Constants::SSI_CACHE_TTL);
        return $response;
    }

The sleep command is to test if the cache and iss is working propriety...

MORE INFOs :

I see in the vendor after reading this in the doc : render_ssi ensures that SSI directive are generated only if the request has the header requirement like Surrogate-Capability: device="SSI/1.0" (normally given by the web server). Otherwise it will embed directly the sub-response.

So I try to find in the code where is the block that decide if use SSI or not :

vendor/symfony/http-kernel/HttpCache/AbstractSurrogate.php

in this line :

    /**
     * {@inheritdoc}
     */
    public function hasSurrogateCapability(Request $request)
    {
        if (null === $value = $request->headers->get('Surrogate-Capability')) {
            return false;
        }

        return false !== strpos($value, sprintf('%s/1.0', strtoupper($this->getName())));
    }

So I think my webserver doesn't send the ISS-Header (Surrogate-Capability) to my php-fpm.

I don't have any Idea on what can I change to make some test...

Thank you all if you can help me...

Regards

EDIT :

I crate a repository with the same problem exposed before, you can test it directly.

https://github.com/alessandro-candon/ssi-symfony


Solution

  • I'm sharing the solution I previously gave you in private, so everybody can have access to it.

    1. First of all, since you are using fastcgi, you must use the fastcgi_cache_* directives,

    for example:

    fastcgi_cache_path  /tmp/nginx  levels=1:2  keys_zone=foobar:10m;
    

    instead of

    proxy_cache_path    /tmp/nginx  levels=1:2  keys_zone=foobar:10m;
    
    1. Since Symfony identifies the ssi fragments with an unique query string, you must include the query string in the cache key parameter,

    otherwise you would always cache the entire page. You can use:

    fastcgi_cache_key   $scheme://$host$saved_uri$is_args$args;
    
    1. From your code on https://github.com/alessandro-candon/ssi-symfony,

    I see you call:

    http://localhost:8101/home
    

    It would match the "/" location, then nginx would make an internal request to ^/index.php(/|$)

    The problem is, in this way the current $uri variable will be changed to "index.php", so you are losing "/home" and can't pass it anymore to Symfony (where it is handled by the Symfony routing). To solve this, save it to a custom nginx variable:

    set $saved_uri $uri;
    

    then, pass it to fastcgi:

    fastcgi_param REQUEST_URI  $saved_uri;
    

    NOTE By default the REQUEST_URI fastcgi param is set to $request_uri. If you don't change it, Symfony will always receive the path provided by curl ("/home") for the ssi fragments requests too! Therefore you'll get an infinite loop when solving the ssi includes. (see: Wrong cache key for SSI-subrequests with FastCGI)

    I suggest you to include the default fastcgi_params asap, so you can override them later. If you put include fastcgi_params at the bottom of you configuration, your custom values would be overridden by the default ones.

    1. You must also consider that when doing an internal request for the ssi inclusion, nginx does not update the $uri variable. To solve this, explicitly update it. (NOTE: Here i'm using $saved_uri instead of $uri because of the problem described before). Assuming you are using _fragment to identify the path for your ssi fregments generated by Symfony,

    you need to add:

    set $saved_uri /_fragment;
    
    1. About the SSI header: to tell to Symfony that nginx has got ssi enabled, the ssi on directive is not enough, because nginx doesn't automatically send the Surrogate-Capability: device="SSI/1.0" header to Symfony.

    To do so, use:

    fastcgi_param HTTP_SURROGATE_CAPABILITY "device=\"SSI/1.0\"";
    
    1. Last but not least, remember that defining the cache path is not enough,

    you also need to tell nginx to use it:

    fastcgi_cache foobar;
    

    In conclusion, the complete configuration will be:

    fastcgi_cache_path  /tmp/nginx  levels=1:2  keys_zone=foobar:10m;
    fastcgi_cache_key   $scheme://$host$saved_uri$is_args$args;  # The query string must be used here too, because Symfony uses it to identify the ssi fragment
    
    server {
        listen 80;
        root /var/www/html/symfony/public;
        client_max_body_size 40M;
    
        include fastcgi_params;  # We must put this here ahead, to let locations override the params
    
        location = /health {
            return 200 "healthy\n";
        }
    
        location = /ping {
            return 200 "pong\n";
        }
    
        location /_fragment {
            set $saved_uri /_fragment;  # We hardcode the value because internal ssi requests DO NOT update the $uri variable !
            try_files $uri /index.php$is_args$args;
            internal;
        }
    
        location / {
             set $saved_uri $uri;  # We need this because the $uri is renamed later when making the internal request towards "index.php", so we would lose the original request !
             try_files $uri /index.php$is_args$args;
        }
    
        location ~ ^/index\.php(/|$) {
    
            fastcgi_cache foobar;  # Remember to not use the directive "proxy_cache" fastcgi
            add_header X-Cache-Status $upstream_cache_status;  # Used for debugging
                                                               # NOTE nginx<->Symfony cache is NOT considered in this
    
            fastcgi_param HTTP_SURROGATE_CAPABILITY "device=\"SSI/1.0\"";  # Nginx doesn't pass this http header to Symfony even if ssi is on, but Symfony needs it to know if the proxy is able to use ssi
            ssi on;
    
            fastcgi_param REQUEST_URI  $saved_uri;  # IMPORTANT The included default "fastcgi_params" uses $request_uri, so internal requests are skipped ! This causes an infinite loop because of ssi inclusion.
            fastcgi_param QUERY_STRING $args;  # For some reason, we need to pass it again even if the included default "fastcgi_params" looks correct
    
            fastcgi_pass php-fpm:9000;
            fastcgi_split_path_info ^(.+\.php)(/.*)$;
            fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
            fastcgi_param DOCUMENT_ROOT   $document_root;
            fastcgi_param REQUEST_METHOD  $request_method;
            fastcgi_param CONTENT_TYPE    $content_type;
            fastcgi_param CONTENT_LENGTH  $content_length;
            fastcgi_read_timeout 300;
            internal;
        }
    
        location ~ \.php$ {
            return 404;
        }
    
        location /status {
            access_log off;
            include fastcgi_params;
            fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
            fastcgi_pass php-fpm:9000;
            fastcgi_index status.html;
        }
    
        error_log /var/log/nginx/error.log;
        access_log /var/log/nginx/access.log;
    }