EST. 2026 — บันทึกงานเซิร์ฟเวอร์จริง SYSADMIN · SECURITY · AI · DIRECTADMIN เล่มที่ 01
THE SYSADMIN ALMANAC
หน้าหลัก / Linux / ส่งไฟล์ขึ้นโฮสต์แล้วทับกันเงียบๆ สามกับดักตอนคัดลอกไฟล์
Linux

ส่งไฟล์ขึ้นโฮสต์แล้วทับกันเงียบๆ สามกับดักตอนคัดลอกไฟล์

โดย Ton-Or ·24 ต.ค. 2026·อ่าน 8 นาที
ส่งไฟล์ขึ้นโฮสต์แล้วทับกันเงียบๆ สามกับดักตอนคัดลอกไฟล์

ความผิดพลาดตอน deploy ที่อันตรายที่สุดไม่ใช่ตัวที่ทำให้เว็บล่ม เพราะเว็บล่มแล้วเรารู้ทันที ตัวที่อันตรายกว่าคือตัวที่ทุกคำสั่งคืน exit code 0 ทุกหน้าตอบ 200 แต่ของข้างในไม่ใช่ของที่เราตั้งใจส่งขึ้นไป

สรุปใน 1 นาที

อาการ/ปัญหา
ส่งไฟล์ขึ้นโฮสต์แล้วทับกันเงียบๆ ทุกหน้าตอบ 200 แต่หน้าอังกฤษกลายเป็นหน้าไทย
ใครควรอ่าน
คนที่ deploy ด้วย pscp หรือเครื่องมือคัดลอกไฟล์ขึ้นโฮสต์
ทางแก้แบบสั้น
ระวังปลายทางเป็นโฟลเดอร์ ตัวมิเรอร์ที่ข้ามไฟล์ และการจับมือต่อไฟล์ มัดเป็นก้อนเดียวแล้วตรวจไฟล์หลังคัดลอก ไม่เชื่อ exit code
ข้ามไปขั้นตอน

พอดีผมต้องอัปเดตเว็บสองภาษาของลูกค้าตัวหนึ่ง เป็นเว็บ PHP ธรรมดา 26 หน้า โครงคือหน้าไทยอยู่รากเว็บ แล้วหน้าอังกฤษเป็นกระจกเงาอยู่ในโฟลเดอร์ en/ ชื่อไฟล์เหมือนกันทุกตัว ผมแก้ทั้งสองภาษาเสร็จแล้วส่งขึ้นโฮสต์ทีเดียวสองไฟล์ เสร็จภายในสิบวินาที ไม่มี error อะไรเลย

อีกสองวันลูกค้าทักมาว่าหน้าอังกฤษเป็นภาษาไทย

เล่มนี้เจาะเรื่องเดียว

เรื่องเขียนสคริปต์บนวินโดวส์แล้วเอาไปรันบนลินุกซ์ ทั้งพาธที่ค้างมาจากเครื่องตัวเองและไฟล์ขยะที่ลืมเก็บบนเซิร์ฟเวอร์ ผมเขียนไว้แยกแล้วที่ เขียนสคริปต์บน Windows ไปรันบน Linux เล่มนี้ผมพูดถึงความเสียหายเงียบตอนคัดลอกไฟล์ ล้วนๆ คือกรณีที่ไฟล์ถูกส่งขึ้นไปสำเร็จจริง เครื่องมือรายงานว่าผ่านจริง แต่ผลลัพธ์บนโฮสต์ไม่ใช่สิ่งที่เราตั้งใจ

กับดักที่หนึ่ง ปลายทางเป็นโฟลเดอร์ แปลว่าชื่อไฟล์คือ basename

คำสั่งที่ทำให้พังหน้าตาแบบนี้ ผมตัดส่วนยืนยันตัวตนกับชื่อโฮสต์จริงออกแล้ว เหลือแต่รูปคำสั่ง

# ผิด: ปลายทางลงท้ายด้วย / คือโฟลเดอร์
pscp -P 22 -i deploy.ppk \
  site/index.php site/en/index.php \
  [email protected]:domains/example.com/public_html/

