พอร์ต CDP ยังตอบ แต่สั่งงานไม่ได้ เพราะ Chrome อัปเดตทับตัวเอง
ด่านตรวจสุขภาพที่เช็กว่าพอร์ตเปิดอยู่ไหม เป็นด่านที่ให้ความรู้สึกปลอดภัยมากที่สุดและบอกอะไรน้อยที่สุด รอบนี้พอร์ตตอบทุกคำถามอย่างสุภาพ ในขณะที่เบราว์เซอร์ข้างหลังมันทำงานอะไรไม่ได้แล้วสักอย่าง
พอดีบนเครื่องที่ผมใช้รันงานอัตโนมัติ มีงานหลายตัวที่ต้องใช้เบราว์เซอร์จริงในการทำงาน เพราะหน้าเว็บปลายทางเรนเดอร์ด้วย JavaScript ล้วน ดึงด้วย HTTP เปล่า ๆ แล้วได้แต่โครงว่าง วิธีที่ผมใช้คือเปิด Chrome ค้างไว้ในโหมด remote debugging แล้วให้สคริปต์ต่อเข้าไปสั่งงานผ่าน Chrome DevTools Protocol โดยตรง
ทำแบบนี้มาพักใหญ่ก็เจอสองเรื่องที่กัดคนละแบบ เรื่องแรกคืองานหลายตัวแย่งเบราว์เซอร์ตัวเดียวกันจนผลลัพธ์ปนกัน เรื่องที่สองคือวันหนึ่ง Chrome อัปเดตตัวเองทับไฟล์ที่โปรเซสเดิมยังใช้อยู่ แล้วอาการที่ออกมาหลอกมากจนผมหลงทางไปครึ่งวัน บทความนี้เก็บทั้งสองเรื่องไว้ครับ
อาการที่หลอกที่สุดเท่าที่เจอมา
เช้าวันนั้นงานที่ต้องใช้เบราว์เซอร์ล้มทั้งหมด แต่ทุกด่านตรวจที่ผมมีอยู่บอกว่าปกติดี
curl -s http://127.0.0.1:9222/json/version
# {"Browser":"Chrome/152.0.7977.65","Protocol-Version":"1.3", ...} ตอบปกติ
curl -s http://127.0.0.1:9222/json | head -20
# [{"description":"","id":"A1B2...","title":"","type":"page","url":"", ...}]
สังเกตแท็บที่มันคืนกลับมาให้ดี title กับ url เป็นค่าว่างทั้งคู่ ทั้งที่ควรจะมีหน้าอะไรสักหน้าค้างอยู่ นั่นคือเบาะแสเดียวที่มีตั้งแต่แรก แต่ตอนนั้นผมยังไม่ได้ให้ความสำคัญ
พอต่อ WebSocket เข้าไปสั่งงานจริงถึงเห็นภาพ:
import json, websocket
ws = websocket.create_connection("ws://127.0.0.1:9222/devtools/page/A1B2...", timeout=20)
ws.send(json.dumps({"id": 1, "method": "Browser.getVersion"}))
print(ws.recv()) # ตอบ
ws.send(json.dumps({"id": 2, "method": "Page.navigate",
"params": {"url": "https://example.com"}}))
print(ws.recv()) # ตอบ
ws.send(json.dumps({"id": 3, "method": "Runtime.evaluate",
"params": {"expression": "1+1", "returnByValue": True}}))
print(ws.recv()) # ค้างจนหมดเวลา ไม่มีอะไรกลับมาเลย
Browser.getVersion ตอบ Page.navigate ตอบ แต่ Page.enable กับ Runtime.evaluate เงียบสนิทจนหมดเวลา
คำสั่งที่ยังตอบทั้งหมดเป็นคำสั่งที่ทำงานอยู่ในตัว browser process ส่วนคำสั่งที่ค้างคือคำสั่งที่ต้องพึ่ง renderer ของหน้าเว็บ พอ renderer สร้างไม่ขึ้น เบราว์เซอร์ก็ยังรับสายและตอบอย่างสุภาพต่อไปเรื่อย ๆ นี่คือกรณีคลาสสิกของ มีอยู่ ไม่เท่ากับ ใช้ได้ ซึ่งด่านตรวจแบบเช็กพอร์ตมองไม่เห็นเลย
พิสูจน์ก่อนว่าไม่ใช่ความผิดของไลบรารี
ก่อนจะเชื่อข้อสรุปอะไร ผมลองสามทางกับเบราว์เซอร์ตัวเดียวกัน เพื่อตัดตัวแปรฝั่งไลบรารีออกให้หมด
# ทาง 1 - websocket-client ตามปกติ -> ค้าง
# ทาง 2 - websocket-client แบบปิด compression
ws = websocket.create_connection(url, timeout=20, suppress_origin=True,
enable_multithread=True, compression=None)
# ทาง 3 - Playwright ต่อเข้า CDP ที่เปิดอยู่
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
b = p.chromium.connect_over_cdp("http://127.0.0.1:9222")
page = b.contexts[0].pages[0]
print(page.evaluate("1+1")) # ค้างเหมือนกัน
ล้มเหมือนกันทั้งสามทาง แปลว่าปัญหาอยู่ที่ตัวเบราว์เซอร์ ไม่ใช่ตัวที่ไปคุยกับมัน
สาเหตุจริง: ไบนารีถูกเปลี่ยนใต้เท้าขณะโปรเซสยังรัน
ตัวชี้ขาดคือการเทียบรุ่นสองที่ — รุ่นที่โปรเซสซึ่งรันค้างอยู่รายงานออกมา กับรุ่นของไฟล์ที่อยู่บนดิสก์ตอนนี้
# รุ่นของไฟล์บนดิสก์ตอนนี้
(Get-Item "C:\Program Files\Google\Chrome\Application\chrome.exe").VersionInfo.ProductVersion
# 152.0.7977.83
# รุ่นที่โปรเซสที่รันอยู่รายงานผ่าน CDP
(Invoke-RestMethod http://127.0.0.1:9222/json/version).Browser
# Chrome/152.0.7977.65
ต่างกันสองรุ่นย่อย นั่นคือคำตอบทั้งหมด Chrome อัปเดตตัวเองแล้วเขียนไฟล์ทับลงไปขณะที่โปรเซสเดิมยังทำงานอยู่ ตัว browser process เดิมยังอยู่ในหน่วยความจำและยังรับคำสั่งได้ แต่มันสร้าง renderer ใหม่ไม่ได้อีกแล้ว เพราะไบนารีที่มันต้องเรียกใช้เปลี่ยนไปแล้ว แท็บที่เห็นเลยเป็นเปลือกเปล่า มี id แต่ไม่มีอะไรข้างใน
วิธีแก้คือปิดโปรเซสให้หมดแล้วเปิดใหม่ จบแค่นั้นครับ ไม่ต้อง downgrade ไม่ต้องแก้ไลบรารี ไม่ต้องล็อกรุ่น เพราะไฟล์บนดิสก์เป็นรุ่นใหม่ที่สมบูรณ์อยู่แล้ว มีแต่โปรเซสเก่าที่ค้างอยู่กับรุ่นที่ไม่มีอยู่บนดิสก์แล้วเท่านั้นที่เป็นปัญหา
กับดักที่ทำให้แก้รอบแรกไม่หาย: kill ผิดโปรไฟล์
ผมสั่งปิดโปรเซสรอบแรกแล้วอาการเหมือนเดิมทุกอย่าง เพราะผมไปไล่ปิดตามชื่อโปรไฟล์ที่จำเอาเอง ซึ่งไม่ตรงกับของจริง
บนเครื่องผมพอร์ต 9222 ใช้โปรไฟล์ที่ชื่อไม่ได้ขึ้นต้นเหมือนพอร์ต และ 9226 ก็สลับกันอยู่ ผมสั่งปิดตามชื่อโปรไฟล์ที่คิดว่าใช่ ผลคือไม่มีอะไรตายเลยสักตัว โปรเซสเก่ายังอยู่ครบ อาการเลยไม่หาย ให้ไล่ปิดจาก บรรทัดคำสั่งจริงของโปรเซส เท่านั้น อย่าไล่จากชื่อที่จำไว้
# ดูของจริงก่อนว่าโปรเซสไหนถือพอร์ตไหน และใช้โปรไฟล์ไหน
Get-CimInstance Win32_Process -Filter "Name='chrome.exe'" |
Where-Object { $_.CommandLine -match 'remote-debugging-port' } |
Select-Object ProcessId,
@{n='port'; e={ [regex]::Match($_.CommandLine,'remote-debugging-port=(\d+)').Groups[1].Value }},
@{n='profile'; e={ [regex]::Match($_.CommandLine,'user-data-dir=([^"]+)').Groups[1].Value }} |
Format-Table -AutoSize
# ปิดเฉพาะกลุ่มที่ใช้โปรไฟล์นั้นจริง ๆ
Get-CimInstance Win32_Process -Filter "Name='chrome.exe'" |
Where-Object { $_.CommandLine -match 'user-data-dir=C:\\cdp-job-a' } |
ForEach-Object { Stop-Process -Id $_.ProcessId -Force }
ด่านตรวจต้องยิงคำสั่งจริง ไม่ใช่เช็กว่าพอร์ตเปิด
หลังจากนั้นผมเขียนตัวตรวจซ่อมขึ้นมาตัวหนึ่ง กติกาของมันมีสองข้อ ข้อแรกคือต้องยิงคำสั่งที่ต้องใช้ renderer จริงเสมอ ข้อสองคือถ้าทุกอย่างปกติต้องไม่ทำอะไรเลย เรียกซ้ำกี่ครั้งก็ต้องได้ผลเดิม
import json, subprocess, sys, time, urllib.request, websocket
def evaluate_ok(port, timeout=15):
"""ตรวจของจริง - ต้องได้คำตอบจาก renderer ไม่ใช่แค่พอร์ตตอบ"""
try:
tabs = json.load(urllib.request.urlopen(
"http://127.0.0.1:%d/json" % port, timeout=5))
page = next(t for t in tabs if t.get("type") == "page")
ws = websocket.create_connection(page["webSocketDebuggerUrl"], timeout=timeout)
ws.send(json.dumps({"id": 1, "method": "Runtime.evaluate",
"params": {"expression": "1+1", "returnByValue": True}}))
got = json.loads(ws.recv())
ws.close()
return got.get("result", {}).get("result", {}).get("value") == 2
except Exception:
return False
def repair(port, profile, launcher):
if evaluate_ok(port):
print("[OK] port %d healthy" % port)
return 0
print("[WARN] port %d answers but cannot evaluate - restarting" % port)
kill_by_profile(profile)
time.sleep(3)
subprocess.run(["schtasks", "/Run", "/TN", launcher], capture_output=True)
for _ in range(20):
time.sleep(3)
if evaluate_ok(port):
print("[OK] port %d back" % port)
return 0
print("[ERR] port %d still dead" % port)
return 1
เช็กว่าพอร์ตตอบไหม
ยิง /json/version แล้วดูว่าได้ 200 กลับมาไหม เขียนง่าย รันเร็ว และตอบว่าปกติดีตลอดช่วงที่พัง เพราะคำสั่งที่มันยิงเป็นคำสั่งที่ไม่ต้องใช้ renderer เลย
สั่งให้คิดเลขให้ดู
ยิง Runtime.evaluate ด้วยนิพจน์ง่ายที่สุดแล้วดูว่าได้ค่าที่ถูกกลับมาไหม ช้ากว่าไม่กี่วินาที แต่ครอบคลุมทั้งเส้นทางที่งานจริงต้องใช้ ตกด่านนี้เมื่อไหร่คือพังจริง
เอาด่านนี้ไปวางเป็นขั้นแรกของทุกงานที่ต้องใช้เบราว์เซอร์ ก่อนที่งานจริงจะเริ่ม:
@echo off
set PY=%USERPROFILE%\anaconda3\envs\ml313\python.exe
"%PY%" C:\jobs\cdp_restart.py --port 9222
if errorlevel 1 (
echo cdp 9222 unavailable, aborting >> C:\jobs\run.log
exit /b 1
)
"%PY%" C:\jobs\collect.py >> C:\jobs\run.log 2>&1
ที่ห้ามใส่คือ bat ที่ทำหน้าที่เปิดเบราว์เซอร์เอง เพราะจะกลายเป็นวนเรียกกันเอง และ bat ที่ผมใช้ทดสอบด้วยมือก็ไม่ต้องใส่ เดี๋ยวมันไปรีสตาร์ทเบราว์เซอร์ที่ผมกำลังนั่งดูอยู่
ของแถมที่ได้จากการติดด่านนี้ครบทุกงาน คือผมพบว่ามีพอร์ตหนึ่งที่ปิดอยู่เฉย ๆ มาทั้งวัน ทั้งที่มีงานสามตัวใช้พอร์ตนั้นอยู่ เบราว์เซอร์ตายเงียบไประหว่างวันโดยไม่มีใครรู้ ตอนนี้ด่านมันปลุกให้เองก่อนงานเริ่มทุกครั้ง
แยกพอร์ต แยกโปรไฟล์ ต่อกลุ่มงาน
อีกเรื่องที่ต้องจัดให้เรียบร้อยตั้งแต่ต้นคือ อย่าให้งานหลายตัวใช้เบราว์เซอร์ตัวเดียวกัน ผมโดนมาแล้วสามรอบ อาการคือสคริปต์ตัวหนึ่งสั่ง navigate ทับแท็บที่อีกงานหนึ่งกำลังใช้อยู่ แล้วภาพที่แคปออกมาเป็นหน้าของงานคนอื่นทั้งดุ้น ไล่หาสาเหตุยากมากเพราะโค้ดของทั้งสองฝั่งไม่ผิดอะไรเลย
กติกาที่ผมใช้อยู่ทุกวันนี้คือ หนึ่งกลุ่มงาน หนึ่งพอร์ต หนึ่งโปรไฟล์ ตั้งเป็นตัวแปรสภาพแวดล้อมไว้ในสคริปต์ของแต่ละกลุ่ม ไม่ต้องมาจำเอง
@echo off
rem start-cdp-job-a.bat - เบราว์เซอร์ประจำกลุ่มงาน A
start "" "C:\Program Files\Google\Chrome\Application\chrome.exe" ^
--user-data-dir=C:\cdp-job-a ^
--remote-debugging-port=9222 ^
--remote-allow-origins=* ^
--no-first-run --no-default-browser-check ^
--disable-features=Translate,MediaRouter
import os
os.environ.setdefault("CDP_PORT", "9222") # กลุ่มงาน A
os.environ.setdefault("CDP_PROFILE", r"C:\cdp-job-a")
ถ้าจำเป็นต้องใช้พอร์ตร่วมกันจริง ๆ ให้เปิดแท็บของตัวเอง
บางกรณีเลี่ยงไม่ได้ ต้องใช้เบราว์เซอร์ตัวเดียวกับงานอื่น กติกาข้อเดียวที่ห้ามละเมิดคืออย่าไปใช้แท็บที่มีอยู่แล้ว ให้เปิดแท็บใหม่เป็นของตัวเอง ใช้เสร็จปิดทิ้งทุกครั้ง
import json, urllib.parse, urllib.request
BASE = "http://127.0.0.1:9222"
def new_tab(url="about:blank"):
req = urllib.request.Request(BASE + "/json/new?" + urllib.parse.quote(url, safe=""),
method="PUT") # ต้องเป็น PUT
return json.load(urllib.request.urlopen(req, timeout=10))
def close_tab(tab_id):
urllib.request.urlopen(BASE + "/json/close/" + tab_id, timeout=10).read()
tab = new_tab("https://example.com")
try:
... # ทำงานบนแท็บของตัวเองเท่านั้น
finally:
close_tab(tab["id"]) # ปิดทุกครั้ง ไม่งั้นแท็บค้างสะสมจนกินแรม
Chrome รุ่นใหม่เปลี่ยน /json/new จาก GET เป็น PUT ด้วยเหตุผลด้านความปลอดภัย โค้ดเก่าที่ยิง GET จะได้ 405 กลับมา แล้วสคริปต์ส่วนใหญ่จะ fallback ไปหยิบแท็บแรกที่เจอแทน ซึ่งพาไปสู่ปัญหาแย่งแท็บพอดี ถ้าเจออาการ "จู่ ๆ ไปทำงานบนแท็บของงานอื่น" ให้ไปดูจุดนี้ก่อนเลย
- ให้ทุกกลุ่มงานมีพอร์ตกับโปรไฟล์ของตัวเอง เขียนไว้ในสคริปต์ ไม่ต้องจำ
- ถ้าต้องใช้พอร์ตร่วม ให้เปิดแท็บเองด้วย PUT และปิดทิ้งเสมอ
- ด่านตรวจสุขภาพต้องยิงคำสั่งที่ต้องใช้ renderer ห้ามเช็กแค่ว่าพอร์ตตอบ
- ตัวซ่อมต้องปิดโปรเซสตามบรรทัดคำสั่งจริง ไม่ใช่ตามชื่อที่จำไว้
- วางด่านเป็นขั้นแรกของทุกงานที่ต้องใช้เบราว์เซอร์ ยกเว้นตัวที่เปิดเบราว์เซอร์เอง
สรุป
ส่วนตัวผมสรุปได้สองข้อครับ
ข้อแรก การตรวจสุขภาพที่ไม่ได้ทำงานเดียวกับงานจริง คือการตรวจที่ไม่มีความหมาย พอร์ตเปิด กระบวนการยังอยู่ หน้าเว็บตอบ 200 — ทั้งหมดนี้เป็นแค่สัญญาณว่ามีอะไรอยู่ตรงนั้น ไม่ได้บอกว่ามันทำงานที่เราต้องการได้ ถ้าจะตรวจ ให้ตรวจด้วยคำสั่งที่งานจริงใช้
ข้อสอง ทรัพยากรที่ใช้ร่วมกันโดยไม่มีกติกา จะกลายเป็นบั๊กที่ทำซ้ำไม่ได้เสมอ เบราว์เซอร์ตัวเดียวที่หลายงานเข้าไปสั่งพร้อมกันคือหนึ่งในนั้น ค่าที่ต้องจ่ายเพื่อแยกกันคือแรมเพิ่มอีกไม่กี่ร้อยเมกกับพื้นที่ดิสก์อีกนิดหน่อย ซึ่งถูกกว่าเวลาที่เสียไปกับการไล่หาว่าทำไมภาพที่ได้เป็นของงานอื่นเยอะมาก
ใครรันงานที่ต้องใช้เบราว์เซอร์หลายตัวบนเครื่องเดียว ลองไปดูว่าตอนนี้มันใช้พอร์ตร่วมกันอยู่หรือเปล่าตามสะดวกครับ ตรงไหนผมอธิบายตกหล่นหรือพิมพ์ผิด เดี๋ยวกลับมาแก้เพิ่มให้ภายหลังครับ
เคยเจองานแบบนี้ไหม? ติดตรงไหน?
มาแลกวิธีกันต่อในบอร์ด LinuxThai



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