ken_nogi/PleasanterSystem/dbbackup/SPEC.md
Kenichiro NOGI ed33892f08 chore: 作業中の変更を整理しコミット(複数プロジェクト分)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-02 11:09:50 +09:00

48 KiB
Raw Blame History

PostgreSQL → LINE WORKS 共有ドライブ 自動バックアップ (Node.js)

障害対応(2026-07-21): 2026-07-15本番cron実行失敗の修正

2026-07-15 02:00の本番cron実行が、pg_dump(56.6GB, 18分割)自体は正常終了したものの、分割ファイルのアップロードに1ファイルあたり約4.5分×18ファイルかかったためLINE WORKSアクセストークン(取得から3時間強経過)が失効し、17番目のアップロードURL発行で401 UNAUTHORIZEDとなり全体失敗した。これを受けて以下3点を修正した。

  1. 起動時の残骸ローカルファイル自動削除(lib/cleanupStale.js新設): 前回実行がアップロード失敗で中断した場合、BACKUP_LOCAL_DIR配下に未削除のダンプ分割ファイル([<hostname>]postgres-*.dmp*)・設定アーカイブ([<hostname>]config-*.tar.gz)が残り続けディスクを圧迫するため、runBackup()の開始直後(pg_dump実行より前)に一致するファイルを走査・削除する。削除時はlogger.warnで記録する(サイレント削除にしない)。
  2. LINE WORKSアクセストークンの定期再取得(lib/tokenRefresher.js新設、createTokenRefresher()): 従来は実行全体を通して初回取得したaccess_tokenを使い回していたが、有効期限(概ね1時間)を考慮していなかった。getUserAccessToken()取得時刻を記録し、閾値(既定50分)を超えた場合はダンプファイル・設定アーカイブのアップロードループの各ファイル処理前、およびローテーション実行前に自動的に再取得するよう変更した。またlib/logUpload.jsuploadLogFile()も、アップロード時に401を検知した場合はgetUserAccessToken()を1回だけ呼び直してリトライするようにした。
  3. DBバックアップと設定ファイルバックアップの相互独立化(lib/trySafely.js新設): 従来はDBダンプのアップロードループで例外が発生すると、コード上その後ろに書かれていた設定ファイルバックアップ処理に到達せず丸ごとスキップされていた(「設定ファイルバックアップの失敗はDBバックアップに影響しない」という片方向の独立性のみ実装・記載されており、逆方向が漏れていた)。runDbDumpAndUpload() / runConfigBackupAndUpload()としてそれぞれ関数化し、trySafely()で個別に包むことで、どちらか一方が失敗してももう一方・ローテーション・ログアップロードは必ず試行されるようにした。片方でも失敗すれば最終的なprocess.exitCode = 1は立つ。

対応する単体テストはtest/配下(cleanupStale.test.js / tokenRefresher.test.js / logUpload.test.js / trySafely.test.jsnode --testまたはnpm testで実行)に追加した。

実装・デプロイ状況(2026-07-11時点)

  • 実装完了・/opt/dbbackup/ に配置済み(本ドキュメント内のdbbackup/という記載は配置先ルートを指す)。
  • Node.js v22.23.1(dnfのAppStreamリポジトリから導入。要件>=18.13.0は満たす。追加npm依存なし)。
  • User Account認証(kenichiro.nogi@next-hd.co.jp)によるセットアップ完了、refresh_token取得・保存済み。
  • PGPASSWORDはPleasanter本体の設定ファイル(/web/pleasanter/Implem.Pleasanter/App_Data/Parameters/Rds.jsonSaConnectionStringPWD=)から転記して設定済み。
  • create-test-dbrun --database=...drop-test-db によるエンドツーエンド検証済み(pg_dump、共有ドライブへのフォルダ作成・アップロード、ローテーション、ログアップロードすべて成功を確認)。
  • 本番PGDATABASE=Implem.Pleasanterに対する実行はまだ未実施(検証はテスト用DBのみ)。cronなどへの自動実行登録も未着手。
  • サーバーホスト名(このサーバーではnextoffice2)をバックアップファイル名の先頭に[nextoffice2]として自動付与する機能を追加済み(詳細は後述「複数サーバーでの運用について」)。

Context

現行の backup.shpg_dump でローカルにダンプを作成するところまでは動いているが、転送処理(sftp.sh 経由)はコメントアウトされて使われていない。転送先を独自VPN上のSFTPサーバーから LINE WORKS ドライブの共有ドライブに変更したいというのが今回の要望。

