cutover: los scripts con los que se ejecuto, y lo que quedo pendiente
Cutover hecho el 3-ago a las 10:50 UTC. feadulta.com y www.feadulta.com ya apuntan a 188.40.120.157, proxied. Ventana real ~3 min 10 s: dump 67 s, saneo 2 s, import 114 s, delta de uploads 6 s (0 ficheros, como estaba medido). Cuadro al digito con produccion: 43.893 posts, 1.228 usuarios, 4.416 terms, 135.959 postmeta. Los options salen 958 frente a 975 porque son transients (hay 580 y caducan solos). Con trafico real: 581 respuestas 200, 62 301, 16 302, cero 5xx y cero errores PHP en 20 minutos, 61 IPs distintas atendidas. Se anaden los cuatro scripts que faltaban, que hasta hoy solo existian como comandos sueltos del ensayo: 01-traer-dump, 02-sanear-dump, 03-importar, 04-delta-uploads y 05-mover-dns. El runbook ya los citaba por nombre. Tres cosas encontradas al ejecutar, anotadas en los propios scripts: - docker exec -i dentro de un ssh 'bash -s' <<EOF se come el resto del script, porque hereda el stdin del heredoc. Las consultas van sin -i y con </dev/null. - Smart Slider cachea el HTML del slider con URLs absolutas, una fila por idioma en wp_nextend2_section_storage con application='cache'. Al verificar el import con la constante del staging todavia puesta quedaron 5 filas apuntando a nuevo.feadulta.com. Se borran y se regeneran solas. - El cliente de MariaDB de la jaula de CDMON exige TLS y el servidor local no lo tiene: con --skip-ssl si se puede consultar la BD de produccion directamente, que es como se comprobo que no habia cambiado nada desde el dump de las 05:09. Pendiente y fuera de nuestro alcance: purgar la cache de Cloudflare (el token solo tiene permiso de DNS) y darle a Inma su contrasena nueva. Y los certificados de Let's Encrypt para www y el apex, que fallan porque el WAF de Cloudflare devuelve 403 a la validacion; no bloquea mientras la zona este en Full, pero por eso mismo no se puede subir a Full (strict) todavia. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Executable
+54
@@ -0,0 +1,54 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Paso 1-2 del cutover: traer el dump de BD del dia. CDMON -> WSL -> Hetzner.
|
||||
#
|
||||
# Dos saltos a proposito: scp/sftp no funcionan contra la jaula de CDMON, y no
|
||||
# queremos dejar la contrasena de CDMON escrita en el Hetzner. Se verifica el
|
||||
# sha256 en los tres puntos y la integridad del gzip en dos.
|
||||
#
|
||||
# Uso: bash 01-traer-dump.sh [nombre-del-dump]
|
||||
# sin argumento coge el mas reciente de /web/wp-content/updraft/
|
||||
#
|
||||
set -euo pipefail
|
||||
set -a; . ~/.hermes/profiles/feadulta/.env 2>/dev/null; set +a
|
||||
|
||||
WORK=/home/rafa/migracion-wp
|
||||
SRV=root@188.40.120.157
|
||||
ORIG=feadulta@134.0.10.170
|
||||
mkdir -p "$WORK"
|
||||
|
||||
s() { sshpass -p "$FEA_PROD_SSH_PASS" ssh -o StrictHostKeyChecking=accept-new "$ORIG" "$@"; }
|
||||
|
||||
DUMP="${1:-}"
|
||||
if [ -z "$DUMP" ]; then
|
||||
# sin xargs ni basename: la jaula de CDMON tiene un PATH minimo
|
||||
DUMP=$(s 'ls -t /web/wp-content/updraft/*-db.gz | head -1 | sed "s|.*/||"')
|
||||
fi
|
||||
echo "== dump elegido: $DUMP"
|
||||
|
||||
echo "== en origen:"
|
||||
s "php -r \"\\\$p='/web/wp-content/updraft/$DUMP'; echo ' ',filesize(\\\$p),' bytes sha256=',hash_file('sha256',\\\$p),\\\"\n\\\";\""
|
||||
|
||||
echo "== descargando a la WSL:"
|
||||
T0=$(date +%s)
|
||||
s "cat /web/wp-content/updraft/$DUMP" > "$WORK/$DUMP"
|
||||
T1=$(date +%s); echo " descarga: $((T1-T0))s"
|
||||
|
||||
echo "== integridad en la WSL:"
|
||||
ls -l "$WORK/$DUMP" | awk '{print " ", $5, "bytes"}'
|
||||
sha256sum "$WORK/$DUMP" | awk '{print " sha256=", $1}'
|
||||
gzip -t "$WORK/$DUMP" && echo " gzip: integro"
|
||||
|
||||
echo "== subiendo al Hetzner:"
|
||||
ssh -o BatchMode=yes "$SRV" 'mkdir -p /data/feadulta-migracion'
|
||||
T2=$(date +%s)
|
||||
scp -q "$WORK/$DUMP" "$SRV:/data/feadulta-migracion/"
|
||||
T3=$(date +%s); echo " subida: $((T3-T2))s"
|
||||
|
||||
echo "== verificacion en destino:"
|
||||
ssh -o BatchMode=yes "$SRV" "cd /data/feadulta-migracion && ls -l $DUMP | awk '{print \" \", \$5, \" bytes\"}' && sha256sum $DUMP | awk '{print \" sha256=\", \$1}' && gzip -t $DUMP && echo ' gzip: integro'"
|
||||
|
||||
echo ""
|
||||
echo "== total: $(( $(date +%s) - T0 ))s"
|
||||
echo "$DUMP" > "$WORK/DUMP-DEL-DIA"
|
||||
echo " (nombre guardado en $WORK/DUMP-DEL-DIA para el paso siguiente)"
|
||||
Executable
+57
@@ -0,0 +1,57 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Paso 3 del cutover: sanear el dump. NO importa: eso es el paso 4.
|
||||
#
|
||||
# Dos cosas a la vez sobre el mismo streaming:
|
||||
# - excluir las 5 tablas de respaldo (*_bak y wp_fea_date_slug_posts_backup)
|
||||
# - mapear las collations de MariaDB 11 que MySQL 8.4 rechaza en el CREATE TABLE
|
||||
#
|
||||
# El troceado por tablas se apoya en los comentarios "# Table: `nombre`" que escribe
|
||||
# UpdraftPlus. Si algun dia el dump lo genera mysqldump, esto hay que rehacerlo.
|
||||
#
|
||||
set -euo pipefail
|
||||
DUMP=$(cat /home/rafa/migracion-wp/DUMP-DEL-DIA)
|
||||
echo "== dump: $DUMP"
|
||||
ssh -o BatchMode=yes root@188.40.120.157 "D='$DUMP' bash -s" <<'EOF'
|
||||
set -euo pipefail
|
||||
cd /data/feadulta-migracion
|
||||
OUT=feadulta-saneado.sql
|
||||
T0=$(date +%s)
|
||||
|
||||
zcat "$D" \
|
||||
| awk '
|
||||
/^# Table: `/ {
|
||||
name = $0; sub(/^# Table: `/, "", name); sub(/`.*$/, "", name)
|
||||
skip = (name ~ /_bak$/ || name == "wp_fea_date_slug_posts_backup") ? 1 : 0
|
||||
if (skip) print " [excluida] " name > "/dev/stderr"
|
||||
}
|
||||
!skip { print }
|
||||
' \
|
||||
| sed -E \
|
||||
-e 's/utf8mb4_uca1400_ai_ci/utf8mb4_0900_ai_ci/g' \
|
||||
-e 's/utf8mb3_uca1400_ai_ci/utf8mb4_0900_ai_ci/g' \
|
||||
-e 's/CHARSET=utf8mb3/CHARSET=utf8mb4/g' \
|
||||
-e 's/CHARACTER SET utf8mb3/CHARACTER SET utf8mb4/g' \
|
||||
-e 's/utf8mb3_general_ci/utf8mb4_general_ci/g' \
|
||||
> "$OUT"
|
||||
|
||||
echo ""
|
||||
echo "== saneo en $(( $(date +%s) - T0 ))s"
|
||||
ls -l "$OUT" | awk '{printf " %.1f MB sin comprimir\n", $5/1048576}'
|
||||
|
||||
echo ""
|
||||
echo "== VERIFICACION (las tres primeras deben dar 0)"
|
||||
u=$(grep -c 'uca1400' "$OUT" || true); echo " restos de uca1400 : $u"
|
||||
m=$(grep -c 'utf8mb3' "$OUT" || true); echo " restos de utf8mb3 : $m"
|
||||
b=$(grep -oE '^CREATE TABLE `[^`]+`' "$OUT" | grep -cE '_bak|backup' || true); echo " tablas de respaldo: $b"
|
||||
echo " tablas en el saneado: $(grep -cE '^CREATE TABLE ' "$OUT")"
|
||||
echo " DROP TABLE IF EXISTS: $(grep -c '^DROP TABLE IF EXISTS' "$OUT" || true) <- si es 0, hay que vaciar la BD antes"
|
||||
echo " collations resultantes:"
|
||||
grep -oE 'COLLATE=[a-z0-9_]+' "$OUT" | LC_ALL=C sort | uniq -c | sed 's/^/ /'
|
||||
echo " epilogo (debe verse el cierre del dump):"
|
||||
tail -3 "$OUT" | sed 's/^/ /'
|
||||
|
||||
[ "$u" = "0" ] && [ "$m" = "0" ] && [ "$b" = "0" ] || { echo ""; echo "!! VERIFICACION FALLIDA, no importar"; exit 1; }
|
||||
echo ""
|
||||
echo "== saneado OK"
|
||||
EOF
|
||||
Executable
+54
@@ -0,0 +1,54 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Paso 4 del cutover: importar el dump saneado en MySQL 8.4.
|
||||
#
|
||||
# --default-character-set=utf8mb4 EN EL CLIENTE es obligatorio: omitirlo es lo que
|
||||
# produjo el mojibake de summaraise. El dump trae DROP TABLE IF EXISTS en las 29
|
||||
# tablas, asi que no hace falta vaciar la BD antes.
|
||||
#
|
||||
set -euo pipefail
|
||||
ssh -o BatchMode=yes root@188.40.120.157 'bash -s' <<'EOF'
|
||||
set -euo pipefail
|
||||
U=r2ssjifwj0r0ghyoqd528uaa
|
||||
cd /data/feadulta-migracion
|
||||
RP=$(docker inspect mysql-$U --format '{{range .Config.Env}}{{println .}}{{end}}' | sed -n 's/^MYSQL_ROOT_PASSWORD=//p')
|
||||
# OJO: sin -i y con </dev/null. Este script llega por el stdin del ssh, y un
|
||||
# "docker exec -i" sin redirigir se come el resto del script.
|
||||
m() { docker exec mysql-$U mysql --default-character-set=utf8mb4 -uroot -p"$RP" -N -B -e "$1" wordpress 2>/dev/null </dev/null; }
|
||||
|
||||
echo "== antes del import:"
|
||||
m "SELECT CONCAT(' tablas: ', COUNT(*)) FROM information_schema.tables WHERE table_schema='wordpress';"
|
||||
|
||||
echo "== importando..."
|
||||
T0=$(date +%s)
|
||||
docker exec -i mysql-$U mysql --default-character-set=utf8mb4 -uroot -p"$RP" wordpress < feadulta-saneado.sql
|
||||
T1=$(date +%s)
|
||||
echo " import: $((T1-T0))s"
|
||||
|
||||
echo ""
|
||||
echo "== recuentos en destino (comparar con produccion):"
|
||||
m "SELECT 'tablas', COUNT(*) FROM information_schema.tables WHERE table_schema='wordpress'
|
||||
UNION ALL SELECT 'posts', COUNT(*) FROM wp_posts
|
||||
UNION ALL SELECT 'users', COUNT(*) FROM wp_users
|
||||
UNION ALL SELECT 'options', COUNT(*) FROM wp_options
|
||||
UNION ALL SELECT 'terms', COUNT(*) FROM wp_terms
|
||||
UNION ALL SELECT 'postmeta', COUNT(*) FROM wp_postmeta;" | sed 's/^/ /'
|
||||
|
||||
echo ""
|
||||
echo "== charset/collation de las tablas (no debe quedar nada raro):"
|
||||
m "SELECT table_collation, COUNT(*) FROM information_schema.tables WHERE table_schema='wordpress' GROUP BY table_collation;" | sed 's/^/ /'
|
||||
|
||||
echo ""
|
||||
echo "== indice FULLTEXT del buscador:"
|
||||
m "SELECT index_name, COUNT(*) FROM information_schema.statistics WHERE table_schema='wordpress' AND index_type='FULLTEXT' GROUP BY index_name;" | sed 's/^/ /'
|
||||
|
||||
echo ""
|
||||
echo "== acentos y tipografia (mojibake = 0):"
|
||||
m "SELECT 'con  raro', COUNT(*) FROM wp_posts WHERE post_title COLLATE utf8mb4_bin LIKE '%Ã%' AND post_title COLLATE utf8mb4_bin NOT LIKE '%ÃÂ%'
|
||||
UNION ALL SELECT '’ (mojibake real)', COUNT(*) FROM wp_posts WHERE post_content COLLATE utf8mb4_bin LIKE '%’%'
|
||||
UNION ALL SELECT '© mojibake', COUNT(*) FROM wp_posts WHERE post_content COLLATE utf8mb4_bin LIKE '%Â%';" | sed 's/^/ /'
|
||||
|
||||
echo ""
|
||||
echo "== siteurl / home que trae el dump:"
|
||||
m "SELECT option_name, option_value FROM wp_options WHERE option_name IN ('siteurl','home');" | sed 's/^/ /'
|
||||
EOF
|
||||
Executable
+59
@@ -0,0 +1,59 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Paso 5 del cutover: delta de uploads.
|
||||
#
|
||||
# El grueso (5,9 GB / 47.920 ficheros) se pre-sincronizo el 31-jul. Aqui solo viaja
|
||||
# lo publicado desde entonces. Dos saltos igual que el dump: CDMON -> WSL -> Hetzner,
|
||||
# para no dejar la contrasena de CDMON escrita en el servidor.
|
||||
#
|
||||
# --partial y ServerAliveInterval porque un rsync largo contra este enlace se corta
|
||||
# EN SILENCIO (la primera pasada del 31-jul murio a 36.289 de 47.920 sin error en el
|
||||
# log). Por eso al final se comparan los recuentos de los dos lados: no vale con que
|
||||
# rsync diga que termino.
|
||||
#
|
||||
# Los .php se excluyen a proposito: en uploads solo hay 2 y son importadores de un
|
||||
# solo uso, no tienen que viajar.
|
||||
#
|
||||
set -euo pipefail
|
||||
set -a; . ~/.hermes/profiles/feadulta/.env 2>/dev/null; set +a
|
||||
|
||||
STAGE=/tmp/fea-uploads-staging
|
||||
SRV=root@188.40.120.157
|
||||
DEST=/var/lib/docker/volumes/r2ssjifwj0r0ghyoqd528uaa_wordpress-files/_data/wp-content/uploads
|
||||
|
||||
echo "== antes:"
|
||||
printf " WSL : "; find "$STAGE" -type f | wc -l
|
||||
printf " Hetzner : "; ssh -o BatchMode=yes "$SRV" "find $DEST -type f | wc -l"
|
||||
printf " CDMON : "; sshpass -p "$FEA_PROD_SSH_PASS" ssh -o StrictHostKeyChecking=no feadulta@134.0.10.170 'find /web/wp-content/uploads -type f | wc -l'
|
||||
|
||||
echo ""
|
||||
echo "== delta CDMON -> WSL:"
|
||||
T0=$(date +%s)
|
||||
sshpass -p "$FEA_PROD_SSH_PASS" rsync -a --partial --info=stats2 --exclude='*.php' \
|
||||
-e "ssh -o StrictHostKeyChecking=accept-new -o ServerAliveInterval=20 -o ServerAliveCountMax=6" \
|
||||
feadulta@134.0.10.170:/web/wp-content/uploads/ "$STAGE/" \
|
||||
| grep -E 'Number of (regular files transferred|deleted)|Total transferred file size' | sed 's/^/ /'
|
||||
echo " $(( $(date +%s) - T0 ))s"
|
||||
|
||||
echo ""
|
||||
echo "== delta WSL -> Hetzner (como www-data, uid 33):"
|
||||
T1=$(date +%s)
|
||||
rsync -a --partial --info=stats2 --exclude='*.php' --chown=33:33 \
|
||||
-e "ssh -o BatchMode=yes -o ServerAliveInterval=20 -o ServerAliveCountMax=6" \
|
||||
"$STAGE/" "$SRV:$DEST/" \
|
||||
| grep -E 'Number of (regular files transferred|deleted)|Total transferred file size' | sed 's/^/ /'
|
||||
echo " $(( $(date +%s) - T1 ))s"
|
||||
|
||||
echo ""
|
||||
echo "== VERIFICACION por recuento (no fiarse de que rsync diga que termino):"
|
||||
a=$(sshpass -p "$FEA_PROD_SSH_PASS" ssh -o StrictHostKeyChecking=no feadulta@134.0.10.170 'find /web/wp-content/uploads -type f ! -name "*.php" | wc -l')
|
||||
b=$(find "$STAGE" -type f | wc -l)
|
||||
c=$(ssh -o BatchMode=yes "$SRV" "find $DEST -type f | wc -l")
|
||||
echo " CDMON (sin .php): $a"
|
||||
echo " WSL : $b"
|
||||
echo " Hetzner : $c"
|
||||
[ "$a" = "$c" ] && echo " OK: origen y destino cuadran" || echo " !! NO CUADRAN, revisar antes de seguir"
|
||||
|
||||
echo ""
|
||||
echo "== propietario en destino (no debe haber nada que no sea www-data):"
|
||||
ssh -o BatchMode=yes "$SRV" "find $DEST ! -user 33 | head -5 | sed 's/^/ /'; echo ' ficheros con otro dueno: '\$(find $DEST ! -user 33 | wc -l)"
|
||||
Executable
+59
@@ -0,0 +1,59 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Paso 8 del cutover: mover el A record del apex y de www a Hetzner.
|
||||
#
|
||||
# Se cambia SOLO el content. proxied=true se mantiene (la web va por Cloudflare) y
|
||||
# no se toca nada mas de la zona: el MX, el wildcard, los NS de cdmon y los TXT
|
||||
# (SPF y brevo) se quedan igual, asi que el correo no se ve afectado.
|
||||
#
|
||||
# ROLLBACK: volver a poner 134.0.10.170 en estos dos mismos ids.
|
||||
#
|
||||
set -uo pipefail
|
||||
set -a; . ~/.hermes/profiles/feadulta/.env 2>/dev/null; set +a
|
||||
|
||||
ZONE=5aa15f0f9cf7d45f3710177bb4a66aba
|
||||
NUEVA_IP=188.40.120.157
|
||||
APEX=807be63daf5a2ce9cfc710dcc1ef0a21
|
||||
WWW=0ce31fc2fbf5316434ace0c944909c59
|
||||
|
||||
mover() {
|
||||
local id=$1 name=$2
|
||||
curl -s -X PATCH \
|
||||
-H "Authorization: Bearer $FEA_CF_API_TOKEN" -H "Content-Type: application/json" \
|
||||
"https://api.cloudflare.com/client/v4/zones/$ZONE/dns_records/$id" \
|
||||
--data "{\"content\":\"$NUEVA_IP\"}" \
|
||||
| python3 -c "
|
||||
import sys,json
|
||||
d=json.load(sys.stdin)
|
||||
if d.get('success'):
|
||||
r=d['result']; print(' OK %-20s -> %s proxied=%s' % (r['name'], r['content'], r['proxied']))
|
||||
else:
|
||||
print(' FALLO %s: %s' % ('$name', d.get('errors')))
|
||||
"
|
||||
}
|
||||
|
||||
echo "== $(date '+%F %T %Z') moviendo los A records:"
|
||||
mover "$APEX" feadulta.com
|
||||
mover "$WWW" www.feadulta.com
|
||||
|
||||
echo ""
|
||||
echo "== estado en la zona tras el cambio:"
|
||||
curl -s -H "Authorization: Bearer $FEA_CF_API_TOKEN" \
|
||||
"https://api.cloudflare.com/client/v4/zones/$ZONE/dns_records?type=A&per_page=100" \
|
||||
| python3 -c '
|
||||
import sys,json
|
||||
for r in json.load(sys.stdin)["result"]:
|
||||
print(" %-22s %-16s proxied=%s" % (r["name"], r["content"], r["proxied"]))
|
||||
'
|
||||
|
||||
echo ""
|
||||
echo "== purgando la cache de Cloudflare:"
|
||||
curl -s -X POST \
|
||||
-H "Authorization: Bearer $FEA_CF_API_TOKEN" -H "Content-Type: application/json" \
|
||||
"https://api.cloudflare.com/client/v4/zones/$ZONE/purge_cache" --data '{"purge_everything":true}' \
|
||||
| python3 -c '
|
||||
import sys,json
|
||||
d=json.load(sys.stdin)
|
||||
print(" purga OK" if d.get("success") else " NO se pudo purgar: %s" % d.get("errors"))
|
||||
print(" (si falla es que el token solo tiene permiso de DNS: hay que purgar desde el panel)")
|
||||
'
|
||||
Reference in New Issue
Block a user