NeeNetです。
今回はAWS環境でECS (Fargate)を用いて、Seleniumによるスクレイピングを定期実行してみたいと思います。
AWS環境で定期実行するため、スクレイピングをするために自分のPCを立ち上げて手動実行する必要がなくなります。
また、今回作成するサーバレスなアーキテクチャではスクレイピングを行う時のみECSを起動し、スクレイピングが終了するとECSを終了させるため、EC2でサーバーを常時起動した上でスケジュール実行によりスクレイピングを行うより料金が断然安くコスト面に関しても経済的です。
アーキテクチャ
今回作成するアーキテクチャ図は以下の通りです。
概要説明
今回はStep Functionsを利用し、起動タイプはFargateでECSタスクを実行します。
SeleniumやPythonスクリプトなどはローカルでイメージ化を行ってECRにPushし、ECSタスク実行時にそのイメージを取得するようにします。
EventBridgeスケジューラを用いて、指定時間に上記で記載したStep Functionsを起動するように設定します。
なお、今回デプロイには AWS SAM (Serverless Application Model) を利用します。
ファイル準備
ディレクトリ構成
今回作成するディレクトリ構成は以下の通りです。
.
├── Dockerfile
├── samconfig.toml
├── src
│ └── scrape.py
└── template.yaml
以下に、それぞれのファイルの記載内容を記述します。
Dockerfile
Dockerfileは以下の通りです。
FROM --platform=linux/amd64 python:3.12-slim-bookworm
# 必要なパッケージをインストール
RUN apt-get update && apt-get install -y \
wget \
gnupg \
unzip \
ca-certificates \
chromium \
chromium-driver \
locales \
&& rm -rf /var/lib/apt/lists/*
# Chromium と Chromium Driver のバージョンを確認
RUN chromium --version && chromedriver --version
# 言語の設定、日本語フォントのインストール
RUN locale-gen ja-JP.UTF-8
RUN apt-get update && apt-get install -y fonts-noto-cjk
# selenium のインストール
RUN pip install selenium
# プログラムファイルのコピー
COPY ./src /src
# ワーキングディレクトリの変更
WORKDIR /src
# コンテナ実行時のデフォルトコマンド
CMD ["python", "test.py"]
ベースイメージは python:3.12-slim-bookworm
を利用しています。
基本的にやっていることはコメントで記載している通りとなります。
ページのスクショを撮ったりPDF化をしたりすることも見据え、言語の設定と日本語フォントのインストールもしています。
M系チップ(Apple sillicon)を搭載したMac端末では、x86-64のchromiumをデプロイできません。
今回ECS Fargateはx86-64で実行するので、Arm以外のアーキテクチャの端末でデプロイ頂く必要があります。
(どうしてもARM64端末でデプロイしたい方は、コンテナ実行時のデフォルトコマンドを変更してECSの起動の都度chromiumをダウンロードするよう変更することでデプロイすることもできます)
samconfig.toml
冒頭に記載したように、今回はAWS SAMを用いてデプロイを行います。
AWS SAMの設定ファイルである samconfig.toml
は以下のように記載します。
version = 0.1
[default]
region = "ap-northeast-1"
[default.build.parameters]
debug = true
[default.deploy.parameters]
stack_name = "<YOUR STACK NAME>"
s3_bucket = "<YOUR BUCKET NAME>"
s3_prefix = "sam-deploy"
capabilities = "CAPABILITY_NAMED_IAM"
confirm_changeset = true
スタック名とデプロイ用のバケット名については、ご自身の環境に合わせて任意に設定して下さい。
今回AWS SAMでIAMロールも作成するため、 capabilities = "CAPABILITY_NAMED_IAM"
も指定しています。
scrape.py
今回実際にスクレイピングを行うスクリプトは、検証用に以下とします。
from tempfile import mkdtemp
from selenium import webdriver
# ブラウザを起動
options = webdriver.ChromeOptions()
options.add_argument("--headless=new")
options.add_argument("--no-sandbox")
options.add_argument("--disable-gpu")
options.add_argument("--window-size=1920,1080"")
options.add_argument("--single-process")
options.add_argument("--disable-dev-shm-usage")
options.add_argument("--disable-dev-tools")
options.add_argument("--no-zygote")
options.add_argument(f"--user-data-dir={mkdtemp()}")
options.add_argument(f"--data-path={mkdtemp()}")
options.add_argument(f"--disk-cache-dir={mkdtemp()}")
options.add_argument("--lang=ja")
service = webdriver.ChromeService("/usr/bin/chromedriver")
driver = webdriver.Chrome(options=options, service=service)
# python.org にアクセス
driver.get("https://www.python.org/")
# titleを取得・表示
title = driver.title
print(title)
# ブラウザを終了
driver.quit()
今回はテスト実行のため、 https://www.python.org/
にアクセスしてタイトルの内容を取得するのみのスクレイピングにしています。
webdriverのオプションで指定している内容の説明と利用ケースは以下の通りです。
オプション | 説明 | 利用ケース |
---|---|---|
--headless=new | ブラウザをヘッドレスモードで実行。GUIなしでブラウザを動作させる。 | CI/CDパイプライン、サーバー環境でのテスト実行、バックグラウンドでのウェブスクレイピング等。 |
--no-sandbox | Chromeのサンドボックス機能を無効にする。 | Dockerコンテナ内やroot権限のない環境で実行する場合。ただし、セキュリティリスクが増加するため注意が必要。 |
--disable-gpu | GPUハードウェアアクセラレーションを無効にする。 | GPUが利用できない環境や、GPUに関連する問題が発生する場合。特にヘッドレスモードで使用。 |
--window-size=1920,1080" | ブラウザウィンドウのサイズを設定。 | レスポンシブデザインのテスト、特定の画面サイズでのスクリーンショット取得する場合など。 |
--single-process | Chromeを単一のプロセスで実行。 | リソースが制限された環境で実行する場合に利用。ただし、安定性が低下する可能性がある。 |
--disable-dev-shm-usage | /dev/shmの使用を無効にし、/tmpを使用。 | Dockerコンテナなど、/dev/shmのサイズが小さい環境で利用。 |
--disable-dev-tools | 開発者ツールを無効にする。 | パフォーマンス向上が必要な場合や、開発者ツールへのアクセスを制限したい場合に利用。 |
--no-zygote | Zygoteプロセスの使用を無効にする。 | ヘッドレスChromiumのゾンビプロセスを発生させないために設定。 |
--user-data-dir={mkdtemp()} | ユーザーデータディレクトリを一時ディレクトリに設定。 | 各セッションで独立した環境が必要な場合や、テスト間の干渉を避けたい場合。 |
--data-path={mkdtemp()} | データパスを一時ディレクトリに設定。 | 上記と同様、独立したデータ環境が必要な場合。 |
--disk-cache-dir={mkdtemp()} | ディスクキャッシュディレクトリを一時ディレクトリに設定。 | キャッシュの分離が必要な場合や、クリーンな状態でのテストが必要な場合。 |
--lang=ja | ブラウザの言語を日本語に設定。 | 特定の言語環境でのテストや、ローカライゼーションのチェックが必要な場合。 |
template.yaml
AWS SAMのテンプレートを記載した template.yaml
は以下の通りです。
AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: "Python SAM Application"
Resources:
# VPC
AppVpc:
Type: "AWS::EC2::VPC"
Properties:
CidrBlock: "10.0.0.0/16"
EnableDnsHostnames: true
EnableDnsSupport: true
InstanceTenancy: default
Tags:
- Key: "Name"
Value: "python-app-vpc"
# Public Subnet
AppPublicSubnet01:
Type: "AWS::EC2::Subnet"
Properties:
VpcId: !Ref AppVpc
CidrBlock: "10.0.0.0/24"
AvailabilityZone: ap-northeast-1a
MapPublicIpOnLaunch: true
Tags:
- Key: "Name"
Value: "python-app-public-subnet-a"
# InternetGateway Create
AppInternetGateway:
Type: "AWS::EC2::InternetGateway"
Properties:
Tags:
- Key: Name
Value: !Sub "python-app-igw"
# IGW Attach
AppInternetGatewayAttachment:
Type: "AWS::EC2::VPCGatewayAttachment"
Properties:
InternetGatewayId: !Ref AppInternetGateway
VpcId: !Ref AppVpc
# Public RouteTable
AppPublicRouteTable01:
Type: "AWS::EC2::RouteTable"
Properties:
VpcId: !Ref AppVpc
Tags:
- Key: Name
Value: !Sub "python-app-public-route-a"
# Routing
AppPublicRoute01:
Type: "AWS::EC2::Route"
Properties:
RouteTableId: !Ref AppPublicRouteTable01
DestinationCidrBlock: "0.0.0.0/0"
GatewayId: !Ref AppInternetGateway
# RouteTable Associate
AppPublicSubnet01RouteTableAssociation:
Type: "AWS::EC2::SubnetRouteTableAssociation"
Properties:
SubnetId: !Ref AppPublicSubnet01
RouteTableId: !Ref AppPublicRouteTable01
# ECR
AppEcr:
Type: "AWS::ECR::Repository"
Properties:
RepositoryName: "python-app-ecr"
# ECS Cluster
AppEcsCluster:
Type: "AWS::ECS::Cluster"
Properties:
ClusterName: "python-app-ecs-cls"
# ECS LogGroup
AppEcsLogGroup:
Type: "AWS::Logs::LogGroup"
Properties:
LogGroupName: "/ecs/logs/python-app-ecs-lg"
# ECS TaskDefinition
AppEcsTaskDefinition:
Type: "AWS::ECS::TaskDefinition"
Properties:
Cpu: 512
ExecutionRoleArn: !Ref AppEcsTaskExecutionRole
TaskRoleArn: !Ref AppEcsTaskRole
Family: "python-app-ecs-task"
Memory: 2048
NetworkMode: awsvpc
RequiresCompatibilities:
- FARGATE
ContainerDefinitions:
- Name: "python-app-container"
Image: !GetAtt AppEcr.RepositoryUri
LogConfiguration:
LogDriver: awslogs
Options:
awslogs-group: !Ref AppEcsLogGroup
awslogs-region: !Ref "AWS::Region"
awslogs-stream-prefix: "ecs"
MemoryReservation: 1024
# IAM Role for ECS
AppEcsTaskExecutionRole:
Type: "AWS::IAM::Role"
Properties:
RoleName: "python-app-ecs-task-exec-role"
AssumeRolePolicyDocument:
Version: '2012-10-17'
Statement:
- Effect: Allow
Principal:
Service: ecs-tasks.amazonaws.com
Action: sts:AssumeRole
Path: "/"
Policies:
- PolicyName: ECSTaskExecutionPolicy
PolicyDocument:
Version: '2012-10-17'
Statement:
- Effect: Allow
Action:
- "logs:CreateLogStream"
- "logs:PutLogEvents"
- "logs:CreateLogGroup"
- "ecr:GetAuthorizationToken"
- "ecr:BatchCheckLayerAvailability"
- "ecr:GetDownloadUrlForLayer"
- "ecr:BatchGetImage"
Resource: '*'
AppEcsTaskRole:
Type: "AWS::IAM::Role"
Properties:
RoleName: "python-app-ecs-task-role"
AssumeRolePolicyDocument:
Version: '2012-10-17'
Statement:
- Effect: Allow
Principal:
Service: ecs-tasks.amazonaws.com
Action: sts:AssumeRole
ManagedPolicyArns:
- "arn:aws:iam::aws:policy/AmazonS3FullAccess"
Path: "/"
# Security Group
AppEcsSecurityGroup:
Type: AWS::EC2::SecurityGroup
Properties:
GroupName: "python-app-ecs-sg"
GroupDescription: Security Group for ECS Task
VpcId: !Ref AppVpc
Tags:
- Key: "Name"
Value: "python-app-ecs-sg"
# Step Functions
AppSfn:
Type: "AWS::Serverless::StateMachine"
Properties:
Name: "python-app-run-task-sf"
Definition:
Comment: State Machine to run a python ECS task
StartAt: RunEcsFargateTask
States:
RunEcsFargateTask:
Type: Task
Resource: arn:aws:states:::ecs:runTask.sync
Parameters:
Cluster: !GetAtt AppEcsCluster.Arn
LaunchType: FARGATE
TaskDefinition: !Ref AppEcsTaskDefinition
NetworkConfiguration:
AwsvpcConfiguration:
Subnets:
- !Ref AppPublicSubnet01
SecurityGroups:
- !Ref AppEcsSecurityGroup
AssignPublicIp: ENABLED
End: true
Role: !GetAtt AppSfnExecutionRole.Arn
Events:
Schedule:
Type: ScheduleV2
Properties:
ScheduleExpressionTimezone: "Asia/Tokyo"
ScheduleExpression: "cron(0 12 ? * * *)"
Input: "{}"
# IAM Role for Step Funtions
AppSfnExecutionRole:
Type: 'AWS::IAM::Role'
Properties:
RoleName: "python-app-sf-role"
AssumeRolePolicyDocument:
Version: 2012-10-17
Statement:
- Effect: Allow
Principal:
Service: states.amazonaws.com
Action: sts:AssumeRole
Policies:
- PolicyName: app-state-machine-policy
PolicyDocument:
Version: 2012-10-17
Statement:
- Effect: Allow
Action:
- ecs:RunTask
Resource:
- !Ref AppEcsTaskDefinition
- Effect: Allow
Action:
- iam:PassRole
Resource:
- !GetAtt AppEcsTaskExecutionRole.Arn
- !GetAtt AppEcsTaskRole.Arn
- Effect: Allow
Action:
- logs:CreateLogStream
- logs:PutLogEvents
Resource: "*"
- Effect: Allow
Action:
- ecs:StopTask
- ecs:DescribeTasks
Resource: "*"
- Effect: "Allow"
Action:
- "events:PutTargets"
- "events:PutRule"
- "events:DescribeRule"
Resource: "*"
長いですが、作成しているリソースについては冒頭で示したアーキテクチャ図の通りとなっています。
なお、ここでEventBridgeスケジューラの起動スケジュールは Asia/Tokyo
のタイムゾーン設定で cron(0 12 ? * * *)
と設定しているため、日本時間で毎日12時にECSタスクを起動するような設定になっています。
デプロイ
必要なファイルの準備ができたため、次はデプロイを行います。
AWS SAMのデプロイ
以下のコマンドを実行し、AWS SAMのデプロイを行います。
$ sam deploy
デプロイが正常に完了すると、先に設定したスタック名でスタックが作成されていると思うので、AWSのマネジメントコンソールからCloudFormationのサービスページに遷移し、スタックが作成されていることを確認して下さい。
Dockerイメージのビルド、Push
AWS SAMのデプロイが完了するとECRも作成されているので、作成したDockerfileを用いてビルドを行い、ECRにPushを行います。
実行コマンドについてはECRのページに記載されている要領で進めれば問題ありません。
実行確認
ここまで準備が完了すると、以下のようにEventBridgeスケジューラで以下のようにスケジュール実行されるようになっています。
また、AWSマネージドコンソールのStep Functionsのページから手動実行をすることもできます。
Step Functionsの実行が正常に終了すると、以下のようにステートが緑色になっていることを確認できます。
CloudWatch Logsからログを確認してみると、以下のようにスクレイピングの実行ログが確認でき、正常にスクレイピングできていることが分かります。
最後に
今回はAWS環境でECS (Fargate)を用いて、Seleniumによるスクレイピングを定期実行してみました。
参考になりましたら幸いです。
ご依頼について
NeeNetではAWSを利用したインフラ環境の構築、およびその上で動くアプリケーションの開発のご依頼・ご相談をお引き受けしております。
個人・法人問わず、何かご相談事項がございましたら、一度ご連絡いただければと思います。