After a while, I noticed that my development setup is quite slow and for no apparent reason. For example, a simple API call to fetch a user resulted in 3.5 seconds of loading time. Looking into Symfony Profiler didn't give me any clues; total execution time from Request to Response was around 160ms, which I think is good enough. However, the browser's "Network" tab disagreed. Getting a response back took about 3.5 seconds. I didn't have a clue what is causing this.
Onto debugging
So I decided to go with the elimination process. First, I decided to check whether the Docker container is responsive enough by itself. I added a simple PHP file inside the /public folder:
# ./public/hello.php
<?php
echo "Hello from the container";
Firing away the request https://localhost/hello.php
resulted in fast response taking only 3ms.
So no problem with Docker, Apache or PHP configuration.
Problematic Docker volume mounting
The next thing that was immediately on my mind was the good old docker volume mounting. When docker container (in which the app will be running) is started it needs access to the application's source files. To do this we usually mount our local folder into container configuring docker-compose.yml
like this:
# docker-compose.yml
services:
php-apache:
volumes:
- '.:/usr/src/app'
...
By doing this we opened a "portal" from inside of a container to our local machine. But it comes with a cost.
When starting the Symfony app there are millions of millions (ok, not millions but something between thousands to tens of thousands) files needed to be read from disk in order to "load" the application into the memory and execute it. Not to mention the files which are also being written as well, such as cache and log files. And all that needs to happen in a split of a second. Obviously (for the reasons I'm not getting into here) there is a slight overhead on these read/write operations when they are being used through docker's mounting mechanism. Surely having this overhead on 10 files wouldn't be noticeable, but multiply it with 10 000 times and...
So to see if this will help I decided to exclude ./vendor
, ./var/cache
, and ./var/log
folders from mounting to the container. My initial idea was to keep the whole project folder mounted as it was until now, and then just add some "exclusion" rules to exclude mounting of previously mentioned folders. But as it turns out there is no such thing as "exclude" rule for mounting volumes in docker-compose.yml
.
Named volumes
So after doing some research and reading Docker's documentation I decided to go with the named volumes. I figured out that this is a common method to achieve 2 things: create dedicated internal volumes inside of a container (which can also be referenced by its name), but also by doing this I can "override" initially mounted folders. Here is what I did:
# docker-compose.yml
services:
php-apache:
volumes:
- '.:/usr/src/app'
- 'cache:/usr/src/app/var/cache'
- 'log:/usr/src/app/var/log'
- 'vendor:/usr/src/app/vendor'
...
volumes:
cache:
log:
vendor:
I added 3 named volumes, cache
, log
, and vendor
, and then mapped them internally where mounted folders used to be. That way, docker mounts these named volumes internally (inside a container), so they are not linked to the host machine. Notice that I also had to add additional volumes
section to declare those mounted volumes (otherwise I got an error that "...no declaration was found in the volumes section"
).
After this was set up I fired up the docker containers, but before I could try to make a call to the API, I had to install dependencies again because now the vendor
folder from the host machine is not accessible to our container anymore.
3, 2, 1 Lift-off
Now the big moment: the first call took about 1.25 seconds, which is better compared to the previous result, but I also knew that Symfony needs to build its cache, which was, same as the vendor
folder, empty.
As the second call was fired, I had a new record, 178ms! It's worth mentioning that this result was achieved with APP_DEBUG
being enabled. Disabling it resulted in a steady 135 to 145 ms, but as this is a development environment I enabled it back and still enjoyed a delightful ~178ms response time.
All good, however...
The downsides
As a result of using named volumes, those volumes were now living only inside of a container, meaning that they were not being synced to a host machine anymore. This lead to a couple of issues:
- the IDE autocompletion was not working properly anymore because, same as your project code itself, it relies on a vendor codebase
- debugging inside the
vendor
folder was not possible because IDE can not reference to nonexisting vendor code - peeking into 3rd-party source code was also not possible anymore, and sometimes you just need to see what's going on inside
- not having the
cache
folder sometimes also breaks the debugging - although you can see the
log
files by entering the container, it's not as practical as having them available on your host machine
How about even better solution?
It turned out there might be an even better solution without the previously mentioned downsides. When mounting volumes in docker it is possible to define different caching strategy for mounted volumes in order to improve performance:
consistent
- this strategy is applied by default and ensures perfect and immediate file sync between host and container. This is the strategy we want to use for our./src
folder so that we can immediately see the changes in our application which runs inside of a container.cached
- this strategy will delay sync from host to the container, but this does not suit our need here.delegated
- opposite ofcached
strategy, this one delays sync from a container to the host.
Since performance problematic folders (vendor
, cache
, and logs
) do not require perfect consistency between a container and the host, I can exploit this situation to my advantage and use delegated
strategy for mounting them.
By applying this strategy the vendor
folder could have been populated from inside a container (when installing/updating dependencies), but also eventually available on the host machine as well. And since a small sync delay between a container and the host for these folders is not critical in any way, this seems like a perfect solution to my problem.
So I removed the named volumes and switched back to mounted volumes but with different mounting strategy, which resulted in the following changes inside of the docker-compose.yml
file:
# docker-compose.yml
services:
php-apache:
volumes:
- '.:/usr/src/app'
- './var/cache:/usr/src/app/var/cache:delegated'
- './var/log:/usr/src/app/var/log:delegated'
- './vendor:/usr/src/app/vendor:delegated'
...
# We don't use named volumes any more:
#volumes:
# cache:
# log:
# vendor:
After restarting the containers I made an API call one more time: the result was around 175ms. Quite satisfying.