Squidでキャッシュプロキシサーバーを構築する

スクレイピングを試す際、短期間でアクセスが集中しないようにとこれまで自力で行っていたキャッシュの管理が難しくなってきたため、キャッシュ機能を持たせたプロキシサーバーを経由してアクセスできるようにしてみました。

環境

  • VSCode: 1.82.2
  • Docker Desktop: 4.18.0
  • Debian bullseye
  • Python: 3.11.3
  • Jupyter Notebook: 1.0.0
  • Playwright: 1.38.0
  • Squid: 4.13

Squidのインストール

今回は既存のDev Containerに環境を構築していきます。

.devcontainer/devcontainer.json.devcontainer/Dockerfile、そこから呼び出しているシェルスクリプトの.devcontainer/postCreateCommand.shは下記となります。

devcontainer.json

{
    "name": "Python 3",
    "build": {
        "dockerfile": "Dockerfile",
        "args": {
            "VARIANT": "3"
        }
    },
    "runArgs": [
        "--cap-add=SYS_PTRACE",
        "--security-opt",
        "seccomp=unconfined"
    ],
    "extensions": [
        "ms-python.python",
        "ms-toolsai.jupyter",
        "alexcvzz.vscode-sqlite",
        "ms-vscode.live-server",
        "eamodio.gitlens"
    ],
    "settings": {
        "terminal.integrated.shell.linux": "/bin/bash"
    },
    "appPort": [
        8888
    ],
    "postCreateCommand": "/bin/bash .devcontainer/postCreateCommand.sh",
    "containerEnv": {
        "DISPLAY": "host.docker.internal:0"
    }
}

Dockerfile

ARG VARIANT=3
FROM mcr.microsoft.com/vscode/devcontainers/python:${VARIANT}

RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
    && apt-get -y install --no-install-recommends sqlite3 \
    && apt-get autoremove -y && apt-get clean -y && rm -rf /var/lib/apt/lists/*

RUN pip install jupyter

postCreateCommand.sh

pip install playwright beautifulsoup4 lxml pandas requests pydantic matplotlib
playwright install
playwright install-deps
apt install libpng-dev
sudo apt install -y squid-openssl

インストール方法

Squidのssl_bump機能を利用するため、Squidのパッケージsquidではなくsquid-opensslをインストールします。 なぜかというと、--with-gnutlsまたは--with-opensslを有効化してコンパイルされたパッケージが必要になるためです。

sudo apt install -y squid-openssl

自己署名証明書の生成とシステムへのインストール

HTTPSの通信内容をキャッシュするには、いわゆる中間者攻撃(MITM攻撃)と同様の仕組みを用いる必要があります。 つまりブラウザとSquid、Squidとアクセス対象のWebサーバーの2つの通信を行えるようにします。 Squidではssl_bump機能によりこれを実現できます。

まずは自己署名証明書を用意し、クライアント(Playwright+ブラウザ)で利用できるようにOSにインストールします。

自己署名証明書ファイルを生成する

証明書を生成し、権限も変更しておきます。

openssl req -x509 -newkey rsa:4096 -keyout key.pem -out cert.pem -days 365 -nodes
chmod 400 key.pem cert.pem

証明書を /usr/local/share/ca-certificates/ディレクトリに移動します。

sudo cp /workspaces/notebook-python/cert.pem /usr/local/share/ca-certificates/your_certificate.crt

下記のコマンドで、証明書のリストを更新します。

sudo update-ca-certificates

公開鍵のハッシュ値を計算する

後の作業で必要になるため、生成したcert.pemに含まれる公開鍵のハッシュ値を取得しておきます。

openssl x509 -pubkey -noout -in /workspaces/notebook-python/cert.pem | openssl pkey -pubin -outform der | openssl dgst -sha256 -binary | openssl enc -base64

この時の計算結果はこのようになりました。

QFWlmpsM4EaZZE55kTrpgmcyGy6YkIL2Bmwyjx2unqQ=

Squidの設定ファイルを編集する

/etc/squid/squid.confを編集します。 とりあえずバックアップをとっておきましょう。

sudo cp /etc/squid/squid.conf /etc/squid/squid.conf.old

試行錯誤の末、squid.confの内容は以下のようになりました。

# DNSのIPv4優先設定を有効化
dns_v4_first on

# ローカルネットワーク(LAN)のアクセス制御リストを作成
acl localnet src 0.0.0.1-0.255.255.255    # RFC 1122 "this" network (LAN)
acl localnet src 10.0.0.0/8               # RFC 1918 ローカルプライベートネットワーク(LAN)
acl localnet src 100.64.0.0/10            # RFC 6598 共有アドレススペース(CGN)
acl localnet src 169.254.0.0/16           # RFC 3927 リンクローカル(直接接続された)マシン
acl localnet src 172.16.0.0/12            # RFC 1918 ローカルプライベートネットワーク(LAN)
acl localnet src 192.168.0.0/16           # RFC 1918 ローカルプライベートネットワーク(LAN)
acl localnet src fc00::/7                 # RFC 4193 ローカルプライベートネットワーク範囲
acl localnet src fe80::/10                # RFC 4291 リンクローカル(直接接続された)マシン

# SSLポート(443)へのアクセス制御リストを作成
acl SSL_ports port 443

# セーフポートへのアクセス制御リストを作成
acl Safe_ports port 80        # http
acl Safe_ports port 21        # ftp
acl Safe_ports port 443       # https
acl Safe_ports port 70        # gopher
acl Safe_ports port 210       # wais
acl Safe_ports port 1025-65535    # 登録されていないポート
acl Safe_ports port 280       # http-mgmt
acl Safe_ports port 488       # gss-http
acl Safe_ports port 591       # filemaker
acl Safe_ports port 777       # 多言語対応のhttp

# CONNECTメソッドへのアクセス制御リストを作成
acl CONNECT method CONNECT

# セーフポート以外へのHTTPアクセスを拒否
http_access deny !Safe_ports

# SSLポート以外へのCONNECTアクセスを拒否
http_access deny CONNECT !SSL_ports

# ローカルホストへのアクセスを許可(マネージャー用)
http_access allow localhost manager

# マネージャーへのアクセスを拒否
http_access deny manager

# 追加の設定ファイルをインクルード
include /etc/squid/conf.d/*

# ローカルホストへのHTTPアクセスを許可
http_access allow localhost

# すべてのアクセスを拒否
http_access deny all

# HTTPポート(3128)でSSLバンプを設定
http_port 3128 ssl-bump \
    cert=/workspaces/notebook-python/cert.pem \
    key=/workspaces/notebook-python/key.pem

# SSLバンプのステップとアクションを設定
ssl_bump stare all
ssl_bump bump all

# SslBump1ステップを定義
acl step1 at_step SslBump1

# キャッシュディレクトリを設定
cache_dir ufs /var/spool/squid 100 16 256

# コアダンプディレクトリを設定
coredump_dir /var/spool/squid

# FTPとGopherのリフレッシュパターンを設定
refresh_pattern ^ftp:        1440    20%    10080
refresh_pattern ^gopher:    1440    0%    1440

# 特定のウェブサイトのリフレッシュパターンを設定
refresh_pattern ^https://webscraper\.io/test-sites/e-commerce/static 10080 100% 10080 reload-into-ims

# CGIとその他のリフレッシュパターンを設定
refresh_pattern -i (/cgi-bin/|\?) 0    0%    0
refresh_pattern .        0    20%    4320

ssl_bump機能を利用するためには、最低限下記の設定を行う必要がありました。
先程生成した証明書ファイルのパスをhttp_portに指定します。

http_port 3128 ssl-bump \
    cert=/workspaces/notebook-python/cert.pem \
    key=/workspaces/notebook-python/key.pem
ssl_bump stare all
ssl_bump bump all
acl step1 at_step SslBump1

SSL証明書データベースを初期化する

SquidはWebサイトにアクセスする際にサーバー証明書を動的に生成し、それをキャッシュして利用するため、Squidを起動する前に以下のコマンドで初期化する必要があります。 またsudoを付加して実行すると所有者がrootになってしまうため、所有者をproxyに戻しておきます。

sudo /usr/lib/squid/security_file_certgen -c -s /var/spool/squid/ssl_db -M 4MB
sudo chown -R proxy:proxy /var/spool/squid/ssl_db

ブラウザ操作のコードを作成する

Python(Jupyter Notebook)でPlaywrightを操作するため、下記のコードを作成しました。

今までの状態ではChromeブラウザでアクセスすると証明書エラーが発生するため、信頼できる証明書としてブラウザに認識させる起動オプションを指定してChromeを起動する必要があります。 --ignore-certificate-errors-spki-listに先程計算したハッシュ値を指定することで、証明書を信頼することができます。 またこのオプションは--user_data_dirも指定する必要があるので、特に指定がなければ/dev/nullを設定します。

Contextを生成する際に指定するプロキシは、ポートを変更していなければhttp://localhost:3128を指定します。

今回アクセスする対象のWebページとして下記を指定しました。 これはスクレイピングを試すために用意されたWebサイトのようで、同じECサイトのサンプルでもいくつかパターンがあります。
Static | Web Scraper Test Sites

import asyncio
from playwright.async_api import async_playwright

async with async_playwright() as p:
    browser = await p.chromium.launch(
        args=[
            "--ignore-certificate-errors-spki-list=QFWlmpsM4EaZZE55kTrpgmcyGy6YkIL2Bmwyjx2unqQ=",
            "--user_data_dir=/dev/null",
        ],
    )
    context = await browser.new_context(proxy={"server": "http://localhost:3128"})  # Squidのデフォルトポートは3128
    page = await context.new_page()

    await page.goto("https://webscraper.io/test-sites/e-commerce/static")
    await page.wait_for_load_state("networkidle")
    content = await page.content()
    print(content)

    await context.close()
    await browser.close()

動作確認

ここまで設定できたら、動作確認のためにSquidを起動してコードを実行してみます。 Squidを起動するには下記を実行します。

sudo service squid start

access.log

この時の/var/log/squid/access.logは以下のようになりました。 2回目以降のアクセスはキャッシュを参照できているようです。

1697204571.489    105 127.0.0.1 NONE/200 0 CONNECT webscraper.io:443 - HIER_DIRECT/13.33.174.51 -
1697204572.108    588 127.0.0.1 TCP_REFRESH_MODIFIED/200 4042 GET https://webscraper.io/test-sites/e-commerce/static - HIER_DIRECT/13.33.174.51 text/html
1697204572.158     21 127.0.0.1 TCP_REFRESH_UNMODIFIED/200 75646 GET https://webscraper.io/css/app.css? - HIER_DIRECT/13.33.174.51 text/css
1697204572.164     24 127.0.0.1 NONE/200 0 CONNECT webscraper.io:443 - HIER_DIRECT/13.33.174.51 -
1697204572.201     25 127.0.0.1 TCP_REFRESH_UNMODIFIED/200 1230 GET https://webscraper.io/css/ws-icons.css? - HIER_DIRECT/13.33.174.51 text/css
1697204572.214     26 127.0.0.1 NONE/200 0 CONNECT webscraper.io:443 - HIER_DIRECT/13.33.174.51 -
1697204572.221     34 127.0.0.1 NONE/200 0 CONNECT webscraper.io:443 - HIER_DIRECT/13.33.174.51 -
1697204572.221     34 127.0.0.1 NONE/200 0 CONNECT webscraper.io:443 - HIER_DIRECT/13.33.174.51 -
1697204572.221     34 127.0.0.1 NONE/200 0 CONNECT webscraper.io:443 - HIER_DIRECT/13.33.174.51 -
1697204572.252     61 127.0.0.1 NONE/200 0 CONNECT fonts.googleapis.com:443 - HIER_DIRECT/142.250.207.10 -
1697204572.253     63 127.0.0.1 NONE/200 0 CONNECT fonts.googleapis.com:443 - HIER_DIRECT/142.250.207.10 -
1697204572.253     63 127.0.0.1 NONE/200 0 CONNECT www.googletagmanager.com:443 - HIER_DIRECT/142.251.222.40 -
1697204572.256     16 127.0.0.1 TCP_REFRESH_UNMODIFIED/200 5571 GET https://webscraper.io/img/logo_white.svg - HIER_DIRECT/13.33.174.51 image/svg+xml
1697204572.257     18 127.0.0.1 TCP_REFRESH_UNMODIFIED/200 74628 GET https://webscraper.io/js/app.js? - HIER_DIRECT/13.33.174.51 application/javascript
1697204572.286     64 127.0.0.1 TCP_REFRESH_UNMODIFIED/200 36056 GET https://webscraper.io/js/vendor.js? - HIER_DIRECT/13.33.174.51 application/javascript
1697204572.357     45 127.0.0.1 TCP_MISS/200 1517 GET https://fonts.googleapis.com/css2? - HIER_DIRECT/142.250.207.10 text/css
1697204572.357     44 127.0.0.1 TCP_MISS/200 1546 GET https://fonts.googleapis.com/css2? - HIER_DIRECT/142.250.207.10 text/css
1697204572.415    102 127.0.0.1 TCP_MISS/200 89371 GET https://www.googletagmanager.com/gtm.js? - HIER_DIRECT/142.251.222.40 application/javascript
1697204572.564     28 127.0.0.1 NONE/200 0 CONNECT webscraper.io:443 - HIER_DIRECT/13.33.174.51 -
1697204572.578     46 127.0.0.1 NONE/200 0 CONNECT webscraper.io:443 - HIER_DIRECT/13.33.174.51 -
1697204572.600     65 127.0.0.1 NONE/200 0 CONNECT fonts.gstatic.com:443 - HIER_DIRECT/142.251.222.35 -
1697204572.600     28 127.0.0.1 TCP_REFRESH_UNMODIFIED/200 39152 GET https://webscraper.io/fonts/Candara-subset.woff2? - HIER_DIRECT/13.33.174.51 application/octet-stream
1697204572.603     70 127.0.0.1 NONE/200 0 CONNECT fonts.gstatic.com:443 - HIER_DIRECT/142.251.222.35 -
1697204572.627      3 127.0.0.1 TCP_HIT/200 16954 GET https://fonts.gstatic.com/s/roboto/v30/KFOlCnqEu92Fr1MmEU9fBBc4.woff2 - HIER_NONE/- font/woff2
1697204572.627      2 127.0.0.1 TCP_HIT/200 34126 GET https://fonts.gstatic.com/s/montserrat/v26/JTUSjIg1_i6t8kCHKm459Wlhyw.woff2 - HIER_NONE/- font/woff2
1697204572.638     50 127.0.0.1 TCP_REFRESH_UNMODIFIED/200 7563 GET https://webscraper.io/fonts/ws-icons.woff2? - HIER_DIRECT/13.33.174.51 application/octet-stream
1697204572.652     59 127.0.0.1 NONE/200 0 CONNECT googleads.g.doubleclick.net:443 - HIER_DIRECT/142.251.42.130 -
1697204572.664     66 127.0.0.1 NONE/200 0 CONNECT www.google-analytics.com:443 - HIER_DIRECT/216.239.36.178 -
1697204572.698      6 127.0.0.1 TCP_REFRESH_UNMODIFIED/200 21644 GET https://www.google-analytics.com/analytics.js - HIER_DIRECT/216.239.36.178 text/javascript
1697204572.744     53 127.0.0.1 TCP_MISS/200 2266 GET https://googleads.g.doubleclick.net/pagead/viewthroughconversion/653162778/? - HIER_DIRECT/142.251.42.130 text/javascript
1697204572.746    151 127.0.0.1 TCP_MISS/200 95313 GET https://www.googletagmanager.com/gtag/js? - HIER_DIRECT/142.251.222.40 application/javascript
1697204572.829     55 127.0.0.1 NONE/200 0 CONNECT www.google-analytics.com:443 - HIER_DIRECT/216.239.36.178 -
1697204572.883     46 127.0.0.1 TCP_MISS/200 641 POST https://www.google-analytics.com/j/collect? - HIER_DIRECT/216.239.36.178 text/plain
1697204572.911     57 127.0.0.1 NONE/200 0 CONNECT www.google.co.jp:443 - HIER_DIRECT/142.251.42.195 -
1697204572.913     64 127.0.0.1 NONE/200 0 CONNECT analytics.google.com:443 - HIER_DIRECT/142.250.207.14 -
1697204572.922     68 127.0.0.1 NONE/200 0 CONNECT www.google.com:443 - HIER_DIRECT/142.251.42.132 -
1697204572.922     70 127.0.0.1 NONE/200 0 CONNECT www.google.co.jp:443 - HIER_DIRECT/142.251.42.195 -
1697204572.941     92 127.0.0.1 NONE/200 0 CONNECT stats.g.doubleclick.net:443 - HIER_DIRECT/142.251.8.157 -
1697204572.964    722 127.0.0.1 TCP_REFRESH_UNMODIFIED/200 13271 GET https://webscraper.io/images/test-sites/e-commerce/items/cart2.png - HIER_DIRECT/13.33.174.51 image/png
1697204572.980     84 127.0.0.1 NONE/200 0 CONNECT stats.g.doubleclick.net:443 - HIER_DIRECT/142.251.8.157 -
1697204572.986     44 127.0.0.1 TCP_MISS/204 566 POST https://analytics.google.com/g/collect? - HIER_DIRECT/142.250.207.14 text/plain
1697204572.986     47 127.0.0.1 TCP_MISS/200 700 GET https://www.google.co.jp/ads/ga-audiences? - HIER_DIRECT/142.251.42.195 image/gif
1697204572.993     53 127.0.0.1 TCP_MISS/200 763 GET https://www.google.co.jp/pagead/1p-user-list/653162778/? - HIER_DIRECT/142.251.42.195 image/gif
1697204572.998     44 127.0.0.1 TCP_MISS/200 711 POST https://stats.g.doubleclick.net/j/collect? - HIER_DIRECT/142.251.8.157 text/plain
1697204573.004     50 127.0.0.1 TCP_MISS/200 763 GET https://www.google.com/pagead/1p-user-list/653162778/? - HIER_DIRECT/142.251.42.132 image/gif
1697204573.030     40 127.0.0.1 TCP_MISS/204 566 POST https://stats.g.doubleclick.net/g/collect? - HIER_DIRECT/142.251.8.157 text/plain