Running 88 Drupal Sites on Acquia Site Factory: An Operations Playbook
Acquia Site Factory (ACSF) is Acquia's product for running many Drupal sites from one codebase. The platform I work on has 88 of them: different brands and countries, each with its own database, domain, theme and editors, all deployed together from one repository to dev, test and live environments.
The code is the easy part. Most of the work is operations: deploying, running updates on every site, backups, cache, moving sites between environments, and helping someone who can't log in. This is the playbook that grew out of doing that for a few years.
Know your deploy types
On Site Factory you deploy a build to a whole stack, and you choose how much happens with it. The difference matters more than anything else on this list:
- Code only: the new code goes out and nothing else runs. It takes 10 to 15 minutes and is safe to do during the day.
- Code and database: the new code goes out and database updates run on every site. On our platform this takes about an hour and a half and is planned for the evening, never "right after lunch".
Most releases are code only. Database updates and config imports then run as separate, controlled steps, which is the next point.
One command, every site
The tool that makes a multisite manageable is acsf-tools. Its sfml command runs any Drush command on every site of an environment:
# run a Drush command on every site of an environment
drush @app.env sfml cr
drush @app.env sfml updb -y
drush @app.env sfml cim -yAfter every deploy that includes config changes, the same four commands run in the same order. Search reindexing is easy to forget: the site works without it, but search results quietly go stale.
drush @app.live sfml updb -y -vvv
drush @app.live sfml search-api-reindex
drush @app.live sfml search-api-index
drush @app.live sfml crNot every command is safe to run on every site at once. Config import is the one that hurt us. In bulk it can run the server out of memory, so during big releases we raise the PHP memory limit temporarily, or import site by site.
When you SSH into an environment directly, remember that there is no "current site". Every Drush command needs --uri with the site's domain, otherwise you are talking to the default site.
The Site Factory API covers the rest
Some operations are not Drush commands: backups, clearing the platform-level cache, refreshing a site's external theme. The Site Factory UI can do them, but one site at a time. The API can do them for all sites, and the client for it is a few lines:
// lib/request.js
module.exports = async ({ url, method = "GET", data = null }) => {
const res = await fetch(SITE_FACTORY_URL + url, {
method,
headers: {
"Content-Type": "application/json",
Authorization: "Basic " + process.env.KEY,
},
body: data,
});
return res.json();
};
// services/getAllSites.js
module.exports = async () => {
const res = await request({
url: "/api/v1/sites?stack_id=" + STACK + "&show_custom_domain=yes&limit=1000",
});
return res.sites;
};The show_custom_domain=yes parameter is worth knowing. Without it you get the internal Site Factory domains, which are useless when you want to report on or test the real sites. With it, the same list is the source for almost every script I wrote for the platform, including the SearchStax migration.
Each operation is then a single small function:
const backup = (siteId) => request({
url: "/api/v1/sites/" + siteId + "/backup",
method: "POST",
data: JSON.stringify({ components: ["database", "public files"] }),
});
const clearCache = (siteId) => request({
url: "/api/v1/sites/" + siteId + "/cache-clear",
method: "POST",
});
const refreshTheme = (siteId) => request({
url: "/api/v1/sites/" + siteId + "/external-theme-refresh",
method: "POST",
});And a loop that does not hammer the platform. These calls start background tasks on Site Factory, and I didn't want 88 of them starting at the same moment. One every five seconds is gentle on the platform and still finishes in under ten minutes:
const sites = await getAllSites();
for (const site of sites) {
try {
console.log(JSON.stringify(await backup(site.id)));
} catch (err) {
console.error("failed: " + site.id, err);
}
await sleep(5000); // one task every 5 seconds
}Before a risky release, a full backup of every site is now a single command instead of an afternoon of clicking.
Moving sites between environments
To get a production site onto test or dev, Site Factory has "staging": copying sites down from live to a lower environment. A few things worth knowing:
- The environments are linked. Deleting a site on live can break its copies on test and dev, so treat live deletions as affecting all three.
- Each site has an owner, and some operations, staging among them, fail if the owner's account was deleted. When people leave the team, move their sites to an active owner before you need to stage them.
- "Remove" in the Site Factory site list only removes the site from that list. It does not delete the site.
- A staged-down copy is still a real site. On non-production environments we always set the global meta tags to
noindex, nofollow, turn off Google Tag Manager, remove the consent manager ID, and set up search for that environment.
Getting a site onto your machine
For local work or a cloud IDE, acli pull --no-code pulls a chosen environment's database and files. Two notes. First, it pulls the latest backup, which may be older than you think. If the data looks stale, create a fresh database backup in Acquia Cloud first. Second, every site keeps its files under sites/<site id>/files, so the local settings need that path or no images will load:
// settings.local.php: serve one site's files locally
$settings['file_public_path'] = 'sites/<site id>/files';When something breaks
- Try it again, twice. Many errors on a busy shared platform are temporary: a task conflict, a busy database, a 409 from the pipeline. Running the same operation two or three more times solves a surprising share of them. If it still fails after that, start debugging.
- A site shows the generic error page. Turn on Database Logging, reload the broken page and read the log. If the cause is a failed config import, uninstalling the module that fails and importing again usually gets the site back.
- Admin actions fail only on the public domain. If a form or batch fails with a 406, try the same thing on the site's internal domain. If it works there, the problem is a CDN or WAF rule, not Drupal.
- You need to test a fix on the server. Acquia's Live Development mode makes an environment's code writable, so you can try a change over SFTP without a deploy. Turn it off and deploy properly once you know the fix.
- The cloud IDE acts strange. Clearing the browser's local storage for the IDE and reloading fixes most of it.
Rules that saved us more than once
- Nobody commits directly to the branch that deploys to production. Everyone works on their own branch and merges into the branch of the environment they are targeting.
- Custom modules live in their own repositories. Every change gets a release, and the main project pins that release in
composer.json. When you add a module, onlycomposer.json,composer.lockandcore.extension.ymlshould change. If more files changed afterdrush cex, find out why before committing. - Some modules are in config ignore on purpose, so that a single site can differ. Changes to those have to be made on that site by hand. Write down which ones they are.
- A block that appears on more than a handful of pages should not have editable text fields in its layout. Once an editor changes the text on one page, that page stops following the global layout. Use translation strings instead.
- Keep notes. Every unusual command, every "this broke, here is why". This post is mostly made of mine.
Takeaway
Running a large multisite isn't about knowing obscure Drupal internals. It's about turning every operation you do more than twice into one command, knowing which operations are safe to run everywhere at once, and writing down the rest.