DevOps và Mở Rộng

Giải CAPTCHA theo sự kiện với AWS SNS và CaptchaAI

Khi một kết quả CAPTCHA cần đến nhiều nơi cùng lúc — hàng đợi xử lý, log kiểm tra, cảnh báo lỗi — thì để mỗi service tự polling res.php là cách tốn kém nhất. Gọn hơn: CaptchaAI gửi kết quả về một callback, callback đẩy message lên AWS SNS topic, rồi mọi consumer phía sau (SQS, Lambda, email) tự nhận và xử lý độc lập. Bài này dựng mô hình fan-out theo sự kiện đó qua năm bước, kèm code Python và Node.js chạy được ngay.

Kiến trúc: một kết quả, nhiều consumer

Ý tưởng cốt lõi là tách trình nhận callback khỏi phần xử lý kết quả. Callback chỉ làm một việc: đẩy kết quả lên SNS topic rồi trả 200 ngay.

[Scraper] → Submit CAPTCHA → [CaptchaAI API]
                                    ↓
                            Solve completes
                                    ↓
                            Callback → [API Gateway + Lambda]
                                    ↓
                            Publish → [SNS Topic]
                                    ↓
                    ┌───────────────┼───────────────┐
                    ↓               ↓               ↓
            [SQS Queue]      [Lambda Logger]   [Email Alert]
            (result store)   (audit trail)     (on failure)

SNS lo phần fan-out: một kết quả giải CAPTCHA kích hoạt nhiều consumer mà callback không cần biết chúng tồn tại — muốn thêm service phân tích mới thì chỉ subscribe thêm vào topic. Mô hình này rất hợp với một team QA gia công ở TP.HCM chạy kiểm thử trên hàng chục cổng khách hàng: cùng một kết quả vừa vào hàng đợi xử lý, vừa ghi log đối soát, vừa bắn cảnh báo cho người trực — ba nhánh chạy song song, không nhánh nào chờ nhánh nào.

Bước 1: Tạo SNS topic

Topic là điểm phát trung tâm. Tạo một lần bằng AWS CLI (rồi lưu lại TopicArn để dùng ở các bước sau), hoặc làm tương tự bằng boto3 trong Python:

aws sns create-topic --name captcha-results --output text
# Returns: arn:aws:sns:us-east-1:123456789:captcha-results
import boto3

sns = boto3.client("sns", region_name="us-east-1")

response = sns.create_topic(Name="captcha-results")
topic_arn = response["TopicArn"]
print(f"Topic ARN: {topic_arn}")

Bước 2: Dựng Lambda nhận callback

Hàm Lambda dưới đây nhận callback từ CaptchaAI qua API Gateway, đóng gói thành message rồi publish lên SNS. Nó giữ mỏng — chỉ chuyển tiếp — để trả 200 thật nhanh, tránh timeout phía CaptchaAI. Dưới đây là bản Python và bản Node.js tương đương:

import json
import os
import boto3

sns = boto3.client("sns")
TOPIC_ARN = os.environ["SNS_TOPIC_ARN"]

def lambda_handler(event, context):
    """Receive CaptchaAI callback and publish to SNS."""
    # Parse query parameters from API Gateway
    params = event.get("queryStringParameters", {}) or {}
    task_id = params.get("id", "")
    solution = params.get("code", "")

    if not task_id or not solution:
        return {"statusCode": 400, "body": "Missing id or code"}

    # Publish to SNS
    message = {
        "task_id": task_id,
        "solution": solution,
        "status": "solved"
    }

    sns.publish(
        TopicArn=TOPIC_ARN,
        Message=json.dumps(message),
        Subject="captcha-solved",
        MessageAttributes={
            "task_id": {
                "DataType": "String",
                "StringValue": task_id
            }
        }
    )

    return {"statusCode": 200, "body": "OK"}
const { SNSClient, PublishCommand } = require("@aws-sdk/client-sns");

const sns = new SNSClient({ region: "us-east-1" });
const TOPIC_ARN = process.env.SNS_TOPIC_ARN;

exports.handler = async (event) => {
  const params = event.queryStringParameters || {};
  const taskId = params.id;
  const solution = params.code;

  if (!taskId || !solution) {
    return { statusCode: 400, body: "Missing id or code" };
  }

  const message = {
    task_id: taskId,
    solution: solution,
    status: "solved",
  };

  await sns.send(
    new PublishCommand({
      TopicArn: TOPIC_ARN,
      Message: JSON.stringify(message),
      Subject: "captcha-solved",
      MessageAttributes: {
        task_id: { DataType: "String", StringValue: taskId },
      },
    })
  );

  return { statusCode: 200, body: "OK" };
};

Bước 3: Gửi CAPTCHA kèm URL callback

Khi gửi task, trỏ tham số pingback vào endpoint API Gateway ở bước 2. Giải xong, CaptchaAI tự gọi URL này kèm idcode, không cần polling res.php:

import os
import requests

API_KEY = os.environ["CAPTCHAAI_API_KEY"]
CALLBACK_URL = os.environ["CALLBACK_GATEWAY_URL"]  # API Gateway URL

def submit_captcha(sitekey, pageurl):
    """Submit CAPTCHA with SNS-backed callback."""
    resp = requests.post("https://ocr.captchaai.com/in.php", data={
        "key": API_KEY,
        "method": "userrecaptcha",
        "googlekey": sitekey,
        "pageurl": pageurl,
        "pingback": CALLBACK_URL,
        "json": 1
    })
    data = resp.json()

    if data.get("status") == 1:
        return data["request"]  # task_id
    raise RuntimeError(f"Submit failed: {data.get('request')}")

