ย้าย 30 โดเมนขึ้น PHP 8.4 ผ่าน DA API พร้อม snapshot และ rollback
การย้ายเวอร์ชัน PHP ทีละโดเมนไม่ได้น่ากลัวที่ตัวคำสั่ง แต่น่ากลัวที่เช็กแล้วบอกว่าผ่านทั้งที่ยังไม่ผ่าน วิธีที่ผมใช้คือถ่ายหน้าเว็บก่อนและหลังด้วยรายการหน้าชุดเดียวกัน แล้วให้สคริปต์ตัดสินใจย้อนกลับเอง
สรุปใน 1 นาที
- อาการ/ปัญหา
- ย้ายเว็บราว 30 โดเมนขึ้น PHP 8.4 โดยกลัวว่าเช็กแล้วผ่านทั้งที่ยังไม่ผ่าน
- ใครควรอ่าน
- คนดูแล DirectAdmin ที่ต้องอัปเวอร์ชัน PHP หลายโดเมน
- ทางแก้แบบสั้น
- สแกนความเข้ากันได้แบบอ่านอย่างเดียว ถ่าย snapshot 60 หน้าก่อนและหลัง ย้ายผ่าน API แล้วให้สคริปต์ย้อนกลับเองเมื่อหน้าไหนกลายเป็น 500
พอดีหลังข่าว php2shell ที่ต้องอัปเดตด่วน ผมต้องถอด PHP 5.6, 7.4 และ 8.1 ออกจากเครื่อง DirectAdmin ที่ผมดูแลอยู่ แล้วย้ายทุกโดเมนที่ยังชี้ไปรุ่นพวกนั้นขึ้น 8.4 เลยทั้งหมด รวมแล้วหลายสิบโดเมนกับอีกหลาย subdomain ถ้าไล่กดในแผงทีละอัน ผมไม่มั่นใจว่าตัวเองจะเช็กหน้าเว็บได้ครบ เลยเขียนสคริปต์ขึ้นมาเองให้ทำเป็นขั้นตอนเดิมซ้ำทุกโดเมน ผลสุดท้ายคือไม่ต้อง rollback สักโดเมน แต่ระหว่างทางเจอกับดักสามข้อที่ถ้าไม่รู้มาก่อน เช็กแล้วก็ยังพลาดได้ ผมเอามาเล่าไว้ตรงนี้
ชื่อโดเมน ชื่อผู้ใช้ และโฟลเดอร์สำรองในบทความนี้ผมเปลี่ยนเป็นค่าสมมติทั้งหมด ส่วนอาการกับลำดับขั้นตอนเป็นของจริง
นี่คือสูตรย้ายเวอร์ชัน PHP แบบ in-place บนเครื่องเดิม ไม่ใช่การย้ายทั้งเครื่องไปเซิร์ฟเวอร์ใหม่ ถ้าเป็นงานย้ายทั้ง OS ไปอ่านชุด cutover จริงของ DA แทน สคริปต์ในบทความต้องรันเป็น root และมันเปลี่ยนค่าบนเครื่องจริง ลองกับโดเมนทดสอบหนึ่งอันก่อนเสมอ
ทำไมต้องย้ายทีละโดเมนผ่าน API ไม่ใช่กดในแผง
กติกาที่ผมตั้งให้ตัวเองมีสามข้อ ข้อแรกคือจัดการ PHP ผ่าน CustomBuild กับ DirectAdmin API เท่านั้น ไม่แก้ไฟล์ปลายทางด้วยมือ ข้อสองคือโดเมนที่ย้ายแล้วต้องมีหลักฐานเทียบก่อนหลัง ไม่ใช่ความรู้สึกว่า "เปิดหน้าแรกแล้วขึ้นปกติ" ข้อสามคือรุ่นเก่าลบไบนารีได้ก็ต่อเมื่อผ่านการเช็กแยกกันสองรอบ
ส่วนตัวผมไม่เชื่อการเช็กด้วยตา เพราะเว็บเก่าหลายตัวหน้าแรกกับหน้าลึก ๆ ใช้โค้ดคนละเส้น หน้าแรกขึ้นแต่หน้ากิจกรรมปีเก่าตายก็เจอมาแล้ว ผมเลยแยกงานเป็นสามชิ้นที่เล็กพอจะเขียนเป็นสคริปต์ได้ คือสแกนความเข้ากันได้ ถ่าย snapshot และสลับเวอร์ชันพร้อมตัดสินใจย้อนกลับ
ขั้นที่ 1 สแกนความเข้ากันได้แบบอ่านอย่างเดียว
ก่อนแตะการตั้งค่าของโดเมนไหน ผมสแกนไฟล์ของโดเมนนั้นก่อน สคริปต์รับ user:domain แล้วทำสี่อย่าง อย่างแรกคือเดาว่าเป็นแอปอะไร (WordPress พร้อมรุ่น core ปลั๊กอิน ธีม, Laravel, Joomla หรือโค้ดเขียนเอง) อย่างที่สองคือรัน php -l ของ 8.4 กับทุกไฟล์ .php ข้ามโฟลเดอร์ uploads กับ cache ข้ามไฟล์ที่ใหญ่เกิน 2 MB รันขนานกัน 4 งานด้วย nice 19 เพื่อไม่ให้เครื่องที่มีลูกค้าใช้งานอยู่สะดุด อย่างที่สามคือ grep หาฟังก์ชันที่ถูกถอดไปใน 7.x กับ 8.x เช่น each, create_function, mysql_*, ereg*, split แล้วนับต่อฟังก์ชันและต่อไฟล์ อย่างสุดท้ายคือนับ curly offset แบบเก่า $x{0} ซึ่งเป็น parse error ใน PHP 8
#!/bin/bash
# สแกนอ่านอย่างเดียว: ใช้ php 8.4 lint ทุกไฟล์ในเว็บหนึ่งโดเมน
PHP84=/usr/local/php84/bin/php
ROOT=/home/webuser/domains/example.com/public_html
find "$ROOT" -type f -name '*.php' -size -2M \
-not -path '*/uploads/*' -not -path '*/cache/*' -print0 \
| xargs -0 -n 20 -P 4 nice -n 19 "$PHP84" -l 2>&1 \
| grep -v '^No syntax errors' > /tmp/lint-example.txt
echo "lint errors: $(wc -l < /tmp/lint-example.txt)"
# ฟังก์ชันที่หายไปแล้ว นับต่อชื่อ
grep -rnoE '\b(each|create_function|mysql_[a-z_]+|ereg[i]?|split)\s*\(' "$ROOT" \
--include='*.php' | sed -E 's/.*:([a-z_]+)\s*\($/\1/' | sort | uniq -c | sort -rn
# curly offset แบบเก่า
grep -rnE '\$[a-zA-Z_]+\{[0-9$]' "$ROOT" --include='*.php' | wc -l
ผลของการสแกนแยกชั้นของปัญหาออกมาเป็นหลายแบบที่ซ้ำกันทุกเว็บ คือ bareword constant, constructor แบบ PHP4 ที่ตั้งชื่อเมธอดเท่าชื่อคลาส, each(), create_function(), curly offset และ ternary ซ้อนกันโดยไม่มีวงเล็บ ผมแก้พวกนี้ก่อนย้ายทุกครั้ง นี่คือเหตุผลที่ท้ายที่สุดไม่มีโดเมนไหนต้อง rollback เพราะของที่จะพังถูกแก้ไปก่อนแล้ว ไม่ได้ผ่านเพราะโชคดี
ที่ต้องรู้ไว้คือ php -l จับได้เฉพาะ parse error กับ compile error เท่านั้น ส่วน bareword อย่าง echo FOO; ผ่าน lint สบาย ๆ แล้วไปตายตอนรันจริงด้วย Error: Undefined constant ส่วน 0 == 'o' ที่ผลพลิกเงียบ ๆ ก็ไม่มีเครื่องมือ lint ตัวไหนบอกได้ นี่เป็นเหตุผลที่ผมไม่เชื่อสแกนเนอร์อย่างเดียว ต้องมีขั้นถ่ายหน้าเว็บจริงตามมาทุกครั้ง สแกนเอาไว้ลดจำนวนของที่จะพัง ส่วน snapshot เอาไว้จับของที่ผมคาดไม่ถึง
ขั้นที่ 2 snapshot หน้าเว็บก่อนย้าย
สคริปต์ snapshot (ผมตั้งชื่อ site-snap) ทำงานจากตัวเซิร์ฟเวอร์เอง โดยยิงตรงเข้า origin ด้วย curl --resolve เพื่อข้าม Cloudflare และแคชของมัน จุดนี้สำคัญเพราะถ้าเช็กผ่านหน้าด่านที่แคชไว้ คุณจะเห็นหน้าเก่าที่แคชอยู่ ไม่ใช่สิ่งที่ PHP ตัวใหม่เพิ่งสร้าง
มันเปิดหน้าแรก แล้วเก็บลิงก์ภายในโดเมนเดียวกันไม่เกิน 60 ลิงก์ ข้ามรูป css js pdf logout admin และ feed ทุกหน้าที่เปิดมันบันทึกสามอย่าง คือ HTTP status, ตัว HTML และข้อความที่มองเห็น (ตัด script กับ style ออก) พอเทียบด้วย --diff มันจะพิมพ์ว่า status ของหน้าไหนเปลี่ยน ข้อความเปลี่ยนกี่บรรทัด หน้าไหนมีข้อความ error ของ PHP หลุดมาในหน้า (Deprecated, Warning, Notice, Fatal error) แล้วปิดท้ายด้วยบรรทัดเดียว unchanged pages N/M

