Docker＋Headless Chromeでスクリーンショットを取る

# 初めに

Headless Chromeを使って綺麗なホームページの画像を取りたかっただけなのに随分と遠回りした話です。

# とりあえず環境から作ろう

```yml:docker-compose.yml
version: '2'
services:
    screenshot_web:
        environment:
            - PHP_APP_HOST=screenshot_app
        build:
            context: ./
            dockerfile: docker/web/Dockerfile
        volumes_from:
            - screenshot_app
        ports:
            - "80:80"
        links:
            - screenshot_app
        environment:
            - FASTCGI_PASS=screenshot_app
        command: /bin/sh -c "envsubst '$$FASTCGI_PASS' < /etc/nginx/conf.d/default.conf.template > /etc/nginx/conf.d/default.conf && nginx -g 'daemon off;'"
        container_name: screenshot_web
    screenshot_app:
        build:
            context: ./
            dockerfile: docker/app/Dockerfile
        expose:
          - "9000"
        volumes:
            - .:/var/www/html
        depends_on:
            - screenshot_chrome
        container_name: screenshot_app
        env_file: .env
    screenshot_chrome:
        build:
            context: ./
            dockerfile: docker/chrome/Dockerfile
        stdin_open: true
        tty: true
        container_name: screenshot_chrome
        expose:
          - "4444"
```

```docker:chrome/Dockerfile
FROM selenium/standalone-chrome
```

screenshot_webとscreenshot_appの設定は割愛します。
ここまで終わったのならとりあえずDockerを起動。
スクリーンショットを取るための中身を作ります。

```json:composer.json
{
    "require": {
        "facebook/webdriver": "^1.6",
        "aws/aws-sdk-php": "^3.70"
    }
}
```

```php:screenshot.php
<?php
require_once '../vendor/autoload.php';

use Aws\S3\S3Client;
use Aws\S3\Exception\S3Exception;
use Aws\S3\Enum\CannedAcl;

use Facebook\WebDriver\Chrome\ChromeOptions;
use Facebook\WebDriver\Remote\DesiredCapabilities;
use Facebook\WebDriver\Remote\RemoteWebDriver;
use Facebook\WebDriver\WebDriverBy;
use Facebook\WebDriver\WebDriverExpectedCondition;
use Facebook\WebDriver\WebDriverDimension;
class Screenshot {
    private $_defaultOrg = [
        '--disable-accelerated-2d-canvas',
        '--disable-accelerated-jpeg-decoding',
        '--disable-gpu',
        '--disable-gpu-sandbox',
        '--disable-impl-side-painting',
        '--disable-setuid-sandbox',
        '--headless',
        '--hide-scrollbars',
        '--no-sandbox',
        '--test-type=ui',
    ];
    public $userAgent       = '';
    public $width           = 0;
    public $height          = 0;
    public $url             = '';
    const GET_HEIGHT_JS     = "return Math.max(document.body.scrollHeight, document.body.offsetHeight, document.documentElement.clientHeight, document.documentElement.scrollHeight, document.documentElement.offsetHeight);";
    const LOCAL_DIR         = "/var/www/html/temp";

    public function getCapture ($url)
    {
        $options    = new ChromeOptions();
        $width      = $this->width;
        $height     = 500;
        // headless
        $options->addArguments(array_merge(
            $this->_defaultOrg, [
                "--window-size={$width},{$height}",
                "--user-agent={$this->userAgent}",
            ]
        ));
        $capabilities = DesiredCapabilities::chrome();
        $capabilities->setCapability(ChromeOptions::CAPABILITY, $options);
        $driver = RemoteWebDriver::create(self::$_myDriver, $capabilities);
        // 指定URLへ遷移 (Google)
        $driver->get($url);
        $contentsHeight = $driver->executeScript(self::GET_HEIGHT_JS);
        $overflowFlg    = $contentsHeight > $height;
        $imgFrame       = imagecreatetruecolor($width, $contentsHeight);
        $scrollHeight   = 0;
        $count          = 0;
        while ($scrollHeight < $contentsHeight) {
             $margin = 0;
             $scrollHeight += $height;
             if ($scrollHeight > $contentsHeight) {
                $margin = $scrollHeight - $contentsHeight;
             }
             $tmpFile = self::LOCAL_DIR."/temp_{$count}.png";
             $driver->takeScreenshot($tmpFile);
             $driver->executeScript("window.scrollBy(0, {$height});");
             $count++;
             $src = imagecreatefrompng($tmpFile);
             // copy
             imagecopy($imgFrame, $src, 0, $scrollHeight - $height - $margin, 0, 0, $width, $height);
        }
        // save
        imagepng($imgFrame, self::LOCAL_DIR."/temp.png");
        $imgFrame = null;
        $driver->quit();
        return self::LOCAL_DIR."/{$tempFileName}.png";
    }
}
```

