Scaling WordPress horizontally on IIS
by Dougal Campbell
This presentation:
Photo credit: Pascal Maramis, Flickr, CC BY 2.0, https://flic.kr/p/f6VuiF
Though this project was using Windows servers, these same basic techniques can be used for scaling Linux servers.
There could be additional front-end WordPress servers. Their setup would be identical to WP-Web1 and WP-Web2.
Actually, there is a fourth server -- a development server. It's a stand-alone server where we can test and experiment without worrying about accidently taking down the live, production site.
https://www.microsoft.com/web/downloads/platform.aspx
Microsoft's WPI is probably the easiest way to get WordPress up and running with IIS.
It will install MySQL, PHP (as a FastCGI process),
and WordPress. It will also create the IIS site configuration, the MySQL user
and database for WordPress to use, and the initial settings for your
wp-config.php
file.
We use WPI to install WordPress to all three of our servers.
Because we want to use a shared content directory, we need to create a domain user which IIS/PHP will use for shared filesystem permissions. Let's call it
MYDOMAIN\IISPHP
.
Keep in mind the security principal of Least Privilege. This account should only have the access it absolutely needs.
This server is not in the load-balancing rotation, and is only
accessible to the front-end servers and the internal network.
However, we still want WordPress running here, because it is
going to hold the master copy of the wp-content
folder which will be shared on the network to the front-end
servers.
We give our MYDOMAIN\IISPHP
account
ownership and control of wp-content
.
After installing WordPress, we configured Multisite in the usual way, by
setting the appropriate define()
statements in
the wp-config.php
file.
/* Multisite */
define( 'WP_ALLOW_MULTISITE', true );
define( 'MULTISITE', true );
define( 'SUBDOMAIN_INSTALL', false );
define( 'DOMAIN_CURRENT_SITE', 'cherokeek12.net' );
define( 'PATH_CURRENT_SITE', '/' );
define( 'SITE_ID_CURRENT_SITE', 1 );
define( 'BLOG_ID_CURRENT_SITE', 1 );
By default, the DB_USER
account can only connect to
MySQL from its own server. We have to tell MySQL to allow the user
to connect from our other front-end servers. Run mysql from the
command line, and grant permissions to the user:
C:\> mysql
Welcome to the MySQL monitor. Commands end with ; or \g.
Your MySQL connection id is 86197
Type 'help;' or '\h' for help. Type '\c' to clear the current input statement.
mysql> GRANT ALL PRIVILEGES ON wpdbname.* TO 'wpdbuser'@'server.ip.number';
Do this for each front-end server
(WP-Web1
& WP-Web2
), substituting
the appropriate values for the database name, user name, and
server IP address.
If you aren't comfortable with the command line, you can also
do this with the MySQL Workbench
app, under
Users and Privileges
.
These are our load-balanced front-ends. We can
disable the MySQL service on these servers, and modify
wp-config.php
to point to the WP-Database server.
They should use the same database settings as WP-DATABASE. For example:
define('DB_NAME', 'wordpress999');
define('DB_USER', 'wordpressuser999');
define('DB_PASSWORD', 'mydbpassword');
define('DB_HOST', '10.1.2.3');
We also delete (or rename, or move) the wp-content
folder on these
servers, and replace it with a link to the network share from
WP-Database:
mklink /d C:\InetPub\wwwroot\wp-content \\WP-Web1\wwwroot\wp-content
This lets WordPress map URLs <==> filenames without additional trickery.
When we reached this point in our server configuration, we discovered that WordPress media uploads did not work on the front-end servers. We spent many hours checking file permissions, Googling, re-checking file permissions, Googling some more, etc.
Over time, we discovered that there were several pieces to this puzzle, and you have to get all of them into place before uploads will work in our shared content directory configuration.
In IIS Manager
, go to mywebsite.net -> Basic Settings -> Connect As
, and set it to use your MYDOMAIN\IISPHP
user account.
Then go to Application Pools
, select your Application Pool, and
click Advanced Settings
. Set the Identity to ApplicationPoolIdentity
.
This will make IIS/PHP identify itself as the domain user to which we have given permissions on the shared wp-content
folder.
upload_tmp_dir
Add new defines in wp-config.php
:
/* Use shared WP-CONTENT directory */
define( 'WP_CONTENT_DIR', '\\\\WP-Database\\inetpub\\wwwroot\\wp-content' );
define( 'WP_TEMP_DIR', '\\\\WP-Database\\inetpub\\wwwroot\\wp-content\\phptemp' );
In php.ini
:
upload_tmp_dir = \\WP-Database\inetpub\wwwroot\wp-content\phptemp
Be sure to create the wp-content/phptemp
directory, and set ownership/permissions for your MYDOMAIN\IISPHP
user.
By using UNC paths for these three settings, and putting the temp upload
directory within our shared folder, the move_uploaded_file()
call
in WordPress Core is able to succeed, because Windows sees that all the paths are in
the same filesystem.
At this point, we had a mini server farm with distributed load. We could scale things further by adding more front-end servers. We could also scale the database with a Master/Slave configuration, with all database writes directed to the Master, and reads distributed between multiple Slaves.
But really, we just want to finally play with our sites!
In Susan's use-case, every school in the district has its own website. They are all using the same theme (her custom child theme, with Enigma as the parent theme), but they have different color schemes.
We made a subdirectory named stylesheets
under the child
theme directory. In there, we can place our site-specific stylesheets
with a name scheme of sitenamestyle.css
.
For example, for Creekview High School, the site name is creekviewhs
, and so we have a site-specific
stylesheet called creekviewhsstyle.css
in the wp-content/themes/ccsd/stylesheets
directory.
/**
* Load parent styles and child theme overrides.
*/
function ccsd_enqueue_styles() {
$parent_style = 'enigma-theme';
$child_style = '/ccsdstyle.css'; // default
// Override per-site
if ( function_exists( 'get_blog_details' ) ) {
$details = get_blog_details();
$path = $details->path; /* e.g.: "/mysitename/" */
$newpath = "/stylesheets" . preg_replace('|/$|', 'style.css', $path);
if ( file_exists( get_stylesheet_directory() . $newpath ) ) {
$child_style = $newpath;
}
}
wp_enqueue_style( $parent_style, get_template_directory_uri() . '/style.css' );
wp_enqueue_style( 'child-style',
get_stylesheet_directory_uri() . $child_style,
array( $parent_style,"bootstrap", "default", "font-awesome", "media-responsive" )
);
}
add_action( 'wp_enqueue_scripts', 'ccsd_enqueue_styles' );
Every site has a common menu of over 30 help links in the footer.
Internally, this is managed as a Nav Menu, which WordPress will
render as a single unordered list, by default. Susan wanted these links
to be distributed in three columns. We took an easy route -- since the
Enigma theme uses Boostrap CSS, there are built-in CSS classes for
columns. We can get our columns by simply adding these classes to each
<li>
with a filter.
/**
* Add Bootstrap column classes to nav menu items, only for items
* in the 'secondary' (footer) nav menu.
*/
function ccsd_nav_menu_css_class($classes, $item, $args, $depth = 1) {
if ( isset( $args->theme_location ) && 'secondary' == $args->theme_location ) {
$classes[] = 'col-md-4';
$classes[] = ' col-sm-6';
}
return $classes;
}
add_filter('nav_menu_css_class', 'ccsd_nav_menu_css_class', 10, 4);
Certain items on the subsites needed to be consistent, and not edited
by the individual school webmasters. These included Widgets and many of
the theme options. Default WordPress Roles and Capabilities do not give
fine-grained control over these things -- you either have the
theme-options
capability, or you don't.
Our first pass at limiting webmaster site control was to simply remove the Dashboard links for those items.
/**
* Restrict dashboard access to only allow users to change Menus and
* Theme Options / Theme Slider Options. Access to other theme options
* requires the user to have the 'edit_themes' capability.
*/
function ccsd_hide_menus() {
global $wp_customize; // Customizer Manager
/**
* Must have at least 'edit_themes' capability to see all theme panels:
*/
if ( current_user_can( 'edit_theme_options' ) && ! current_user_can( 'edit_themes' ) ) {
// Remove 'Themes', 'Widgets', and 'About Enigma' menu entries:
remove_submenu_page('themes.php', 'themes.php');
remove_submenu_page('themes.php', 'widgets.php');
remove_submenu_page('themes.php', 'enigma');
// Remove 'Background' menu entry:
$customize_url = add_query_arg( 'return', urlencode( wp_unslash( $_SERVER['REQUEST_URI'] ) ), 'customize.php' );
$customize_background_url = esc_url( add_query_arg( array( 'autofocus' => array( 'control' => 'background_image' ) ), $customize_url ) );
remove_submenu_page('themes.php', $customize_background_url );
/*
* Remove all Customizer menu entries except 'Menus' and
* the Enigma Theme Options:
*/
if ( isset( $wp_customize ) ) {
$main_panels = $wp_customize->panels();
foreach( $main_panels as $panel_id => $panel_settings ) {
if ( 'nav_menus' == $panel_id ) {
continue; // keep nav menus
}
else if ( 'enigma_theme_option' == $panel_id ) {
continue;
} else {
// Everything else gets hidden.
$wp_customize->remove_panel( $panel_id );
}
}
}
}
}
add_action( 'admin_menu', 'ccsd_hide_menus' );
/**
* Hide all Enigma controls except the ones needed for
* the Slider and Nav Menus:
*/
function ccsd_customizer_filter( $active, $control ) {
if ( current_user_can( 'edit_theme_options' ) && ! current_user_can( 'edit_themes' ) ) {
// By default, turn everything off so that we can be selective:
$active = false;
// Activate the Theme Options / Slider settings
if ( isset( $control->panel ) && 'enigma_theme_option' == $control->panel ) {
$active = true;
}
if ( isset( $control->section ) && 'slider_sec' == $control->section ) {
$active = true;
}
// Activate the Nav Menus panel and sub-sections:
if ( isset( $control->id ) && 'nav_menus' == $control->id ) {
$active = true;
}
if ( isset( $control->panel ) && 'nav_menus' == $control->panel ) {
$active = true;
}
if ( isset( $control->section ) && 'menu_locations' == $control->section ) {
$active = true;
}
if ( isset( $control->section ) && 'add_menu' == $control->section ) {
$active = true;
}
if ( isset( $control->section ) && preg_match( '/nav_menu\[/', $control->section ) ) {
$active = true;
}
}
return $active;
}
add_filter( 'customize_control_active', 'ccsd_customizer_filter', 10, 2 );
Some users are too smart for our own good. And not smart enough for their own good! Even though the Dashboard choices were not seen, they would navigate directly to the URLs for widgets and such. So more drastic measures were required -- a total block from accessing those features.
Enter the ccsd_redirect_naughty_children()
function!
We used the Role Editor plugin to create a custom "Webmaster" role, which has the "edit_theme_options" capability, but does not have the "edit_themes" capability. This is how we know that they should not have access to certain things.
/**
* If users try to visit the Themes or Widgets pages by manually altering
* the URL, slap them on the wrist. Or redirect to the main Dashboard.
*/
function ccsd_redirect_naughty_children() {
if ( current_user_can( 'edit_theme_options' ) && ! current_user_can( 'edit_themes' ) ) {
$restrictions = array(
'/wp-admin/widgets.php',
'/wp-admin/themes.php',
);
foreach ( $restrictions as $restriction ) {
if ( $_SERVER['PHP_SELF'] === $restriction ) {
$domain = get_current_site()->domain;
$path = dirname( $_SERVER['REQUEST_URI'] );
wp_redirect( 'http://' . $domain . $path );
exit;
}
}
}
}
add_action( 'admin_init', 'ccsd_redirect_naughty_children');
/**
* Hide all Enigma controls except the ones needed for
* the Slider and Nav Menus:
*/
function ccsd_customizer_filter( $active, $control ) {
if ( current_user_can( 'edit_theme_options' ) && ! current_user_can( 'edit_themes' ) ) {
// By default, turn everything OFF so that we can be selective:
$active = false;
/*
* Now, we selectively allow access to *just* certain Customizer panels,
* sections, and controls.
*/
// Activate the Theme Options / Slider settings
if ( isset( $control->panel ) && 'enigma_theme_option' == $control->panel ) {
$active = true;
}
if ( isset( $control->section ) && 'slider_sec' == $control->section ) {
$active = true;
}
// Activate the Nav Menus panel and sub-sections:
if ( isset( $control->id ) && 'nav_menus' == $control->id ) {
$active = true;
}
if ( isset( $control->panel ) && 'nav_menus' == $control->panel ) {
$active = true;
}
if ( isset( $control->section ) && 'menu_locations' == $control->section ) {
$active = true;
}
if ( isset( $control->section ) && 'add_menu' == $control->section ) {
$active = true;
}
if ( isset( $control->section ) && preg_match( '/nav_menu\[/', $control->section ) ) {
$active = true;
}
}
return $active;
}
add_filter( 'customize_control_active', 'ccsd_customizer_filter', 10, 2 );
Dougal Campbell
@dougal
Photo credit: Alex Griffioen, Flickr, CC BY 2.0, http://flic.kr/p/nU3Jv