meta charset utf-8: 404 por direcciones de imágenes en coreano que rompió el rastreador de AdSense y php-fpm lleno
Si el lector no encuentra la declaración meta charset utf-8 al principio del HTML y el encabezado de respuesta tampoco trae charset, tiene que adivinar la codificación de caracteres del documento o leerlo con un valor predeterminado. En un servidor WordPress con Nginx y Apache, el rastreador de AdSense envió una ráfaga de solicitudes a direcciones de imágenes en coreano (no ASCII) rotas, y el manejo de los 404 llenó todos los workers de PHP. Tras seguirlo en los logs, añadí charset al encabezado de respuesta de las páginas en caché e hice que nginx respondiera directamente con un 404 a los archivos estáticos inexistentes, para que las mismas solicitudes no vuelvan a llenar los workers.
1️⃣ meta charset utf-8: por qué debe estar en los primeros 1.024 bytes
El servidor puede indicar la codificación de caracteres del documento en dos lugares: el charset que va detrás del encabezado de respuesta [Content-Type] y la línea meta charset escrita dentro del HTML.
El estándar HTML exige que la declaración de codificación aparezca solo dentro de los primeros 1.024 bytes, y recomienda al lector que busque leyendo por adelantado solo esos primeros 1.024 bytes. Por eso, aunque esté dentro de head, un meta charset desplazado más allá de 1.024 bytes puede pasar desapercibido para algunos lectores.
Para decidir la codificación, el lector mira primero el BOM (marca de orden de bytes), después el charset del encabezado de respuesta y luego el meta charset encontrado al leer por adelantado los primeros 1.024 bytes. Si no hay ninguno de los tres, adivina por la forma de los bytes o usa un valor predeterminado, y en la tabla de valores predeterminados que propone el estándar HTML, la mayoría de los locale tienen windows-1252.
Si se lee un documento UTF-8 como windows-1252, un solo carácter de varios bytes, como una letra coreana, se convierte en varios caracteres distintos.
2️⃣ Direcciones de imágenes en coreano (no ASCII) que rompió el rastreador de AdSense Mediapartners-Google
Al revisar la página en caché de antes del arreglo con el criterio de los primeros 1.024 bytes, el encabezado de respuesta Content-Type no tenía charset y la línea meta charset estaba en el byte 15.199. Así que la página en caché que recibió el rastreador de AdSense Mediapartners-Google no tenía una codificación que pudiera encontrarse en ninguno de los dos lugares.
curl -sI https://example.com/sample-post/ | grep -i '^content-type'content-type: text/htmlEn esta configuración, si añades una cadena de consulta a la dirección, se salta la página en caché y llega una respuesta generada directamente por PHP, y esa respuesta sí llevaba charset.
curl -sI 'https://example.com/sample-post/?nocache=1' | grep -i '^content-type'content-type: text/html; charset=UTF-8Con [grep -b] puedes ver, byte a byte, en qué posición del archivo de caché empieza el meta charset.
grep -bo '<meta charset' /var/www/html/wp-content/cache/wp-rocket/example.com/sample-post/index-https.html | head -115199:<meta charsetLas etiquetas img del mismo archivo de caché contenían nombres de archivo multilingües en UTF-8 (en este caso, en coreano) tal cual, sin codificación porcentual. Si este HTML se lee como windows-1252, un nombre como [대시보드-화면-예시.png] se convierte en caracteres como [대시보드-화면-예시.png], y si esos caracteres se vuelven a codificar en porcentaje como UTF-8 y se solicitan, el servidor busca un archivo que no existe.
Si extraes del log de acceso los 404 de imágenes que recibió este rastreador, aparecen una tras otra direcciones con nombres rotos.
grep 'Mediapartners-Google' /var/log/nginx/access.log | grep ' 404 ' | grep 'GET /wp-content/uploads/' | cut -d' ' -f1,4,7,9 | head -2203.0.113.3 [03/Nov/2026:20:40:51 /wp-content/uploads/2025/07/---1024x682.png 404
203.0.113.1 [03/Nov/2026:20:40:51 /wp-content/uploads/2025/07/---300x159.jpg 404Las direcciones que quedaron en el log coincidían byte a byte con los bytes UTF-8 del nombre original leídos como caracteres windows-1252 y codificados de nuevo en UTF-8. Ese día, el log tenía 202 solicitudes a direcciones de imágenes rotas de esta misma forma desde este rastreador y 6 desde un script (python-requests), y 0 desde visitantes.
Si pasas el nombre roto [대시보드-화면-예시.png] por la herramienta Conversor UTF-8, se detecta como bytes UTF-8 leídos como windows-1252 y aparece el nombre original [대시보드-화면-예시.png].
3️⃣ Cómo los 404 de imágenes inexistentes llenaron php-fpm
Como las imágenes de las direcciones rotas no existen en el servidor, nginx pasó las solicitudes a Apache siguiendo el último elemento de try_files en su regla de ubicación de imágenes, y WordPress generó la página 404 con PHP. Mirando los 587 404 registrados durante el día en esa misma fecha, cada página 404 sin caché tardó una mediana de 1,4 segundos.
Las solicitudes de imágenes con nombres rotos se acumularon hasta algo más de 200 en los 3 minutos desde las 20:40, y 133 de ellas terminaron en 404. Cada solicitud recibe un worker de php-fpm, y como las solicitudes seguían llegando mientras cada 404 tardaba de 4 a 6 segundos, el pool llegó a su límite a las 20:40:55.
El momento en que el pool llega a su límite queda registrado en el log de errores de php-fpm como aviso de php-fpm max_children.
grep 'max_children' /var/log/php-fpm/error.log[03-Nov-2026 20:40:55] WARNING: [pool www] server reached pm.max_children setting (20), consider raising itLas solicitudes que llegaron después de llenarse el pool esperaron a que quedara un worker libre. El 404 que más esperó terminó a los 58 segundos, las solicitudes que el rastreador cortó tras esperar 60 segundos quedaron registradas como 499, y una solicitud que no recibió respuesta del backend quedó como 504.
La precarga de la caché, las tareas programadas (Action Scheduler) que corrían a la misma hora y wp-cron, que se ejecuta cada minuto, se revisaron y se descartaron como causa. La precarga funcionó todo el día al mismo ritmo bajo, las tareas programadas solo procesaban trabajos de precarga a esa hora, y wp-cron se ejecuta con PHP de línea de comandos, así que no usa workers de php-fpm.
4️⃣ nginx charset utf-8 y 404 directo para archivos estáticos inexistentes
Añadí la línea [charset utf-8;] a la ubicación de nginx que sirve las páginas en caché para que el encabezado de respuesta lleve charset, y para las imágenes y archivos estáticos de la carpeta de subidas, si no están en el disco, nginx devuelve un 404 directamente en vez de pasarlos a WordPress.
# Bloque http: fija un valor de Cache-Control solo para las respuestas 404
map $status $up404_cc { 404 'no-store'; default ''; }
# Ubicación que sirve directamente el HTML en caché
location / {
try_files $cache_file @backend;
charset utf-8;
}
# Carpeta de subidas: las imágenes inexistentes reciben un 404 directo sin pasar al backend
location ~* ^/wp-content/uploads/.+[.](jpg|jpeg|png)$ {
try_files $uri =404;
add_header Cache-Control $up404_cc always;
}El map va en el bloque http, y como nginx no envía un encabezado cuyo valor es una cadena vacía, las respuestas 200 no reciben no-store. Las ubicaciones con expresiones regulares se aplican de arriba abajo y gana la primera que coincide, así que la coloqué antes de la ubicación de imágenes existente y añadí otra ubicación del mismo tipo para archivos estáticos como css, js y fuentes.
Al volver a solicitar la misma dirección tras el arreglo, el encabezado de respuesta de la página en caché ya llevaba charset.
curl -sI https://example.com/sample-post/ | grep -i '^content-type'content-type: text/html; charset=utf-8curl -sI https://example.com/wp-content/uploads/2025/07/no-such-image.png | grep -i -E '^(HTTP|content-type|cache-control)'# Antes del arreglo: un 404 generado por WordPress
HTTP/2 404
content-type: text/html; charset=UTF-8
cache-control: no-cache, must-revalidate, max-age=0, no-store, private
# Después del arreglo: un 404 devuelto directamente por nginx
HTTP/2 404
content-type: text/html
cache-control: no-storeEl 404 de una imagen inexistente pasó de una página de WordPress que tardaba 1,01 segundos en generar 319.058 bytes a la página predeterminada de nginx, devuelta en 0,015 segundos con 555 bytes. Al añadir [Cache-Control: no-store] solo a los 404, la CDN no guarda los 404 de imágenes inexistentes. nginx no vuelve a añadir charset a las respuestas que PHP ya envió con él, así que apareció una sola vez en el encabezado de respuesta.
| Solicitud comprobada | Antes de la corrección | Después del arreglo |
|---|---|---|
| Imagen inexistente | 319.058 B, 1,01 segundos (404 de WordPress) | 555 B, 0,015 segundos (404 de nginx) |
| Imagen con nombre roto | 1,05 segundos (404 de WordPress) | 0,014 segundos (404 de nginx) |
| Encabezado de respuesta del HTML en caché | text/html | text/html; charset=utf-8 |
| Encabezado de respuesta generado directamente por PHP | text/html; charset=UTF-8 | Igual (charset una vez) |
| Imagen existente | 200 | Sigue en 200 |
Este manejo de 404 no sirve para sitios con plugins de imágenes que generan miniaturas o WebP con PHP después de que llega la solicitud. Como nginx devuelve primero el 404, la imagen nunca se genera. Si tu configuración crea los archivos WebP de antemano al subirlos, puedes aplicarlo tal cual.
5️⃣ Por qué meta charset quedó desplazado: Delay JS de WP Rocket
Incluso después de añadir charset al encabezado de respuesta, el meta charset del HTML en caché sigue en el byte 15.199. En la página generada directamente por PHP, esa misma línea estaba en el byte 135.
Con Delay JS de WP Rocket activado, al crear el archivo de caché se insertan scripts de ejecución diferida como lazyload justo después de head, y luego un paso vuelve a mover meta charset al principio de head. Ese paso aplica una expresión regular a todo el documento, así que en una entrada grande de unos 1,2 MB supera el límite de backtrack de PHP y falla.
Cuando la expresión regular falla, WP Rocket guarda en caché el HTML tal cual, con los scripts insertados pero sin mover meta charset, y por eso meta charset quedó fuera de los primeros 1.024 bytes. En una reproducción aparte con la misma configuración de PHP, el movimiento falló con el límite de backtrack en el valor predeterminado de 1.000.000 y en 5.000.000, y funcionó a partir de 10.000.000.
Esta vez lo resolví añadiendo charset al encabezado de respuesta y no corregí la posición de meta charset. Subir el límite de backtrack recupera el comportamiento original de WP Rocket, pero multiplica por diez el límite de todas las expresiones regulares que corren en el mismo PHP, así que no lo recomiendo.
🔢 FAQ & contenido recomendado
HTTP 404
Error HTTP 404 (error del cliente): causas y solución
La página de error HTTP 404, uno de los códigos de estado HTTP, aparece cuando no se encuentra una página.
AdSense
Configuración de ads.txt en WordPress: cómo resolver la advertencia de AdSense
ads.txt es un archivo de texto que se coloca en la raíz del sitio y que publica la lista de vendedores autorizados que pueden vender el espacio publicitario del dominio.
Caché de WP Rocket
WP Rocket y URL en coreano: por qué nginx no encuentra el archivo de caché y la solución con njs
En las entradas con slug en inglés, nginx sirve directamente el archivo de caché, pero en las entradas con slug en coreano, aunque exista el mismo archivo de caché, se sirve solo después de que PHP se haya ejecutado una vez.
ℹ️ Aviso de afiliados
El contenido de este sitio incluye enlaces de afiliado. Cuando un visitante compra un producto o servicio a través de uno de ellos, el sitio recibe una comisión del vendedor. El importe que paga el comprador(baja durante las promociones ↓)no aumenta. Los precios, descuentos y existencias publicados corresponden al momento de redacción y pueden variar, así que confírmalos con el vendedor antes de comprar. Los productos se eligen y evalúan con criterios propios, y las comisiones no influyen en el orden ni en el contenido de las reseñas.