Sorteando el desalojo de caché con un CDN «push»: bunny.net

La entrega más habitual en CDNs es de tipo «pull». Veamos: un primer visitante accede a un recurso, que no estará en caché. Se pide al servidor web de origen y se almacena durante un tiempo para posibles visitantes futuros.
Este método funciona bien para contenido popular. Sin embargo, la situación es distinta para webs con tráfico escaso: sus recursos serán desalojados rápidamente de caché. El operador del CDN lo hará así porque tiene almacenamiento limitado. El max-age que se especifique en el header cache-control será por tanto el mejor de los casos.
Es por todo esto que he estado buscando CDNs «push», es decir, a los que pueda subir contenido que quede permanentemente guardado. Vemos un post interesante de StackOverflow:
We have tried to similar things with different CDN providers, and for CloudFront I don’t think there is any existing way for you to push (what we call pre-feeding) your specific contents to nodes/edges if the cloudfront distribution is using your custom origin.
One way I can think of, also as mentioned by @Xint0 is set up another S3 bucket to specifically hosting those files you would like to push (in your case those resized images). Basically you will have two cloudFront distributions one to pull those files rarely accessed and another to push for those files accessed frequently and also those images you expect to be resized. This sounds a little bit complex but I believe that’s the tradeoff you have to make.https://stackoverflow.com/a/10491489
Another point I can recommend you to look at is EdgeCast which is another CDN provider and they do provide function called load_to_edge (which I spent quite a lot of time last month to integrate this with our service, that’s why I remember it clearly) which does exactly what you expect. They also support custom origin pull, so that maybe you can take a trial there.
En CloudFront se puede establecer un bucket S3 como origen, a donde subiríamos nuestros recursos. No me entusiasmaba esta solución porque el bucket está ligado a una región de AWS. Tendríamos que sincronizar por nuestra cuenta los buckets, y pagar un precio alto por tráfico.
En el post se muestra una segunda opción más interesante, el CDN EdgeCast, propiedad de Verizon. Según parece, se puede almacenar contenido directamente en los PoPs. El problema es que EdgeCast está orientado a empresas y no anuncia los precios en la web, hay que pedirlos en privado. Me imagino que es demasiado caro para una web personal, como es mi caso.
Después encontré Bunny CDN. Se puede establecer un almacenamiento como origen, igual que comenté antes con S3 y CloudFront, pero con la posibilidad adicional de replicar automáticamente entre varias regiones. Denominan a esta funcionalidad «Geo-Replication». Existen cinco ubicaciones: Falkenstein (Alemania), Nueva York, Los Ángeles, Singapur y Sídney. Cada una con su propio coste por GB almacenado.
Los PoPs o cachés son más numerosos. Junto con las regiones de almacenamiento, suman actualmente 53 emplazamientos. Si un recurso no está disponible en la caché del PoP, éste lo pide a la región de almacenamiento más cercana.
Configuración de zonas de almacenamiento y pull
Veamos el proceso de configuración que he seguido en el panel de Bunny:








