4 steps to improve Laravel + Docker performance issues
Tags: laravel, nginx, docker, performance
Published: 2022-01-04 11:31:38

Ok, so the title is a little misleading.  I was attempting to improve the performance of a Laravel application that was running inside docker but the improvements I made would work with other frameworks i.e. Symfony, CodeIgniter or even your own bespoke application.  In this particular case, these changes resulted in a performance gain of almost **90%**.

The performance improvements that I introduced were (I'll cover each of these in a bit more detail below).

- Checking for DNS issues
- Installing PHP Opcache
- Configuring Nginx to handle OPTIONS requests
- Installing docker-sync

## Checking for DNS issues

DNS resolution issues can greatly affect the performance of your application and can leave you trying to debug applications for performance issues when your application is working fine.  In my case, I traced the DNS resolution issues down to the use of a `.test` domain extension, i.e. example.domain.test.   To try and identify the root cause, I created some sample entries in the `/etc/hosts` file to test with i.e.

```systemd
127.0.0.1   example.domain.test
127.0.0.1   example.domain.xyz
```

With these set up, `localhost`, `example.domain.test` and `example.domain.xyz` will all resolve to 127.0.0.1.  I was suspecting performance issues with `example.domain.test` and I set up `example.domain.xyz` as an alternative mapping to see if the issue was isolated to the `.test` extension or also affected the `.xyz` one.

Next, I spun up a standard nginx docker container so there was some content being served for our test cURL request to receive.

```bash
docker run --rm -p 8081:80 nginx
```

With this running, we can make a request to http://localhost:8081 or http://example.domain.test:8081 or http://example.domain.xyz:8081) and we should be presented with the default "Welcome to nginx!" page.

Now we can test if there are any DNS resolution issues by timing the requests.

### Via localhost
```systemd
sh-3.2$ /usr/bin/time curl -I  localhost:8081
HTTP/1.1 200 OK
Server: nginx/1.21.4
Date: Fri, 17 Dec 2021 08:32:50 GMT
Content-Type: text/html
Content-Length: 615
Last-Modified: Tue, 02 Nov 2021 14:49:22 GMT
Connection: keep-alive
ETag: "61814ff2-267"
Accept-Ranges: bytes

        0.01 real         0.00 user         0.00 sys
```

### Via example.domain.test
```systemd
sh-3.2$ /usr/bin/time curl -I  example.domain.test:8081
HTTP/1.1 200 OK
Server: nginx/1.21.4
Date: Fri, 17 Dec 2021 08:33:17 GMT
Content-Type: text/html
Content-Length: 615
Last-Modified: Tue, 02 Nov 2021 14:49:22 GMT
Connection: keep-alive
ETag: "61814ff2-267"
Accept-Ranges: bytes

        5.10 real         0.00 user         0.00 sys
```

### Via example.domain.xyz

```systemd
sh-3.2$ /usr/bin/time curl -I  example.domain.xyz:8081
HTTP/1.1 200 OK
Server: nginx/1.21.4
Date: Fri, 17 Dec 2021 08:33:42 GMT
Content-Type: text/html
Content-Length: 615
Last-Modified: Tue, 02 Nov 2021 14:49:22 GMT
Connection: keep-alive
ETag: "61814ff2-267"
Accept-Ranges: bytes

        0.05 real         0.00 user         0.00 sys
```

In my case, I was running this on Mac OSX and `localhost` and `example.domain.xyz` returns in a few milliseconds but `example.domain.test` has a 5-second delay.  This turned out to be due to attempts to resolve the hostname via IPV6 and I'd only defined the IPV4 mapping in `/etc/hosts` file. Once I added the IPV6 mapping into my `/etc/hosts` file, like below, 

```systemd
127.0.0.1   example.domain.test
127.0.0.1   example.domain.xyz

::1 example.domain.test
```

I could then see that the cURL request to `example.domain.test` were resolving quickly as expected.

```systemd
sh-3.2$ /usr/bin/time curl -I  example.domain.test:8081
HTTP/1.1 200 OK
Server: nginx/1.21.4
Date: Fri, 17 Dec 2021 08:37:11 GMT
Content-Type: text/html
Content-Length: 615
Last-Modified: Tue, 02 Nov 2021 14:49:22 GMT
Connection: keep-alive
ETag: "61814ff2-267"
Accept-Ranges: bytes

        0.01 real         0.00 user         0.00 sys
```
Note: If your application is an API that is accessed via an AJAX request, you could be affected by this delay twice, once for the preflight (OPTIONS) request and again for the GET / POST request, so this change could save you around 10 seconds overall per AJAX request.

## Installing PHP Opcache

The PHP docker image doesn't have `opcache` enabled by default.  You can add this to your `Dockerfile` in a similar way to below

```docker
FROM php:7.4-fpm

RUN apt-get update && apt-get install opcache -y
```

Unless you have specific needs, just enabling `opcache` should be enough

## Configuring Nginx to handle OPTIONS requests
If your application is being accessed via an AJAX request then there are 2 requests that are being made.  An `OPTIONS` (CORS) request (to see if the action is allowed) and the `GET / POST / PATCH` etc request (the actual action) and if your application is handling the `OPTIONS` request then you have a delay as a result of passing the request over to PHP and PHP booting up the framework etc to process the request.  

In a development environment, you might be happy to leave your application open to all requests, in which case we can configure Nginx to handle the `OPTIONS` request directly and cut out your application which will speed up the request.  To achieve this we need to add the following into your nginx configuration

```nginx 
# for OPTIONS return these headers and HTTP 200 status
if ($request_method = OPTIONS) {
    add_header Access-Control-Allow-Methods "*";
    add_header Access-Control-Allow-Headers "*";
    add_header Access-Control-Allow-Origin "*";
    return 200;
}
```

For example, in our garage API case, a full `server` block for development might look like this.

```nginx
server {
	server_tokens off;

    listen 80;
    listen [::]:80;

    root /var/www/garage-api/public;
    index index.php index.html index.htm;

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

        # for OPTIONS return these headers and HTTP 200 status
        if ($request_method = OPTIONS) {
            add_header Access-Control-Allow-Methods "*";
            add_header Access-Control-Allow-Headers "*";
            add_header Access-Control-Allow-Origin "*";
            return 200;
        }
    }

    location ~ \.php$ {
        try_files $uri =404;
        fastcgi_split_path_info ^(.+\.php)(/.+)$;
        fastcgi_pass garage-api:9000;
        fastcgi_index index.php;
        include fastcgi_params;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
        fastcgi_param PATH_INFO $fastcgi_path_info;
    }
}
```

## Installing docker-sync

Install `docker-sync` using the following command

```bash
sudo gem install docker-sync
```

Configure

### docker-sync.yml

```yaml
version: "2"
syncs:
  garage-api-sync:
    src: './application'
    sync_userid: 33
    sync_groupid: 33
```

### docker-compose.yml

```yaml
version: '3.7'

services:
  garage-api:
    volume:
      garage-api-sync:/app:nocopy

  garage-nginx:
    image: nginx
```

### Spinning up

```yaml
docker-sync start
docker-compose -f docker-compose.yml -f docker-compose.dev.yml up -d
```

### Summary

Although I was looking to boost the performance of my Laravel application running inside docker, these changes are not related to Laravel and so could be applied to any other framework or PHP application but these 4 changes did have a significant impact on the performance of my application.  

I focused on one particularly heavy area of the application in which had a frontend application was making 18 API calls (so 9 preflight requests + 9 GET requests) and originally this took around **32 seconds** to complete all the API calls.  After the changes had been put in place this dropped to around **3.5 seconds** resulting in a performance boost of almost **90%**.
 
## References
- Docker Sync documentation - [https://docker-sync.readthedocs.io/en/latest/index.html](https://docker-sync.readthedocs.io/en/latest/index.html)