Back in December, Tom Friedhof shared how we set up our Drupal 8 development and build process utilizing Docker. It has been working well in the several months we have used it and worked within its framework. Within the time-span however, we experienced a few issues here and there which led me to come up with an alternative process which keeps the good things we like and getting rid of/resolving the issues we encountered.
First, I'll list some improvements that we'd like to see:
-
Solve file-syncing issues
One issue that I keep running into when working with our development process is that the file-syncing stops working when the host machine powers off in the interim. Even though Vagrant's
rsync-auto
can still detect changes on the host file-system and initiates anrsync
to propel files up into the containers via a mounted volume, the changes do not really appear within the containers themselves. I had a tough time debugging this issue, and the only resolution in sight was to do avagrant reload
-- it's a time-consuming process as it rebuilds every image and running them again. Having to do this every morning when I turn on my laptop at work was no fun. -
Performant access to Drupal's root
Previously, we had to mount Drupal's document root to our host machine using
sshfs
to explore in it, but it's not exactly performant. For example, performing agrep
orag
to search within files contents under Drupal 8's core takes ~10 seconds or more. Colleagues using PhpStorm report that mounting the Drupal root unto the host system brings the IDE to a crawl while it indexes the files. -
Levarage Docker Compose
Docker Compose is a great tool for managing the life-cycle of Docker containers, especially if you are running multiple applications. I felt that it comes with useful features that we were missing out because we were just using Vagrant's built-in Docker provider. Also with the expectation that Docker for Mac Beta will become stable in the not-so-distant future, I'd like the switch to a native Docker development environment as smooth as possible. For me, introducing Docker Compose into the equation is the logical first-step.
dlite just got into my attention quite recently which could fulfill the role of Docker for Mac before its stable release, but haven't gotten the chance to try it yet.
-
Use Composer as the first-class package manager
Our previous build primarily uses Drush to build the Drupal 8 site and download dependencies and relegating the resolution of some Composer dependencies to Composer Manager. Drush worked really well for us in the past and there is no pressing reason why we should abandon it, but considering that Composer Manager is deprecated for Drupal 8.x and that there is already a Composer project for Drupal sites, I thought it would be a good idea to be more proactive and rethink the way we have been doing Drupal builds and adopt the de-facto way of putting together a PHP application. At the moment, Composer is where it's at.
-
Faster and more efficient builds
Our previous build utilizes a Jenkins server (also ran as a container) to perform the necessary steps to deploy changes to Pantheon. Since we were mostly deploying from our local machines anyway, I always thought that perhaps running the build steps via
docker run ...
would probably suffice (and it doesn't incur the overhead of a running Jenkins instance). Ultimately, we decided to explore Platform.sh as our deployment target, so basing our build in Composer became almost imperative as Drupal 8 support (via Drush) on Platform.sh is still in beta.
With these in mind, I'd like to share our new development environment & build process.
1. File & directory structure
Here is a high-level tree-view of the file structure of the project:
/<project_root>
├── Vagrantfile
├── Makefile
├── .platform/ # Platform.sh high-level configuration
│ └── routes.yaml
├── bin/ # Executables that are used within the development workflows.
│ ├── drupal*
│ ├── drush*
│ └── sync-host*
├── docker-compose.yml # Defines the relationships and run-time properties of the Docker containers.
├── environment # File containing environment variables
├── src/ # The drupal-project root
│ ├── .gitignore
│ ├── .platform.app.yaml # Platform.sh app route configuration
│ ├── Dockerfile
│ ├── LICENSE
│ ├── bin/ # Some executables installed in the containers for proper pass-through of DrupalConsole and Drush commands.
│ │ ├── drupal-portal*
│ │ └── drush-portal*
│ ├── composer.json
│ ├── composer.lock
│ ├── custom/
│ ├── phpunit.xml.dist
│ ├── scripts/
│ ├── vendor/
│ └── web/ # the Drupal 8 root -- the directory exposed by the web-server.
└── zsh/ # Some zsh configurations
├── zshrc
├── async.zsh
└── pure.zsh
2. The Vagrantfile
Vagrant.configure("2") do |config|
config.vm.box = "debian/jessie64"
config.vm.network "private_network", ip: "192.168.100.47"
config.vm.hostname = 'activelamp.dev'
config.vm.provider :virtualbox do |vb|
vb.name = "activelamp.com"
vb.memory = 2048
end
config.ssh.forward_agent = true
config.vm.provision "shell",
inline: "apt-get install -y zsh && sudo chsh -s /usr/bin/zsh vagrant",
run: "once"
config.vm.provision "shell",
inline: "[ -e /home/vagrant/.zshrc ] && echo '' || ln -s /vagrant/zsh/zshrc /home/vagrant/.zshrc",
run: "once"
config.vm.provision "shell",
inline: "[ -e /usr/local/share/zsh/site-functions/prompt_pure_setup ] && echo '' || ln -s /vagrant/zsh/pure.zsh /usr/local/share/zsh/site-functions/prompt_pure_setup",
run: "once"
config.vm.provision "shell",
inline: "[ -e /usr/local/share/zsh/site-functions/async ] && echo '' || ln -s /vagrant/zsh/async.zsh /usr/local/share/zsh/site-functions/async",
run: "once"
if ENV['GITHUB_OAUTH_TOKEN']
config.vm.provision "shell",
inline: "sudo sed -i '/^GITHUB_OAUTH_TOKEN=/d' /etc/environment && sudo bash -c 'echo GITHUB_OAUTH_TOKEN=#{ENV['GITHUB_OAUTH_TOKEN']} >> /etc/environment'"
end
# This is here to install Docker on the virtual machine and nothing else.
config.vm.provision :docker
config.vm.provision :docker_compose, yml: "/vagrant/docker-compose.yml", run: "always", compose_version: "1.7.1"
config.vm.synced_folder ".", "/vagrant", type: "nfs"
config.vm.synced_folder "./src", "/mnt/code", type: "rsync", rsync__exclude: [".git/", "src/vendor"]
end
Compare this new manifest to the old one and you will notice that we reduce Vagrant's involvement in defining and managing Docker containers. We are simply using this virtual machine as the Docker host, using the vagrant-docker-compose plugin to provision it with the Docker Compose
executable and having it (re)build the images during provisiong stage and (re)start the containers on vagrant up
.
We are also setting up Vagrant to sync file changes on src/
to /mnt/code/
in the VM via rsync
. This directory in the VM will be mounted into the container as you'll see later.
We are also setting up zsh
as the login shell for the vagrant
user for an improved experience when operating within the virtual machine.
3. The Drupal 8 Build
For now let's zoom in to where the main action happens: the Drupal 8 installation. Let's remove Docker from our thoughts for now and focus on how the Drupal 8 build works.
The src/
directory cotains all files that constitute a Drupal 8 Composer project:
<project_root>/src/
├── composer.json
├── composer.lock
├── phpunit.xml.dist
├── scripts/
│ └── composer/
├── vendor/ # Composer dependencies
│ └── ...
└── web/ # Web root
├── .htaccess
├── autoload.php
├── core/ # Drupal 8 Core
├── drush/
├── index.php
├── modules/
├── profiles/
├── robots.txt
├── sites/
│ ├── default/
│ │ ├── .env
│ │ ├── config/ # Configuration export files
│ │ │ ├── system.site.yml
│ │ │ └── ...
│ │ ├── default.services.yml
│ │ ├── default.settings.php
│ │ ├── files/
│ │ │ └── ...
│ │ ├── services.yml
│ │ ├── settings.local.php.dist
│ │ ├── settings.php
│ │ └── settings.platform.php
│ └── development.services.yml
├── themes/
├── update.php
└── web.config
The first step of the build is simply executing composer install
within src/
. Doing so will download all dependencies defined in composer.lock
and scaffold files and folders necessary for the Drupal installation to work. You can head over to the Drupal 8 Composer project repository and look through the code to see in depth how the scaffolding works.
3.1 Defining Composer dependencies from custom installation profiles & modules
Since we cannot use the Composer Manager module anymore, we need a different way of letting Composer know that we may have other dependencies defined in other areas in the project. For this let's look at composer.json
:
{
...
"require": {
...
"wikimedia/composer-merge-plugin": "^1.3",
"activelamp/sync_uuids": "dev-8.x-1.x"
},
"extra": {
...
"merge-plugin": {
"include": [
"web/profiles/activelamp_com/composer.json",
"web/profiles/activelamp_com/modules/custom/*/composer.json"
]
}
}
}
We are requiring the wikimedia/composer-merge-plugin
and configuring it in the extra
section to also read the installation profile's composer.json
and one's that are in custom modules within it.
We can define the contrib modules that we need for our site from within the installation profile.
src/web/profiles/activelamp_com/composer.json
:
{
"name": "activelamp/activelamp-com-profile",
"require": {
"drupal/admin_toolbar": "^8.1",
"drupal/ds": "^8.2",
"drupal/page_manager": "^8.1@alpha",
"drupal/panels": "~8.0",
"drupal/pathauto": "~8.0",
"drupal/redirect": "~8.0",
"drupal/coffee": "~8.0"
}
}
As we create custom modules for the site, any Composer dependencies in them will be picked up everytime we run composer update
. This replicates what Composer Manager allowed us to do in Drupal 7. Note however that unlike Composer Manager, Composer does not care if a module is enabled or not -- it will always read its Composer dependencies and resolve them.
3.2 Drupal configuration
3.2.1 Settings file
Let's peek at what's inside src/web/settings.php
:
<?php
/**
* Load services definition file.
*/
$settings['container_yamls'][] = __DIR__ . '/services.yml';
$config_directories[CONFIG_SYNC_DIRECTORY] = __DIR__ . '/config';
/**
* Include the Platform-specific settings file.
*
* n.b. The settings.platform.php file makes some changes
* that affect all envrionments that this site
* exists in. Always include this file, even in
* a local development environment, to insure that
* the site settings remain consistent.
*/
include __DIR__ . "/settings.platform.php";
$update_free_access = FALSE;
$drupal_hash_salt = '<some hash>';
$local_settings = __DIR__ . '/settings.local.php';
if (file_exists($local_settings)) {
require_once($local_settings);
}
$settings['install_profile'] = 'activelamp_com';
$settings['hash_salt'] = $drupal_hash_salt;
Next, let's look at settings.platform.php
:
<?php
if (!getenv('PLATFORM_ENVIRONMENT')) {
return;
}
$relationships = json_decode(base64_decode(getenv('PLATFORM_RELATIONSHIPS')), true);
$database_creds = $relationships['database'][0];
$databases['default']['default'] = [
'database' => $database_creds['path'],
'username' => $database_creds['username'],
'password' => $database_creds['password'],
'host' => $database_creds['host'],
'port' => $database_creds['port'],
'driver' => 'mysql',
'prefix' => '',
'collation' => 'utf8mb4_general_ci',
];
We return early from this file if PLATFORM_ENVIRONMENT
is not set. Otherwise, we'll parse the PLATFORM_RELATIONSHIPS
data and extract the database credentials from it.
For our development environment however, we'll do something different in settings.local.php.dist
:
<?php
$databases['default']['default'] = array(
'database' => getenv('MYSQL_DATABASE'),
'username' => getenv('MYSQL_USER'),
'password' => getenv('MYSQL_PASSWORD'),
'host' => getenv('DRUPAL_MYSQL_HOST'),
'driver' => 'mysql',
'port' => 3306,
'prefix' => '',
);
We are pulling the database values from the environment, as this is how we'll pass data in a Docker run-time. We also append .dist
to the file-name because we don't actually want settings.local.php
in version control (otherwise, it will mess up the configuration in non-development environments). We will simply rename this file as part of the development workflow. More on this later.
3.2.2 Staged configuration
src/web/sites/default/config/
contains YAML files that constitute the desired Drupal 8 configuration. These files will be used to seed a fresh Drupal 8 installation with configuration specific for the site. As we develop features, we will continually export the configuration entities and place them into this folder so that they are also versioned via Git.
Configuration entities in Drupal 8 are assigned a universally unique ID (a.k.a UUID). Because of this, configuration files are typically only meant to be imported into the same (or a clone of the) Drupal site they were imported from. The proper approach is usually getting hold of a database dump of the Drupal site and use that to seed a Drupal 8 installation which you plan to import the configuration files into. To streamline the process during development, we wrote the
drush
command sync-uuids
that updates the UUIDs of the active configuration entities of a non-clone site (i.e. a freshly installed Drupal instance) to match those found in the staged configuration. We packaged it as Composer package named activelamp/sync_uuids
.
The complete steps for the Drupal 8 build is the following:
$ cd src
$ composer install
$ [ -f web/sites/default/settings.local.php ] && : || cp web/sites/default/settings.local.php.dist web/sites/default/settings.local.php
$ drush site-install activelamp_com --account-pass=default-pass -y
$ drush pm-enable config sync_uuids -y
$ drush sync-uuids -y
$ drush config-import -y
These build steps will result a fresh Drupal 8 installation based on the activelamp_com
installation profile and will have the proper configuration entities from web/sites/default/config
. This will be similar to any site that is built from the same code-base minus any of the actual content. Sometimes that is all that you need.
Docker
Now let's look at the development workflow utilizing Docker. Let's start with the src/Dockerfile
:
FROM php:7.0-apache
RUN apt-get update && apt-get install -y \
vim \
git \
unzip \
wget \
curl \
libmcrypt-dev \
libgd2-dev \
libgd2-xpm-dev \
libcurl4-openssl-dev \
mysql-client
ENV PHP_TIMEZONE America/Los_Angeles
# Install extensions
RUN docker-php-ext-install -j$(nproc) iconv mcrypt \
&& docker-php-ext-configure gd --with-freetype-dir=/usr/include/ --with-jpeg-dir=/usr/include/ \
&& docker-php-ext-install -j$(nproc) gd pdo_mysql curl mbstring opcache
# Install Composer & add global vendor bin dir to $PATH
RUN curl -sS https://getcomposer.org/installer | php
RUN mv composer.phar /usr/local/bin/composer
RUN echo 'export PATH="$PATH:/root/.composer/vendor/bin"' >> $HOME/.bashrc
# Install global Composer dependencies
RUN composer global require drush/drush:8.1.2 drupal/console:0.11.3
RUN $HOME/.composer/vendor/bin/drupal init
RUN echo source '$HOME/.console/console.rc' >> $HOME/.bashrc
# Set timezone.
RUN echo "date.timezone = \"$PHP_TIMEZONE\"" > /usr/local/etc/php/conf.d/timezone.ini
ARG github_oauth_token
# Register a GitHub OAuth token if present in build args.
RUN [ -n $github_oauth_token ] && composer config -g github-oauth.github.com $github_oauth_token || echo ''
RUN [ -e /etc/apache2/sites-enabled/000-default.conf ] && sed -i -e "s/\/var\/www\/html/\/var\/www\/web/" /etc/apache2/sites-enabled/000-default.conf || sed -i -e "s/\/var\/www\/html/\/var\/www\/web/" /etc/apache2/apache2.conf
# Copy scripts used by pass-through bin/drush and bin/drupal
COPY bin/drush-portal /usr/bin/drush-portal
COPY bin/drupal-portal /usr/bin/drupal-portal
COPY . /var/www/
WORKDIR /var/www/
RUN composer --working-dir=/var/www install
The majority of the Dockerfile
should be self-explanatory. The important bits are the provisioning of a GitHub OAuth token & adding of the {drupal,drush}-portal
executables which are essential
for the bin/{drush,drupal}
pass-through scripts.
Provisioning a GitHub OAuth token
Sometimes it is necessary to configure Composer to use an OAuth token to authenticate on GitHub's API when resolving dependencies. These tokens must remain private and should not be committed into
version control. We declare that our Docker build will take github_oauth_token
as a build argument. If present, it will configure Composer to authenticate using it to get around API rate limits. More on this later.
DrupalConsole
and Drush
pass-through scripts
Our previous build involved opening up an SSH port on the container running Drupal so that we can execute Drush commands remotely. However, we should already be able to run Drush commands inside the container without having
SSH access by utilizing docker run
. However the commands can get too lengthy. In fact, they will be extra lengthy because we also need to execute this from within the Vagrant machine using vagrant ssh
.
Here are a bunch of scripts that makes it easier to execute drush
and drupal
commands from the host machine:
Here are the contents of bin/drush
and bin/drupal
:
#!/usr/bin/env bash
cmd="docker-compose -f /vagrant/docker-compose.yml run --no-deps --rm server drupal-portal $@"
vagrant ssh -c "$cmd"
#!/usr/bin/env bash
cmd="docker-compose -f /vagrant/docker-compose.yml run --no-deps --rm server drush-portal $@"
vagrant ssh -c "$cmd"
This allow us to do bin/drush
to run Drush commands and bin/drupal ...
to run DrupalConsole commands, and the arguments will be pass over to the executables in the container.
Here are the contents of src/bin/drupal-portal
and src/bin/drush-portal
:
#!/usr/bin/env bash
/root/.composer/vendor/bin/drupal --root=/var/www/web $@
#!/usr/bin/env bash
/root/.composer/vendor/bin/drush --root=/var/www/web $@
The above scripts are added to the container and is essential to making sure drush
and drupal
commands are applied to the correct directory.
In order for this to work, we actually have to remove Drush and DrupalConsole from the project's composer.json
file. This is easily done via the composer remove
command.
The docker-compose.yml
file
To tie everything together, we have this Compose file:
version: '2'
services:
server:
build:
context: ./src
args:
github_oauth_token: ${GITHUB_OAUTH_TOKEN}
volumes:
- /mnt/code:/var/www
- composer-cache:/root/.composer/cache
env_file: environment
links:
- mysql:mysql
ports:
- 80:80
mysql:
image: 'mysql:5.7.9'
env_file: environment
volumes:
- database:/var/lib/mysql
volumes:
database: {}
composer-cache: {}
There are four things of note:
-
github_oauth_token: ${GITHUB_OAUTH_TOKEN}
This tells Docker Compose to use the environment variable
GITHUB_OAUTH_TOKEN
as thegithub_oauth_token
build argument. This, if not empty, will effectively provision the Composer with an OAuth token. If you go back to theVagrantfile
, you will see that this environment variable is set in the virtual machine (becausedocker-compose
is run under it) by appending it to the/etc/environment
file. All it needs is that the environment variable is present in the host environment (OS X) during the provisioning step.For example, it can be provisioned via:
GITHUB_OAUTH_TOKEN=<token> vagrant provision
-
composer-cache:/root/.composer/cache
This tells Docker to mount a volume on
/root/.composer/cache
so that we can persist the contents of this directory between restarts. This will ensure thatcomposer install
andcomposer update
is fast and would not require re-downloading packages from the web every time we run. This will drastically imrpove the build speeds. -
database:/var/lib/mysql
This will tell Docker to persist the MySQL data between builds as well. This is so that we don't end up with an empty database whenever we restart the containers.
-
env_file: environment
This let us define all environment variables in a single file, for example:
MYSQL_USER=activelamp MYSQL_ROOT_PASSWORD=root MYSQL_PASSWORD=some-secret-passphrase MYSQL_DATABASE=activelamp DRUPAL_MYSQL_HOST=mysql
We just configure each service to read environment variables from the same file as they both need these values.
Syncing files
We employ rsync
to sync files from the host machine to the VM since it offers by far the fastest file I/O compared to the built-in alternatives in Vagrant + VirtualBox. In the Vagrantfile
we specified that we sync src/
to /mnt/code/
in the VM. Following this we configured Docker Compose to mount this directory into the server
container. This means that any file changes we make on OS X will get synced up to /mnt/code
, and ultimately into /var/www/web
in the container. However, this only covers changes that originate from the host machine.
To sync changes that originates from the container -- files that were scaffolded by drupal generate:*
, Composer dependencies, and Drupal 8 core itself -- we'll use the fact that our project root is also available at /vagrant
as a mount in the VM. We can use rsync
to sync files the other way -- rsync
ing from /mnt/code
to /vagrant/src
will bring file changes back up to the host machine.
Here is a script I wrote that does an rsync
but will ask for confirmation before doing so to avoid overwriting potentially uncommitted work:
#!/usr/bin/env bash
echo "Dry-run..."
args=$@
diffs="$(vagrant ssh -- rsync --dry-run --itemize-changes $args | grep '^[><ch.][dfLDS]\|^\*deleted')"
if [ -z "$diffs" ]; then
echo "Nothing to sync."
exit 0
fi
echo "These are the differences detected during dry-run. You might lose work. Please review before proceeding:"
echo "$diffs"
echo ""
read -p "Confirm? (y/N): " choice
case "$choice" in
y|Y ) vagrant ssh -- rsync $args;;
* ) echo "Cancelled.";;
esac
We are keeping this generic and not bake in the paths because we might want to sync arbitrary files to arbitrary destinations.
We can use this script like so:
$ bin/sync-host --recursive --progress --verbose --exclude=".git/" --delete-after /mnt/code/ /vagrant/src/
If the rsync
will result in file changes on the host machine, it will bring up a summary of the changes and will ask if you want to proceed or not.
Makefile
We are using make
as our task-runner just like in the previous build. This is really useful for encapsulating operations that are common in our workflow:
# Sync files back to host machine (See Vagrantfile synced folder config)
sync-host:
bin/sync-host --recursive --progress --verbose --delete-after --exclude='.git/' /mnt/code/ /vagrant/src/
sync:
vagrant rsync-auto
sync-once:
vagrant rsync
docker-rebuild:
vagrant ssh -- docker-compose -f /vagrant/docker-compose.yml build
docker-restart:
vagrant ssh -- docker-compose -f /vagrant/docker-compose.yml up -d
composer-install:
vagrant ssh -- docker-compose -f /vagrant/docker-compose.yml run --no-deps --rm server composer --working-dir=/var/www install
composer-update:
vagrant ssh -- docker-compose -f /vagrant/docker-compose.yml run --no-deps --rm server composer --working-dir=/var/www update --no-interaction
# Use to update src/composer.lock if needed without `sync-host`
# i.e. `make lock-file > src/composer.lock`
lock-file:
@vagrant ssh -- cat /mnt/code/composer.lock
install-drupal: composer-install
vagrant ssh -- '[ -f /mnt/code/web/sites/default/settings.local.php ] && echo '' || cp /mnt/code/web/sites/default/settings.local.php.dist /mnt/code/web/sites/default/settings.local.php'
-bin/drush si activelamp_com --account-pass=secret -y
-bin/drush en config sync_uuids -y
bin/drush sync-uuids -y
[ $(ls -l src/web/sites/default/config/*.yml | wc -l) -gt 0 ] && bin/drush cim -y || echo "Config is empty. Skipping import..."
init: install-drupal
yes | bin/sync-host --recursive --progress --verbose --delete-after --exclude='.git/' /mnt/code/ /vagrant/src/
platform-ssh:
ssh <site_id>@ssh.us.platform.sh
The Drupal 8 build steps are simply translated to use bin/drush
and the actual paths within the virtual machine in the install-drupal
task. After cloning the repository for the first time, a developer should just be able to execute make init
, sit back with a cup of coffee and wait until the task is complete.
Try it out yourself!
I wrote the docker-drupal-8
Yeoman generator so that you can easily give this a spin. Feel free to use it to look around and see it in action, or even to start off your Drupal 8 sites in the future:
$ npm install -g yo generator-docker-drupal-8
$ mkdir myd8
$ cd myd8
$ yo docker-drupal-8
Just follow through the instructions, and once complete, run vagrant up && make docker-restart && make init
to get it up & running.
If you have any questions, suggestions, anything, feel free to drop a comment below!