【悪用厳禁】AWS Lambda で Tor を使う
AWS の Lambda 経由でクローリング/スクレイピングを行いたい場合、Headless Chrome (chromium) を用いることで実現できますが、更にその際、Tor を用いることで IP アドレスを秘匿化する方法について紹介したいと思います。
※ 実装は TypeScript で行っていきます。
Tor のレイヤー化
「Tor ブラウザ」があるので勘違いされやすいですが、Tor は別に Tor ブラウザに限定されているものではなく、自分でtorコマンドを実行することで、例えば普段使っている Chrome 等の一般のブラウザも「Tor化」することができます。
torコマンドは こちら の Tor プロジェクトでバージョン管理されており、ダウンロード&コンパイルすることで誰でも簡単に使用することができます。また、自分でコンパイルせずとも、例えば MacOS だと Homebrew 経由で brew install tor で簡単にインストールすることもできます。
Lambda上で使うには、Linux環境でコンパイルしたファイルを用いなければエラーが起きてしまうので、Dockerコンテナ内でダウンロード&コンパイルするのが良いでしょう。今回はその手順は省略しますが、事前にコンパイルして zip 化したものをこちらのリポジトリに用意したので、zip ファイルを Lambda レイヤーとしてアップロードするだけで簡単に参照することができます。実行ファイルのパスは /opt/bin/tor となります。
また、リポジトリ内に serverless.yml も合わせて書いてあるので、sls deploy を実行するだけでアップロードが完了するようにもなっています。torファイル自体は15MB弱なので、充分 Lambda で扱うことができるサイズです。
Headless Chrome
※ 最終成果物はGitHubリポジトリにアップロードしてあります。こちらも serverless に対応しています。
Tor のレイヤー化は終わったので、あとは Headless Chrome から呼び出すだけです。ひと昔前は Headless化するのに一苦労した記憶がありますが、今は簡単にインストールして使うことができますね。
$ yarn add chrome-aws-lambda
でモジュールをインストールしたら、下記のように chromium をインポートするだけで準備は完了です。
import chromium from 'chrome-aws-lambda'
あとは、
await chromium.puppeteer.launch({ options })
とすることで Headless Chrome を立ち上げることができます。Lambda で Tor を使うには、
- Headless Chrome 起動前に 
torを実行(起動) - 上記の 
optionsに Tor を使う旨を記述 
という流れをとることになります。
Tor のモジュール化(クラス化)
tor の起動を簡単に行えるように、クラス化しておきましょう。tor は設定ファイルとして .torrc というファイルを見ますが、その中には下記の2つが書かれている必要があります。
- 
SOCKSPort: SOCKSプロキシ通信用ポート - 
DataDirectory: データ送受信用ディレクトリパス 
Lambda では一時ディスク容量が用意されており、tmpファイル・ディレクトリを作成することができるので、ここに .torrc および DataDirectory に対応するディレクトリを作成します。
結局、主に実装すべき点は以下の3つになります。
- 一時ファイル・ディレクトリの作成
 - 
torの実行 - 終了時のクリーンアップ
 
以上を踏まえて、Torクラス化した実装を見てみましょう。コメント付きで全体を書いてみました。
/* tor.ts */
import { spawn, execSync, ChildProcessWithoutNullStreams } from 'child_process'
import tempfile from 'tempfile'
import { IS_LOCAL } from './env' // ローカル環境かどうかの判別。ファイルの中身は後述
export default class Tor {
  port: number       // SOCKSPort に対応
  dataDir: string    // DataDirectory に対応
  torrcPath: string  // 一時ファイルとしての .torrc のパス
  torPath: string = `/opt/bin/tor`
  proc: ChildProcessWithoutNullStreams
  constructor(port: number) {
    this.port = port
  }
  async launch() {
    if (IS_LOCAL) { // ローカルでは tor は起動済という前提
      return
    }
    this.createTempfiles() // 一時ファイル・ディレクトリを作成
    // tor の起動には時間がかかるため Promise を返しておく
    return new Promise((resolve, reject) => {
      // tor の起動
      this.proc = spawn(this.torPath, ['-f', this.torrcPath], {
        cwd: this.dataDir,
      })
      
      // 起動完了まで待機
      this.proc.stdout.on('data', (data: Buffer) => {
        if (data.toString().match(/100%/)) { // 起動完了
          resolve()
        }
      })
      this.proc.stderr.on('data', (data) => {
        reject(`Failed tor initialization: ${data}`)
      })
      this.proc.on('close', (code) => {
        if (code) {
          reject()
        } else {
          resolve()
        }
      })
    })
  }
  createTempfiles() {
    // 一時ファイル・ディレクトリのパス生成
    this.dataDir = tempfile('.data')
    this.torrcPath = tempfile('.torrc')
    // .data および .torrc の作成
    const cmds = [
      `mkdir ${this.dataDir}`,
      `touch ${this.torrcPath}`,
      `echo 'SOCKSPort ${this.port}\n' > ${this.torrcPath} && echo 'DataDirectory ${this.dataDir}\n' >> ${this.torrcPath}`,
    ]
    for (let cmd of cmds) {
      execSync(cmd)
    }
  }
  close() {
    if (IS_LOCAL) {
      return
    }
    // tor の終了
    this.proc.kill('SIGINT')
    const cmds = [`rm -rf ${this.dataDir}`, `rm -rf ${this.torrcPath}`]
    for (let cmd of cmds) {
      execSync(cmd)
    }
  }
}
上記コード内の env ファイルは、下記となります。
/* env.ts */
export const IS_LOCAL = process.env.IS_LOCAL === 'true' ? true : false
Lambdaをローカルで試すことができる serverless-offline を利用すると、
$ sls invoke local -f <function_name>
とすることで関数を実行できますが、その際に IS_LOCAL=true という環境変数がセットされます。これをきちんと boolean で評価できるように記述したものになります。
Headless Chrome w/ Tor のモジュール化(クラス化)
さて、ここまでで Headless Chrome および Tor を利用する準備は整ったので、両者を組み合わせて使えるように実装をしていきましょう。
先述したとおり、chromium.puppeteer.launch({ options }) の引数に、Torを使用するための記述をするだけです。
/* browser.ts */
import 'source-map-support/register'
import chromium from 'chrome-aws-lambda'
import { Browser } from 'puppeteer-core'
import { IS_LOCAL } from './env'
import os from './os' // ローカルで Win/Mac の判別用。ファイルの中身は後述
import Tor from './tor'
// Tor のポート
const PORT = 9050
// Chromium のデフォルトのユーザーエージェントは `webdriver`
export const UA =
  'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_5)' +
  'AppleWebKit/537.36 (KHTML, like Gecko) Chrome/84.0.4147.125 Safari/537.36'
