ブラウザをサーバーで動かし、Playwrightと人の両方がブラウザ操作できるようにする

# やりたいこと

- PlaywrightまたはPuppeteerを使って、ブラウザ操作してクローリングしたい。
- そのブラウザは、サーバーやクラウドで動かしたい。
- 本記事では、ブラウザはサーバー／クラウドで、Puppeteer／Playwrightはローカルで動かす構成を目指します。
- サーバーで動くブラウザを、クライアントでも人が見て操作したい（reCAPTCHA突破等）。

## 構成図

試行錯誤の結果、最終的な構成は以下の図のようになりました。
おおまかなイメージ図です。等幅フォントで見てね。

┏━━━━━━━━━━━━━━━━━━━━━━┓　　　　　┏━━━━━━━━┓
┃　　サーバー／クラウドのDockerコンテナ　　　┃　　　　　┃　クライアント　┃
┃　　　　　　　　　　　　　　　　　　　┏━━┻━━━┓　┃　　　　　　　　┃
┃┌──────┐　　┌────┐PORT　┃　　　　　　┃ PORT ┌─────┐┃
┃│Xvfb　　　　│←─┤Vnc 　　│←──╂──────╂─╂─┤　Vnc 　　│┃
┃│ 仮想Display├─→│　Server├───╂──────╂─╂→│　Viewer　│┃
┃└───┬──┘　　└────┘5900　┃　　　　　　┃ 5900 └─────┘┃
┃　　　↑│　　　　　　　　　　　　　　┃　　SSH 　　┃　┃　　　　　　　　┃
┃　　　│↓　　　　　　　　　　　　　　┃Portforward ┃　┃　　　　　　　　┃
┃┌──┴───┐PORT┌────┐PORT　┃　　　　　　┃ PORT ┌─────┐┃
┃│　Google　　│←─┤Reverse │←──╂──────╂─╂─┤Playwright│┃
┃│　　Chrome　├─→│ Proxy　├───╂──────╂─╂→│/Puppeteer│┃
┃└──────┘9222└────┘19222 ┃　　　　　　┃ 9222 └─────┘┃
┃　　　(Remote Debugging Port) 　　　　┗━━┳━━━┛　┃　　　　　　　　┃
┃　　　　　　　　　　　　　　　　　　　　　　┃　　　　　┃　　　　　　　　┃
┗━━━━━━━━━━━━━━━━━━━━━━┛　　　　　┗━━━━━━━━┛

ただし、 本記事ではDockerコンテナもローカルで動かすにとどめています。

# Dockerfile作成

## Step 1：Google Chromeをインストール