scp และ pscp มีกติกาเดียวกันคือ ถ้าปลายทางเป็นโฟลเดอร์ มันจะตั้งชื่อไฟล์ปลายทางจากชื่อไฟล์ล้วนๆ ของต้นทาง โดยโยนโครงสร้างโฟลเดอร์ของต้นทางทิ้งทั้งหมด และไม่มีอ็อปชันแบบ --relative ให้ใช้

ผลคือทั้งสองไฟล์ไปลงที่ public_html/index.php ตำแหน่งเดียวกัน ตัวที่สองในบรรทัดคำสั่งเขียนทับตัวแรก โปรแกรมรายงานว่าโอนสำเร็จสองไฟล์ ขึ้น 100% ทั้งคู่ exit code เป็นศูนย์ ไม่มีคำเตือนสักตัว

ทำไมสองไฟล์กลายเป็นไฟล์เดียวsite/index.php->basenameindex.php->public_html/index.php
และตัวที่สองก็ไปที่เดิมsite/en/index.php->basenameindex.php->public_html/index.phpทับตัวแรก

เว็บนี้มีไฟล์ชื่อซ้ำกันระหว่างสองภาษาอยู่หลายคู่ ทั้ง index.php, sitemap.php และไฟล์บทความในโฟลเดอร์ย่อยอีกหลายตัว แปลว่ากับดักนี้รอผมอยู่ทุกครั้งที่แก้สองภาษาพร้อมกัน ซึ่งเป็นเรื่องปกติของเว็บสองภาษา

วิธีแก้ตรงไปตรงมามาก คือระบุชื่อไฟล์ปลายทางให้ครบทุกครั้ง ห้ามปล่อยให้ปลายทางเป็นโฟลเดอร์เปล่าเมื่อส่งหลายไฟล์พร้อมกัน

# ถูก: ปลายทางเป็นชื่อไฟล์เต็ม หนึ่งคำสั่งต่อหนึ่งไฟล์
pscp -P 22 -i deploy.ppk site/index.php \
  [email protected]:domains/example.com/public_html/index.php

pscp -P 22 -i deploy.ppk site/en/index.php \
  [email protected]:domains/example.com/public_html/en/index.php
อย่าใส่รหัสผ่านไว้ในบรรทัดคำสั่ง

เครื่องมือตระกูลนี้มีอ็อปชันให้ใส่รหัสผ่านตรงๆ ในบรรทัดคำสั่งได้ และมันสะดวกมากจนติดเป็นนิสัย แต่บรรทัดคำสั่งไปโผล่ใน history ของเชลล์ ใน log ของสคริปต์ และในรายการโปรเซสที่คนอื่นบนเครื่องเดียวกันมองเห็นได้ ใช้คีย์เถอะครับ ถ้าจำเป็นต้องใช้รหัสผ่านจริงๆ ให้อ่านจากตัวแปรสภาพแวดล้อมหรือไฟล์ที่ตั้งสิทธิ์ไว้แน่นหนา อย่าพิมพ์ลงไปในคำสั่งตรงๆ และอย่าเอาไปแปะในบทความหรือใน repo

อาการบนเว็บจริง ตอบ 200 ทุกหน้า แต่เป็นเนื้อหาผิดภาษา

สิ่งที่ทำให้กับดักนี้อยู่รอดได้นานคือมันไม่สร้างอาการที่เครื่องมือตรวจจับได้เลย

  • ไม่มี 404 เพราะไฟล์อยู่ครบ
  • ไม่มี 500 เพราะ PHP ในไฟล์นั้นถูกต้องสมบูรณ์
  • error log ว่างเปล่า
  • ระบบเช็กว่าเว็บล่มหรือเปล่ารายงานเขียว 100%

อาการเดียวที่มีคือคนอ่านเปิดหน้าอังกฤษแล้วเจอภาษาไทย แล้วท้ายหน้ายังได้ footer ผิดภาษาติดมาด้วย เพราะหน้าเป็นคนเลือกว่าจะ include footer ตัวไหน พอไฟล์ถูกทับ มันก็ลากทุกอย่างของอีกภาษามาพร้อมกันหมด รวมถึงปุ่มสลับภาษาที่ชี้กลับมาหาตัวเอง

