広告 プログラミング

【Python】PyDrive2とGASを用いてVPS上のデータをGoogleDriveにバックアップする方法を解説

※本ページには、プロモーション(広告)が含まれています。

悩んでいる人

前回のPyDrive2を用いた方法でもリフレッシュトークンが自動更新されず、途中でバックアップが止まってしまった。

恒久的な対策方法を教えて欲しい。

こんなお悩みを解決します。

前回、PyDrive2を用いてVPS上のデータをGoogle Driveにバックアップする方法について解説しました。

あわせて読みたい
【Python】PyDrive2を用いてVPS上のデータをGoogleDriveにバックアップする方法を解説

続きを見る

一方、OAuth2により取得した認証情報には有効期限があるため、有効期限が切れた時点で再度認証が必要になります。このため、定期的にユーザによる認証手続きが必要になっていました。

実は、「サービスアカウント」というものを利用するとこの問題を回避できます。

なぜなら、これまでの方法では「ユーザによる認可」が必須でしたが、サービスアカウントを用いることで、このプロセスが不要となるためです。

この記事では、サービスアカウントの設定方法とプログラムの変更箇所について解説します。また、権限の都合上、Google Drive上のファイルが削除ができないため、GASを利用した対応策も提示します。

記事を読み終えると、Google Driveに定期的にバックアップする仕組みを構築できますよ!

VPSサーバをまだ契約していない方

カスタマイズの自由度が高いサーバを求めている場合、仮想専用サーバ(VPS)を利用することをおすすめします。

また、まだVPSサーバを契約していない方は、以下の記事が参考になると思うので、ぜひご覧ください。

あわせて読みたい
【比較】おすすめのVPS 4選

続きを見る

原因と解決策

以下に、これまでの方法で上手くいかなかった原因とその解決策を示します。

原因:OAuthにより取得した認証情報の有効期限切れ

当たり前ですが、OAuth2により取得した認証情報には有効期限があります。具体的には、以下の通りです。

アクセストークンの有効期限

アクセストークンの有効期限は、1時間となっています。

デフォルトでは、アクセス トークンの有効期間は 1 時間(3,600 秒)です。アクセス トークンが期限切れになると、トークン管理コードで新しいトークンを取得する必要があります。

https://cloud.google.com/docs/authentication/token-types?hl=ja#at-lifetime

リフレッシュトークンの有効期限

リフレッシュトークンの有効期限は、7日間となっています。(Google Cloud上の「OAuth同意画面」の公開ステータスが「テスト」となっている場合)

外部ユーザータイプに OAuth 同意画面が構成され、公開ステータスが「テスト中」の Google Cloud Platform プロジェクトでは、名前、メールアドレス、ユーザー プロファイルのサブセット( userinfo.email, userinfo.profile, openid スコープまたは同等の OpenID Connect)のみが要求される場合を除いて、7 日後に期限切れとなる更新トークンが発行されます。

https://developers.google.com/identity/protocols/oauth2?hl=ja#expiration

解決策:サービスアカウントの利用

Googleが提供しているOAuthの認証方法として、サービスアカウントを利用する方法があります。

Google API Consoleから取得されるサービス アカウントの認証情報には、一意のメールアドレス、クライアント ID、少なくとも 1 つの公開鍵/秘密鍵のペアが含まれます。クライアント ID と 1 つの秘密鍵を使用して、署名付き JWT を作成し、適切な形式のアクセス トークン リクエストを作成します。次に、アプリケーションはそのトークン リクエストを Google OAuth 2.0 認可サーバーに送信します。サーバーはアクセス トークンを返します。アプリケーションはこのトークンを使用して Google API にアクセスします。トークンの有効期限が切れると、アプリケーションはこのプロセスを繰り返します。

https://developers.google.com/identity/protocols/oauth2?hl=ja#serviceaccount

「トークンの有効期限が切れると、アプリケーションはこのプロセスを繰り返します。」とあるように、期限切れになった場合も自動的に再度トークンを取得することが分かります。

これを用いることで、トークンの有効期限切れによりバックアップができない問題を解決できます。

修正方針と個々の対応結果

修正方針

前回のプログラムも活用しつつ、以下のステップで修正していきます。

  1. サービスアカウント&公開鍵/秘密鍵を作成する。
  2. Google Driveに作成したバックアップ用ディレクトリを共有フォルダに変更する。
    この時、サービスアカウントにアクセス権を与える。
  3. 以前作成したプログラムと設定ファイルを修正する。
  4. Google Apps Script(GAS)を用いて一定期間以上経過したファイルを削除するプログラムを作成する。
    共有フォルダとして扱う場合、サービスアカウントに削除権限が与えられないため。

    →サービスアカウントが作成したファイルであれば削除可能なため、この操作は不要です。