Consideraciones adicionales para WordPress
Subida de media
Las imágenes que subimos a través del Media Library se almacenan en nuestro servidor, pero tenemos que subirlas también a la zona de almacenamiento. No podemos hacerlo por separado porque WordPress genera miniaturas que también deben estar disponibles.
Encontré un plugin que hacía esto, pero era de pago y al final lo he hecho por mi cuenta, con filtros y acciones en mi tema:
// functions.php
function filename_extension($basename) {
// https://stackoverflow.com/a/45268539
setlocale(LC_ALL,'C.UTF-8');
$paths = pathinfo($basename);
$result = array();
$result["filename"] = $paths['filename'];
$result["extension"] = $paths['extension'];
return $result;
}
// CUIDADO: getcwd() aquí es wp-admin, NO wp-admin/themes/okaeri_theme
$cdn_log = "../cdn/cdn.txt";
function save_filenames($metadata, $attachment_id, $context) {
global $cdn_log;
if (array_key_exists("file", $metadata)){
$filename_extension = filename_extension($metadata["file"]);
}
else { # En los vídeos no me aparece $metadata["file"], lo saco con get_attached_file
$filename_extension = filename_extension(basename ( get_attached_file( $attachment_id ) ));
}
$result = $attachment_id . TAB . $filename_extension["filename"] . TAB . $filename_extension["extension"] . TAB;
foreach ($metadata["sizes"] as $size) {
$result.= $size["width"] . "x" . $size["height"] . TAB;
}
file_put_contents($cdn_log, mb_substr($result, 0, -1) . PHP_EOL, FILE_APPEND | LOCK_EX);
return $metadata;
}
add_filter('wp_generate_attachment_metadata', 'save_filenames', 10, 3);
function delete_filenames( $post_id) {
global $cdn_log;
$lines = explode(PHP_EOL, file_get_contents($cdn_log));
$non_equal = [];
foreach($lines as $line) {
if (empty($line))
continue;
$exploded_line = explode(TAB, $line);
$found_id = $exploded_line[0];
if($found_id != $post_id) {
$non_equal[] = $line;
}
}
$out = implode(PHP_EOL, $non_equal);
if (!empty($non_equal)){
$out .= PHP_EOL;
file_put_contents($cdn_log, $out);
}
};
// add the action
add_action( 'delete_attachment', 'delete_filenames');
Este código registra en un fichero «cdn.txt» las imágenes subidas al Media Library. Se guarda el ID, nombre, extensión y resoluciones de miniaturas, separado por tabulador . Por ejemplo, para las imágenes de este post:
2482 bunny_01_2021-03-06_Welcome_mod png 768x437 300x171 1024x583 1536x875
2483 bunny_02_2021-03-06_Add_Storage_Zone_mod png 768x437 300x171 1024x583 1536x875
2484 bunny_03_2021-03-06_Add_Storage_Zone_main_region png 300x162
2485 bunny_05_2021-03-06_Add_Storage_Zone_replicated_regions png 768x179 300x70 1024x239
2486 bunny_06_2021-03-06_Manage_Storage_Zone_mod png 768x437 300x171 1024x583 1536x875
2487 bunny_08_2021-03-06_Add_Pull_Zone_mod png 768x437 300x171 1024x583 1536x875
2488 bunny_09_2021-03-06_Pull_Zone_Installation_Instructions_mod png 768x437 300x171 1024x583 1536x875
2489 bunny_11_2021-03-06_WordPress png 768x626 300x244 1024x834
Después subo los ficheros con otro script que procesa este fichero. Para la subida en sí misma empleo la librería oficial de PHP de Bunny CDN. Me genera una salida tal que así:
{
"bunny_01_2021-03-06_Welcome_mod.png": {
"HttpCode": 201,
"Message": "File uploaded."
},
"bunny_01_2021-03-06_Welcome_mod-768x437.png": {
"HttpCode": 201,
"Message": "File uploaded."
},
"bunny_01_2021-03-06_Welcome_mod-300x171.png": {
"HttpCode": 201,
"Message": "File uploaded."
},
"bunny_01_2021-03-06_Welcome_mod-1024x583.png": {
"HttpCode": 201,
"Message": "File uploaded."
},
"bunny_01_2021-03-06_Welcome_mod-1536x875.png": {
"HttpCode": 201,
"Message": "File uploaded."
},
"bunny_02_2021-03-06_Add_Storage_Zone_mod.png": {
"HttpCode": 201,
"Message": "File uploaded."
},
"bunny_02_2021-03-06_Add_Storage_Zone_mod-768x437.png": {
"HttpCode": 201,
"Message": "File uploaded."
},
"bunny_02_2021-03-06_Add_Storage_Zone_mod-300x171.png": {
"HttpCode": 201,
"Message": "File uploaded."
},
// [...]
"_result": {
"move_cdn_file": {
"result": true,
"filename": "/tmp/cdn/2021_03_12__06_04_49.txt"
},
"all_succeeded": true
}
}
Actualizaciones de WordPress y plugins
No sólo sirvo imágenes desde el CDN, sino también CSS y JavaScript. Tanto el propio WordPress como plugins y temas utilizan este tipo de recursos. Esto implica que por cada actualización de WordPress, plugins o temas deberemos resubir los ficheros correspondientes:
- WordPress: todo menos
wp-content/uploads
(wp-admin
,wp-content
,wp-includes
…) - Temas: carpeta correspondiente en
wp-content/themes
- Plugins: carpeta correspondiente en
wp-content/plugins
Puesto que hago esta subida manualmente, he desactivado las actualizaciones automáticas con el plugin «WP Disable Automatic Updates».
No hace falta subir todos los ficheros de esas carpetas, basta con los utilizados por el front. En el siguiente comando busco las extensiones de fichero y las copio a otra carpeta, que es la que subo por FTP al storage zone de Bunny (renombrar wp-content-filtrado a wp-content y wp-includes-filtrado a wp-includes antes de subir, porque Bunny no deja renombrar):
j@furin ~/Documents/trabajo/wordpress/backup/www/wp-content % find . -not \( -path ./cache -prune \) -regex '\(.*css\|.*js\|.*htm\|.*html\|*.txt\|.*jpg\|.*jpeg\|.*png\|.*svg\|.*ico\)' -exec cp --parents -rp -t wp-content-filtrado {} \+
j@furin ~/Documents/trabajo/wordpress/backup/www/wp-includes % find . -regex '\(.*css\|.*js\|.*htm\|.*html\|*.txt\|.*jpg\|.*jpeg\|.*png\|.*svg\|.*ico\)' -exec cp --parents -rp -t wp-includes-filtrado {} \+
Versionado del tema
En mi caso utilizo un tema propio y no lo tenía versionado. Escribimos la versión en style.css:
/*
Theme Name: <nombre del tema>
Version: 1.0.0
*/
Al hacer enque del tema en functions.php cargamos la versión:
wp_enqueue_style( 'main', get_stylesheet_uri(), array(), wp_get_theme()->get( 'Version' ) );
De esta manera, el query string para cache busting (?ver=
) utilizará nuestra versión y no la utilizada por defecto, la de WordPress. Así podemos actualizar el tema y WordPress por separado. Para la caché distinga por query string debemos activar la opción correspondiente en la configuración de la zona:

Fichero wp-emoji-release.js
El plugin de Bunny CDN no reescribe la URL para el fichero wp-emoji-release, porque se genera la URL a través de JavaScript. Lo he arreglado con el filtro ‘script_loader_src’.
Cache-control
El cache-control: max-age que establece la zona de almacenamiento es muy alto, de 25600000 segundos (296 días). En el panel de control de Bunny pido que se respete este tiempo de origen para los PoPs (aunque por supuesto será desalojado antes de llegar a esa fecha). Sin embargo, hago override para el cache control que se envía al usuario final, a un tiempo bajo (20 minutos). La razón es que puedo purgar manualmente la caché de los PoPs, pero una vez un recurso ha llegado al navegador del usuario, no se puede purgar, sólo experar a que expire, y no quiero esperar 296 días. La única alternativa es o cambiar el nombre o hacer cache busting con un query string.

Deshabilitar CDN para el usuario admin
El plugin de Bunny muestra una opción «Disable CDN for admin user», que por defecto está deshabilitada pero que he activado. De esta manera, puedo ver el preview del post con las imágenes sin que todavía estén subidas al CDN. Así puedo decidir si quiero cambiar una imagen si el engorro de borrar manualmente del CDN.