【悪用厳禁】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