以降では、上記のステップのやり方を順に説明していきます。

サービスアカウント&公開鍵/秘密鍵の作成

サービスアカウントの作成

まずは、サービスアカウントの作成方法について解説します。

Google Cloudのページにアクセスし、左側のメニューから「認証情報」→「認証情報を作成」→「サービスアカウント」の順に押下していきます。

「サービスアカウントの詳細」を入力するように促されるため、自身の好みにあわせて設定後、「完了」を押下してください。今回は、以下のように設定しました。

項目内容
サービスアカウント名rclone-service-account-oauth2
サービスアカウントIDrclone-service-account-oauth2
サービスアカウントの説明-
サービスアカウントの設定内容

公開鍵/秘密鍵の作成

次に、公開鍵/秘密鍵を作成します。作成したサービスアカウントの右側にあるペンマークを押下します。

「キー」→「鍵を追加」→「新しい鍵を作成」の順に押下し、公開鍵/秘密鍵を作成してください。

その後、自動的に秘密鍵がダウンロードされます。ダウンロードしたファイルは、service_account.jsonにリネームしておいて下さい。

Google Drive上のバックアップ用ディレクトリの共有フォルダ化

先ほど作成したサービスアカウントに対し、Google Drive上にあるバックアップ用ディレクトリにアクセス権を付与します。

まず、サービスアカウントのメールアドレスを控えておきます。

次に、Google Driveにアクセスし、バックアップ用のディレクトリの共有を行います。また、URLにあるIDは、プログラムからアクセスする際に利用するため、こちらも控えておいて下さい。

表示された画面のうち「ユーザーやグループを追加」の部分に先ほど控えたサービスアカウントのメールアドレスを入力してください。

該当するアカウントに対する設定内容が以下のようになっていることを確認した上で、共有」を押下します。

項目内容
アクセス権編集者
通知チェックが外れている
確認事項

アクセス権付与に成功すれば、アカウントが追加されていると思います。

また、編集権限を持つ人がアクセス権を変更できないようにするため、「編集者は権限を変更して共有できます」のチェックを外しておいて下さい。

プログラムと設定ファイルの修正

前回のディレクトリ構成は、以下のようになっていました。


${HOME}/
|-- ddns-settings/
|-- cron-configs/
|   |-- gdrive_backup.py
|   |-- client_secret.json
|   |-- saved_credentials.json # 自動生成されるファイル
|   `-- settings.yaml
|-- mydns.log
|-- vpnaccess-wireguard-nginx/
|   |-- LICENSE
|   |-- README.md
|   |-- docker-compose.yml
|   |-- envs/
|   |-- nginx/
|   |-- wireguard/
|   `-- wrapper.sh
`-- venv/
    |-- bin/
    |-- include/
    |-- lib64/
    `-- pyvenv.cfg

このうち、gdrive_backup.pysettings.yamlを修正します。

gdrive_backup.pyの修正

修正後のプログラムは以下のようになります。

import argparse
import os
import shutil
import sys
import logging
import logging.config
from datetime import datetime, timedelta
from glob import glob
from pydrive2.auth import GoogleAuth
from pydrive2.drive import GoogleDrive

class CmdWrapper:
    def __init__(self, log_config, args, timelimit):
        gauth = GoogleAuth(settings_file=args.config)
        gauth.ServiceAuth()
        logging.config.dictConfig(log_config)
        self.logger = logging.getLogger('backup')
        self.drive = GoogleDrive(gauth)
        self.is_dryrun = args.dry_run
        self.remote_path_id = args.remote_path_id
        self.log_message = '[dry-run] ' if self.is_dryrun else ''

        # logging
        self.__loginfo(f'=== Parameters ===')
        self.__loginfo(f' target: {args.target}')
        self.__loginfo(f' output: {args.output}')
        self.__loginfo(f' limit:  {timelimit.strftime("%Y/%m/%d")}')
        self.__loginfo(f'==================')

    def __loginfo(self, message):
        self.logger.info(f'{self.log_message}{message}')

    def mkdir(self, dirname):
        self.__loginfo(f'mkdir -p {dirname}')

        if not self.is_dryrun:
            os.makedirs(dirname, exist_ok=True)

    def copy(self, srcdir, dstdir):
        targets = [
            name.rstrip('/') for name in glob(f'{srcdir}/*[!.log]')
            if all([name.find(ignore_file) < 0 for ignore_file in [os.path.basename(dstdir), 'venv']])
        ]

        for dirname in targets:
            self.__loginfo(f'copy {dirname} to {dstdir}')

            if not self.is_dryrun:
                if os.path.isdir(dirname):
                    basedir = os.path.basename(dirname)
                    shutil.copytree(dirname, os.path.join(dstdir, basedir))
                else:
                    shutil.copy2(dirname, dstdir)

    def archive(self, dstdir):
        self.__loginfo(f'zip -r {dstdir}.zip {dstdir}')
        file_path = shutil.make_archive(dstdir, format='zip', root_dir=dstdir, dry_run=self.is_dryrun)

        return file_path

    def delete_remote_files(self, timelimit, max_results):
        for file_list in self.drive.ListFile({'q': f"'{args.remote_path_id}' in parents and trashed=false", 'maxResults': max_results}):
            for gdrive_file in file_list:
                created_time = datetime.strptime(gdrive_file['createdDate'].split('T')[0], '%Y-%m-%d')

                # Check threshold
                if created_time < timelimit:
                    # Delete remote file
                    filename = gdrive_file['title']
                    self.__loginfo(f'delete remote file({filename})')

                    if not self.is_dryrun:
                        gdrive_file.Trash()

    def upload(self, local_file_path):
        self.__loginfo(f'upload {local_file_path} to remote directory')

        if not self.is_dryrun:
            gdrive_file = self.drive.CreateFile({
                'title': os.path.basename(local_file_path),
                'parents': [{'id': self.remote_path_id}]
            })
            gdrive_file.SetContentFile(local_file_path)
            gdrive_file.Upload()

    def delete_local_data(self, local_path):
        self.__loginfo(f'delete local file or directory({local_path})')

        if not self.is_dryrun:
            if os.path.isdir(local_path):
                shutil.rmtree(local_path)
            else:
                os.remove(local_path)

