vagrant with ansible provisionning docker containers for symfony2

  • April 12, 2015

In this article we’re going to cover:

  • How to use ansible+docker+vagrant to create a one size for all environment for Symfony2 + Nginx + Postgresql + PHP-fpm
  • How to transition an existing application to fit in that new environment

This article take the assumption that you have already heard about all the technologies above without being too familiar with them.

As an example will take the existing Oauth2 server I have created: Oauth2Symfony2

Introduction

Recently I’ve been reading a lot about Docker, Vagrant and Ansible. At my company we’re already using vagrant and ansible to create our development machines, and as now the continous testing tool is using a docker container to run the test, the next step was logically to start using docker everywhere, in order to have the same environment from the developers machine to production.

My requirements were the following:

  • Get a model to split our each of our web projects into a set of docker containers, to increase reusability and reproductability
  • Use ansible for provisionning, for its powerful feature while yet staying simple.
  • Be able to have one command, the same, to create my dev environment on my linux laptop and to create staging / production etc.
  • Still be able to have a Vagrant for the developers on Mac/Windows but just as a layer to provide them the linux necessary to have Docker (and ansible)
  • In case of Heisenbug, be able to nuke my environment and recreate quickly in a way I’m sure to have something clean, while being sure not to have broken my computer
  • To works fine with Symfony2 web applications

Step 1, getting the Docker containers ready

For this first try, we’re not going to push the logic to its maximun and we will keep things simple we’re going to have 2 Docker containers

  • Postgresql container hosting the database
  • Nginx+PHP-fpm container hosting the application code

We will put all our dockerfiles in a directory DockerFiles with one subdirectory by container.

Postgresql container

The docker repository has already a very nice official postgresql container it contains instructions on how to extend it, perfect

So in DockerFiles/postgres we now have

# vim:set ft=dockerfile:
FROM library/postgres

Here nothing funky, we’re just saying our container’s image will use the official postgres image as a base. As we progress it will be completed with a script to create a database and a database user, but outside of this it currently perfectly fits our needs

Nginx + PHP-fpm

The docker repository is full of container for PHP-fpm with Nginx but I didn’t find any which met my needs:

  • Getting a recent version of PHP (5.5)
  • PHP with enough PHP-modules pre-instaled (php5-redis and php-posgresql)
  • Small image size (we’re located in China and 300mo instead of 500mo can means an hour or two saved)

So I started from a the stock Debian Wheezy image, Debian and not ubuntu because that’s on what the Postgres image is based, and it will not necessitate to redownload an other full distribution.

Here’s the base docker file we’re creating in DockerFiles/Symfony2/, comments have been added inside

# vim:set ft=dockerfile:
FROM debian:wheezy

# so that my colleagues know who to yell at
# when they will find a problem in it.
MAINTAINER SIMON Allan <simona@gobeta.com.cn>