認証方式について: 当初 get-token.js / 姉妹プロジェクトの lineworks-anythingllm.js(LINE WORKS 掲示板連携)と同じ「Service AccountのJWT自己署名 → OAuth2トークン取得」を共有ドライブAPIにも流用する想定だったが、実装・検証の結果、Drive/共有ドライブAPIはService Account認証(JWT)に対応しておらず、User Account認証(OAuth 2.0 認可コードフロー)が必須であることが判明した(公式ドキュメント https://developers.worksmobile.com/jp/docs/drive に明記、かつ実機検証でも「スコープ不足」とは異なるエラーNot allowed apiを確認)。そのため:

  • 掲示板API等、既存のService Account認証で動くもの → 従来通り get-token.js / lib/lineworksAuth.js
  • Drive/共有ドライブAPI(backup.jsが使うもの) → 新設の lib/lineworksUserAuth.js(User Account認証、OAuth認可コードフロー)

を使い分ける。User Account認証は実ユーザー(kenichiro.nogi@next-hd.co.jp)による一度きりのブラウザ認可が必要なため、lineworks-authorize.js という一回限りのセットアップスクリプトを用意し、そこで取得した refresh_token.env に保存、以降 backup.js は無人で refresh_token から access_token を再発行する。

保存先は 共有ドライブ「システムバックアップ」 > Pleasanter_DB_Backup > 日付フォルダ(YYYYMMDD) で、フォルダが無ければ都度作成。ファイル名側に秒単位まで含むタイムスタンプを付与し、同日複数回実行しても上書きされないようにする。アップロード後は日付フォルダの個数を数え、保持回数(既定100・.envで変更可)を超えた古いフォルダをLINE WORKS側から削除してローテーションする。

さらに、バックアップ開始・終了、アップロード開始・終了、LINE WORKSログイン(トークン取得)からディレクトリ取得・作成までの各過程を秒単位のタイムスタンプ付きで詳細ログに記録する。ログは同じ共有ドライブの Pleasanter_DB_Backup/logs/ 配下に月1回ローテーション(月ごとに1ファイル)で保存し、実行のたびに上書きアップロードする。通常の経過はINFO、リトライ等の異常系はWARN/ERRORで記録する。

sftp.sh(旧転送手段)および旧 backup.sh(node版移行前のシェル版バックアップ)は、node版(backup.js)への移行が完了し不要となったため、2026-07-11に削除した。.env内の旧backup.sh専用変数(DATE/REMOTEDIR/SAVEPATH/PREFIX/EXT/FNAME/DBNAME/PASSWORD。特に平文パスワードを含んでいた)も同時に削除済み。

任意にパイプライン全体(pg_dump→分割→アップロード→ローテーション→ログ)を試せるよう、本番DBに触れずに使えるテスト用DBの作成・削除コマンドも backup.js のサブコマンドとして用意する。

参照した公式ドキュメント(エンドポイント確定分)

操作 Method URL
共有ドライブ一覧 GET /v1.0/sharedrives{ sharedrives: [{ sharedriveId, name, ... }] }
ルート直下 一覧 GET /v1.0/sharedrives/{sharedriveId}/files{ files: [...], responseMetaData: { nextCursor } }
フォルダ配下 一覧 GET /v1.0/sharedrives/{sharedriveId}/files/{fileId}/children (同形式)
ルート直下 フォルダ作成 POST /v1.0/sharedrives/{sharedriveId}/files/createfolder body { fileName } → 201 { fileId, fileType:"FOLDER", ... }
フォルダ配下 フォルダ作成 POST /v1.0/sharedrives/{sharedriveId}/files/{fileId}/createfolder body { fileName }
アップロードURL発行(段階1) POST /v1.0/sharedrives/{sharedriveId}/files/{fileId} body { fileName, fileSize, modifiedTime, overwrite, resume, suffixOnDuplicate }{ uploadUrl, offset }
実バイト送信(段階2) POST uploadUrl (別ホスト apis-storage.worksmobile.com) — multipart/form-data, フィールド名 Filedata, Authorization: Bearer 同梱 → 201 { fileId, fileName, fileSize }
削除(ファイル/フォルダ) DELETE /v1.0/sharedrives/{sharedriveId}/files/{fileId} → 204

必要スコープ: file(読み書き全般)。.envLW_SCOPEget-token.js(Service Account認証)専用に残し、バックアップ用(User Account認証)には別変数 LW_SCOPE_DRIVE を新設して混同しないようにする。

認可コードフロー / リフレッシュ関連エンドポイント

操作 Method URL
認可リクエスト GET(ブラウザ) https://auth.worksmobile.com/oauth2/v2.0/authorize?client_id=...&redirect_uri=...&response_type=code&scope=file&state=...
認可コード→トークン交換 POST https://auth.worksmobile.com/oauth2/v2.0/token (grant_type=authorization_code)
リフレッシュ POST 同上 (grant_type=refresh_token)

Refresh Token Rotation が ON のため、リフレッシュのたびに新しい refresh_token が発行され古い方は無効化される。取得の都度 .envLW_USER_REFRESH_TOKEN を書き換えて保存する。

新規ファイル構成 (dbbackup/ 配下)

dbbackup/
  backup.js                 … エントリポイント (node backup.js)
  lineworks-authorize.js     … User Account認証の一回限りのセットアップ(ブラウザ認可→refresh_token取得・.env保存)
  lib/
    env.js                  … .env の読み込み(process.envへ反映。.envが常に正、実行環境変数より優先)・LW_SHAREDRIVE_ID/LW_USER_REFRESH_TOKEN書き戻し
    logger.js                … 月次ローテーションするローカルログファイルへの書き込み(INFO/WARN/ERROR、時分秒付き)
    lineworksAuth.js         … get-token.js が使うService Account認証(JWT)。コード内にデフォルト値は持たず、.env未設定なら例外
    lineworksUserAuth.js     … backup.jsが使うUser Account認証(OAuth認可コードフロー)。認可URL組立て・コード交換・refresh_tokenによる再発行(rotation対応)
    lineworksDrive.js        … 共有ドライブAPI一覧のラッパー(共有ドライブID解決・フォルダ確保・アップロード・一覧・削除)、429リトライ付きfetch。logger連携で各API呼び出しをINFO/WARNログに記録
    pgDump.js                … pg_dump をspawnし、標準出力を3072MB単位でローカルファイルに分割書き出し
    testDb.js                … createdb/dropdb をspawnしてテスト用DBを作成・削除するヘルパー
  logs/                      … ログのローカル置き場(月次ファイル、既定 backup-YYYYMM.log)
  package.json               … 依存ゼロ(fetch/FormData/Blob/fs.openAsBlob等 Node標準のみ)。engines: node>=18.13
  get-token.js               … Service Account認証の動作確認用CLI。内部実装は lib/lineworksAuth.js を呼ぶ薄いラッパー。コード内にデフォルト値を持たず、必要な環境変数が.envに無ければ実行不可
  .env                       … 下記キーを追記
  SPEC.md                    … 本仕様書(このplanの内容)をそのまま保存。実装の最初のステップとしてコミットする

sftp.sh・旧backup.shは2026-07-11に削除済み(前述)。

.env 追記内容

# --- Drive(共有ドライブ)バックアップ関連 (User Account認証) ---
LW_SCOPE_DRIVE=file
LW_REDIRECT_URI=https://example.com/callback
LW_USER_REFRESH_TOKEN=
LW_SHAREDRIVE_NAME=システムバックアップ
LW_SHAREDRIVE_ID=
LW_BACKUP_ROOT_FOLDER_NAME=Pleasanter_DB_Backup

# --- PostgreSQL接続 ---
PG_DUMP_BIN=/usr/bin/pg_dump
PGHOST=localhost
PGPORT=5432
PGUSER=postgres
PGPASSWORD=
PGDATABASE=Implem.Pleasanter

# --- ローカル一時ファイル・分割・ローテーション ---
BACKUP_LOCAL_DIR=/var/lib/pgsql/backup/dmp/
PG_DUMP_SPLIT_SIZE_MB=3072
BACKUP_RETENTION_COUNT=100

# --- ログ ---
LOG_LOCAL_DIR=./logs
LW_LOG_FOLDER_NAME=logs

# --- テスト用DB(create-test-db / drop-test-db) ---
PG_CREATEDB_BIN=createdb
PG_DROPDB_BIN=dropdb
TEST_DB_NAME=pleasanter_backup_test

LW_SHAREDRIVE_ID は初回実行時に名前(LW_SHAREDRIVE_NAME)から解決し、lib/env.js.env ファイルの当該行を書き換えて永続化する。2回目以降はID解決APIを呼ばずキャッシュ値を使う。

処理フロー (backup.js)(2026-07-21障害対応後)

全体を try { ... } catch { ...ERRORログ... } finally { ...ログファイルのアップロード... } で包む。accessToken / sharedriveId / backupFolderId / logsFolderId は関数スコープの変数に保持し、finally 内でログアップロード時に(まだ未取得なら)再利用・再取得する。accessTokenlib/tokenRefresher.jscreateTokenRefresher()で作ったtokenManager経由で取得し、取得済みトークンが閾値(既定50分、thresholdMs)を超えて経過していればtokenManager.getToken()呼び出し時に自動でgetUserAccessToken()を再実行する。

  1. .env 読み込み(lib/env.jsprocess.env に無いキーだけ反映=実環境変数を優先)。logger.info("===== バックアップ開始 =====")
  2. lib/cleanupStale.jscleanupStaleLocalFiles(): BACKUP_LOCAL_DIR配下を走査し、[<hostname>]postgres-*.dmp* / [<hostname>]config-*.tar.gzに一致する前回実行の残骸ファイルがあれば削除しlogger.warn("前回実行の残骸ローカルファイルを削除: ...")を記録する。
  3. logger.info("LINE WORKSアクセストークン取得開始 (User Account認証)")tokenManager.getToken()(内部でlib/lineworksUserAuth.jsgetUserAccessToken()を呼び、LW_USER_REFRESH_TOKENからaccess_tokenを再発行。rotationで返る新しいrefresh_tokenは即座に.envへ保存) → 成功で logger.info("アクセストークン取得成功")、失敗で logger.error(...)(LW_USER_REFRESH_TOKEN未設定時は「先にlineworks-authorize.jsを実行してください」という内容の例外)。
  4. lib/lineworksDrive.js(各呼び出し前後でINFOログ、429リトライはWARNログをlogger経由で出力):
    • resolveSharedDriveId(): .envLW_SHAREDRIVE_ID があれば logger.info("共有ドライブID: キャッシュ値を使用 (...)")。無ければ一覧取得→LW_SHAREDRIVE_NAMEと一致するものを検索→.envへ書き戻し、logger.info("共有ドライブID解決: name=... id=...")。見つからなければlogger.errorしてエラー終了。
    • ensureFolder(parentFileId, name): 一覧を見て同名のFOLDERがあれば logger.info("フォルダ確認: 既存を使用 name=... id=...")、無ければ作成し logger.info("フォルダ確認: 新規作成 name=... id=...")。これを Pleasanter_DB_Backup(ルート直下)→ 日付フォルダYYYYMMDD(その配下)の2階層分呼ぶ。
  5. ここから先、DBバックアップ(5a)と設定ファイルバックアップ(5b、詳細は次節)はlib/trySafely.jstrySafely()でそれぞれ独立したtry/catchに包んで実行する。どちらか一方が例外を投げても、もう一方は必ず実行される(2026-07-15の障害では、DBダンプのアップロードループでの401失敗により設定ファイルバックアップが丸ごとスキップされていた)。
    • 5a. runDbDumpAndUpload(): lib/pgDump.jslogger.info("pg_dump開始: host=... db=...")PG_DUMP_BIN -h $PGHOST -p $PGPORT -U $PGUSER -Fc $PGDATABASE を spawn(PGPASSWORD 設定時のみ子プロセスのenvに渡す)。標準出力を BACKUP_LOCAL_DIR 配下に [<os.hostname()>]postgres-<YYYYMMDD-HHMMSS>.dmp000, .dmp001, … として PG_DUMP_SPLIT_SIZE_MB ごとに書き出す(split -d -a 3 と同じ3桁ゼロ埋め連番)。完了時 logger.info("pg_dump終了: 合計サイズ=... 所要時間=...")。続けて分割ファイルを順番に、各ファイル処理前にtokenManager.getToken()でトークンの鮮度を確認しつつuploadFile(sharedriveId, dateFolderId, filePath)でアップロード。各ファイルについて logger.info("アップロード開始: ファイル名=... サイズ=...") → 段階1でuploadUrl取得→段階2でPOST → 成功で logger.info("アップロード成功: ファイル名=... fileId=... 所要時間=...")。失敗時は例外を投げtrySafely()logger.error("DBバックアップ失敗(設定ファイルバックアップは継続): ...")を記録して5bへ進む(ローカルファイルは削除しない=次回起動時に2.のクリーンアップで削除される)。全パートのアップロードに成功した場合のみBACKUP_LOCAL_DIR上の当該一時ファイルを削除しlogger.info("ローカル一時ファイル削除完了")。大きいファイルは fs.openAsBlob() でメモリに全展開せず送信。
    • 5b. runConfigBackupAndUpload(): 次節「環境設定ファイルバックアップ」参照。
  6. ローテーション: tokenManager.getToken()で最新のトークンを取得してから実行。logger.info("ローテーション開始 (保持件数=...)")Pleasanter_DB_Backup 配下のフォルダ一覧を取得し、フォルダ名(=日付文字列なので辞書順=時系列順)でソート。BACKUP_RETENTION_COUNT を超えた古い方から deleteFile(sharedriveId, fileId) で削除しつつ logger.info("削除: フォルダ名=... fileId=...")logger.info("ローテーション終了: 削除件数=...")
  7. 5a・5bの両方が成功した場合のみ logger.info("===== バックアップ終了(成功) ===== 所要時間=...")。どちらか一方でも失敗していればlogger.error("===== バックアップ終了(失敗) ===== 所要時間=...")を出しprocess.exitCode = 1(pg_dump自体の起動失敗など、5a/5bに到達する前の例外はcatch節で同様にログ・exitCode処理)。
  8. finally 節: lib/logUpload.jsuploadLogFile()logger.info("ログファイルアップロード開始") → 4.で解決済みの sharedriveId を使って(未解決なら改めて解決を試み、それも失敗したらログアップロードは諦めローカルログのみ残す)Pleasanter_DB_Backup/logs フォルダを確保し、当月ログファイル(backup-YYYYMM.log)を overwrite: true でアップロード(段階1リクエストのoverwriteフラグをtrueにして同名ファイルを上書き)。アップロード時に401を検知した場合はgetUserAccessToken()を1回だけ呼び直してリトライする。成功/失敗いずれも最後にコンソールへも出力。

環境設定ファイルバックアップ(2026-07-11追加)

DBのみでは「サーバーが完全に失われた場合に同じ環境を再構築できない」ため、DB以外の設定ファイル一式もbackup.js実行のたびに合わせてバックアップする機能を追加。

対象(.envCONFIG_BACKUP_PATHS、カンマ区切りで変更可能)

パス 理由
/etc/nginx リバースプロキシ設定一式
/web/pleasanter/Implem.Pleasanter/App_Data/Parameters Pleasanterの環境固有設定(ユーザー指定)
/web/pleasanter/Implem.Pleasanter/wwwroot 静的ファイル・添付ファイル等(ユーザー指定)
/web/pleasanter/Implem.CodeDefiner/Implem.License.dll ライセンスファイル、再ダウンロード不可(ユーザー指定)
/web/pleasanter/Implem.Pleasanter/Implem.License.dll 同上(ユーザー指定)
/etc/letsencrypt HTTPS証明書一式(調査の上追加。無いと証明書の再発行が必要になる)
/etc/systemd/system/pleasanter.service Pleasanterを起動するための自作systemdユニット(調査の上追加。パッケージ由来ではないため他に控えが無い)
/var/lib/pgsql/18/data/postgresql.conf PostgreSQLサーバー設定(調査の上追加。pg_dumpはデータのみでサーバー設定は含まない)
/var/lib/pgsql/18/data/pg_hba.conf PostgreSQL接続認証設定(同上)

/web/pleasanter/Implem.Pleasanter/配下の実行バイナリ本体(dotnet依存関係含め434MB)は、Pleasanter公式配布物から再構築可能なため対象外とした(ユーザー指定の対象からも除外されていた)。

検討したが対象外としたもの

  • /opt/dbbackup自体(このバックアップ/リストアの仕組み一式): .envにLINE WORKSのクライアントシークレットやDBパスワード、秘密鍵ファイルが含まれるため、それらの認証情報をアップロード先であるLINE WORKS共有ドライブ自身に保存することはセキュリティ上推奨しないと判断し、あえて含めなかった。この一式は別途安全な方法(パスワード管理ツール、社内の別のセキュアな保管場所等)で保管することを推奨。
  • firewalldの設定: 確認したところ本サーバーではfirewalld自体が起動していないため対象外。
  • cronの設定: 2026-07-11時点でまだcron未登録のため対象なし。今後cron登録した場合は/etc/cron.d/dbbackup等を追加で対象に含めることを推奨。
  • SSHホスト鍵: 新サーバーでは通常再生成すれば足りるため対象外。

処理内容(lib/configBackup.jsbackup.jsrunConfigBackupAndUpload()から呼び出し)

  • 対象パスのうち実在するものだけをtar -czfで1つの.tar.gzにまとめる(存在しないパスは警告ログのみでスキップ)。
  • ファイル名は[hostname]config-<timestamp>.tar.gz(DBダンプと同じ日付フォルダへアップロード。ローテーション対象にも自動的に含まれる)。
  • DBバックアップ(runDbDumpAndUpload())とは互いに独立したtry/catch(lib/trySafely.jstrySafely())で実行するため、どちらが失敗してももう一方の成否には影響しない(エラーはログに記録されるのみ)。2026-07-21以前はDBバックアップ→設定ファイルバックアップの順に同じtryブロック内で実行していたため、DBバックアップ側で例外が起きると設定ファイルバックアップに到達できず丸ごとスキップされる問題があった(2026-07-15の本番cron失敗時に顕在化)。現在は両方向とも独立して必ず試行される。
  • --database指定時(テスト実行)でもスキップせず毎回実行する(読み取り専用でサーバーへの影響が無いため)。

動作検証(2026-07-11)

  • テスト用DBでのバックアップ実行時に、9件すべてのパスが検出されアーカイブ化・アップロード成功を確認(約21.7MB)。
  • 手動で同じロジックを実行し、tar内に9件すべてのパスが正しく含まれていることを確認済み。

cron登録コマンド(未登録・参考用、2026-07-11時点)

運用方針: 週1回、毎週水曜日の午前2時にバックアップを実行する。現時点ではまだcron登録は行っていない。実際に登録する際は以下のいずれかの方法で設定する。

方法1: /etc/cron.d/dbbackup を新規作成する場合

0 2 * * 3 root cd /opt/dbbackup && /usr/bin/node backup.js run >> /opt/dbbackup/logs/cron-stdout.log 2>&1

方法2: crontab -e(rootのcrontab)に追記する場合

0 2 * * 3 cd /opt/dbbackup && /usr/bin/node backup.js run >> /opt/dbbackup/logs/cron-stdout.log 2>&1
  • 0 2 * * 3: 分=0, 時=2, 日=任意, 月=任意, 曜日=3(水曜日。cronの曜日は0=日曜始まり)
  • nodeのパスは/usr/bin/node(本サーバーへのdnf install nodejsによるインストール先)。
  • backup.js自体がlib/logger.jsでのローカルログ記録・LINE WORKS共有ドライブへのログアップロードを行うため、cron-stdout.logへのリダイレクトは「nodeプロセス自体が起動できなかった」等の想定外の異常のみを拾うための保険。通常の実行結果は共有ドライブのPleasanter_DB_Backup/logs/backup-<年月>.log、およびローカルの/opt/dbbackup/logs/backup-<年月>.logを参照すること。
  • 登録前にlogsディレクトリの書き込み権限があることを確認(/opt/dbbackup/logsはrootで作成される想定)。

リストア機能 (restore.js, 2026-07-11追加)

node restore.js [--database=<dbName>] [--yes] で実行する、共有ドライブからのデータベース復元機能。実装・テストDBでの検証済み。

処理内容

  1. このサーバーのホスト名(os.hostname()、例: nextoffice2)に一致する[hostname]postgres-...形式の最新バックアップを、共有ドライブのPleasanter_DB_Backup配下から特定する(lib/backupLocator.js)。日付フォルダを新しい順に探索し、対象ホスト名のファイルが無いフォルダは遡ってスキップする(複数サーバー運用でその日はまだ対象サーバーの分が無いケースに対応)。同一タイムスタンプの分割ファイル(.dmp000, .dmp001, …)をすべて特定する。
  2. 対話確認: 復元元・復元先DB名・Webサービス停止有無を表示し、復元先DB名そのものを入力させて一致した場合のみ続行(--yes指定時はスキップ、検証・自動化用途)。
  3. 特定したバックアップの各パートをダウンロードし(lib/lineworksDrive.jsdownloadFile()/downloadエンドポイントの302リダイレクト先URLも含め、両方のリクエストにAuthorization: Bearerヘッダーが必要 — 片方だけだと401になることを実機で確認済み)、パート順に結合して1つの-Fc形式ダンプファイルに復元する(分割は単純なバイト分割のため、結合するだけで元のpg_dump出力に戻る)。
  4. Webサービス停止(lib/serviceControl.js、既定pleasanter.envWEB_SERVICE_NAMEで変更可): DBへの接続を切るため、ドロップ前に停止する。--databaseでDB名を上書きした場合(テスト用DB等)は、本番サービスを巻き込まないよう停止処理自体をスキップする(--database未指定=本番DB復元の場合のみ実施)。停止前に稼働中か確認し、既に停止していた場合は「対象外」として記録するのみで、完了後の自動再起動も行わない(=元の状態を変えない)。
  5. 復元対象をダウンロードし終えた後で、現在のデータベースを[hostname]pre-restore-<timestamp>という別名(通常のpostgres-とは異なるプレフィックス)でpg_dump・共有ドライブへアップロードする。この順序(ダウンロード→現状バックアップ)を守ることで、この安全バックアップ自体が次回の「最新バックアップ検索」に混入しない(postgres-プレフィックスではないためlib/backupLocator.jsの検索対象外)。
  6. データベースをドロップ(--if-existsなし。対象DBが実在することを前提とし、無い場合はエラーで中断)→同名で再作成。
  7. 結合済みのダンプファイルからpg_restore -h ... -U ... -d <dbName> --no-owner <dumpfile>で復元。
  8. ローカル一時ファイル(ダウンロード分)を削除。Webサービスを再起動(4.で実際に停止していた場合のみ。成功・失敗いずれの場合もfinally節で実行し、再起動自体が失敗した場合は手動でのsystemctl start実行を促すエラーログを出す)。成否に関わらずlib/logUpload.js(backup.jsと共用)で当月ログを共有ドライブへアップロード。

新規/変更ファイル

  • restore.js … エントリポイント
  • lib/backupLocator.js … 最新バックアップ特定ロジック
  • lib/pgRestore.jspg_restoreのspawnラッパー
  • lib/serviceControl.jssystemctl is-active/stop/startのラッパー(Webサービスの安全な停止・再起動用)
  • lib/lineworksDrive.jsdownloadFile()を追加
  • lib/testDb.js … 汎用のcreateDatabase()/dropDatabase()を追加(既存のcreateTestDb/dropTestDbはそのまま維持)
  • lib/pgDump.jsformatDateFolderName()を追加(backup.js/restore.js共用)
  • lib/logUpload.js … backup.js内にあったuploadLogFile()を切り出し、backup.js/restore.js共用に変更
  • .envPG_RESTORE_BIN, WEB_SERVICE_NAME(既定pleasanter)を追加

動作検証(2026-07-11、テストDBにて実施。本番DBには一切触れていない)

  • テスト用DBにマーカー行を入れてバックアップ→内容を変更→node restore.js --database=pleasanter_backup_test --yesを実行→復元後にバックアップ時点の値が正しく戻っていることを2回確認(=最新バックアップからの復元であり、直前の状態ではないことを確認)。
  • 確認プロンプトで誤った入力をした場合、ドロップされずに中断されることを確認。
  • ダウンロードAPIの2段階目(実際のファイル取得URL)でもAuthorizationヘッダーが必須であること(無いと401)を実機で確認済み。
  • --database指定時はpleasanter.serviceの状態が実行前後で一切変化しないことを確認(本番サービスを巻き込まない設計の検証)。
  • lib/serviceControl.jsisServiceActive/stopService/startService自体は、systemdのtransientユニット(systemd-run --collect、実行後は自動的に消える使い捨てユニットで本番には一切影響しない)を使って個別に動作検証済み(起動確認→停止→停止後にis-active=false→存在しないユニットへのstart失敗時のエラーメッセージも確認)。

未検証・注意事項

  • 本番DB(Implem.Pleasanter)に対する--database未指定でのフルリストア(実際のサービス停止・再起動を伴う本番実行)はまだ行っていない。
  • pg_restoreは--no-ownerのみ付与(--clean等は付けていない。ドロップ→再作成した空DBへの復元のため不要と判断)。
  • テスト中に本番バックアップの「最新」ポインタが一時的にテストDBのものに上書きされたため、検証後に本番DB(Implem.Pleasanter)へのnode backup.js runを再実行し、最新バックアップが本番データに戻っていることを確認済み(2026-07-11 12:29)。--databaseでテスト実行した直後は、本番のnode backup.js runを必ず1回実行してから運用に戻すこと(そうしないと--database無指定のrestore.jsが誤ってテストDBのデータを最新として拾ってしまう)。

設定ファイルリストア機能 (restore.js config, 2026-07-11追加)

DB以外の環境設定ファイル(前述「環境設定ファイルバックアップ」参照)を復元するための機能。DBリストア(restore.js database)とは独立した別コマンドとして実装した(node restore.js config [--yes])。Pleasanter自体は既にインストール済みであることが前提(dotnet本体・パッケージ一式434MBは対象外、Parameters/wwwroot/ライセンスDLL等の設定・データ部分のみが対象)。

処理内容

  1. このホスト名に一致する最新の設定アーカイブ([hostname]config-<timestamp>.tar.gz)をlib/backupLocator.jsfindLatestConfigArchive()で特定・ダウンロード。
  2. 対話確認: 復元元・復元先パス一覧・退避方式・停止対象サービスを表示し、ホスト名そのものを入力させて一致した場合のみ続行(--yesでスキップ可)。
  3. .envCONFIG_RESTORE_SERVICES(既定nginx,postgresql-18,pleasanter)に列挙された全サービスを、個々のパスとの対応関係を考慮せず一律に事前停止する(ユーザー指示による。DBリストアと異なり「一部だけ止める」判定はしない)。
  4. CONFIG_BACKUP_PATHSの各パスについて、復元先に既存のファイル/ディレクトリがあれば削除せず、必ず<path>.pre-restore-<timestamp>にリネームして保持してから、アーカイブ側の内容を配置する(lib/configRestore.jsrestoreConfigPaths())。アーカイブ側にそのパスが存在しない場合は警告ログのみでスキップ(退避も配置も行わない)。
  5. サービスの自動再起動は行わない(ユーザー指示による)。3.で実際に停止したサービスの一覧をログ・コンソールに表示し、手動で実行すべきsystemctl start ...コマンド(nginxのみnginx -t && systemctl start nginx)を案内する。
  6. 成否に関わらずlib/logUpload.jsで当月ログを共有ドライブへアップロード。

新規ファイル

  • lib/configRestore.js … tar展開(extractArchive)、退避+配置(restoreConfigPaths)
  • lib/backupLocator.jsfindLatestConfigArchive()を追加(既存のfindLatestBackupFileSetとは別関数。設定アーカイブは分割されない単一ファイルのため)
  • .envCONFIG_RESTORE_SERVICESを追加

動作検証(2026-07-11)

  • サンドボックスでの事前検証: extractArchive/restoreConfigPathsのロジックのみを検証(バックアップ時点の内容で固めたアーカイブに対し、現在の内容を意図的に変更(ドリフトを模擬)した状態から復元し、復元先が正しく戻ること・退避ファイルにドリフト後の内容が保持されること・アーカイブ非含有パスは無変更であることを確認)。

本番環境での実行・インシデントと修正(2026-07-11)

ユーザーの指示により、本番のnginx/postgresql-18/pleasanterを実際に停止した上でnode restore.js configをフル実行(確認プロンプトにホスト名を入力して実施)。結果:

  • 9パス中8パス(nginx設定・Parameters・wwwroot・ライセンスDLL×2・systemdユニット・postgresql.conf・pg_hba.conf)は正常に復元完了。
  • /etc/letsencryptのみ、nginxが起動失敗。原因は、live/*.pemが持つ相対シンボリックリンク(例: fullchain.pem -> ../../archive/<domain>/fullchain1.pem、certbotの標準構成)を配置する際に使っていたfs.cpSync(source, dest, {recursive:true})が、相対リンクをコピー元ディレクトリ(tar展開用の一時ディレクトリ)を基準とした絶対パスに書き換えてしまうという挙動を持っていたため(Node.js の既知の挙動。サンドボックスで再現・確認済み)。tar自体(extractArchive)はシンボリックリンクを正しく相対パスのまま保存・展開できていた。
  • 復旧: 退避してあった/etc/letsencrypt.pre-restore-<timestamp>(正常な状態)を/etc/letsencryptにスワップして即座に復旧 → postgresql-18pleasanternginxの順で起動 → HTTP/HTTPS双方の応答・DBテーブル数(93件)を確認し、正常復旧を確認済み。
  • 恒久対応: lib/configRestore.jsの配置処理を、シンボリックリンクをそのまま複製するcp -a(spawn経由)に変更(restoreConfigPathsは非同期関数に変更)。修正後、サンドボックスで同様の相対シンボリックリンク構成を再現し、復元後もリンクが../archive/cert1.pemのような相対パスのまま正しく保持されることを再検証済み。
  • 本番環境での再実行によるフル(サービス停止込み)なエンドツーエンド再検証は、サンドボックス検証で十分と判断し実施していない(ユーザー確認の上で見送り)。
  • インシデント調査用に一時的に残していた壊れた/etc/letsencrypt.broken-cpsync-symlink-bugは削除済み。設計通りの退避ファイル(*.pre-restore-20260711-132523、7件)はそのまま保持している。

教訓・今後の注意点

  • 対象パスにシンボリックリンクを含む可能性がある場合(今回は/etc/letsencrypt)、Node.jsのfs.cpSync/fs.cprecursiveコピーは要注意。シンボリックリンクを正確に複製する必要がある処理はcp -a等のOSコマンドを使うこと。
  • 今後CONFIG_BACKUP_PATHSに新しいパスを追加する際は、そのパス配下にシンボリックリンクが無いか(find <path> -type l)を事前に確認することが望ましい。

複数サーバーでの運用について

このバックアップシステムは、複数のサーバーが同一の共有ドライブ「システムバックアップ」へ並行してアップロードする運用を想定している。区別のため、ダンプファイル名先頭に[<サーバーのホスト名>]が自動付与される(例: nextoffice2というホスト名のサーバーなら[nextoffice2]postgres-....dmp000)。

サーバーごとに個別のUser Account認証(refresh_token)が必要な理由

  • refresh_tokenはLINE WORKSアプリ(LW_CLIENT_ID)+ユーザーアカウント(kenichiro.nogi@next-hd.co.jp)に紐づくものであり、サーバー自体に紐づくものではない。そのため.envごとコピーしてそのまま使うこと自体はLINE WORKS側の制約としては禁止されていない。
  • ただし本アプリはRefresh Token Rotationが有効なため、「リフレッシュのたびに新しいrefresh_tokenが発行され、古い方は無効化される」(公式ドキュメント・SPEC内既述の通り)。
  • 複数のサーバーが同一のrefresh_tokenをコピーして持った状態で、それぞれ独立してbackup.jsを実行し続けると、どちらか一方が先にローテーションした時点で、もう一方が持つrefresh_tokenは無効化済みとなり、次回実行時に認証エラーで失敗する。
  • そのため、恒久的に並行稼働させる各サーバーは、それぞれ個別にnode lineworks-authorize.jsを実行し、サーバーごとに独立してローテーションするrefresh_tokenを持たせることLW_CLIENT_ID / LW_CLIENT_SECRET / LW_SHAREDRIVE_ID はアプリ・共有ドライブ自体を指す値なので、全サーバーで共通の値をコピーして問題ない。
  • 一時的な移行(旧サーバーを廃止し新サーバーへ完全に切り替える場合など、今後backup.jsを実行するサーバーが常に1台のみ)であれば、.envをそのままコピーしてよい。

refresh_tokenの有効期限

  • refresh_token自体の有効期限は90日間(LINE WORKS公式ドキュメントによる)。
  • Refresh Token Rotationが有効なため、getUserAccessToken()実行のたびに新しい90日間有効なrefresh_tokenが発行され.envに書き戻される。90日以内に最低1回backup.jsを実行し続ける限り、有効期限は自動更新され続け、再度ブラウザでの手動認証は不要
  • 逆に、90日以上backup.jsを一度も実行しない期間ができると、その時点のrefresh_tokenが失効し、次回実行時に認証エラーとなる。その場合はnode lineworks-authorize.jsによる再認証が必要。cron等で定期実行を止めないことが実質的な前提条件。

初回セットアップ (lineworks-authorize.js)

Drive API利用に必須のUser Account認証を一度だけ行うスクリプト。

  1. 事前にDeveloper Consoleの「User Account認証」欄のRedirect URLに .envLW_REDIRECT_URI と完全一致する値を登録しておく。
  2. node lineworks-authorize.js を実行すると認可URLが表示されるので、ブラウザで開き対象アカウント(例: kenichiro.nogi@next-hd.co.jp)でログイン・同意する。パスワードなど認証情報はこちらのスクリプトや対話には一切入力しない(ブラウザ上のLINE WORKSログイン画面でのみ入力する)。
  3. 同意後リダイレクトされた画面のURL(またはcodeパラメータの値)をコンソールに貼り付ける。
  4. codeaccess_token/refresh_tokenに交換し、refresh_token.envLW_USER_REFRESH_TOKENに保存する。
  5. 以降 backup.js は無人でこのrefresh_tokenからaccess_tokenを再発行する。Refresh Token Rotationにより毎回新しいrefresh_tokenが発行されるため、lib/lineworksUserAuth.jsgetUserAccessToken()は取得の都度.envへ書き戻す。

CLIサブコマンド (backup.js)

process.argv[2] をコマンド名として単純にディスパッチする(追加npm依存なし)。

コマンド 内容
node backup.js または node backup.js run [--database=<dbName>] 通常のバックアップフロー(上記1〜8)を実行。--database指定時は同プロセス内でPGDATABASEをその値に上書きしてから実行(.envは書き換えない=使い捨ての一時上書き)。
node backup.js create-test-db [dbName] テスト用DBを作成(lib/testDb.js)。省略時は.envTEST_DB_NAMEを使用。
node backup.js drop-test-db [dbName] テスト用DBを削除(lib/testDb.js)。省略時は.envTEST_DB_NAMEを使用。

これにより、本番DBに触れず以下のようなセルフテストが可能になる:

node backup.js create-test-db
node backup.js run --database=pleasanter_backup_test
node backup.js drop-test-db

lib/testDb.js

  • createTestDb(dbName): PG_CREATEDB_BIN -h $PGHOST -p $PGPORT -U $PGUSER <dbName> を spawn(PGPASSWORD設定時は子プロセスのenvへ渡す)。既に存在する場合はcreatedbのエラーをそのまま表示して終了(先にdrop-test-dbを促すメッセージを添える)。
  • dropTestDb(dbName): PG_DROPDB_BIN -h $PGHOST -p $PGPORT -U $PGUSER --if-exists <dbName> を spawn。--if-existsにより未作成でもエラーにしない。
  • どちらも接続先ホスト/ポート/ユーザーは通常バックアップと同じ.envPGHOST/PGPORT/PGUSER/PGPASSWORDを再利用する(接続先サーバーは同じ、対象DB名だけが違う)。
  • 実行結果はconsole.log/console.errorのみ(通常バックアップの月次ログ・LINE WORKSへのログアップロードとは無関係な開発用ユーティリティのため、logger/Driveアップロードには連携しない)。

ログ設計 (lib/logger.js)

  • 出力先はローカルの LOG_LOCAL_DIR/backup-YYYYMM.log(月が変わったら自動的に別ファイルに切り替わる=月1回ローテーション)。プロセス内でファイルハンドルを使い回さず、書き込みの都度対象月のパスを計算して追記(fs.appendFileSync)することで日付またぎにも自然に対応する。
  • 1行フォーマット: YYYY-MM-DD HH:mm:ss [LEVEL] message(時分秒まで必須)。
  • logger.info(msg) / logger.warn(msg) / logger.error(msg) の3レベル。すべて console.log/console.warn/console.error にも同時出力し、標準出力からも経過が追えるようにする。
  • 記録対象(すべて時分秒付きINFO、異常系はWARN/ERROR):
    • バックアップ処理全体の開始・終了(成功/失敗、所要時間)
    • pg_dumpの開始・終了(失敗時はERRORでstderr内容も記録)
    • LINE WORKSアクセストークン取得(scope、開始・成功・失敗)
    • 共有ドライブID解決(キャッシュ使用/新規検索の別)
    • Pleasanter_DB_Backup・日付フォルダ・logsフォルダそれぞれの取得/作成(既存流用か新規作成か、fileId)
    • 各ダンプファイルのアップロード開始・成功・失敗
    • 429レートリミット時のリトライ(WARN、待機秒数)
    • ローカル一時ファイル削除
    • ローテーションで削除したフォルダ一覧
    • ログファイル自体のアップロード開始・成功・失敗
  • lib/lineworksDrive.js の各関数は logger を受け取って(または直接requireして)上記のログを出す。lwFetch内の429リトライ箇所も console.warn ではなく logger.warn に統一する。

再利用・共通化する既存ロジック

  • JWT組立て・トークン取得: get-token.js / lineworks-anythingllm.jscreateJwtAssertion / getAccessToken をほぼそのまま lib/lineworksAuth.js に切り出す(scopeを引数化するだけで、get-token.jsの board向けデフォルトはCLI側に残す)。
  • 429リトライ付きfetch: lineworks-anythingllm.jslwFetch(スロットリング+Retry-After対応)と同じ実装を lib/lineworksDrive.js 内に置く(ロジックはコピーだが依存関係を増やしたくないため別ファイル化はしない)。
  • ファイル名の日時フォーマットは旧backup.sh(2026-07-11削除)で使われていたdate '+%Y%m%d-%H%M%S'相当をJSで再現。

動作確認方法

nextoffice2サーバーの/opt/dbbackupにて、以下1〜3を実施し成功を確認済み(2026-07-11)。4〜7は未実施(必要に応じて追加で実施)。

  1. node dbbackup/get-token.js でService Account認証 … 今回は未実施(Drive/バックアップ用途では使用しないため)。
  2. node lineworks-authorize.js を実行し、User Account認証(kenichiro.nogi@next-hd.co.jp)でrefresh_tokenが.envLW_USER_REFRESH_TOKENに保存されることを確認 → OK
  3. node backup.js create-test-db でテスト用DBを作成し、node backup.js run --database=pleasanter_backup_test(本番PGDATABASEには触れない)を実行して、
    • LINE WORKSの「システムバックアップ」共有ドライブに Pleasanter_DB_Backup/<today>/ が作成され、[nextoffice2]postgres-....dmp000 としてダンプファイルが格納されること → OK
    • Pleasanter_DB_Backup/logs/backup-<今月>.log がアップロードされ、開始〜終了までの各ステップが時分秒付き・INFOで記録されていること → OK
    • .envLW_SHAREDRIVE_ID(@2101000001789666)が書き込まれること → OK
    • ローカル logs/backup-<今月>.log にも同内容が残っていること → OK を確認。その後 node backup.js drop-test-db でテスト用DB・ローカル一時ファイルを後始末済み。

以下、未実施(今後必要になった際の参考手順として残す)

  1. 同日にもう一度 run --database=pleasanter_backup_test を実行し、同じ日付フォルダ・同じ月次ログファイルが上書き(ログは追記)されること、ダンプファイル名は秒まで異なるため衝突しないことを確認。
  2. BACKUP_RETENTION_COUNT を小さい値(例: 2)に一時変更して連続実行し、古い日付フォルダが削除されローテーションが機能すること、ログに削除対象フォルダがINFOで記録されることを確認。
  3. 異常系: DB接続情報を意図的に間違え、アップロード・ローテーションに進まず失敗終了すること、ローカルログにERRORが記録されつつfinallyでログファイル自体はアップロードされることを確認。
  4. 本番PGDATABASE=Implem.Pleasanterに対する実運用実行、およびcronへの登録(未着手)。