ผมตรวจเจอด้วยการเทียบเนื้อหา ไม่ใช่เทียบสถานะ วิธีที่ถูกคือหลัง deploy ทุกครั้ง ยิง URL สำคัญแล้วเช็กว่าเนื้อข้างในมีคำที่ควรมีจริงหรือเปล่า

# ตรวจว่าได้ 200 แล้วยัง "มีข้อความที่คาดว่าจะมี" ด้วย
for u in "" "en/" "en/contact"; do
  code=$(curl -s -o /tmp/page.html -w "%{http_code}" "https://example.com/$u")
  hit=$(grep -c "Contact us" /tmp/page.html)
  echo "/$u  http=$code  marker=$hit"
done

หน้าที่อยู่ใต้ /en/ แล้ว marker ของภาษาอังกฤษเป็นศูนย์ คือหน้าที่โดนทับ เช็กแค่ http=200 อย่างเดียวจับไม่ได้แน่นอน

กับดักที่สอง ตัวมิเรอร์ที่ข้ามไฟล์ขึ้นต้นด้วยจุด

กับดักตัวที่สองอยู่ฝั่งเครื่องตัวเอง ตอนผมซิงก์โฟลเดอร์งานไปที่ XAMPP เพื่อทดสอบก่อนส่งขึ้นโฮสต์ ผมใช้ robocopy โหมดมิเรอร์

robocopy "$src" "$dst" /MIR /XD assets\img\sources /NFL /NDL /NJH /NJS /NP
# /MIR ไม่ได้คัดลอก .htaccess มาให้สม่ำเสมอ ต้องสั่งคัดลอกเองต่างหาก
Copy-Item "$src\.htaccess" "$dst\.htaccess" -Force

ผลคือบางรอบ .htaccess มา บางรอบไม่มา แล้ว Apache ตอบ 403 พร้อมข้อความทำนองว่าอ่านไฟล์ htaccess ไม่ได้ ซึ่งเป็นข้อความที่ชวนให้ไปไล่เรื่องสิทธิ์ไฟล์ทั้งที่ต้นเหตุคือไม่มีไฟล์นั้นอยู่

สาเหตุจริงไม่ได้อยู่ที่จุดหน้าชื่อไฟล์ เพราะบน NTFS จุดนำหน้าไม่มีความหมายพิเศษ ตัวที่มีผลคือแอตทริบิวต์ Hidden กับ System ที่ติดมากับไฟล์ ไฟล์ที่ได้มาจากการแตก zip จากการ checkout หรือจากการคัดลอกข้ามเครื่องมาก่อนหน้า อาจจะติด H มาโดยที่เราไม่รู้ ต้นทางเดียวกันแต่แอตทริบิวต์ต่างกัน ผลเลยต่างกัน ซึ่งเป็นที่มาของคำว่า "บางรอบ"

ที่ทำให้แย่กว่าเดิมคือชุด /NFL /NDL /NJH /NJS /NP ที่ผมใส่ไว้ให้จอสะอาด มันปิดรายงานทั้งหมด รอบที่ไม่คัดลอกอะไรเลยกับรอบที่คัดลอกครบ หน้าจอออกมาเหมือนกันเป๊ะ

สอง 403 ที่หน้าตาเหมือนกันแต่คนละเรื่อง

แบบแรกคือไม่มีไฟล์ แล้วคอนฟิกข้างบนบังคับให้ต้องมี ผลเป็น 403 ทั้งเว็บ แบบที่สองคือมีไฟล์แต่ขนาด 0 ไบต์ อันนี้ร้ายกว่า เพราะหน้าแรกยังตอบ 200 ปกติ เมนูยังขึ้นครบ แต่ URL ที่พึ่ง rewrite ทุกตัวกลายเป็น 404 หมด คนทดสอบที่เปิดแต่หน้าแรกจะไม่เห็นอะไรผิดเลย