# RUN is going to create a new layer, in order to avoid creating
#     dozen of layers, we chain the command with &&
#
# we're adding packages.dotdeb.org repository to get PHP5.5
# and activating the backport to get php5-redis
#
# once done we install the necessary package
# curl to later get composer)
# git to clone non-stable composer packages
# a huge list of php5 modules to cover future usage
#
# at the end we clean all temporary files so that when docker
# create the layer, it is as slim as possible
RUN apt-key adv --keyserver keyserver.ubuntu.com --recv-keys E9C74FEEA2098A6E && \
    echo "deb http://packages.dotdeb.org/ wheezy-php55 all" > /etc/apt/sources.list.d/php.list && \
    echo "deb http://ftp.debian.org/debian wheezy-backports main contrib non-free" >> /etc/apt/sources.list.d/php.list && \
    apt-get update && \
    apt-get install -y \
        curl \
        git \
        nginx \
        php5-fpm \
        php5-cli \
        php5-xdebug \
        php5-imagick \
        php5-gd \
        php5-mongo \
        php5-curl \
        php5-mcrypt \
        php5-intl \
        php5-mysql \
        php5-sqlite \
        php5-redis \
        php5-pgsql \
    && rm -rf /var/lib/apt/lists/*

# we directly put composer in the image, so that
# even in case of China blocking the composer website, the image
# will still be usable, as this command may fails (thanks great firewall)
# we put it on a separate RUN, so that we don't need to run the previous
# commands again and again in case of failure.
RUN curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer

# our docker image will put this port as 'public', and then people
# using this image will be able to map it to port on their host machine
EXPOSE 80
EXPOSE 443

# a good practice in docker, as the container themselves can be run
# as demon, is to run the service inside it directly, so that the
# ouput of the service goes on STDOUT and can be gathered using
# standard tool (more on that in an other article)
RUN echo "\ndaemon off;" >> /etc/nginx/nginx.conf

Building the Docker images using Ansible

For those who don’t know Ansible, it’s a tool made to automate all the steps you do in order to setup an environment, be it creating files, installing packages, modifying configuration, making sure a service is started and an other stopped. It works also if the environment is made of several machines scattered over the world, as long as you can SSH to them.

Here the first goal is to make Ansible build these docker images. Latter we will improve it to start containers based on these images and linking them together.

Ansible is used by creating what they call a “playbook”, a playbook is one or several Yaml files describing the tasks to accomplish or the state to reach. Common tasks like installing packages have pre-made modules to make their description short and easy-to-read.

Before anything make sure you have the latest version of ansible pip install ansible --upgrade, we assume from now that you have the versio 1.9.0.1. Then at the root of your working directory create a file name playbook.yml and put that in it

---
- hosts: all
  sudo: yes
  tasks:
    - name: Install Docker-py
      pip: name=docker-py state=present

    - name: check or build image for postgres
      docker_image: path="./DockerFiles/postgres" name="allansimon/postgres-for-symfony" state=build

    - name: check or build image for symfony2
      docker_image: path="./DockerFiles/Symfony2" name="allansimon/symfony2" state=build


Here we have a very basic playbook that can be applied to every hosts and that will run every tasks using sudo. Then we describe one task (name is up to you) that will use pip to install docker-py if not already present. Docker-py will be needed for ansible to communicate with Docker.

Then we have two tasks that will build the Dockerfile in path and will associate it ot a name.

Note: the docker_image is marked as deprecated in the Ansible, however the new docker module has nothing to build Dockerfiles…

Note 2: on ubuntu 14.10 I got a bug with the pip installed by apt and I needed to add these tasks at the beginning

  tasks:
    - name: remove pip if installed from apt (fix bug of pip in ubuntu 14.XX)
      apt: name=python-pip state=absent

    - name: Easy install (fix bug of pip in ubuntu 14.XX)
      easy_install: name=pip
    # then the other tasks

Modifying our Postgres image to create a database for Symfony

Our postgres image is currently very basic, there’s postgresql installed inside but no database or user created for our application. Let’s fix that.According to the documentation you can add your custom script or SQL query to be launched at the container start by adding your script to the /docker-entrypoint-initdb.d directory. Let’s do that, create a file DockerFiles/postgres/make_db.sh

with this content

#!/bin/bash
echo "******CREATING DOCKER DATABASE******"
gosu postgres postgres --single <<- EOSQL
    CREATE USER "$APP_DB_USER_NAME" WITH PASSWORD '$APP_DB_USER_PASSWORD';
    CREATE DATABASE "$APP_DB_NAME" WITH OWNER="$APP_DB_USER_NAME" ENCODING='UTF-8';
EOSQL
echo "******DOCKER DATABASE CREATED******"

We’re going to see just after where the variables come from.

and now lets tell docker to add this script inside the image and to make it executable

# vim:set ft=dockerfile:
FROM library/postgres

MAINTAINER SIMON Allan <simona@gobeta.com.cn>

ADD make_db.sh /docker-entrypoint-initdb.d/
RUN chmod +x /docker-entrypoint-initdb.d/make_db.sh 

Et voila! nothing more for the Dockerfile of postgres.

/!\ One important remark, the file is added once, it means that if you latter modify the file, you need to rebuild the image. For that the ‘easy’ way I found is to add a blank line in the dockerfile to force it to rebuild, but I guess there’s a better way.

Start a Postgresql container based on our image from Ansible.

Now that our image is ready, it’s time to start it, add at the end of your playbook.yml


    # we define a new task
    - name: postgresql container
      # this task use the module docker to manage docker from ansible
      docker:
        # it's going to start a container based on our image
        image: allansimon/postgres-for-symfony
        # this specific container will be named app_database
        name: app_database
        # if the container does not exist it will be started, if already
        # started it will be restarted (it was useful while creating
        # the image
        state: restarted
        # we map our local /tmp/postgres to /var/lib/postgresql/data
        # in the container, this way the database is persisted
        volumes:
            - /tmp/postgres:/var/lib/postgresql/data
        # this precise the environment variable that will be given
        # to the container with their value
        env:
            # this one is needed by the base postgres container
            # to define a password to postgres user
            # the explanation about {{ }} notation is coming
            POSTGRES_PASSWORD: "{{ DB_PASSWORD }}"
            # here we have the variables used by our make_db.sh script
            APP_DB_USER_NAME: "{{ APP_DB_USER_NAME }}"
            APP_DB_USER_PASSWORD: "{{ APP_DB_USER_PASSWORD }}"
            APP_DB_NAME: "{{ APP_DB_NAME }}"


The {{ }} notation is for Ansible variables, there’s several ways to declare, we’re going to see two, that can be used at the same time, either by putting them directly in your playbook.yml or in a dedicated file. The advatange of the second is that this way you can add this file to your gitignore.

  vars:
    DB_PASSWORD: postgres
  vars_files:
    - external_vars.yml

and in external_var.yml

#for symfony
APP_DB_USER_NAME: symfony_db_user
APP_DB_USER_PASSWORD: symfony_db_password
APP_DB_NAME: symfony_db

Start a Nginx+PHP-fpm container linked to the Postgresql one

Now that we have our postgres container started it’s time to do the same with our container for Nginx and PHP-fpm it takes the same structure as the task for postgres, with just some little thing in more (explained in comments)

    - name: nginx and php-fpm
      docker:
        name: app_webserver
        image: allansimon/symfony2
        state: restarted
        # here we say that the container 'app_database' will be linked
        # to this container, with the local name app_database, i.e all
        # the env variables of app_database will be accessible from app_webserver
        # prefixed with `APP_DATABASE_ENV_` as well as the port exposed by
        # the container (so only the webserver will have access to the db)
        links:
            - "app_database:app_database"
        # Here as we need the webserver to be accessible from the outside
        # we map our laptop port 8088 to the port 80 of the container
        ports:
            - "8088:80"
        # it is possible to map as many directory as you need
        # the only restriction is that it must be an asbolute path
        # hence why we use a variable APP_DIR
        volumes:
            - "{{ APP_DIR }}:/var/www"
            - /tmp/nginx-logs:/var/logs/nginx
        env:
            # more on these variables sooon
            GITHUB_TOKEN : "{{ GITHUB_TOKEN  }}"
            APP_DB_USER_NAME: "{{ APP_DB_USER_NAME }}"
            APP_DB_USER_PASSWORD: "{{ APP_DB_USER_PASSWORD }}"
            APP_DB_NAME: "{{ APP_DB_NAME }}"
            APP_MAILER_HOST: "{{ APP_MAILER_HOST }}"
            APP_MAILER_USER: "{{ APP_MAILER_USER }}"
            APP_MAILER_PASSWORD: "{{ APP_MAILER_PASSWORD }}"

With that, our Symfony app will have all the information it needs to access to the database, and to be accessed from the outside

Finalize the configuration of the Nginx+PHP-fpm image

Our two containers are now able to communicate but the same as we needed to create a database to make our postgres container useful, we’re going to need to tweak a little our Nginx+PHP-fpm container in order to

  • have a virtualhost that sends file to PHP-fpm
  • get a timezone precised in our php.ini (otherwise symfony2 will refuse to work)
  • have our container that run composer install when started
  • get nginx service started as well as php-fpm

so we’re going to add this at the end of our DockerFiles/Symfony2/Dockerfile

# we put our self-defined vhost (see below for the content)
COPY config/vhost.conf /etc/nginx/sites-enabled/default

# we put our additional php configuration and we enable it
COPY config/php-extra.ini /etc/php5/mods-available/extra.ini
RUN php5enmod extra

# we copy our script and we make sure it is executable
COPY entrypoint.sh /root/entrypoint.sh
RUN chown root:root /root/entrypoint.sh 
RUN chmod +x /root/entrypoint.sh

# more on that in a latter article
VOLUME ["/var/www", "/var/log/nginx/"]

# when our container is started this script will be run
# (it was not in our postgres dockerfile because the base image already
# had it.
ENTRYPOINT ["/root/entrypoint.sh"]

now the configuration file themselves

DockerFiles/Symfony2/config/vhost.conf

This vhost is specifically for symfony2 applications you maybe to adapt it if you plan to run other kind of php websites.


server {
    listen 80 default_server;
    listen [::]:80 default_server ipv6only=on;

    server_name localhost;

    root /var/www/web;
    index index.html index.htm index.php;

    location / {
        try_files $uri /app.php$is_args$args;
    }

    error_page 500 502 503 504 /50x.html;
    location = /50x.html {
        root /usr/share/nginx/www;
    }

    location ~ ^/(app|app_dev|config)\.php(/|$) {
        fastcgi_split_path_info ^(.+\.php)(/.+)$;
        fastcgi_pass unix:/var/run/php5-fpm.sock;
        include fastcgi_params;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
        fastcgi_param HTTPS off;
    }
}

DockerFiles/Symfony2/config/php-extra.ini

nothing fancy in it

date.timezone = UTC

DockerFiles/Symfony2/entrypoint.sh

#!/bin/bash

# for development machines
if [ $DEBUG ]; then
    echo "xdebug.remote_connect_back=On" >> /etc/php5/fpm/conf.d/20-xdebug.ini
    echo "xdebug.remote_enable=On" >> /etc/php5/fpm/conf.d/20-xdebug.ini
fi

cd /var/www

# if no specific command are precised when the container is started:
if [ -z "$1" ];
    then

    # if you're in china and that for some reason
    # the docker build has failed to download composer.phar...
    if ! which composer; then
        curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer
    fi
    # variable provided by ansible when launching the docker container
    mkdir ~/.composer
    cat > ~/.composer/config.json <<EOS
{
    "config": {
        "github-oauth": { "github.com": "$GITHUB_TOKEN" }
    }
}
EOS
    # we make sure to start fresh
    rm app/config/parameters.yml
    composer install
    rm -rf app/cache/*
    php app/console assets:install --symlink web/
    php app/console c:c
    php app/console c:w
    # not pretty ...
    chmod -R 777 app/logs
    chmod -R 777 app/cache

    service php5-fpm restart
    nginx
else
    exec "$@"
fi

Several things:

  • We create a config.json for composer to put inside our github token to avoid composer install to hang because we’re reached github anonymous limit.
  • We delete the parameters.yml so that we’re sure it’s recreated everytime, which is important in case we change the environment variables or the parameters.yml.dist
  • we clear and warmup the cache
  • it’s a bit hackish , but we make sure the logs and cache directory are writeable by symfony. (I did like this because it seems the application is run with a user that only have a UID but no username, and searching on the internet, different people seems to have different UID)
  • then we start the php5-fpm service (in a perfect docker world, it should be on dedicated container so that we can easily its output)
  • we start nginx as a process in the foreground

To get your github token, you can take a look at my previous article on how to generate one to use with composer.

Now our environment is fully ready we’re only left with adapting our appliction

Adapting our symfony application to fit in

Here’s there’s nothing much to change itself in the code, we simply need to tell Symfony2 to look in priority for the environment variables. The problem is Symfony2 itself looks for variable starting with SYMFONY__ and the second problem is that the parameters.yml has priority over the environment variables…

To solve that there’s a simple trick, (first make sure your parameters.yml is not versionned), composer.json has a package that can be used to generate your parameters.yml and it can be used to generate it based on environment variables

Make sure you have in your composer.json

"require": {
    ...

    "incenteev/composer-parameter-handler": "~2.0",
    ...
}
...
"scripts": {
    ...
    "post-install-cmd": [
        "Incenteev\\ParameterHandler\\ScriptHandler::buildParameters",
        ...
    ],
    "post-update-cmd": [
        "Incenteev\\ParameterHandler\\ScriptHandler::buildParameters",
        ...
    ]
},

Then in the extra section you can add env-map section to say to which environment variables to use (if present) to generate which parameter of your parameters.yml

In our case

    "extra": {
        ...
        "incenteev-parameters": {
            "env-map" : {
                "database_host": "APP_DATABASE_PORT_5432_TCP_ADDR",
                "database_port": "APP_DATABASE_PORT_5432_TCP_PORT",

                "database_name": "APP_DB_NAME",
                "database_user": "APP_DB_USER_NAME",
                "database_password": "APP_DB_USER_PASSWORD",

                "mailer_host": "APP_MAILER_HOST",
                "mailer_user": "APP_MAILER_USER",
                "mailer_password": "APP_MAILER_PASSWORD"
            },
            "file": "app/config/parameters.yml"
        },
        ...
    }

The two first environment variables come from the variables given by docker to the app_webserver when linking with app_database

Running everything

Now that everything is done you can run your playbook using:

sudo  ansible-playbook playbook.yml --connection=local -i "[default] localhost," 

it tells to run the playbook on your local machine.

All of that works fine if you’re on a Linux machine, so for your fellow Mac or Windows colleague you can create this Vagrantfile


def host_box_is_unixy?
  (RUBY_PLATFORM !~ /cygwin|mswin|mingw|bccwin|wince|emx/)
end


Vagrant.configure("2") do |config|
    config.vm.box = "ubuntu/trusty64"

    config.vm.network "forwarded_port", guest: 80, host: 8080
    config.vm.network "private_network", ip: "192.168.50.12"

    if host_box_is_unixy?
        config.vm.synced_folder "./", "/vagrant", type: "nfs"
    else
        config.vm.synced_folder "./", "/vagrant", type: "smb", mount_options: ['ip=192.168.50.1'] #host side of :private_network
    end


    config.vm.provision :shell, :inline => <<-END
        set -e
        if ! which ansible-playbook ; then
            sudo sh -c "wget -qO- https://get.docker.io/gpg | apt-key add -"
            sudo sh -c "
                echo deb http://get.docker.io/ubuntu docker main > \
                /etc/apt/sources.list.d/docker.list
            "
            sudo apt-get update
            sudo apt-get -y install \
                lxc-docker \
                python-software-properties \
                python-pip
            sudo pip install ansible
        fi
        cd /vagrant
        sudo  ansible-playbook playbook.yml --connection=local -i "[default] localhost," 
    END
end

it is not treated in this article but for Symfony2 performance reason on Vagrant, you may want to put the cache and logs folder outside of the share folder.

Useful commands

Run a command in a given container

for example you want to run phpunit

sudo docker exec -it app_webserver   /var/www/bin/phpunit -c  /var/www/app

or if you want to ‘ssh’ into the machine

sudo docker exec -it app_webserver bash

Get the logs

sudo docker logs -f app_webserver   

Delete an image

Let’s say it’s 3AM you’re trying desperately to modify the Dockerfile of the database but nothing change, so you want to nuke everything and start fresh

First stop and remove the container using

sudo docker stop app_database2
sudo docker rm app_database2

then delete the image itself

sudo docker rmi allansimon/postgres-for-symfony

Further improvements

With that you should have a good starters to have your application running in dockers , while still providing an easy environment to deploy for your developers, but things can be of course furthered improve (I will try to cover them in other articles)

  • Integrate it smoothly with your continous-testing system (for example gitlab-ci)
  • Split the containers in smaller ones (for example nginx and php-fpm in separate ones)
  • Put the data in Docker volumes to easily backup your data
  • Have a procedure to easily rollout a new release that include database migrations (using the excellent DoctrineMigration) without stopping the service or breaking things
  • Use kubernetes to manage your running containers to restart them if one crash etc.
  • Make your application scalable by being able to pop-out more instance of the web application and getting it to work nice with a loadbalancer
  • Make your database scalable by using tools like pgpool II
  • Be able to have our containers over several physical machines

Articles that helped me wrote this one and further reading

About the content of this article

Further reading