backend, Locastic Feb 24

Deploying Symfony apps with cron and Supervisor

5 min read –

Even the simplest applications can rarely avoid the need for running some operations periodically or just asynchronously, unbounded from web requests. We may be tempted to do some nasty hackery instead but let’s first try to do it properly and make our mothers proud. After all, it’s only a one time effort to upgrade our infrastructure and deployment process.

Our framework of choice can probably help us a lot with this. E.g. Symfony provides tools for rapid console commands development, required for running cron jobs, and asynchronous processing handlers with the Messenger component.

Unfortunately, things get a little bit messier when the time for application deployment comes. Managing cron jobs and message consumers is not fun at all, especially if we plan to do it unattended.

Cron configuration is done using crontab command, created mostly with manual editing in mind. Messenger consumers, on the other hand, need babysitting of Supervisor (or similar process controller) to help them stay up and running. Both introduce complexity in our technical stack.

This article will try to help you glue it all together with full automation in mind, so you don’t have to worry about deployments anymore and to allow you to focus on your application functionality. Most of this applies to any framework, even programming language, not just PHP and Symfony.

Configuration

Good development practice is to place infrastructure code near application source code. That way they can be versioned together and commit history can reveal reasoning behind changes.

The only issue is that our code repository is missing some information about environments inside which our application will be deployed and run. Let’s naively use environment variable references instead of real values for now and think about resolving them later.

Both cron and Supervisor configuration can be represented as individual configuration files. I will put those files in the project’s subdirectory called “etc”.

etc/supervisord.conf.dist content:

[group:${GROUP_NAME}]
programs=${GROUP_NAME}-messenger-consume-async-default,${GROUP_NAME}-messenger-consume-async-special

[program:${GROUP_NAME}-messenger-consume-async-default]
command=/usr/bin/php ${APP_DIR}/bin/console messenger:consume async_default -vv --limit=10 --time-limit=3600 --memory-limit=128M
user=${APP_USER}
numprocs=3
autorestart=true
process_name=%(program_name)s_%(process_num)02d

[program:${GROUP_NAME}-messenger-consume-async-special]
command=/usr/bin/php ${APP_DIR}/bin/console messenger:consume async_special -vv --limit=10 --time-limit=3600 --memory-limit=128M
user=${APP_USER}
numprocs=1
autorestart=true
process_name=%(program_name)s_%(process_num)02d

Second message consumer is added for better understanding of the program grouping feature (“[group:group-name]”) only. Program group is a wrapper around all our application’s programs and it allows us to control all programs at the same time. That adds some flexibility to our setup.

Program group name is parametrized to allow multiple deployments on the same server. E.g. you could install both staging and integration testing application instances on the same server thanks to this. You just have to provide a different group name for every instance.

etc/crontab.dist content:

@daily /usr/bin/php ${APP_DIR}/bin/console app:remove-expired-carts
30 4 * * * /usr/bin/php ${APP_DIR}/bin/console app:import-prices

As you can see, all application server related details are replaced with environment variable references. This way we can use the same configuration (configuration template actually) for each deployment target. This begs the question how can we “render” these configuration templates and put results in their right places?

Generating server specific configuration

Real configuration can be generated from templates (“dist” files) using a dedicated tool called envsubst which is part of the GNU gettext system. Luckily, all it does is all we actually need.

These are commands we need to execute eventually, during deployment:

# Supervisor configuration
APP_DIR=/var/www/my-app-staging \
APP_USER=www-data \
GROUP_NAME=my-app-staging \
envsubst '$APP_DIR,$APP_USER,$GROUP_NAME' < etc/supervisord.conf.dist > etc/supervisord.conf

# Crontab
APP_DIR=/var/www/my-app-staging \
envsubst '$APP_DIR' < etc/crontab.dist > etc/crontab

Let’s dissect the first command only (same applies for the second one). It renders template file “etc/supervisord.conf.dist” by replacing it’s environment variable references with their actual values. This is done only for environment variables specified (whitelisted) by the first argument: APP_DIR, APP_USER and GROUP_NAME. The result is written to the “etc/supervisord.conf” file.

Note that we defined all necessary environment variables immediately before the envsubst call. That may be unneeded, depending on the deployment process in place. For example, your deployment script may already have all environment variables defined at the beginning or they might be defined by the system administrator. In both cases you can call envsubst immediately.