if __name__ == '__main__':
    HOMEDIR = os.getenv('HOME', default='/home/yuruto') # 環境に合わせてdefault値を変更すること
    parser = argparse.ArgumentParser(
        prog='python3 gdrive_backup.py',
        description='Backup VPS files to Google-Drive',
        formatter_class=argparse.ArgumentDefaultsHelpFormatter,
    )
    parser.add_argument('-t', '--target', required=True, type=str, help='target directory')
    parser.add_argument('-o', '--output', type=str, help='output directory')
    parser.add_argument('-r', '--remote-path-id', type=str, default='root', help='remote path')
    parser.add_argument('-p', '--dry-run', default=False, action='store_true', help='dry-run option')
    parser.add_argument('-d', '--delete-after-days', default=7, type=int, help='number of days elapsed at time of deletion')
    parser.add_argument('-c', '--config', type=str, default='settings.yaml', help='Automatic authentication configuration file when this script accesses to Google Drive.')
    args = parser.parse_args()

    # log config
    log_config = {
        'version': 1,
        'disable_existing_loggers': False,
        'formatters': {
            'info': {
                'format': '[%(asctime)s %(levelname)s] %(name)s %(message)s',
                'datefmt': '%Y/%m/%d %H:%M:%S',
            },
        },
        'handlers': {
            'timeRotate': {
                'class': 'logging.handlers.TimedRotatingFileHandler',
                'formatter': 'info',
                'filename': f'{HOMEDIR}/pydrive2.log',
                'when': 'W6', # Sunday
                'backupCount': 1
            },
            'console': {
                'class': 'logging.StreamHandler',
                'formatter': 'info',
            },
        },
        'loggers': {
            'backup': {
                'level': 'INFO',
                'handlers': ['timeRotate', 'console']
            }
        }
    }

    # Check arguments
    if not os.path.exists(args.target):
        print(f'Invalid target directory(target: {args.target})')
        sys.exit(1)
    if not os.path.exists(args.config):
        print(f'Does not exist configuration file(config: {args.config})')
        sys.exit(1)
    # Check output directory
    if args.output is None:
        today = datetime.now().strftime('%Y%m%d')
        args.output = os.path.join(HOMEDIR, f'backup{today}')
    elif not os.path.exists(args.output):
        args.output = os.path.join(HOMEDIR, os.path.basename(args.output))
    # Calculate  timelimit
    timelimit = datetime.now() - timedelta(days=args.delete_after_days)

    # ================
    # = main routine =
    # ================
    wrapper = CmdWrapper(log_config, args, timelimit)

    # Step1: Create output directory if it does not exist.
    if not os.path.exists(args.output):
        wrapper.mkdir(args.output)
    # Step2: Copy data to output directory.
    wrapper.copy(args.target, args.output)
    # Step3: Compress output directory.
    tar_file_path = wrapper.archive(args.output)
    # Step4: Search for old files and delete them if they exceed the threshold.
    wrapper.delete_remote_files(timelimit, args.delete_after_days * 2)
    # Step5: Copy backup file to remote path
    wrapper.upload(tar_file_path)
    # Step6: Delete temporary file and directory
    wrapper.delete_local_data(args.output)
    wrapper.delete_local_data(tar_file_path)

