ECS (Fargate)でSeleniumによるスクレイピングを定期実行してみた

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-sandboxChromeのサンドボックス機能を無効にする。Dockerコンテナ内やroot権限のない環境で実行する場合。ただし、セキュリティリスクが増加するため注意が必要。
--disable-gpuGPUハードウェアアクセラレーションを無効にする。GPUが利用できない環境や、GPUに関連する問題が発生する場合。特にヘッドレスモードで使用。
--window-size=1920,1080"ブラウザウィンドウのサイズを設定。レスポンシブデザインのテスト、特定の画面サイズでのスクリーンショット取得する場合など。
--single-processChromeを単一のプロセスで実行。リソースが制限された環境で実行する場合に利用。ただし、安定性が低下する可能性がある。
--disable-dev-shm-usage/dev/shmの使用を無効にし、/tmpを使用。Dockerコンテナなど、/dev/shmのサイズが小さい環境で利用。
--disable-dev-tools開発者ツールを無効にする。パフォーマンス向上が必要な場合や、開発者ツールへのアクセスを制限したい場合に利用。
--no-zygoteZygoteプロセスの使用を無効にする。ヘッドレス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を利用したインフラ環境の構築、およびその上で動くアプリケーションの開発のご依頼・ご相談をお引き受けしております。

個人・法人問わず、何かご相談事項がございましたら、一度ご連絡いただければと思います。

  • URLをコピーしました!