Create a PHP command based on docker

tutorial docker php

The purpose of this post is to see if it is possible to avoid installing PHP on development machines. To achieve this, the idea is to create a PHP command based on a docker container.

PHP and docker

PHP can be used with docker. In production, docker brings consistency with other applications coded with different languages. And this can greatly simplify their deployment.

But docker can also be useful in a development environment. There is no doubt that when there is a new developer on a project, docker makes it far easier for him to install all the necessary dependencies. And at the same time it ensures that the environment is exactly the same for everyone, in development but also in production. This could keep you away from strange bugs.

There are good tutorials that explain how to use docker-compose to configure your project, like this one.

To use a production container in development, you just need to create a volume. So the code on your system replaces the one inside the container. This way the code can be modified outside the container in a code editor. The changes are reflected immediately. And this works both ways. You can use docker exec to run some commands in the container and it will affect the code on your machine.

So far so good, no need to install PHP outside docker for this use case.

Need a PHP command after all ?

All this works fine. But there are still cases where PHP is needed outside of the container of the application.

  • If you want to use a linter or a code formatter in your code editor, it will need access to PHP.
  • At some point you may also want to run some small scripts that do not belong to a project.

For this you still need PHP outside docker.

Creating a PHP command

There still is a solution to avoid installing PHP outside docker.

You can use docker run to create a container and execute PHP. You just need to add some volumes to the container. These volumes should include the paths to your code.

1
2
# the current directory and the script directory should be included in the home directory for this to work
docker run -it --rm -v /home:/home -w $PWD php:cli php /home/my_script.php

Note that this could be included in a script:

1
2
#!/bin/bash
docker run -it --rm -v /home:/home -w $PWD php:cli php $@

Which can be used like this: ./php.sh arg1 arg2 ...

And now you have your php command !

It works but it increases the startup time so much that this makes it unusable in a code editor. At least if your code formatter is called each time you save a file. The delay is due to the creation of the container. So there may be a way to reduce this overhead.

You can create a container that runs in the background. Instead of executing a script, the container sleeps indefinitely. Then you can use docker exec to execute a PHP script in the container.

1
2
3
4
5
6
7
8
# create a sleeping container
docker run -d -i --name php_worker -v /home:/home php:cli bash -c "while true; do sleep 1; done;"

# execute the php script
docker exec -it -w $PWD php_worker php /home/my_script.php

# remove the container
docker kill php_worker && docker rm php_worker

On my machine I get these results for php -v:

Method Execution time
docker run ~ 900 ms
docker exec ~ 180 ms
php ~ 15 ms

The docker exec version saves a few millisecond compared to docker run. It is not perfect but it is enough to make it usable.

In practice

Creating the container and then using docker exec is not really practical. So I wrote a python script that uses this idea.

It uses docker exec to run the PHP command. Before running the exec command, it checks to see if the sleeping PHP container exists. If it does not, it creates it. This container will run for a defined duration, so the overhead induced by its creation will only impact the first call. It also can write logs to help debug eventual issues.

Beware that the script may need to download the docker image, so it can take time on the first call.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
#!/usr/bin/python

import os
import sys
import subprocess
import datetime

#---------------------
# configuration
#---------------------
volumes = ["/tmp", "/opt", "/home"] # add the directories where your php files are located
imageName = "php:cli"  # set imageName to php:5.6-cli to use php 5.6
command = "php"
containerName = "php_cmd_worker" # name of the sleeping container
containerLifetime = 12 * 60 * 60 # clear the container after 12 hours
logFile = "" # set logFile to log the output in a file, useful for debugging if the script does not work
debug = False # set debug to True to log the output even if it is not an error


#---------------------
# execute command
#---------------------
def main():
    # ensure the php container is running
    if not isRunning():
        restartContainer()

    # executes the php command inside the php container
    output = execCmd(
        ["docker", "exec", "-i", "-w",
         os.getcwd(), containerName, command] + sys.argv[1:],
        "execute command")

    sys.stdout.write(output)


# isRunning returns true if the php container is running.
def isRunning():
    output = execCmd([
        "docker", "ps", "-f", "status=running", "-f", "name=" + containerName
    ], "check if container is running")
    length = len(output.split('\n'))
    return length == 3


# restartContainer starts (or restarts) the php container.
def restartContainer():
    sleepCmd = "sleep " + str(containerLifetime)
    if containerLifetime <= 0:
        sleepCmd = "while true; do sleep 1; done;"
    execCmd(["docker", "kill", containerName], "kill existing container")
    execCmd(["docker", "rm", containerName], "remove existing container")
    execCmd(["docker", "run", "-d", "-i", "--name", containerName] +
            formatVolumes(volumes) + [imageName, "bash", "-c", sleepCmd],
            "create container")


# formatVolumes returns a list of string
# containing the volume arguments for the docker run command.
def formatVolumes(volumes):
    vols = []
    for v in volumes:
        vols += ["-v", v + ":" + v]
    return vols


# execCmd runs a unix command.
def execCmd(cmd, descr=""):
    child = subprocess.Popen(
        cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
    output = child.communicate()[0]
    if debug or child.returncode > 0:
        log(output, descr)
    return output


# log logs a message to the logFile.
def log(msg, descr):
    if logFile == "":
        return
    with open(logFile, "a") as f:
        f.write("### " + datetime.datetime.now().isoformat() + " [" + descr +
                "]" + "\n" + msg + "\n")


if __name__ == "__main__":
    main()

Copy the script in /usr/local/bin/php (or any other destination included in your $PATH) and you have a PHP command that works with docker:

1
2
sudo vim /usr/local/bin/php # I used vim here but you can use your favorite editor
sudo chmod +x /usr/local/bin/php

The script has two restrictions:

  • It must be called from a directory that is shared with the docker container.
  • The script and its dependencies must be in the directories shared with the container.

Why use this ?

With this script it is possible to avoid installing PHP on your system. But using a package manager like apt is still a good choice. So what are the advantages of this docker solution ?

  • Depending on your package manager repositories, it may take time to get the latest version of PHP. On the other hand, the official PHP docker images are updated frequently. So you may have the latest version more quickly.
  • With this script you can run different versions of PHP with ease. You just need to change the name of the images in the script.
  • You can create and use your own PHP image with additional extensions. Share the image with the rest of your team so they can have exactly the same environment.
  • You can also use the image of one of your PHP project.

In practice you probably will want to install PHP with a package manager. But this script can be a good complement for specific use cases.

Other applications

In this example the script is used for PHP. But it is not restricted to that. It can be adapted to be used with other scripting languages like Node.js or ruby for example. You just need to change the configuration parameters.