Environment variable whitelist argument is optional but recommended. If not specified, all environment variable references are replaced (naturally, only if referenced environment variables actually exist). Whitelist is used to prevent accidental replacements of regular text parts formatted as “…$something…”. Also, it serves as self-documentation for your deployment process.

Supervisor configuration update

Generated Supervisor config can be applied with this one-liner:

cp -f etc/supervisord.conf /etc/supervisor/conf.d/my-app.conf && supervisorctl update && supervisorctl start my-app:*

This command updates Supervisor config in a non intrusive way – it doesn’t mess with other applications’ configuration and it doesn’t restart the whole supervisord daemon. Programs are referenced by a group name which allows their management without changing this command.

Crontab update

Generated crontab can be applied with this one-liner:

(((sudo crontab -u www-data -l || true) | sed '/^### BEGIN my-app;/,/^### END my-app;/{d;}'); echo "### BEGIN my-app; generated by deploy script, do not edit"; awk 1 etc/crontab; echo "### END my-app; generated by deploy script, do not edit") | sudo crontab -u www-data -

This command will not touch other, non-related user’s cron jobs. It operates only with our app related cron jobs.

It removes the app’s old cron jobs and appends freshly generated ones. To make that possible, application’s cron jobs are delimited by marker lines “### BEGIN …” and “### END …”.

Failure inverting in “crontab -l || true” is used in case the user has no cron jobs yet (first run). “awk 1” is used instead of a simple “cat” to ensure that output ends with a line break.

Fully automated setup

Now that we have all the building blocks – configuration file templates, ways to render them and methods for applying them – let’s extend our deployment script to do all of that unattended.

I will assume that you have traditional deployment structure in place but feel free to adjust the instructions to your own needs:

/var/www/my-app-staging
|_ current (symlinked to newest release “release3”)
|_ shared
|_ releases
|_ release1
|_ release2
|_ release3

This is deploy script with relevant parts only:

# These environment variables are specific to your server
# They differ among servers so they can’t be versioned along your code
# As a matter of fact, they might be already prepared by system administrator and ready for use by deployment script
export APP_DEPLOY_DIR=/var/www/my-app-staging
export APP_RELEASE_NAME=release4
export APP_IDENTIFIER=my-app-staging
export APP_USER=www-data
export APP_SUPERVISOR_CONFIG_DIR=/etc/supervisor/conf.d

# These environment variables are derived and used for convenience only
export APP_RELEASE_DIR="$APP_DEPLOY_DIR/releases/$APP_RELEASE_NAME"
export APP_CURRENT_DIR="$APP_DEPLOY_DIR/current"
export APP_SUPERVISOR_CONFIG_FILE="$APP_SUPERVISOR_CONFIG_DIR/$APP_IDENTIFIER.conf"

# Generating system configuration files
APP_DIR="$APP_CURRENT_DIR" envsubst '$APP_DIR' < $APP_RELEASE_DIR/etc/crontab.dist > $APP_RELEASE_DIR/etc/crontab
APP_DIR="$APP_CURRENT_DIR" GROUP_NAME="$APP_IDENTIFIER" envsubst '$APP_DIR,$APP_USER,$GROUP_NAME' < $APP_RELEASE_DIR/etc/supervisord.conf.dist > $APP_RELEASE_DIR/etc/supervisord.conf

# ...release preparation tasks (cache warmup, etc.)...

# Activating release
ln -sfn $APP_RELEASE_DIR $APP_CURRENT_DIR
# Release is LIVE

# Applying new system configuration
(((sudo crontab -u $APP_USER -l || true) | sed '/^### BEGIN $APP_IDENTIFIER;/,/^### END $APP_IDENTIFIER;/{d;}'); echo "### BEGIN $APP_IDENTIFIER; generated by deploy script, do not edit"; awk 1 $APP_RELEASE_DIR/etc/crontab; echo "### END $APP_IDENTIFIER; generated by deploy script, do not edit") | sudo crontab -u $APP_USER -
cp -f $APP_RELEASE_DIR/etc/supervisord.conf $APP_SUPERVISOR_CONFIG_FILE && supervisorctl update && supervisorctl start $APP_IDENTIFIER:*

# ...other post activation tasks...

Conclusion

Every once in a while, we developers are forced to leave our comfort zone and do something beyond everyday coding. That includes build and deployment tasks, once dealt by system administrators and now often left to ourselves. You might see those as obstacles or a chance to learn something new. In any case, I hope this article will help you achieve your goals.

If you have any recommendations or improvements on this subject please leave the comment. Thanks!