// ローカルの場合は、インストールしてある Chrome を起動
let path: string = ''
switch (os) {
  case 'win':
    path = 'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe'
    break
  case 'mac':
    path = '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'
    break
}
export interface LaunchOptions {
  headless?: boolean
  useTor?: boolean
}
export class Chrome {
  browser?: Browser // Headless Chrome 本体
  tor: Tor | undefined
  async launch({ headless, useTor }: LaunchOptions) {
    headless = headless === false ? false : true
    let { args } = chromium
    if (useTor) {
      // 事前に tor を実行
      this.tor = new Tor(PORT)
      await this.tor.launch()
      
      // Tor を利用するために、引数にSOCKSプロキシの設定を追加
      args.push(`--proxy-server=socks5://127.0.0.1:${PORT}`)
    }
    this.browser = await chromium.puppeteer.launch({
      args,
      defaultViewport: chromium.defaultViewport,
      executablePath: IS_LOCAL ? path : await chromium.executablePath,
      headless,
      ignoreHTTPSErrors: true,
    })
  }
  async close() {
    if (this.tor) {
      this.tor.close()
    }
    if (this.browser != null) {
      await this.browser.close()
    }
  }
}
コード内のOS判別は下記の実装で行っています。
/* os.ts */
export const platform = process.platform
export let os: 'win' | 'mac' | 'linux' | 'unknown' = 'unknown'
switch (platform) {
  case 'win32':
    os = 'win'
    break
  case 'darwin':
    os = 'mac'
    break
  case 'linux':
    os = 'linux'
    break
}
export default os
あとは
const chrome = new Chrome({ headless: true, useTor: true })
とすれば、Torブラウザ化の完了です。
Let's Try!
実際に試してみましょう!
/* main.ts */
import 'source-map-support/register'
import { Handler } from 'aws-lambda'
import { Chrome } from './browser'
export const handler: Handler = async (_event, _context, callback) => {
  const chrome = new Chrome()
  const url = 'https://ipinfo.io/'
  let content: string[]
  try {
    await chrome.launch({ headless: true, useTor: true })
    const page = await chrome.browser.newPage()
    await page.goto(url, { waitUntil: 'networkidle2' })
    await page.waitForSelector('.json-widget-entry')
    content = await page.$$eval('.json-widget-entry', (elems: any) => {
      return elems.map((el: any) =>
        (el.textContent as string).replace(/\n|\s/g, '')
      )
    })
  } catch (e) {
    console.error(e, e.stack)
    return callback(e, null)
  } finally {
    await chrome.close()
  }
  return callback(null, content)
}
以上をデプロイ&実行すると、
[
  ...
  "city:\"Wiesbaden\"",
  "region:\"Hesse\"",
  "country:\"DE\"",
  ...
  "tor:true",
]
という結果となり、海外からのアクセスになっていることが確認できます。 tor:true という表示もあるので、「Tor化」できていることもわかりました。
実装は以上です!Lambda上でTor化するのは意外と簡単だったことがわかったかと思います。これで自分の身元は隠匿することはできますが、決して悪用はしないようにしてください。
(また、先述した tor:true とあるように、Torかどうかは判別できるので、そもそもサービスによってはアクセスを弾いているものもあります。)
リポジトリ内では、Chromeクラスで preventBotDetection というコードも記述しています。これは Chromium 経由のアクセスであることを隠すための実装ですが、この内容に関しては別の機会に紹介したいと思います。
Discussion