前回の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 プロジェクトでは、名前、メールアドレス、ユーザー プロファイルのサブセット(
https://developers.google.com/identity/protocols/oauth2?hl=ja#expirationuserinfo.email, userinfo.profile, openid
スコープまたは同等の OpenID Connect)のみが要求される場合を除いて、7 日後に期限切れとなる更新トークンが発行されます。
解決策:サービスアカウントの利用
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
「トークンの有効期限が切れると、アプリケーションはこのプロセスを繰り返します。」とあるように、期限切れになった場合も自動的に再度トークンを取得することが分かります。
これを用いることで、トークンの有効期限切れによりバックアップができない問題を解決できます。
修正方針と個々の対応結果
修正方針
前回のプログラムも活用しつつ、以下のステップで修正していきます。
- サービスアカウント&公開鍵/秘密鍵を作成する。
- Google Driveに作成したバックアップ用ディレクトリを共有フォルダに変更する。
この時、サービスアカウントにアクセス権を与える。 - 以前作成したプログラムと設定ファイルを修正する。
Google Apps Script(GAS)を用いて一定期間以上経過したファイルを削除するプログラムを作成する。
共有フォルダとして扱う場合、サービスアカウントに削除権限が与えられないため。
→サービスアカウントが作成したファイルであれば削除可能なため、この操作は不要です。
以降では、上記のステップのやり方を順に説明していきます。
サービスアカウント&公開鍵/秘密鍵の作成
サービスアカウントの作成
まずは、サービスアカウントの作成方法について解説します。
Google Cloudのページにアクセスし、左側のメニューから「認証情報」→「認証情報を作成」→「サービスアカウント」の順に押下していきます。
「サービスアカウントの詳細」を入力するように促されるため、自身の好みにあわせて設定後、「完了」を押下してください。今回は、以下のように設定しました。
項目 | 内容 |
---|---|
サービスアカウント名 | rclone-service-account-oauth2 |
サービスアカウントID | rclone-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.py
とsettings.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.json
とclient_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選
続きを見る