また、差分は以下のようになります。

class CmdWrapper:
    def __init__(self, log_config, args, timelimit):
        gauth = GoogleAuth(settings_file=args.config)
-       gauth.CommandLineAuth()
+       gauth.ServiceAuth()
        logging.config.dictConfig(log_config)

settings.yamlの修正

修正後の設定ファイルは以下のようになります。

client_config_backend: service
service_config:
  client_json_file_path: service_account.json

save_credentials: True
save_credentials_backend: file
save_credentials_file: saved_credentials.json

また、差分は以下のようになります。

+ client_config_backend: service
- client_config_file: client_secret.json
+ service_config:
+   client_json_file_path: service_account.json

save_credentials: True
save_credentials_backend: file
save_credentials_file: saved_credentials.json
- 
- get_refresh_token: True

事前準備

あらかじめ、アップロード先にあるファイルのうち、サービスアカウント以外でアップロードしたファイル(所有者がサービスアカウント以外のもの)を削除しておいて下さい。

動作確認

先ほど作成したservice_account.jsonをVPS上にコピーしておきます。また、saved_credentials.jsonclient_secret.jsonは削除しておきます。


${HOME}/
|-- ddns-settings/
|-- cron-configs/
|   |-- gdrive_backup.py
|   |-- service_account.json # 追加(saved_credentials.jsonとclient_secret.jsonは削除)
|   `-- settings.yaml
|-- mydns.log
|-- vpnaccess-wireguard-nginx/
|   |-- LICENSE
|   |-- README.md
|   |-- docker-compose.yml
|   |-- envs/
|   |-- nginx/
|   |-- wireguard/
|   `-- wrapper.sh
`-- venv/
    |-- bin/
    |-- include/
    |-- lib64/
    `-- pyvenv.cfg

今回は、以下のコマンドを実行し、動作確認を行いました。

# -rのオプションは、Google Driveにアクセスした際に控えた文字列を指定すること
${HOME}/venv/bin/python3 gdrive_backup.py -t ${HOME} -r 16prZpffgtvAMumJ5JvA7S1xD0qeTL8gR

実行結果は、以下に示すイメージのようになり、Google Drive上にもアップロードされていることが確認できています。

[1999/12/31 23:57:03 INFO] backup === Parameters ===
[1999/12/31 23:57:03 INFO] backup  target: /home/yuruto
[1999/12/31 23:57:03 INFO] backup  output: /home/yuruto/backup19991231
[1999/12/31 23:57:03 INFO] backup  limit:  1999/12/24
[1999/12/31 23:57:03 INFO] backup ==================
[1999/12/31 23:57:04 INFO] backup mkdir -p /home/yuruto/backup19991231
[1999/12/31 23:57:05 INFO] backup copy /home/yuruto/vpnaccess-wireguard-nginx to /home/yuruto/backup19991231
[1999/12/31 23:57:15 INFO] backup copy /home/yuruto/ddns-settings to /home/yuruto/backup19991231
[1999/12/31 23:57:16 INFO] backup copy /home/yuruto/cron-configs to /home/yuruto/backup19991231
[1999/12/31 23:57:16 INFO] backup delete remote file(backup19991224.zip)
[1999/12/31 23:57:20 INFO] backup zip -r /home/yuruto/backup19991231.zip /home/yuruto/backup19991231
[1999/12/31 23:57:23 INFO] backup upload /home/yuruto/backup19991231.zip to remote directory
[1999/12/31 23:57:58 INFO] backup delete local file or directory(/home/yuruto/backup19991231)
[1999/12/31 23:57:59 INFO] backup delete local file or directory(/home/yuruto/backup19991231.zip)

【参考】Google Apps Script(GAS)によるクリーンアップ

こちらの機能は不要となりましたが、参考までに残しておきます。

まとめ

今回は、トークンの有効期限に関する問題を解消するため、サービスアカウントを用いたGoogle OAuth2の仕組み構築方法について解説しました。

トークンの有効期限を気にすることなく、VPS上のデータをGoogle Driveにアップロードできるようになりました。一方、古いファイルを削除できなくなったため、代替手段としてGoogle Apps Script(GAS)を用いた方法も紹介しました。

今回の対応により、トークンの有効期限に関する問題は解消されたため、恒久的な対策が打てたと思います。同様に困っている方がいましたら、参考にしてください。

VPSサーバをまだ契約していない方

カスタマイズの自由度が高いサーバを求めている場合、仮想専用サーバ(VPS)を利用することをおすすめします。

また、まだVPSサーバを契約していない方は、以下の記事が参考になると思うので、ぜひご覧ください。

あわせて読みたい
【比較】おすすめのVPS 4選

続きを見る

スポンサードリンク



-プログラミング
-, , , ,