Bước 4: Đăng ký consumer vào topic

Mỗi kiểu xử lý là một subscription riêng: SQS lưu kết quả cho scraper đọc, Lambda ghi log kiểm tra, và email cảnh báo khi có lỗi. Ba đoạn dưới đây đăng ký lần lượt từng loại:

# Subscribe an SQS queue to receive all results
sqs_arn = "arn:aws:sqs:us-east-1:123456789:captcha-results-queue"

sns.subscribe(
    TopicArn=topic_arn,
    Protocol="sqs",
    Endpoint=sqs_arn
)
# Subscribe a Lambda for audit logging
lambda_arn = "arn:aws:lambda:us-east-1:123456789:function:captcha-audit-logger"

sns.subscribe(
    TopicArn=topic_arn,
    Protocol="lambda",
    Endpoint=lambda_arn
)
# Subscribe email for error notifications with filter
sns.subscribe(
    TopicArn=topic_arn,
    Protocol="email",
    Endpoint="ops@example.com"
)

Bước 5: Đọc kết quả từ SQS

Giờ scraper đọc thẳng từ SQS thay vì hỏi CaptchaAI. Dùng long polling (tối đa 20 giây) để giảm request rỗng, và xóa message sau khi xử lý xong — bản Python và Node.js:

import json
import boto3

sqs = boto3.client("sqs", region_name="us-east-1")
QUEUE_URL = os.environ["SQS_QUEUE_URL"]

def get_solved_captcha(timeout=30):
    """Wait for a CAPTCHA solution from the SQS queue."""
    response = sqs.receive_message(
        QueueUrl=QUEUE_URL,
        MaxNumberOfMessages=1,
        WaitTimeSeconds=min(timeout, 20)  # Long polling (max 20s)
    )

    messages = response.get("Messages", [])
    if not messages:
        return None

    msg = messages[0]
    # SNS wraps the message — unwrap it
    sns_envelope = json.loads(msg["Body"])
    result = json.loads(sns_envelope["Message"])

    # Delete message after processing
    sqs.delete_message(
        QueueUrl=QUEUE_URL,
        ReceiptHandle=msg["ReceiptHandle"]
    )

    return result
const {
  SQSClient,
  ReceiveMessageCommand,
  DeleteMessageCommand,
} = require("@aws-sdk/client-sqs");

const sqs = new SQSClient({ region: "us-east-1" });
const QUEUE_URL = process.env.SQS_QUEUE_URL;

async function getSolvedCaptcha(timeout = 30) {
  const response = await sqs.send(
    new ReceiveMessageCommand({
      QueueUrl: QUEUE_URL,
      MaxNumberOfMessages: 1,
      WaitTimeSeconds: Math.min(timeout, 20),
    })
  );

  const messages = response.Messages || [];
  if (messages.length === 0) return null;

  const msg = messages[0];
  const snsEnvelope = JSON.parse(msg.Body);
  const result = JSON.parse(snsEnvelope.Message);

  await sqs.send(
    new DeleteMessageCommand({
      QueueUrl: QUEUE_URL,
      ReceiptHandle: msg.ReceiptHandle,
    })
  );

  return result;
}

Lọc message ngay trong SNS

Không phải consumer nào cũng cần mọi kết quả. Với filter policy, bạn định tuyến theo thuộc tính message — ví dụ chỉ đẩy ca thất bại sang hàng đợi vận hành:

# Only send failures to the ops queue
sns.subscribe(
    TopicArn=topic_arn,
    Protocol="sqs",
    Endpoint=failure_queue_arn,
    Attributes={
        "FilterPolicy": json.dumps({
            "status": ["failed", "error"]
        })
    }
)

Câu hỏi thường gặp

SNS có thay thế hoàn toàn việc polling res.php không?

Có, một khi callback chạy ổn thì scraper đọc từ SQS thay vì polling. Nên giữ polling làm phương án dự phòng khi callback hoặc API Gateway gặp sự cố.

Thêm nhiều consumer qua SNS có làm tăng phí giải CAPTCHA không?

Không. CaptchaAI tính theo thread (số luồng giải đồng thời), không theo số lần giải, nên gắn thêm consumer không làm tăng phí giải — bạn vẫn trả theo gói thread, ví dụ BASIC ($15/tháng, 5 thread). Chi phí SNS và SQS do AWS tính riêng.

Nếu một consumer xử lý lỗi thì kết quả có bị mất không?

Không, nếu consumer là SQS: message nằm trong hàng đợi cho đến khi được xóa thành công. Cấu hình thêm dead-letter queue để giữ lại các message thất bại, tránh mất dữ liệu.

Xử lý sự cố thường gặp

Vấn đề Nguyên nhân Cách xử lý
Callback trả về 403 API Gateway bật xác thực, chặn CaptchaAI Tắt xác thực trên route callback; thay bằng kiểm tra token
Message không tới SQS Thiếu quyền SNS → SQS Thêm quyền sns:Publish vào policy của hàng đợi SQS
Kết quả bị xử lý trùng SNS giao theo cơ chế at-least-once Thêm idempotency — kiểm tra task_id trước khi xử lý
Cold start làm chậm callback Chưa bật provisioned concurrency Bật provisioned concurrency cho Lambda callback

Bài viết liên quan

Bước tiếp theo

Dựng pipeline giải CAPTCHA theo sự kiện — lấy API key CaptchaAI và nối vào luồng sự kiện AWS.

Hướng dẫn liên quan:

Os comentários estão desativados para este artigo.