เว็บตัวนี้ใช้ URL แบบไม่มีนามสกุลทั้งเว็บ ซึ่งทั้งหมดวางอยู่บนไฟล์ตัวนี้ตัวเดียว

<IfModule mod_rewrite.c>
  RewriteEngine on
  RewriteCond %{REQUEST_FILENAME} !-f
  RewriteCond %{REQUEST_FILENAME} !-d
  RewriteCond %{REQUEST_FILENAME}.php -f
  RewriteRule ^(.*)$ $1.php [L]
  RewriteCond %{THE_REQUEST} \s/+(.+?)\.php([\s?])
  RewriteRule ^ /%1 [R=301,L]
</IfModule>

หายไปเมื่อไหร่ ลิงก์ภายในทุกลิงก์ของเว็บตายหมด ในขณะที่หน้าแรกยังเสิร์ฟได้จาก DirectoryIndex ตามปกติ

อีกเรื่องที่ทำให้วินิจฉัยผิดทางได้ง่ายคือ โฮสต์หลายเจ้าตอนนี้เป็น nginx ข้างหน้าแล้วมี Apache อยู่ข้างหลัง พอ header ตอบว่า nginx คนมักสรุปว่าโฮสต์นี้ไม่อ่าน .htaccess อยู่แล้ว แล้วเลิกสงสัยไฟล์ตัวนี้ ทั้งที่ Apache ข้างหลังอ่านมันอยู่เต็มๆ

ไฟล์ 0 ไบต์ที่ทุกเครื่องมือบอกว่าสำเร็จ

เรื่องไฟล์ 0 ไบต์ผมเคยเจอแบบที่ทำเว็บล่มทั้งเว็บมาแล้ว และต้นเหตุคือคำสั่งบรรทัดเดียวที่ดูไม่มีพิษภัย

# ผิด: 'wb' ตัดไฟล์เหลือ 0 ไบต์ ก่อนที่ตัวอ่านข้างในจะได้อ่าน
open(p, 'wb').write(open(p, 'rb').read().replace(b'\r', b''))

# ถูก: อ่านให้จบก่อน ปิดไฟล์ แล้วค่อยเขียนทับ
data = open(p, 'rb').read().replace(b'\r', b'')
open(p, 'wb').write(data)

ตัวที่ผิดจะได้ไฟล์เปล่าที่สมบูรณ์แบบ แล้วพอเราส่งไฟล์เปล่านั้นขึ้นโฮสต์ ทุกขั้นตอนรายงานว่าสำเร็จ เพราะการโอนไฟล์ 0 ไบต์ก็คือการโอนที่สำเร็จ

กฎที่ผมใช้หลังจากนั้นคือวัดขนาดไฟล์หลังแตะไฟล์ทุกครั้ง ทั้งฝั่งเครื่องตัวเองและฝั่งโฮสต์ ไม่ต้องไปดูเนื้อข้างในก็ได้ แค่ดูว่าขนาดไม่ใช่ศูนย์และไม่เปลี่ยนไปจากที่ควรเป็น

อ้อ อีกเรื่องคือการไล่ตัด CR ออกจากทุกไฟล์แบบเหมารวมนั้น ส่วนใหญ่ไม่จำเป็น Apache อ่าน .htaccess ที่ลงท้ายบรรทัดแบบวินโดวส์ได้ปกติ ที่ต้องตัดจริงๆ คือสคริปต์เชลล์ที่จะเอาไปรันบนลินุกซ์ เพราะ CR ที่ติดมาบนบรรทัด shebang ทำให้ได้ error ว่าหาตัวแปลคำสั่งไม่เจอ

กับดักที่สาม 26 ไฟล์ = 26 การจับมือ

กับดักตัวสุดท้ายไม่ได้ทำของเสียหาย แต่มันทำให้คนเลือกทางที่เสี่ยงกว่าโดยไม่รู้ตัว