[このページ](https://github.com/puppeteer/puppeteer/blob/main/docs/troubleshooting.md)のDockerfileをアレンジしました（感謝）。
変更点として、DockerでPuppeteerは動かしませんので、nodeも入れないようにしました。

作成中のDockerfile
```Dockerfile

FROM debian:bullseye

RUN apt-get update \
    && apt-get install -y wget gnupg \
    && wget -q -O - https://dl-ssl.google.com/linux/linux_signing_key.pub | apt-key add - \
    && sh -c 'echo "deb [arch=amd64] http://dl.google.com/linux/chrome/deb/ stable main" >> /etc/apt/sources.list.d/google.list' \
    && apt-get update \
    && apt-get install -y google-chrome-stable fonts-ipafont-gothic fonts-wqy-zenhei fonts-thai-tlwg fonts-kacst fonts-freefont-ttf libxss1 \
      --no-install-recommends \
    && rm -rf /var/lib/apt/lists/*
```
## Step 2：Xvfb（仮想Display）とVNCサーバーをインストール

Google Chromeは--headlessオプションをつけずに起動し、Xvfb（仮想Display）に表示させることにします。
そのDisplay画面をクライアントで表示・操作したいので、リモートデスクトップ接続用にVNCサーバーもインストールします。

Dockerfileに以下のapt-get installを加えます。

```Dockerfile

    && apt-get install -y xauth xvfb x11vnc x11-apps \
```

## Step 3：SSH Serverのインストールと設定

セキュリティーの理由により、SSHポートフォワーディングを使用します。
- 構成図の5900ポートと9222ポートが、SSHログインしたユーザーのみに開放されます。
- ポートを通じた通信が暗号化されます。

Dockerfileに以下のapt-get installを加えます。

```Dockerfile

    && apt-get install -y openssh-server \
```

また、DockerfileでSSHの設定も行います。

```Dockerfile

RUN mkdir /root/.ssh \
    && chmod 700 /root/.ssh
COPY ./id_ed25519.pub /root/.ssh/authorized_keys
RUN chmod 600 /root/.ssh/authorized_keys
COPY ./sshd_config /etc/ssh/
```

公開鍵（本記事では id_ed25519.pub）と設定ファイル（sshd_config）は事前に用意しておきます。

sshd_configは以下を設定します。
- 公開鍵認証を許可する：PubkeyAuthentication yes
- パスワード認証を禁止する：PasswordAuthentication no
- SSHポートをデフォルトから変える：Port 2222
- （本記事では設定しませんが必要に応じて）GatewayPorts clientspecified 

参考までに、以下に sshd_config 全体を載せます。

```sshd_config

#       $OpenBSD: sshd_config,v 1.103 2018/04/09 20:41:22 tj Exp $

# This is the sshd server system-wide configuration file.  See
# sshd_config(5) for more information.

# This sshd was compiled with PATH=/usr/bin:/bin:/usr/sbin:/sbin

# The strategy used for options in the default sshd_config shipped with
# OpenSSH is to specify options with their default value where
# possible, but leave them commented.  Uncommented options override the
# default value.

Include /etc/ssh/sshd_config.d/*.conf

Port 2222
#AddressFamily any
#ListenAddress 0.0.0.0
#ListenAddress ::

#HostKey /etc/ssh/ssh_host_rsa_key
#HostKey /etc/ssh/ssh_host_ecdsa_key
#HostKey /etc/ssh/ssh_host_ed25519_key

# Ciphers and keying
#RekeyLimit default none

# Logging
#SyslogFacility AUTH
#LogLevel INFO

# Authentication:

#LoginGraceTime 2m
#PermitRootLogin prohibit-password
#StrictModes yes
#MaxAuthTries 6
#MaxSessions 10

PubkeyAuthentication yes

# Expect .ssh/authorized_keys2 to be disregarded by default in future.
#AuthorizedKeysFile     .ssh/authorized_keys .ssh/authorized_keys2

#AuthorizedPrincipalsFile none

#AuthorizedKeysCommand none
#AuthorizedKeysCommandUser nobody

# For this to work you will also need host keys in /etc/ssh/ssh_known_hosts
#HostbasedAuthentication no
# Change to yes if you don't trust ~/.ssh/known_hosts for
# HostbasedAuthentication
#IgnoreUserKnownHosts no
# Don't read the user's ~/.rhosts and ~/.shosts files
#IgnoreRhosts yes

# To disable tunneled clear text passwords, change to no here!
PasswordAuthentication no
#PermitEmptyPasswords no

# Change to yes to enable challenge-response passwords (beware issues with
# some PAM modules and threads)
ChallengeResponseAuthentication no

# Kerberos options
#KerberosAuthentication no
#KerberosOrLocalPasswd yes
#KerberosTicketCleanup yes
#KerberosGetAFSToken no

# GSSAPI options
#GSSAPIAuthentication no
#GSSAPICleanupCredentials yes
#GSSAPIStrictAcceptorCheck yes
#GSSAPIKeyExchange no

# Set this to 'yes' to enable PAM authentication, account processing,
# and session processing. If this is enabled, PAM authentication will
# be allowed through the ChallengeResponseAuthentication and
# PasswordAuthentication.  Depending on your PAM configuration,
# PAM authentication via ChallengeResponseAuthentication may bypass
# the setting of "PermitRootLogin without-password".
# If you just want the PAM account and session checks to run without
# PAM authentication, then enable this but set PasswordAuthentication
# and ChallengeResponseAuthentication to 'no'.
UsePAM yes

#AllowAgentForwarding yes
#AllowTcpForwarding yes
GatewayPorts no
X11Forwarding yes
#X11DisplayOffset 10
#X11UseLocalhost yes
#PermitTTY yes
PrintMotd no
#PrintLastLog yes
#TCPKeepAlive yes
#PermitUserEnvironment no
#Compression delayed
#ClientAliveInterval 0
#ClientAliveCountMax 3
#UseDNS no
#PidFile /var/run/sshd.pid
#MaxStartups 10:30:100
#PermitTunnel no
#ChrootDirectory none
#VersionAddendum none

# no default banner path
#Banner none

# Allow client to pass locale environment variables
AcceptEnv LANG LC_*

# override default of no subsystems
Subsystem       sftp    /usr/lib/openssh/sftp-server

# Example of overriding settings on a per-user basis
#Match User anoncvs
#       X11Forwarding no
#       AllowTcpForwarding no
#       PermitTTY no
#       ForceCommand cvs server
```

## Step 4：Reverse Proxyの設定

Google Chromeに--remote-debugging-port=9222オプションをつけてリモートデバッグ有効で起動し、9222ポートのエンドポイントに疎通させたいのですが、ここで問題が発生しました。
- Chromeに--headlessオプションをつけて起動すると、Dockerコンテナの外からも疎通する
- Chromeに--headlessオプションをつけずに起動すると、Dockerコンテナの外から疎通しない

セキュリティーの理由から、--headlessをつけないと外からの疎通を許さないようです。
本記事では、セキュリティーはSSHポートフォワーディングで担保されているとして、--headlessオプションをつけずに起動しても疎通するようにします。

そのために、構成図のようにリバースプロキシを導入します。
リバースプロキシのソースコードは以下の通りです（Go言語）。
- 参考サイト（感謝します）： https://nnt339es.hatenablog.com/entry/2021/11/16/070340

```reverse_proxy.go

package main

import (
	"log"
	"net/http"
	"net/http/httputil"
	"net/url"
)

func main() {
	loc := "/"
	proxyPass := "http://localhost:9222"

	url, err := url.Parse(proxyPass)
	if err != nil {
		log.Fatal(err)
	}
	http.Handle(loc, httputil.NewSingleHostReverseProxy(url))
	http.ListenAndServe(":19222", nil)
}
```

そしてDockerfileの最初の方に以下を加えて、reverse_proxyを導入します。

```Dockerfile

FROM golang:bullseye as go-build
WORKDIR /app
COPY ./reverse_proxy.go ./
RUN go build -o reverse_proxy reverse_proxy.go

FROM debian:bullseye
WORKDIR /app

# リバースプロキシ設定
COPY --from=go-build /app/reverse_proxy /app/
```

## 最終的なDockerfile

まずDockerfile以外に、事前に以下のファイルを用意しました。
- id_ed25519.pub（前述）
- sshd_config（前述）
- reverse_proxy.go（前述）
- cmd.sh

cmd.shの内容は以下の通りです。

```cmd.sh

#!/bin/bash
set -eo pipefail

echo "service start."
service ssh start
service dbus start

echo "exec reverse_proxy."
exec /app/reverse_proxy &

echo "exec xvfb-run."
exec xvfb-run \
    --auth-file /tmp/xvfb-run \
    --server-args=":99 -screen 0 1024x768x24" \
    google-chrome \
    --no-sandbox \
    --disable-dev-shm-usage \
    --disable-gpu \
    --no-first-run \
    --no-default-browser-check \
    --remote-debugging-address=0.0.0.0 \
    --remote-debugging-port=9222 \
    about:blank &

echo "exec x11vnc."
exec x11vnc -display :99 -auth /tmp/xvfb-run -forever &

# Exit immediately when one of the background processes terminate.
echo "wait."
wait -n
```

そして最終的なDockerfileは以下の通りです。

```Dockerfile

FROM golang:bullseye as go-build
WORKDIR /app
COPY ./reverse_proxy.go ./
RUN go build -o reverse_proxy reverse_proxy.go

FROM debian:bullseye
WORKDIR /app

# リバースプロキシ設定
COPY --from=go-build /app/reverse_proxy /app/

# Google Chrome、SSH Server、Xvfb、VNCのインストール
RUN apt-get update \
    && apt-get install -y wget gnupg \
    && wget -q -O - https://dl-ssl.google.com/linux/linux_signing_key.pub | apt-key add - \
    && sh -c 'echo "deb [arch=amd64] http://dl.google.com/linux/chrome/deb/ stable main" >> /etc/apt/sources.list.d/google.list' \
    && apt-get update \
    && apt-get install -y google-chrome-stable fonts-ipafont-gothic fonts-wqy-zenhei fonts-thai-tlwg fonts-kacst fonts-freefont-ttf libxss1 \
      --no-install-recommends \
    && apt-get install -y openssh-server \
    && apt-get install -y xauth xvfb x11vnc x11-apps \
    && rm -rf /var/lib/apt/lists/*

# SSH設定
RUN mkdir /root/.ssh \
    && chmod 700 /root/.ssh
COPY ./id_ed25519.pub /root/.ssh/authorized_keys
RUN chmod 600 /root/.ssh/authorized_keys
COPY ./sshd_config /etc/ssh/

# コマンド起動
COPY ./cmd.sh ./
RUN chmod +x ./cmd.sh
CMD ["/app/cmd.sh"]
```

# 動かしてみる

docker buildします。

```sh

docker build -t chrome-linux .
```

docker runします。

```sh

docker run --publish 2222:2222 chrome-linux
```

SSHポートフォワーディングを有効にするオプションをつけて、sshでdockerコンテナにログインします。

```sh

ssh -o ExitOnForwardFailure=yes -p 2222 \
    -L 9222:localhost:19222 -L 5900:localhost:5900 root@localhost
```

9222ポートへの疎通を確認します。

```sh

curl -I http://127.0.0.1:9222/json/list
```

以下のように200が返ればOKです。

```

HTTP/1.1 200 OK
Content-Length: 5
Content-Security-Policy: frame-ancestors 'none'
Content-Type: application/json; charset=UTF-8
Date: Sun, 19 Feb 2023 13:57:31 GMT
```

VNC Viewerを起動して、Dockerコンテナへのリモートデスクトップ接続を試みます。
本記事では TightVNC Viewerを使用します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/327312/3baf97cc-9817-72a1-ded9-b6d2f0cb9369.png)

以下のように繋がりました。
リモートデスクトップ接続ですので、人がGoogle Chromeを操作できます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/327312/80505bc6-fa5c-2918-1532-7b6c7130e2a5.png)

このブラウザをPlaywrightから操作する場合は、launch()の代わりにconnectOverCDP()を呼びます。

```ts

const browser = await playwright.chromium.connectOverCDP("http://localhost:9222");
const context = browser.contexts()[0];
const page = await context.newPage();
```

このブラウザをPuppeteerから操作する場合は、launch()の代わりにconnect()を呼びます。

```ts

const browser = await puppeteer.connect({
    browserURL: "http://localhost:9222",
});
const page = await browser.newPage();
```