ส่วนที่ผมว่าแอบสำคัญที่สุดคือ "ชุดหน้าเดียวกัน" ตอนถ่ายหลัง สคริปต์ไม่ได้ crawl ใหม่ มันอ่านรายการ path จากโฟลเดอร์ snapshot ก่อนมาใช้ซ้ำ ถ้า crawl ใหม่ทั้งสองรอบ รอบหลังอาจเจอลิงก์คนละชุดกับรอบก่อน แล้วเทียบกันไม่ได้
หน้าตาของผลเทียบที่ผมอ่านทุกโดเมนเป็นแบบนี้ (ชื่อโดเมนเป็นค่าสมมติ ตัวเลขเป็นรูปแบบตัวอย่างเท่านั้น)
$ site-snap --diff before after
status changed: 0 page(s)
text changed lines: 2 (/news/ , /contact/)
php-error-text in html: none
unchanged pages N/M
บรรทัดที่ผมสนใจที่สุดคือ php-error-text เพราะเว็บเก่าหลายตัวตั้ง display_errors เปิดไว้ ทำให้ข้อความ Deprecated หรือ Warning หลุดไปอยู่ในหน้าเว็บทั้งที่ status ยังเป็น 200 ถ้าดูแค่ status code จะผ่านหมด แต่ผู้เข้าชมจะเห็นข้อความแปลก ๆ แทรกกลางหน้า ส่วนบรรทัด text changed ที่ไม่ใช่ศูนย์ไม่ได้แปลว่าพังเสมอไป หน้าที่มีวันที่ เวลา หรือจำนวนผู้เข้าชมเปลี่ยนเองตามธรรมชาติ ผมอ่านบรรทัดที่เปลี่ยนด้วยตาเสมอ ไม่ปล่อยให้สคริปต์ตัดสินจากข้อความ มีแค่เรื่อง status ที่ผมยอมให้เครื่องตัดสินและย้อนกลับเอง
ขั้นที่ 3 ย้ายผ่าน API แล้วรอ opcache
เมื่อสแกนและแก้เสร็จ ตัวย้ายจริงเรียก DirectAdmin API ในนามเจ้าของโดเมน คำสั่ง directadmin api-url --user=NAME ให้ URL ที่มี login key ใช้ครั้งเดียว ผมแยก key ออกมาใช้กับ curl -u แล้ว POST ไปที่ CMD_API_DOMAIN ด้วย action=php_selector ตัว php1_select คือเลขช่องใน CustomBuild ที่เก็บ 8.4 ไว้ (ดูเลขช่องจาก options ของเครื่องตัวเอง ผมไม่ใส่เลขจริงไว้ตรงนี้)
api() { # user domain select
local URL B K
URL=$(/usr/local/directadmin/directadmin api-url --user="$1" 2>/dev/null | tail -1)
B=$(echo "$URL" | sed -E 's#https?://[^@]+@([^/]+).*#https://\1#')
K=$(echo "$URL" | sed -E 's#https?://([^@]+)@.*#\1#')
curl -sk -u "$K" "$B/CMD_API_DOMAIN" \
-d "action=php_selector&domain=$2&php1_select=$3&save=yes" >/dev/null
}
bad() { # นับหน้าที่ต่ำกว่า 500 ก่อน แล้วกลายเป็น 500 ขึ้นไปหลัง
join <(awk '{print $2, $1}' "$1/codes" | sort) \
<(awk '{print $2, $1}' "$2/codes" | sort) \
| awk '$2<500 && $3>=500' | wc -l
}
หลังเรียก API ผมให้สคริปต์รอ 65 วินาทีก่อนถ่ายภาพหลัง เหตุผลคือ opcache ของ pool ตั้ง opcache.revalidate_freq=60 โค้ดที่ compile ไว้แล้วจะถูกตรวจใหม่ทุก 60 วินาที ถ้าถ่ายทันทีหลังสลับ ผมอาจได้ผลจากโค้ดชุดเก่าที่ยังค้างอยู่ใน opcache เลยรอเกิน 60 ไป 5 วินาทีเผื่อไว้ ผมจะกลับมาที่กับดักข้อนี้อีกทีข้างล่าง
ขั้นตัดสินใจสั้นมาก ถ้า bad คืนค่ามากกว่า 0 สคริปต์เรียก api อีกรอบด้วยเลขช่องเดิม (อ่านมาจาก php1_select ในไฟล์ conf ที่สำรองไว้ ถ้าไม่มีบรรทัดนั้นถือว่าเป็นช่อง 1) แล้วพิมพ์ diff ให้ดู ถ้าเป็น 0 ก็พิมพ์ OK พร้อมสรุปจำนวนหน้าที่ไม่เปลี่ยน
- สำรองไฟล์ conf ของโดเมนก่อนทุกครั้ง แล้ว snapshot ก่อนย้าย
- เรียก API สลับรุ่น แล้วรอ 65 วินาทีให้ opcache ตรวจโค้ดใหม่
- ถ่ายภาพหลังด้วยรายการ path ชุดเดิม แล้วนับหน้าที่จากต่ำกว่า 500 เป็น 500 ขึ้นไป
- มีหน้าเปลี่ยนแม้หน้าเดียวให้ย้อนกลับอัตโนมัติ แล้วอ่าน diff ทีหลัง ไม่ต้องรีบ
รอบแรกผมย้ายชุดเล็กก่อน หลังจากนั้นย้ายเพิ่มจนครบทุกโดเมนกับ subdomain ที่เหลือ ไม่มี rollback เลยสักครั้ง
กับดักที่ 1: subdomain ไม่ตามโดเมนหลัก
เรื่องนี้ผมเจอตอนที่ API ตอบสำเร็จหมดแต่ subdomain หนึ่งยังรันรุ่นเดิม การเรียก CMD_API_DOMAIN แตะแค่ไฟล์ conf ของโดเมนหลัก ส่วน PHP ของ subdomain เก็บอยู่คนละไฟล์ คือ <domain>.subdomains.docroot.override ในโฟลเดอร์ข้อมูลของผู้ใช้ แต่ละ subdomain มีหนึ่งบรรทัด ค่า php1_select ถูก url-encode ไว้ในบรรทัดนั้น
ผลที่ตามมาคือหลังย้ายโดเมนหลักไป 8.4 แล้ว subdomain ที่ถูกปักไว้ที่ 8.2 ก็ยังอยู่ 8.2 ถ้าแอปตัวเดียวกันใช้ vendor ร่วมกันแล้วมีการ composer update ด้วย PHP 8.4 subdomain ตัวนั้นจะตอบ 500 ทันที (เรื่องนี้ผมแยกไปเขียนไว้ใน ตอน platform_check ของ composer)
ผมเลยเขียนสคริปต์ย้าย subdomain แยกอีกตัว
# php_move_sub.sh SELECT user:domain:sub (ย่อ)
O=/usr/local/directadmin/data/users/$U/domains/$DOM.subdomains.docroot.override
cp -a "$O" "$BACKUP_DIR/$U-$DOM.override.$SUB"
C0=$(code "$SUB.$DOM") # สถานะก่อน (ตาม redirect)
sed -i -E "s/^($SUB=php%31%5Fselect=%3)[0-9]/\1$SEL/" "$O" # แก้เฉพาะเลขรุ่น
echo "action=rewrite&value=httpd&user=$U" >> /usr/local/directadmin/data/task.queue
/usr/local/directadmin/dataskq d
systemctl reload httpd nginx # แล้ว reload php-fpm ของรุ่นนั้นด้วย
sleep 5
C1=$(code "$SUB.$DOM")
if [ "$C0" -lt 500 ] && [ "$C1" -ge 500 ]; then
cp -a "$BACKUP_DIR/$U-$DOM.override.$SUB" "$O" # คืนไฟล์ แล้ว rewrite ซ้ำ
fi
ผมลองใส่ value=phpfpm ในบรรทัด task.queue ก่อน ปรากฏว่าไม่มีอะไรเกิดขึ้น ที่ใช้ได้จริงคือ value=httpd ส่วน da build rewrite_confs ใช้ไม่ได้ในงานนี้เพราะมันแตะผู้ใช้ทุกคน ผมต้องการแก้ทีละคน
กับดักที่ 2 กับ 3: รีไดเรกต์ และ opcache
กับดักข้อสองคือการเช็กหลังย้ายต้องตามรีไดเรกต์ ผมเคยเช็กแค่ https://example.com/ แล้วได้ 301 ซึ่งต่ำกว่า 500 เลยนับว่าปกติ แต่ apex ตอบ 301 ไปที่ www และความพังจริงอยู่ที่ www ผมเลยใช้ curl -L --max-redirs 4 ทุกครั้ง และเทียบสถานะปลายทาง ไม่ใช่สถานะของคำขอแรก
code() {
curl -sk -L --max-redirs 4 -o /dev/null -w '%{http_code}' \
-A 'Mozilla/5.0' --max-time 30 \
--resolve "$1:443:203.0.113.10" "https://$1/"
}
ข้อสามคือ opcache ซึ่งผมเล่าเรื่องรอ 65 วินาทีไปแล้ว แต่ยังมีอีกฝั่ง ถ้าคุณใช้เครื่องมืออื่นเปลี่ยนโค้ดของเว็บ (เช่นอัป core ของ WordPress) แล้วใช้ snapshot หลังที่ติดมากับเครื่องมือนั้น มันยังเห็นผลของโค้ดเก่าได้ ผมเจอตอนอัป core ของเว็บหนึ่ง ภาพหลังที่เครื่องมือถ่ายให้เองยังออกผลแบบเดิม ต้องถ่ายซ้ำอีกครั้งหลังรอ 62 วินาทีถึงเห็นของจริง (เรื่อง core ผมเล่าต่อใน ตอนอัป WordPress รุ่นเก่ามากก่อนขึ้น 8.4)