เว็บนี้มี 26 หน้า และสคริปต์ช่วยงานของผมหลายตัวแก้ทีเดียวทั้ง 26 ไฟล์ เช่นตัวเปลี่ยน footer ตัวเปลี่ยนโลโก้ ตัวแทรกไอคอน พอส่งขึ้นโฮสต์ทีละไฟล์ก็เท่ากับเปิดเซสชัน SSH ใหม่ 26 รอบ

26ไฟล์ที่แก้
26ครั้งที่ต่อ SSH
60-90 วิส่งทีละไฟล์
5 วิส่งเป็นก้อน

ต่อครั้งหนึ่งคือ TCP หนึ่งรอบ แลกกุญแจหนึ่งรอบ ยืนยันตัวตนหนึ่งรอบ เปิดระบบย่อยสำหรับโอนไฟล์อีกหนึ่งรอบ แล้วปิดทิ้ง ทั้งที่เนื้อไฟล์จริงรวมกันไม่ถึงหลักร้อยกิโล เวลาที่เสียไปคือเวลาจับมือล้วนๆ ประมาณ 2-3 วินาทีต่อไฟล์

พอมันช้าขนาดนั้น คนเลยเริ่มขี้เกียจ แล้วกลับไปใช้ท่าส่งทีเดียวหลายไฟล์ลงโฟลเดอร์ ซึ่งก็คือกับดักที่หนึ่งเป๊ะๆ กับดักสองตัวนี้ป้อนกันเอง

มัดเป็นก้อนเดียว แก้สองกับดักพร้อมกัน

วิธีที่ผมใช้ตอนนี้คือมัดเป็นก้อนเดียวแล้วแตกที่ปลายทาง

tar czf ./out.tar.gz -C ./ site/.htaccess site/includes site/index.php site/en

pscp -P 22 -i deploy.ppk ./out.tar.gz [email protected]:~/out.tar.gz

plink -ssh -P 22 -i deploy.ppk -batch [email protected] \
  "cd domains/example.com/public_html \
   && tar xzf ~/out.tar.gz --strip-components=1 \
   && rm ~/out.tar.gz && echo DEPLOYED"

ท่านี้แก้กับดักที่หนึ่งไปในตัวด้วย เพราะ tar เก็บพาธสัมพัทธ์ไว้ครบ site/index.php กับ site/en/index.php เป็นคนละรายการในก้อนเดียวกัน ไม่มีทางยุบชื่อชนกันได้เลย ส่วน --strip-components=1 คือตัวตัดคำนำหน้า site/ ออกตอนแตก ให้ไฟล์ลงตรงรากเว็บพอดี

ก้อน out.tar.gz ตอนแตกที่ปลายทางด้วย strip-components=1รายการในก้อนไฟล์บนโฮสต์site/index.phppublic_html/index.phpsite/en/index.phppublic_html/en/index.phpsite/.htaccesspublic_html/.htaccess--strip-components=1ชื่อไม่ชนกัน เพราะ tar เก็บพาธสัมพัทธ์ไว้ครบ
ไฟล์ชื่อ index.php สองตัวอยู่คนละรายการในก้อน เลยลงคนละตำแหน่งไม่ทับกัน

สามรายละเอียดที่ผมจดไว้กันลืม หนึ่ง ต้องใส่ site/.htaccess ลงไปในรายการเองแบบระบุชื่อเต็ม เพราะ tar ก็มีเรื่องไฟล์ขึ้นต้นด้วยจุดของมันเหมือนกัน สอง คำสั่งลบไฟล์ก้อนต้องอยู่ในชุดคำสั่งเดียวกันกับตอนแตก ไม่ใช่ค่อยไปลบทีหลัง เพราะเดี๋ยวลืม สาม tar ไม่เก็บโฟลเดอร์ว่างให้ ถ้าโปรเจกต์ต้องการโฟลเดอร์ว่างไว้เขียน cache หรือ log ต้อง mkdir -p แล้วตั้งสิทธิ์เองหลังแตกทุกครั้ง

ส่งทีละไฟล์

ปลอดภัยถ้าระบุปลายทางครบ

