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

พอร์ต CDP ยังตอบ แต่สั่งงานไม่ได้ เพราะ Chrome อัปเดตทับตัวเอง

โดย Ton-Or ·23 ก.ย. 2026·อ่าน 9 นาที
พอร์ต 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 เงียบสนิทจนหมดเวลา

สคริปต์ CDPต่อ WebSocketbrowser processโปรเซสเดิม รุ่น .65 ยังอยู่Browser.getVersionPage.navigaterenderer ของหน้าเว็บต้องสร้างจากไบนารีที่ถูกทับPage.enableRuntime.evaluateตอบกลับทันทีคำสั่งอยู่ใน browser processค้างจนหมดเวลาคำสั่งที่ต้องพึ่ง renderer
คำสั่งที่ทำงานในตัว browser process ยังตอบ ส่วนคำสั่งที่ต้องพึ่ง renderer ค้างจนหมดเวลา
พอร์ตตอบ ไม่ได้แปลว่าใช้งานได้

คำสั่งที่ยังตอบทั้งหมดเป็นคำสั่งที่ทำงานอยู่ในตัว 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 แต่ไม่มีอะไรข้างใน

ลำดับที่ทำให้พังเบราว์เซอร์เปิดค้างรุ่น .65->ตัวอัปเดตเขียนทับดิสก์เป็น .83->โปรเซสเดิมยังอยู่ตอบพอร์ตปกติ->สั่งงานที่ต้องใช้ rendererค้างจนหมดเวลา
.65 / .83รุ่นโปรเซส vs ดิสก์
3 ทางไลบรารีที่ลองแล้วล้มหมด
200 OKสิ่งที่ด่านเดิมเห็น
restartทางแก้ทั้งหมด

วิธีแก้คือปิดโปรเซสให้หมดแล้วเปิดใหม่ จบแค่นั้นครับ ไม่ต้อง 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"])     # ปิดทุกครั้ง ไม่งั้นแท็บค้างสะสมจนกินแรม
ทำไมต้อง PUT

Chrome รุ่นใหม่เปลี่ยน /json/new จาก GET เป็น PUT ด้วยเหตุผลด้านความปลอดภัย โค้ดเก่าที่ยิง GET จะได้ 405 กลับมา แล้วสคริปต์ส่วนใหญ่จะ fallback ไปหยิบแท็บแรกที่เจอแทน ซึ่งพาไปสู่ปัญหาแย่งแท็บพอดี ถ้าเจออาการ "จู่ ๆ ไปทำงานบนแท็บของงานอื่น" ให้ไปดูจุดนี้ก่อนเลย

  1. ให้ทุกกลุ่มงานมีพอร์ตกับโปรไฟล์ของตัวเอง เขียนไว้ในสคริปต์ ไม่ต้องจำ
  2. ถ้าต้องใช้พอร์ตร่วม ให้เปิดแท็บเองด้วย PUT และปิดทิ้งเสมอ
  3. ด่านตรวจสุขภาพต้องยิงคำสั่งที่ต้องใช้ renderer ห้ามเช็กแค่ว่าพอร์ตตอบ
  4. ตัวซ่อมต้องปิดโปรเซสตามบรรทัดคำสั่งจริง ไม่ใช่ตามชื่อที่จำไว้
  5. วางด่านเป็นขั้นแรกของทุกงานที่ต้องใช้เบราว์เซอร์ ยกเว้นตัวที่เปิดเบราว์เซอร์เอง

สรุป

ส่วนตัวผมสรุปได้สองข้อครับ

ข้อแรก การตรวจสุขภาพที่ไม่ได้ทำงานเดียวกับงานจริง คือการตรวจที่ไม่มีความหมาย พอร์ตเปิด กระบวนการยังอยู่ หน้าเว็บตอบ 200 — ทั้งหมดนี้เป็นแค่สัญญาณว่ามีอะไรอยู่ตรงนั้น ไม่ได้บอกว่ามันทำงานที่เราต้องการได้ ถ้าจะตรวจ ให้ตรวจด้วยคำสั่งที่งานจริงใช้

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

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

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

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

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

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

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