Một máy chỉ gánh được khoảng năm phiên Chrome trước khi RAM và CPU bắt đầu nghẹt. Selenium Grid gỡ đúng nút thắt đó: hub nhận request, các node chạy trình duyệt, còn CAPTCHA thì mọi node đẩy về một chỗ — CaptchaAI, với một API key dùng chung. Node gửi task tới in.php, polling res.php cho đến khi có token, rồi đặt token vào form.
Bài này gồm file Docker Compose dựng hub và ba node Chrome, client Python dùng chung, bản Java tương đương, và cách tính số thread cho khớp số slot — chỗ đội nào cũng tính hụt trong lần chạy đầu.
Khi nào Grid đáng công dựng
Grid chỉ đáng dựng khi bạn cần quy mô:
- Trên 20 phiên trình duyệt cùng lúc — ví dụ một agency ở TP.HCM chạy hồi quy đêm cho form đăng ký của nhiều khách hàng trên staging, form nào cũng có reCAPTCHA v2.
- Nhiều trình duyệt trong một lần chạy: Chrome cho luồng chính, Firefox và Edge cho test tương thích.
- Job theo dõi giá công khai trên Shopee, Lazada hay Tiki cho danh mục của chính bạn, chạy mỗi đêm.
Dưới năm phiên song song thì một máy với ThreadPoolExecutor vẫn gọn hơn.
Kiến trúc: một hub, nhiều node, một API key
┌─────────────┐ ┌──────────────┐ ┌──────────────┐
│ Test Script │────▶│ Grid Hub │────▶│ Node 1 │
│ (Client) │ │ (Router) │ │ Chrome x 5 │
└─────────────┘ └──────────────┘ └──────────────┘
│ ┌──────────────┐
├─────────────▶│ Node 2 │
│ │ Chrome x 5 │
│ └──────────────┘
│ ┌──────────────┐
└─────────────▶│ Node 3 │
│ Chrome x 5 │
└──────────────┘
All nodes share ──▶ CaptchaAI API (single API key)
Điểm cần nhớ: CAPTCHA không được giải trên node. Node chỉ mở trang, lấy sitekey, gọi API và nhận token. Thêm bao nhiêu node cũng không phải đổi cấu hình CaptchaAI — chỉ số request đồng thời tăng lên.
Dựng Grid 4 bằng Docker Compose
File compose cho hub và ba node Chrome
Mỗi node đặt SE_NODE_MAX_SESSIONS=5, tức 15 slot trình duyệt — con số bạn sẽ đối chiếu với số thread ở phần sau.
version: "3"
services:
selenium-hub:
image: selenium/hub:4.21.0
container_name: selenium-hub
ports:
- "4442:4442"
- "4443:4443"
- "4444:4444"
chrome-node-1:
image: selenium/node-chrome:4.21.0
depends_on:
- selenium-hub
environment:
- SE_EVENT_BUS_HOST=selenium-hub
- SE_EVENT_BUS_PUBLISH_PORT=4442
- SE_EVENT_BUS_SUBSCRIBE_PORT=4443
- SE_NODE_MAX_SESSIONS=5
- SE_NODE_OVERRIDE_MAX_SESSIONS=true
chrome-node-2:
image: selenium/node-chrome:4.21.0
depends_on:
- selenium-hub
environment:
- SE_EVENT_BUS_HOST=selenium-hub
- SE_EVENT_BUS_PUBLISH_PORT=4442
- SE_EVENT_BUS_SUBSCRIBE_PORT=4443
- SE_NODE_MAX_SESSIONS=5
- SE_NODE_OVERRIDE_MAX_SESSIONS=true
chrome-node-3:
image: selenium/node-chrome:4.21.0
depends_on:
- selenium-hub
environment:
- SE_EVENT_BUS_HOST=selenium-hub
- SE_EVENT_BUS_PUBLISH_PORT=4442
- SE_EVENT_BUS_SUBSCRIBE_PORT=4443
- SE_NODE_MAX_SESSIONS=5
- SE_NODE_OVERRIDE_MAX_SESSIONS=true
Khởi động toàn bộ Grid:
docker-compose up -d
Mở http://localhost:4444 để kiểm tra hub đã nhận đủ ba node chưa. Thiếu node thường là do node khởi động trước hub; restart riêng node đó là xong.
Client CaptchaAI dùng chung cho mọi node
Luồng xử lý giống hệt khi chạy trên một máy, chỉ khác webdriver.Remote trỏ vào hub. Với mỗi task: mở trang, đọc sitekey, gửi task tới in.php, polling res.php, đặt token vào g-recaptcha-response rồi submit.
import requests
import time
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from concurrent.futures import ThreadPoolExecutor, as_completed
class GridCaptchaSolver:
CAPTCHAAI_URL = "https://ocr.captchaai.com"
def __init__(self, api_key, grid_url="http://localhost:4444"):
self.api_key = api_key
self.grid_url = grid_url
def create_session(self):
"""Create a new browser session on the Grid."""
options = webdriver.ChromeOptions()
options.add_argument("--no-sandbox")
options.add_argument("")
options.add_argument("--window-size=1920,1080")
driver = webdriver.Remote(
command_executor=self.grid_url,
options=options,
)
return driver
def solve_recaptcha_v2(self, site_url, sitekey):
"""Solve reCAPTCHA v2 via CaptchaAI API."""
# Submit
resp = requests.post(f"{self.CAPTCHAAI_URL}/in.php", data={
"key": self.api_key,
"method": "userrecaptcha",
"googlekey": sitekey,
"pageurl": site_url,
"json": 1,
})
data = resp.json()
if data["status"] != 1:
raise Exception(f"Submit: {data['request']}")
task_id = data["request"]
# Poll
for _ in range(60):
time.sleep(5)
resp = requests.get(f"{self.CAPTCHAAI_URL}/res.php", params={
"key": self.api_key, "action": "get",
"id": task_id, "json": 1,
})
data = resp.json()
if data["request"] == "CAPCHA_NOT_READY":
continue
if data["status"] != 1:
raise Exception(f"Solve: {data['request']}")
return data["request"]
raise Exception("Timeout")
def solve_turnstile(self, site_url, sitekey):
resp = requests.post(f"{self.CAPTCHAAI_URL}/in.php", data={
"key": self.api_key, "method": "turnstile",
"sitekey": sitekey, "pageurl": site_url, "json": 1,
})
data = resp.json()
if data["status"] != 1:
raise Exception(f"Submit: {data['request']}")
task_id = data["request"]
for _ in range(60):
time.sleep(5)
resp = requests.get(f"{self.CAPTCHAAI_URL}/res.php", params={
"key": self.api_key, "action": "get",
"id": task_id, "json": 1,
})
data = resp.json()
if data["request"] == "CAPCHA_NOT_READY":
continue
if data["status"] != 1:
raise Exception(f"Solve: {data['request']}")
return data["request"]
raise Exception("Timeout")
def process_task(self, task):
"""Process a single CAPTCHA-protected task on a Grid node."""
driver = self.create_session()
try:
driver.get(task["url"])
time.sleep(2)
# Detect sitekey
sitekey = task.get("sitekey")
if not sitekey:
sitekey = driver.execute_script(
"return document.querySelector('[data-sitekey]')?.getAttribute('data-sitekey')"
)
if not sitekey:
return {"url": task["url"], "status": "no_captcha", "data": driver.page_source[:500]}
# Solve
token = self.solve_recaptcha_v2(task["url"], sitekey)
# Inject
driver.execute_script(f"""
document.querySelector('#g-recaptcha-response').value = '{token}';
document.querySelectorAll('[name="g-recaptcha-response"]').forEach(
el => el.value = '{token}'
);
""")
# Fill form and submit
if task.get("form_data"):
for field, value in task["form_data"].items():
driver.find_element(By.NAME, field).send_keys(value)
if task.get("submit_selector"):
driver.find_element(By.CSS_SELECTOR, task["submit_selector"]).click()
time.sleep(3)
return {
"url": task["url"],
"status": "success",
"result_url": driver.current_url,
"data": driver.page_source[:1000],
}
except Exception as e:
return {"url": task["url"], "status": "error", "error": str(e)}
finally:
driver.quit()
process_task luôn gọi driver.quit() trong finally. Bỏ chi tiết này là nguyên nhân số một khiến Grid cạn slot: phiên lỗi không bao giờ được trả về.
Chạy nhiều task song song trên Grid
Số worker ở client nên bằng hoặc thấp hơn số slot của Grid. Đặt cao hơn không nhanh hơn — request thừa chỉ nằm chờ ở hub và dễ chạm timeout khi tạo phiên.
def run_parallel_tasks(api_key, tasks, max_workers=10):
"""Run CAPTCHA tasks in parallel across Grid nodes."""
solver = GridCaptchaSolver(api_key)
results = []
with ThreadPoolExecutor(max_workers=max_workers) as executor:
futures = {
executor.submit(solver.process_task, task): task
for task in tasks
}
for future in as_completed(futures):
task = futures[future]
try:
result = future.result(timeout=600)
results.append(result)
print(f"[{result['status']}] {result['url']}")
except Exception as e:
results.append({
"url": task["url"],
"status": "exception",
"error": str(e),
})
return results
# Usage
tasks = [
{
"url": "https://site-a.com/form",
"sitekey": "6Le-wvkSAAAAAPBMRTvw0Q4Muexq9bi0DJwx_mJ-",
"form_data": {"name": "Test User", "email": "[email protected]"},
"submit_selector": "#submit",
},
{
"url": "https://site-b.com/register",
"sitekey": "6LdKlZEpAAAAAAOQjzC2v_mJ-",
"form_data": {"username": "testuser"},
"submit_selector": "button[type='submit']",
},
# Add more tasks...
]
results = run_parallel_tasks("YOUR_API_KEY", tasks, max_workers=15)
# Summary
success = sum(1 for r in results if r["status"] == "success")
print(f"\nCompleted: {success}/{len(results)} successful")
Đọc số slot trống trước khi tăng worker
Thay vì đoán, hãy hỏi hub: endpoint /status trả về danh sách node kèm số slot đang bận.
import requests
def check_grid_status(grid_url="http://localhost:4444"):
"""Check Selenium Grid status and available nodes."""
try:
resp = requests.get(f"{grid_url}/status")
data = resp.json()
nodes = data.get("value", {}).get("nodes", [])
total_slots = 0
available_slots = 0
print(f"Grid Status: {data['value']['ready']}")
print(f"Nodes: {len(nodes)}")
for i, node in enumerate(nodes):
slots = node.get("slots", [])
free = sum(1 for s in slots if not s.get("session"))
total_slots += len(slots)
available_slots += free
print(f" Node {i+1}: {free}/{len(slots)} slots available")
print(f"Total capacity: {available_slots}/{total_slots} available")
return available_slots
except Exception as e:
print(f"Grid check failed: {e}")
return 0
# Adjust workers based on grid capacity
available = check_grid_status()
optimal_workers = min(available, 20)
print(f"Optimal workers: {optimal_workers}")
Với job theo lịch, gọi hàm này ở đầu mỗi lần chạy — số node có thể đã bị thu nhỏ.
Tự động co giãn node bằng Kubernetes
Khi hồi quy chỉ chạy từ 1h đến 4h sáng, HorizontalPodAutoscaler giữ 2 node lúc rảnh và đẩy lên 20 node lúc cao điểm.
# selenium-grid-k8s.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: selenium-chrome-node
spec:
replicas: 5
selector:
matchLabels:
app: selenium-chrome
template:
metadata:
labels:
app: selenium-chrome
spec:
containers:
- name: chrome
image: selenium/node-chrome:4.21.0
env:
- name: SE_EVENT_BUS_HOST
value: selenium-hub
- name: SE_EVENT_BUS_PUBLISH_PORT
value: "4442"
- name: SE_EVENT_BUS_SUBSCRIBE_PORT
value: "4443"
- name: SE_NODE_MAX_SESSIONS
value: "3"
resources:
limits:
memory: "2Gi"
cpu: "1"
requests:
memory: "1Gi"
cpu: "500m"
---
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: chrome-node-hpa
spec:
scaleRef:
apiVersion: apps/v1
kind: Deployment
name: selenium-chrome-node
minReplicas: 2
maxReplicas: 20
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 70
Lưu ý resources: mỗi pod Chrome cần khoảng 2Gi bộ nhớ cho ba phiên. Đặt thấp hơn thì pod bị OOM kill, và phía client chỉ thấy WebDriverException chứ không thấy lỗi bộ nhớ.
Bản Java cho team dùng JVM
Nhiều đội QA ở Việt Nam đã có sẵn bộ test JUnit hoặc TestNG. Cấu trúc không đổi: RemoteWebDriver trỏ vào hub, ExecutorService giới hạn số luồng, gọi API bằng HttpClient.
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.remote.RemoteWebDriver;
import org.openqa.selenium.chrome.ChromeOptions;
import java.net.URL;
import java.net.http.*;
import java.net.URI;
import java.util.concurrent.*;
public class GridCaptchaSolver {
private final String apiKey;
private final String gridUrl;
private final HttpClient httpClient;
public GridCaptchaSolver(String apiKey, String gridUrl) {
this.apiKey = apiKey;
this.gridUrl = gridUrl;
this.httpClient = HttpClient.newHttpClient();
}
public WebDriver createSession() throws Exception {
ChromeOptions options = new ChromeOptions();
options.addArguments("--no-sandbox", "--window-size=1920,1080");
return new RemoteWebDriver(new URL(gridUrl), options);
}
public List<Map<String, String>> runParallel(
List<Map<String, String>> tasks, int workers
) throws Exception {
ExecutorService executor = Executors.newFixedThreadPool(workers);
List<Future<Map<String, String>>> futures = new ArrayList<>();
for (Map<String, String> task : tasks) {
futures.add(executor.submit(() -> processTask(task)));
}
List<Map<String, String>> results = new ArrayList<>();
for (Future<Map<String, String>> future : futures) {
results.add(future.get(600, TimeUnit.SECONDS));
}
executor.shutdown();
return results;
}
}
Cân số slot Grid với số thread CaptchaAI
Grid quyết định bạn mở được bao nhiêu trình duyệt; gói CaptchaAI quyết định bao nhiêu CAPTCHA được giải cùng lúc. Một thread là một luồng giải đồng thời: phiên đang chờ token chiếm một thread, giải xong là nhận task tiếp.
- BASIC ($15/tháng, 5 thread) — một node Chrome hoặc môi trường phát triển.
- STANDARD ($30/tháng, 15 thread) — khớp đúng cấu hình ba node × 5 phiên ở trên.
- ADVANCE ($90/tháng, 50 thread) — Grid khoảng mười node.
- PREMIUM ($170/tháng, 100 thread) — hạ tầng chạy liên tục nhiều ca trong ngày.
Giá tính theo thread chứ không theo lượt giải, và số lượt giải trên mỗi thread trong tháng là không giới hạn. Nên chỉ có một câu cần trả lời: cao điểm có bao nhiêu phiên cùng chờ token? Hiếm khi cả 15 phiên chạm CAPTCHA cùng lúc, vì mỗi phiên còn mất thời gian tải trang. Giá niêm yết bằng USD.
Sự cố thường gặp trên Grid
| Triệu chứng | Nguyên nhân | Cách xử lý |
|---|---|---|
SessionNotCreated |
Không còn slot trống | Thêm node hoặc tăng SE_NODE_MAX_SESSIONS |
| Timeout khi tạo phiên | Node quá tải | Giảm số phiên trên mỗi node |
WebDriverException giữa chừng |
Node ngắt kết nối hoặc bị OOM kill | Retry bước tạo phiên, nâng giới hạn bộ nhớ |
| Slot cạn dần | Phiên lỗi không được đóng | Luôn gọi driver.quit() trong finally |
CAPCHA_NOT_READY kéo dài |
Đã dùng hết thread | Giãn thời gian thử lại (backoff), kiểm tra số thread |
| Phiên cũ treo trên node | Grid dọn dẹp chậm | Đặt SE_SESSION_TIMEOUT |
Loại CAPTCHA nào chạy được trong mô hình này
Node chỉ gửi sitekey cùng pageurl rồi nhận token, nên mô hình này dùng được với mọi loại CaptchaAI hỗ trợ: reCAPTCHA v2 và v3 (kể cả reCAPTCHA Enterprise), Cloudflare Turnstile, Cloudflare Challenge, GeeTest v3, CAPTCHA ảnh/OCR, grid-image, BLS CAPTCHA. Đang ở giai đoạn beta: CaptchaFox (beta), Friendly Captcha (beta), Lemin (beta).
CaptchaAI không hỗ trợ hCaptcha và FunCaptcha (Arkose Labs); GeeTest v4 mới ở trạng thái sắp ra mắt. Nếu hệ thống bạn kiểm thử dùng những loại đó, hãy tách chúng khỏi hàng đợi Grid từ khâu lập kế hoạch.
Câu hỏi thường gặp
Grid có 20 slot thì cần bao nhiêu thread?
Bắt đầu ở mức thread ngang số slot. Nếu chỉ khoảng một nửa số phiên chạm CAPTCHA cùng lúc thì 15 thread vẫn đủ — đo log trước khi nâng gói.
Token giải xong có dùng lại cho phiên khác được không?
Không. Token gắn với sitekey, với phiên đang mở trang và hết hạn sau ít phút — mỗi phiên phải xin token riêng.
Nên tạo phiên mới cho mỗi task hay dùng lại phiên?
Tạo mới cho mỗi task để cô lập cookie và state. Chỉ dùng lại khi các task cùng domain và cần giữ trạng thái đăng nhập.
CaptchaAI có giải hCaptcha trên Grid không?
Không — hCaptcha và FunCaptcha nằm ngoài danh sách hỗ trợ. Với các trang dùng chúng, hãy đề nghị đội phát triển tắt CAPTCHA trên staging.
Đọc thêm
- Selenium với Python và CaptchaAI
- Chặn và sửa request bằng Selenium Wire
- Worker threads Node.js cho giải song song
Lấy API key CaptchaAI và cho cả ba node dùng chung một key ngay từ lần chạy đầu.