เหมาะกับงานแก้ไฟล์เดียวสองไฟล์ ข้อดีคือเห็นชัดว่าอะไรไปลงตรงไหน ข้อเสียคือช้า และพอช้าแล้วคนจะเริ่มมัดรวมส่งลงโฟลเดอร์ ซึ่งพาไปเจอกับดัก basename ทันที

มัดเป็นก้อนเดียว

เร็วกว่าและกันชื่อชนในตัว

หนึ่งเซสชันจบ พาธสัมพัทธ์ครบ ไม่มีการยุบชื่อ แต่ต้องระวังไฟล์ขึ้นต้นด้วยจุด โฟลเดอร์ว่างที่หายไป และต้องเก็บไฟล์ก้อนทิ้งในคำสั่งเดียวกัน

ตรวจหลังคัดลอก ไม่ใช่เชื่อ exit code

ทุกอาการในบทความนี้มีจุดร่วมข้อเดียวครับ คือทุกคำสั่งคืนค่าสำเร็จ เครื่องมือโอนไฟล์ทำหน้าที่ของมันครบถ้วนทุกครั้ง สิ่งที่ผิดคือความคาดหวังของเรา ไม่ใช่การทำงานของมัน แปลว่าจะกันได้ทางเดียวคือ ตรวจผลลัพธ์จริงหลังคัดลอกเสมอ

  1. ตรวจว่ามีไฟล์และขนาดไม่ใช่ศูนย์ โดยเฉพาะไฟล์ที่ขึ้นต้นด้วยจุด
  2. ยิง URL สำคัญแล้วเช็กทั้งรหัสตอบกลับและข้อความข้างใน ไม่ใช่เช็กแค่ 200
  3. ถ้าเป็นเว็บหลายภาษาหรือมีไฟล์ชื่อซ้ำ ให้เทียบ marker เฉพาะของแต่ละภาษาทีละหน้า
  4. ถ้าเป็นเว็บที่มี cache หรือ CDN อยู่ข้างหน้า ต้องล้าง cache หรือเติมตัวแปรท้าย URL ก่อนตรวจ ไม่งั้นจะได้ของเก่ามาแล้วสรุปผิด
  5. เก็บคำสั่งตรวจเหล่านี้ไว้เป็นสคริปต์ ไม่ใช่ทำด้วยมือ เพราะวันที่รีบคือวันที่คนข้ามขั้นตอนตรวจ

ส่วนตัวผมสรุปได้ประโยคเดียวว่า เครื่องมือโอนไฟล์ไม่มีหน้าที่รู้ว่าเราตั้งใจอะไร มันรู้แค่ว่าเราสั่งอะไร คำสั่งที่เขียนกำกวมแล้วมันตีความไปอีกทาง ไม่นับว่าเป็นข้อผิดพลาดของมัน เราเลยต้องเขียนคำสั่งให้ไม่มีที่ให้ตีความ แล้วตรวจผลทุกครั้ง

ใครมีสคริปต์ deploy ที่ส่งไฟล์หลายตัวลงโฟลเดอร์เดียวอยู่ตอนนี้ ลองไปไล่ดูก่อนว่ามีชื่อไฟล์ซ้ำกันข้ามโฟลเดอร์อยู่หรือเปล่า ใช้เวลาสองนาที แต่ถ้ามี มันรอทับกันอยู่แล้วครับ ตรงไหนผมอธิบายตกหล่นหรือพิมพ์ผิด เดี๋ยวกลับมาแก้เพิ่มให้ภายหลังครับ

อยากให้เราทำให้ไหม?

ให้เราช่วยติดตั้ง ตั้งค่า ย้ายระบบ หรือแก้ปัญหาเซิร์ฟเวอร์ เพื่อให้คุณมีเวลาไปทำงานที่สำคัญกว่า

คุยเรื่องงานกับเรา

เคยเจองานแบบนี้ไหม? ติดตรงไหน?

มาแลกวิธีกันต่อในบอร์ด LinuxThai

→ คุยต่อในบอร์ด

ความคิดเห็น 0

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