Chromeはスクリーンショットを今描画してる分だけしか取れない模様。
それで、スクロールさせて、取った画像を一つづつ繋げて、一つの画像にするという荒技です。

よしこれで行ける！
…と思ったら、色んなところで問題が多発し始める。

# slick.jsが埋め込まれてるページでChromeがコケる

必ずというわけでは無いが、感覚的には８割ぐらい。
一体何が起きてるんだ。
とりあえずGETパラメータ埋め込んでパラメータ渡されたらautoplay=falseにするようにする。

```php
        if(empty(parse_url($url, PHP_URL_QUERY))) {
            $url .= "?debug=1";
        } else {
            $url .= "&debug=1";
        }
```
こいつを埋め込んでリトライ。
…あれ？相変わらずだぞ？
もしかして、PHP側で画像データがいっぱいになって動かないとか？
と思い、gcを強制的に動かすようにしてsleepで少し休むよう設定。

```php
        sleep(0.1);
        gc_collect_cycles();
```

うううむ…少しはマシに、なった、かな？
感覚的すぎてわからんけどほぼ同じな気もしてきた。
で、少し調べて見たらコケた時に上手くセッションが切られて無かった。
try catchで処理囲んで、必ずquitでChromeのセッションを切るようにする。

```php
        try {
            /* スクリーンショット処理 */
        } catch (Exception $e) {
            $thrownExcption = $e;
        }
        try {
            $driver->quit();
        } catch (Exception $e) {
            //　もしここでコケたら前のエラーを優先してスローする。
            if(null !== $thrownExcption) {
                throw $thrownExcption;
            } else {
                throw $e;
            }
        }
        if(null !== $thrownExcption) {
            throw $thrownExcption;
        }
```

相変わらず感覚的だけどマシになった気がする！
でもどちらも根本的な解決にならず。

Firefoxとかもあったから試してみたら普通に動いてくれた（こちらのコードは書きません）けど、&lt;input type=checkbox&gt;が全然描画されない＋&lt;video&gt;タグを撮ってくれない（Chromeは撮ってた）から安定はしたけど逆戻り。

結局謎バグと戦うしかない羽目になったけど、
そもそもSelenium辺りは未だに資料が少ない。探しても見つからない途中。
shared memory辺りの話を聞いた。

…えっ、Headless Chromeってデフォルトの共有メモリーが64mbしか無いの？

# 共有メモリーを増やしたい！

とりあえず、手元でやる分には問題なく、やり方も簡単だった。

```yml:docker-compose.yml
    screenshot_chrome:
        build:
            context: ./
            dockerfile: docker/chrome/Dockerfile
        stdin_open: true
        tty: true
        container_name: screenshot_chrome
        expose:
          - "4444"
        shm_size: 1g
```
shm_sizeって追加するだけでおｋ。
早速やってみると…やったコケない！
これで問題解決やん！と思ったのに、どうも今の環境ではこれが使えないらしい。
通常メモリーはバケモノ級に増やしてるのに…
Fargateが一体何だっていうんだってばよ。

# Chromeに共有メモリー使わせないオプションがあった

--disable-dev-shm-usageがそれ。
でも、追加して動かしてみても結局変わらず。
サーバー環境じゃ無いからなのかもと思ってあげてみても駄目。

で、今のところは方法がなかったので、一番安定してたFireFoxでとりあえず動かすことに。
コード自体は変わらないけど、細かいオプションの付け方が別物だった。
そもそもChromeより資料が少ないから公式っぽいドキュメント読んでも関数と引数情報しか入ってない。
そんなんじゃなくて、何のデータ入れれば良いのか説明しやがれ！
と思ったのは数え切れないほどであった。

まあ、どちらにしろ、ほぼ打つ手がなくなった。

# 環境…変えるか ＜今ここ

正確には言語を変える。PHPからNode.jsの方に行く予定だけど、共有メモリー不足が元々の原因だから治る気がしないんだよな…という感じ。
これで駄目だったら本当に環境を変える予定。

Seleniumって元々は表示系のテスト自動化ツールだったはずなのにこんなにコケて良いのか？
私が悪いのだろうか…という気分がしなくもない今の現状でした。

一番怖いのは、これが現在進行中ということさ！