webスクレイピング inコンテナ
定期的にwebスクレイピングを実施してほしかったため、Python+Selenium構成のアプリケーションを作成しコンテナ環境で起動させることにした。FireFoxのaddonも操作したかったが、Seleniumの機能では難しかったため諦めAddonの機能はnodeで動かす構成に変えた。
概要
とにかく初物なので最小限な構成で動作させることを目指した。動作させる環境はファイルサーバとして使っているUbuntuとした。何らかのCloudサービスでも良かったが、リソースを持て余しているため今後コンテナアプリケーションをもろもろ動かしてやろう野望も持ったためそれにした。また、ざっくりと対応方針は下記。
- ホスト
- local Ubuntuサーバ
- ログ
- 保存せず、標準出力に出して捨てる
- リリース方法
- 手動
- CI/CD
- 無し
リリースやCI/CDはgithub actionでbuildしコンテナimageを共有するとかやってみたかったが、何らかの方法でlocalUbuntuサーバへ通信を投げるかサーバ側が定期的にポーリングする構成を取る必要があるため一旦諦めた。そもそもlocal Ubuntuサーバを公開することは避けたいためリクエストを受けなくリリース可能な方法、、、結局全くスマートでは無いがサーバ上でgit cloneで対象コードを落としてdocker buildでイメージ作成、cronにdocker runを定期的に叩かせる対応とした。
環境構築
開発環境
途中までVsCodeのdevcontainerで実装していたが、流石にGUI無いとスクレイピングがどこでミスったか分かりづらすぎたため、途中からコンテナは使用せずローカルにPythonを入れて開発した。
# Python導入
% brew install pyenv
% pyenv install 3.14.0
% pyenv global 3.14.0
# 仮想環境立ち上げ
% python -m venv venv
% source venv/bin/activate
Docker導入
公式のマニュアル通りにインストールした。 念の為再起動後も立ち上がる設定になっていることだけ確認。
$ sudo systemctl is-enabled docker
enabled
このままではsudo権限が無いとdockerを起動できないため、使用ユーザをdocker groupにも所属させる。これも公式マニュアル通りに設定した。
# 権限が無いためsudo無しではbuildに失敗
$ docker build -t my-app -f etc/docker/Dockerfile .
ERROR: permission denied while trying to connect to the docker API at unix:///var/run/docker.sock
Slack
適当にアカウントを登録し、Slack AppでIncomming Webhookを設定すればweb hook用のURLをゲットできる。
コード
Dockerfile
FROM python:3.14-slim
# Geckodriver version can be overridden at build time
ARG GECKODRIVER_VERSION=0.36.0
ARG NODE_VERSION=20
# Set locale to suppress warnings
ENV LANG=C.UTF-8 \
LC_ALL=C.UTF-8
# Install dependencies
RUN apt update && apt install -y \
firefox-esr \
wget \
curl \
git \
jq \
&& rm -rf /var/lib/apt/lists/*
# Install Node.js
RUN curl -fsSL https://deb.nodesource.com/setup_${NODE_VERSION}.x | bash - && \
apt-get install -y nodejs && \
rm -rf /var/lib/apt/lists/*
# Install geckodriver
RUN ARCH=$(uname -m) && \
if [ "$ARCH" = "x86_64" ]; then \
GECKODRIVER_ARCH="linux64"; \
elif [ "$ARCH" = "aarch64" ]; then \
GECKODRIVER_ARCH="linux-aarch64"; \
else \
echo "Unsupported architecture: $ARCH" && exit 1; \
fi && \
wget https://github.com/mozilla/geckodriver/releases/download/v${GECKODRIVER_VERSION}/geckodriver-v${GECKODRIVER_VERSION}-${GECKODRIVER_ARCH}.tar.gz && \
tar -xvzf geckodriver-v${GECKODRIVER_VERSION}-${GECKODRIVER_ARCH}.tar.gz -C /usr/local/bin/ && \
chmod +x /usr/local/bin/geckodriver && \
rm geckodriver-v${GECKODRIVER_VERSION}-${GECKODRIVER_ARCH}.tar.gz
WORKDIR /app
# Create result directory
RUN mkdir -p /app/result
# Copy project files
COPY src/ ./src/
COPY scripts/ ./scripts/
COPY package.json ./
# Install Python dependencies
RUN pip3 install --upgrade -r src/requirements.txt
# Make scripts executable
RUN chmod +x scripts/*.sh
# Volume mount point for results
VOLUME ["/app/result"]
CMD [ "./scripts/main.sh" ]
やりたかったことがPythonで完結できなかったため、Pythonに中間成果物を出力させ後続のnodeが読み込み最終的な処理行う構成にした。Pythonとnode別のコンテナにすることもできたが、コストが高いためPythonコンテナにnodeを入れることにした。また、それぞれのスクリプトをシェルスクリプトが順に呼び出す構成とした。
FirefoxをSeleniumでコントロールするために別途geckodriverが必要である。geckodriverは各アーキテクチャ(x86_64など)を提供しているため、適切なを選択する必要がある。どのアーキテクチャを使用しているか判断をaptに任せられれば楽なのだが、提供されていなかったためやむなくif文で分岐させた。
Pythonスクリプト
Seleniumの公式サイト通りに実装すれば特に問題なくFirefoxもaddonも導入できた。ただ結局addonをSeleniumでコントロールできなかったためaddon部分は削除した。そのためもはやFirefoxである意味も無くった。
import json
import time
import re
import sys
import subprocess
from datetime import datetime, timedelta
from pathlib import Path
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.common.keys import Keys
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
if __name__ == "__main__":
geckodriver_path = '/usr/local/bin/geckodriver'
service = webdriver.FirefoxService(log_output=subprocess.STDOUT, executable_path=geckodriver_path)
options = webdriver.FirefoxOptions()
options.add_argument('--headless') # ヘッドレスモード(GUI不要)
options.set_preference('intl.accept_languages', 'ja-JP, ja, en-US, en') # 日本語を優先
driver = webdriver.Firefox(service=service, options=options)
driver.get("https://target.uri/")
driver.implicitly_wait(5) # ページが完全に読み込まれるまで待機
# いろいろ操作(省略)
Nodeスクリプト
addonをうまいこと動かせるようにAI先生にお願いして作ってもらった。全く読めなかったため割愛。
監視
成功/失敗の通知をslackに投げ、定期的に人間がチェックする方法で監視することにした。docker runした結果によって通知内容を変える必要があるためラッパースクリプトを作成した。AI先生に要件だけ伝えて作ってもらったら、指示してない実行時間とかも考慮してくれたスクリプトを作ってくれた。素敵っ。ただSlackに通知成功/失敗したことをSlackに通知するみたいなことをやりだしたので、いらないものはもろもろ削ってもらった。頑張ってエラーコードで通知内容を変えてくれてるが、ログだけ貼り付けて送ってくれれば良い気がしている。とりあえず現状のスクリプトで成功・失敗はわかるためコレで良しとした。
#!/bin/bash
# Webスクレイピング - Cron実行用スクリプト
#
# Dockerコンテナを実行し、結果をSlackへ通知します
#
# 使い方:
# 1. SLACK_WEBHOOK_URLを環境変数に設定するか、このスクリプト内で直接設定
# 2. crontabに登録:
# 0 3 * * 0 /path/to/run_with_notification.sh
set -u # 未定義変数の使用時にエラー
# ==================== 設定 ====================
# Slack Webhook URL(環境変数または直接設定)
SLACK_WEBHOOK_URL="${SLACK_WEBHOOK_URL:-https://hooks.slack.com/services/YOUR/WEBHOOK/URL}"
# ダウンロード先ディレクトリ
DOWNLOAD_DIR="${DOWNLOAD_DIR:-/tmp}"
# Dockerイメージ名
DOCKER_IMAGE="my-app"
# タイムアウト(秒)- 長時間応答がない場合に強制終了
TIMEOUT=3600 # 1時間
# ============================================
# コンテナ実行開始時刻を記録
START_TIME=$(date +%s)
# Dockerコンテナを実行(タイムアウト付き)
timeout ${TIMEOUT} docker run --rm -v "${DOWNLOAD_DIR}:/app/result" "${DOCKER_IMAGE}" > /dev/null 2>&1
EXIT_CODE=$?
# 実行時間を計算
END_TIME=$(date +%s)
DURATION=$((END_TIME - START_TIME))
DURATION_MIN=$((DURATION / 60))
DURATION_SEC=$((DURATION % 60))
# 終了コードをチェック
if [ ${EXIT_CODE} -eq 0 ]; then
# 成功時のSlack通知
SLACK_MESSAGE=$(cat <<EOF
{
"text": "✅ Completed Successfully",
"blocks": [
{
"type": "header",
"text": {
"type": "plain_text",
"text": "✅ Success"
}
},
{
"type": "section",
"fields": [
{
"type": "mrkdwn",
"text": "*Status:*\nCompleted"
},
{
"type": "mrkdwn",
"text": "*Duration:*\n${DURATION_MIN}m ${DURATION_SEC}s"
},
{
"type": "mrkdwn",
"text": "*Timestamp:*\n$(date '+%Y-%m-%d %H:%M:%S')"
},
{
"type": "mrkdwn",
"text": "*Host:*\n$(hostname)"
}
]
}
]
}
EOF
)
curl -s -X POST \
-H 'Content-type: application/json' \
--data "${SLACK_MESSAGE}" \
"${SLACK_WEBHOOK_URL}" > /dev/null 2>&1
exit 0
else
# エラーメッセージを構築
ERROR_MESSAGE="Failed"
if [ ${EXIT_CODE} -eq 124 ]; then
# timeoutコマンドのタイムアウトコード
ERROR_MESSAGE="${ERROR_MESSAGE} (Timeout after ${TIMEOUT}s)"
elif [ ${EXIT_CODE} -eq 125 ]; then
ERROR_MESSAGE="${ERROR_MESSAGE} (Docker command error)"
elif [ ${EXIT_CODE} -eq 126 ]; then
ERROR_MESSAGE="${ERROR_MESSAGE} (Command cannot be executed)"
elif [ ${EXIT_CODE} -eq 127 ]; then
ERROR_MESSAGE="${ERROR_MESSAGE} (Command not found)"
else
ERROR_MESSAGE="${ERROR_MESSAGE} (Exit code: ${EXIT_CODE})"
fi
# 失敗時のSlack通知
SLACK_MESSAGE=$(cat <<EOF
{
"text": "🚨 ${ERROR_MESSAGE}",
"blocks": [
{
"type": "header",
"text": {
"type": "plain_text",
"text": "🚨 Failed"
}
},
{
"type": "section",
"fields": [
{
"type": "mrkdwn",
"text": "*Exit Code:*\n${EXIT_CODE}"
},
{
"type": "mrkdwn",
"text": "*Duration:*\n${DURATION_MIN}m ${DURATION_SEC}s"
},
{
"type": "mrkdwn",
"text": "*Timestamp:*\n$(date '+%Y-%m-%d %H:%M:%S')"
},
{
"type": "mrkdwn",
"text": "*Host:*\n$(hostname)"
}
]
}
]
}
EOF
)
# Slackに送信
curl -s -X POST \
-H 'Content-type: application/json' \
--data "${SLACK_MESSAGE}" \
"${SLACK_WEBHOOK_URL}" > /dev/null 2>&1
exit ${EXIT_CODE}
fi