อีกเรื่องเล็กที่เจอคือ หลัง reload ครั้งสุดท้ายผมต้องพัก 5 วินาที เพราะเคยเจอ 503 ครั้งเดียวที่เกิดจากการ reload ของ FPM เอง ถ้าเช็กทันทีมันดูเหมือนย้ายแล้วพัง แต่พอเช็กซ้ำก็กลับมาปกติ
ข้อสี่ที่เกี่ยวกับลำดับ reload หลังเขียน config ใหม่ ผมเจอเว็บ WordPress เว็บหนึ่งที่ย้อนกลับแล้วไม่ยอมกลับมาที่รุ่นเดิมจนกว่าจะสั่ง systemctl reload httpd ด้วยมือ สคริปต์ของผมเลยเรียก reload ต่อท้ายทุกครั้งที่เขียน config ใหม่
curl หน้าแรกครั้งเดียว
เร็ว แต่เห็นแค่ 301 ของ apex เห็นหน้าที่แคชไว้ที่ Cloudflare และไม่เห็นหน้าลึกที่ใช้โค้ดคนละเส้น
60 หน้า ยิงตรง origin ตาม redirect
ช้ากว่า แต่ได้ตัวเลขเทียบก่อนหลัง ได้ข้อความ error ที่หลุดในหน้า และย้อนกลับเองได้
สรุปลำดับที่ใช้
ถ้าจะเอาสูตรนี้ไปใช้ ผมแนะนำลำดับนี้ ส่วนตัวผมไม่ขอย้ายโดเมนไหนโดยไม่มี snapshot คู่อีกแล้ว เพราะตัวเลขแบบ unchanged pages 58/60 ให้ความมั่นใจต่างจากการเปิดหน้าแรกคนละเรื่อง
- สแกนอ่านอย่างเดียวทุกโดเมนก่อน แล้วแก้สิ่งที่จะพัง (bareword, constructor แบบเก่า,
each,create_function, curly offset) ก่อนสลับรุ่น - snapshot ก่อน โดยยิงตรง origin ด้วย
--resolveและตาม redirect ทุกครั้ง - สลับด้วย API ในนามเจ้าของโดเมน ไม่แก้ไฟล์ conf ด้วยมือ
- รอเกิน
opcache.revalidate_freqอย่างน้อยห้าวินาทีก่อนถ่ายหลัง - subdomain มีไฟล์ override แยก ต้องมีสคริปต์ของมันเอง
- ตั้งเงื่อนไขย้อนกลับเป็นตัวเลข คือหน้าไหนจากต่ำกว่า 500 เป็น 500 ขึ้นไป ไม่ใช้ความรู้สึก
- ลบไบนารีรุ่นเก่าหลังผ่านการเช็กแยกกันสองรอบเท่านั้น
ใครจะเขียนสคริปต์ตัวเองให้ละเอียดหรือหยาบกว่านี้ก็ตามสะดวก ขอแค่มีเงื่อนไขย้อนกลับที่เครื่องตัดสินแทนคนก็พอ ตรงไหนผมอธิบายไม่ครบหรือรายละเอียดเลขช่องของแต่ละเครื่องไม่ตรงกัน เดี๋ยวกลับมาแก้เพิ่มให้ภายหลังครับ
อยากให้เราทำให้ไหม?
ให้เราช่วยติดตั้ง ตั้งค่า ย้ายระบบ หรือแก้ปัญหาเซิร์ฟเวอร์ เพื่อให้คุณมีเวลาไปทำงานที่สำคัญกว่า
เคยเจองานแบบนี้ไหม? ติดตรงไหน?
มาแลกวิธีกันต่อในบอร์ด LinuxThai



ความคิดเห็น 0
ยังไม่มีความคิดเห็น — เป็นคนแรกเลย