こんな人に向けて
- とにかくバックアップの維持費を下げたい
- NASを持っているが、Docker(Immich)が直接NAS上で動かない
- バックアップはすぐにロールバックできなくても良い
- スマホからの簡易な閲覧環境は欲しい
- 便利な検索機能が制限されてもいい
- 家にRaspberry Pi4が落ちている(Pi5高杉)
あくまで我が家ではうまくいったというだけです。
写真は大切なデータでお金には代えられません。実行の際は別途バックアップを取ったうえで慎重に実行し、うまくいったら移行するようにしてください。
写真の欠損もしくはその他の損害について筆者は一切責任を取りませんし、取れません。それを承知の方のみご利用ください。
背景ポエム
数年前、RAID1のNASのHDDが二台とも同時に壊れ、これまでのデータが吹き飛びました。RAID1信用なりません。それはまぁいいのですが、最近子供が産まれこれからの写真を失うわけにはいきません。
なので別環境にもバックアップをすることにしましたが、容量的にGoogle フォトなどのSaaSを利用する場合月額1000円弱で年間一万弱かかります。あと4,50年の維持と考えると馬鹿になりません。
顔識別や検索機能は確かに魅力ですが、一眼写真はイベントごとにフォルダ分けしているのでさほど問題にならない状況です。
ただS3に突っ込むだけなら簡単なのですが、妻も気軽に写真を見たいと言い出しました。高額な一眼を維持するために奥様の理解は不可欠なため対応することにします。
また初めてClaude Codeと一緒に組んでみたのですが、あまりの速度にこちら側のミスが目立ちました。初心者のようで恥ずかしいのですが、はまったところに備忘録としても残しておきます。
今後の展望
- 顔識別(リモートML): 顔識別機能を使いたいですが、Pi4だとスペックが頼りなく初回で200GB分の識別が大変そうなので、リモートMLで初回のみデータをつくるようにしたいと思います。Immich は機械学習だけを別のマシン(手元の Windows PC など)で動かせるので、初回だけ PC に任せる想定です。
- iPhone の写真のバックアップ: 今は一眼の写真だけが対象です。スマホの写真も Immich アプリ経由で NAS に入れ、そこから S3 にも流れるようにしたいと思っています。
-
イベントフォルダをアルバムにする: 一眼の写真は
2025/1203_誕生日のようにイベントごとのフォルダに分けているので、フォルダ名そのままの Immich のアルバムを自動で作れば、妻もスマホからイベント単位で見られて便利そうです。
機能要件
- NASへのバックアップやフォルダ分けは人間がやるとする
- NASの写真のS3(DeepArchive)へのバックアップ
- Pi4でホストしたImmichで写真を閲覧(家のLAN内のみ)
用意するもの
- Buffalo NAS LS220
- Raspberry Pi4
- 32GB MicroSD
- AWS アカウント
- 「高度な設定」をオンにしないことで支出制限を有効にし月20ドルを超えたら止まるようにしておく。
- 各種権限やセキュリティは気を付けましたが、抜け漏れがあるかもしれないので、高額請求の可能性はできるだけつぶしておきます。
- またBudget Actionsを使って月5ドルを超えたらアップロードを一時停止するようにし、二段構えで高額な請求を抑制します
- 「高度な設定」をオンにしないことで支出制限を有効にし月20ドルを超えたら止まるようにしておく。
構成
[PC] --(SMB で手動保存)--> [NAS: Buffalo LS220D]
|
SMB マウント(写真は読み取り専用)
v
[Raspberry Pi 4 (nas-pi)]
├─ rclone : 毎晩 02:00 に S3 へ「追加のみ」コピー
└─ Immich : NAS を外部ライブラリとして読み取り専用で取り込み
| ^
v | 家の LAN のみ
[S3 (ap-southeast-2) / DEEP_ARCHIVE] [PC・スマホ]
| 項目 | 選んだもの |
|---|---|
| バックアップ先 | S3 Glacier Deep Archive(シドニー ap-southeast-2) |
| 転送ツール | rclone(copy --immutable) |
| 実行 | systemd timer(毎日 02:00) |
| AWS の構築 | Terraform(AWS プロバイダ v5) |
| 写真の閲覧 | Immich(Docker Compose、機械学習は無効) |
| 外からのアクセス | なし(家の LAN 内だけで見る。ポート開放もしない) |
| セットアップ | スクリプト 3 本(AWS 用、バックアップ用、Immich 用) |
リージョンがシドニーなのは、新しい AWS エクスペリエンス(ソーシャルログインで始められる AWS)でプロジェクトのリージョンがシドニーに決まっていたためです。Deep Archive の料金は東京とほぼ同じでした。
クイックスタート
コード一式は yusuke-git/nas-photo-backup-s3 に置いています。手で打つコマンドを減らすため、セットアップは スクリプト 3 本 にまとめました。どれも冒頭の「設定」を書き換えてから実行するだけです。中でやっていることは後の各節で説明しています。
| スクリプト | 実行する場所 | すること |
|---|---|---|
setup/1-aws.ps1(Windows)setup/1-aws.sh(Mac / Linux) |
手元の PC |
terraform.tfvars を作り、aws login → terraform plan → 確認して apply
|
setup/2-pi-backup.sh |
ラズパイ | NAS のマウント、rclone のキー設定、試運転、確認して本番とタイマー |
setup/3-pi-immich.sh |
ラズパイ | Docker、Immich の .env(DB のパスワードは自動生成)、NAS 待ちの設定、起動 |
AWS に作る(apply)ときと、本番のアップロードを始めるときは、内容を表示してから yes と入力したときだけ進むようにしています。
0. 事前の準備(手作業)
- AWS のアカウント。ルートに MFA を付け、管理者で
aws loginできるようにしておく - 手元の PC に AWS CLI v2 と Terraform 1.6 以上
- NAS の管理画面でラズパイ専用ユーザー
nas-piを作り、Photo共有は 読み取りのみ、Thumbnail共有は 読み書き を許可する - ラズパイに Raspberry Pi OS(64bit)を入れ、SSH で入れるようにする
私のBuffalo LS220Dではトップレベル下のフォルダに対してSMBユーザでのアクセス権限を設定できなかったため、トップレベルにPhoto/Thumbnailフォルダを配置しアクセス権限を設定しています。
1. AWS を構築する(手元の PC)
git clone https://github.com/yusuke-git/nas-photo-backup-s3.git
cd nas-photo-backup-s3
notepad setup\1-aws.ps1 # 冒頭の BucketName と BudgetEmail を書き換える
powershell -ExecutionPolicy Bypass -File setup\1-aws.ps1
書き換えるのは冒頭のここだけです。
$BucketName = "example-nas-photo-backup-XXXX" # 世界で一意のバケット名(小文字・数字・ハイフン)
$BudgetEmail = "you@example.com" # 予算と異常検知の通知先
$Region = "ap-southeast-2" # AWS のリージョン(新しい AWS エクスペリエンスはプロジェクトのリージョン)
$AwsProfile = "nas-admin" # 管理者でサインインする AWS CLI のプロファイル名
$MonthlyBudgetUsd = 5 # 月の予算(USD)。超えるとアップロードを自動で止める
Mac / Linux は setup/1-aws.sh を同じように書き換えて bash setup/1-aws.sh です。plan の最後が 16 to add, 0 to change, 0 to destroy なら yes と入力します。後述のハマりどころ(aws login の認証を Terraform が読めない件)の回避もスクリプトの中でやっています。
state(terraform.tfstate)は clone したフォルダの infra/ にできます。以後の Terraform の作業は必ずこのフォルダで行ってください(別の場所に clone して plan すると、全部を新しく作ろうとします。ハマりどころ 4 を参照)。
2. バックアップを設定する(ラズパイ)
sudo apt-get install -y git
sudo install -d -o "$USER" -g "$USER" /opt/nas-photo-backup
git clone https://github.com/yusuke-git/nas-photo-backup-s3.git /opt/nas-photo-backup
cd /opt/nas-photo-backup
nano setup/2-pi-backup.sh # 冒頭の NAS_IP と BUCKET_NAME を書き換える
bash setup/2-pi-backup.sh
NAS_IP="<NASのIP>" # NAS の IP アドレス(例 192.168.0.10)
NAS_USER="nas-pi" # NAS に作ったラズパイ専用ユーザー
NAS_PHOTO_SHARE="Photo" # 写真の共有フォルダ名(ラズパイからは読み取り専用)
NAS_THUMB_SHARE="Thumbnail" # Immich のサムネイル用の共有フォルダ名(読み書き)
SMB_VERSION="2.0" # つながらなければ 1.0(古い方式なので LAN の中だけで使う)
BUCKET_NAME="example-nas-photo-backup-XXXX" # 1-aws で作ったバケット名
REGION="ap-southeast-2" # バケットのリージョン
途中で sudo のパスワード、NAS のパスワード、AWS のアクセスキーを聞かれます(画面には表示されません)。アクセスキーは聞かれたときに AWS コンソールで発行して貼り付けます(IAM → ユーザー → nas-backup → アクセスキーを作成)。キーはラズパイの ~/.config/rclone/rclone.conf にだけ保存されます。
最後に試運転(DRY_RUN、一覧を取るだけなのでほぼ無料)の結果がこんな感じで出ます。
=== 6/7 試運転(DRY_RUN。一覧を取るだけで、アップロードはしない) ===
アップロード予定: 4909 件(拡張子ごと)
221 CR2
4673 JPG
15 MOV
初回のリクエスト料金の目安: 約 0.29 ドル(1,000 件 0.06 ドル)
=== 7/7 本番のアップロードと毎晩のタイマー ===
Deep Archive は180日以内に消しても課金されます。上の件数と拡張子が想定どおりか確認してください。
本番のアップロードを始め、毎晩 02:00 のタイマーを有効にしますか? (yes/no)
件数と拡張子が想定どおりなら yes で本番のアップロードと毎晩 02:00 のタイマーが始まります。初回は数時間かかりますが、systemd で動かすので SSH を切っても止まりません。
3. Immich を起動する(ラズパイ)
cd /opt/nas-photo-backup
nano setup/3-pi-immich.sh # SD カードで動かすなら DB_STORAGE_TYPE="HDD"
bash setup/3-pi-immich.sh
終わったらブラウザで http://<ラズパイのIP>:2283 を開いて管理者を作り、「管理」→「外部ライブラリ」でフォルダ /mnt/media/photos を登録してスキャンします。
どのスクリプトも、作成済みのファイル(/etc/nas-credentials、rclone.conf、backup.env、.env、/etc/fstab の行)は上書きしません。途中で止まっても、直してからもう一度実行すれば続きから進みます。
費用
シドニーの単価(保管 0.002 ドル/GB・月、PUT 1,000 件 0.06 ドル)で見積もると、こうなります。
| 項目 | 100GB | 2TB |
|---|---|---|
| 保管料(月) | 約 0.2 ドル | 約 4 ドル |
| 初回アップロード(1 回だけ) | 約 1〜2 ドル | 約 20〜30 ドル |
| 全部を復元(1 回あたり) | 約 15 ドル | 約 250 ドル |
我が家の実績は、JPG・RAW(CR2)・動画(MOV)合わせて 約 4,900 ファイル・15GB 前後 で、初回アップロードが約 0.3 ドル、保管料が 月 0.03 ドル程度 です。
Deep Archive の注意点は次の 2 つです。
- 180 日以内に削除・上書きすると、残りの日数分も課金される。なので「追加のみ」で運用する
- 復元は 12〜48 時間かかり、費用の大半はインターネットへの転送料。全部を戻すときは月 100GB の無料枠に収まるように分けると安い
1. AWS 側(Terraform)
Terraformで作ります(クイックスタートの 1-aws がやっていることです)。
S3 バケット
resource "aws_s3_bucket" "backup" {
bucket = var.bucket_name
# 誤って terraform destroy してもバックアップが消えないようにする
lifecycle {
prevent_destroy = true
}
}
# アクセスキーが漏れて同じ名前で上書きされても、元の写真を古い版として残す
resource "aws_s3_bucket_versioning" "backup" {
bucket = aws_s3_bucket.backup.id
versioning_configuration {
status = "Enabled"
}
}
ほかに次を設定しています。
- パブリックアクセスをすべてブロック、ACL 無効(
BucketOwnerEnforced) - SSE-S3 で暗号化(追加料金なし)
- バケットポリシーで
aws:SecureTransport = falseを Deny(HTTPS 以外を拒否) - 未完了のマルチパートアップロードを 7 日で削除(中断したアップロードの断片に課金され続けるのを防ぐ)
先述しましたがあくまで最終手段のバックアップなのでGlacier Deep Archiveに入れます。今回は rclone 側で storage_class = DEEP_ARCHIVE を指定し、アップロード時に直接 Deep Archive に入れています(Standard から移すライフサイクルルールは使っていません)。
ラズパイ専用の IAM ユーザー(最小権限)
ラズパイが使う IAM ユーザー nas-backup には、一覧とアップロードだけ を許可します。
data "aws_iam_policy_document" "backup" {
statement {
sid = "ListBucket"
actions = ["s3:ListBucket"]
resources = [aws_s3_bucket.backup.arn]
}
statement {
sid = "UploadOnly"
actions = [
"s3:PutObject",
"s3:AbortMultipartUpload",
"s3:ListMultipartUploadParts",
]
resources = ["${aws_s3_bucket.backup.arn}/*"]
}
}
-
GetObject(読み出し)とDeleteObject(削除)は与えない。キーが漏れても写真を読まれたり消されたりしない -
アクセスキーは Terraform で作らない。
aws_iam_access_keyで作ると、シークレットが state ファイルに平文で残るため。apply 後に本人がコンソールで発行し、ラズパイのrclone.confにだけ書く(2-pi-backup.shが途中でキーを聞いてくるのはこのため)
予算を超えたら自動でアップロードを止める(Budget Actions)
いちばん怖いのは「キーが漏れて大量にアップロードされ、請求が膨らむ」ことです。そこで AWS Budgets の Budget Actions を使い、実績が月 5 ドルに達したら nas-backup に書き込み拒否ポリシーを自動で付けます。
resource "aws_iam_policy" "backup_deny_write" {
name = "nas-backup-deny-write"
policy = data.aws_iam_policy_document.backup_deny_write.json # PutObject などを Deny
}
resource "aws_budgets_budget_action" "stop_backup_writes" {
budget_name = aws_budgets_budget.monthly.name
action_type = "APPLY_IAM_POLICY"
approval_model = "AUTOMATIC"
notification_type = "ACTUAL"
execution_role_arn = aws_iam_role.budget_action.arn # budgets.amazonaws.com だけが引き受けられるロール
action_threshold {
action_threshold_type = "PERCENTAGE"
action_threshold_value = 100
}
definition {
iam_action_definition {
policy_arn = aws_iam_policy.backup_deny_write.arn
users = [aws_iam_user.backup.name]
}
}
subscriber {
address = var.budget_email
subscription_type = "EMAIL"
}
}
Budgets 用のロールには iam:AttachUserPolicy / DetachUserPolicy だけを、対象ユーザーと拒否ポリシーを条件で絞って 許可しています(iam:PolicyARN の ArnEquals 条件)。
あわせて Cost Anomaly Detection(サービスごとに 1 ドル以上の急増を毎日メール)も入れました。どちらも無料です。
| 起きること | 対策 | 止まるか |
|---|---|---|
nas-backup のキーが漏れ、大量にアップロードされる |
Budget Actions が書き込みを拒否 | 止まる(Budgets の更新が数時間遅れるので、その日のうち) |
| 管理者の認証情報が漏れ、EC2 などを起動される | 異常検知が通知 | 止まらない。管理者は MFA 必須・アクセスキーを作らないことで防ぐ |
予算にフィルターを付けていないので、S3 以外が原因で 5 ドルを超えてもバックアップが止まります。解除はコンソールの Budgets → アクションで「元に戻す」です。
バージョニングを後から有効にした理由
最初はバージョニングを無効にしていました。rclone は上書きしないので不要だと思っていたからです。しかし見直してみると、s3:PutObject は同じ名前での上書きもできる ので、キーが漏れると全写真をゴミで上書きされて元に戻せません(クラウドのバックアップを狙うランサムウェアの典型的な手口です)。
バージョニングを有効にすれば上書きされても古い版が残り、nas-backup には古い版を消す権限(s3:DeleteObjectVersion)がありません。rclone が上書きしない限り古い版はできないので、普段の費用は 0 円のままです。
「上書き禁止のポリシーを付ければいいのでは?」 と考えましたが、IAM だけでは「新規は可、上書きは不可」を書けません。S3 の 条件付き書き込みの強制(バケットポリシーで If-None-Match: * を必須にする)なら書けますが、執筆時点の rclone は S3 へのアップロードでこのヘッダーを送れず(--header-upload でも無視される)、入れるとバックアップが全部失敗します。そのため、バージョニングで守ることにしました。
2. ラズパイ側(rclone + systemd)
クイックスタートの 2-pi-backup.sh がやっていることです。
NAS をマウントする
NAS にラズパイ専用のユーザー nas-pi を作り、Photo 共有は 読み取りのみ、Immich のサムネイル置き場にする Thumbnail 共有は 読み書き を許可しました。ラズパイ側のマウントも Photo は読み取り専用にして、二重に守ります。
# ro = read-only(読み取り専用)、rw = read-write(読み書き)
//<NASのIP>/Photo /mnt/nas/photos cifs credentials=/etc/nas-credentials,vers=2.0,ro,iocharset=utf8,uid=pi,gid=pi,_netdev,nofail 0 0
//<NASのIP>/Thumbnail /mnt/nas/thumbnail cifs credentials=/etc/nas-credentials,vers=2.0,rw,iocharset=utf8,uid=pi,gid=pi,file_mode=0644,dir_mode=0755,_netdev,nofail 0 0
-
credentials=… ユーザー名とパスワードは別ファイル(権限 600)から読む -
iocharset=utf8…1203_誕生日のような日本語のフォルダ名を化けさせない -
_netdev,nofail… ネットワークが上がってからマウントし、NAS が落ちていてもラズパイの起動は止めない - LS220D はファームウェアによって SMB のバージョンが違うので、まず
vers=2.0で試します(SMB1 は避けたい)
rclone の設定
[s3backup]
type = s3
provider = AWS
access_key_id = <nas-backup のアクセスキー>
secret_access_key = <シークレットキー>
region = ap-southeast-2
location_constraint = ap-southeast-2
storage_class = DEEP_ARCHIVE
server_side_encryption = AES256
# バケット作成権限を持たないので、存在確認をしない
no_check_bucket = true
バックアップスクリプト
#!/usr/bin/env bash
# NAS の写真を S3 Glacier Deep Archive に追加のみでコピーする。
set -euo pipefail
CONF_FILE="${CONF_FILE:-/etc/nas-backup/backup.env}"
# コマンドラインで指定した DRY_RUN(例: DRY_RUN=1 backup.sh)を backup.env の値より優先する
ENV_DRY_RUN="${DRY_RUN:-}"
[ -f "$CONF_FILE" ] && . "$CONF_FILE"
[ -n "$ENV_DRY_RUN" ] && DRY_RUN="$ENV_DRY_RUN"
SRC="${SRC:-/mnt/nas/photos}"
DEST="${DEST:?DEST を backup.env に設定してください}"
LOG_DIR="${LOG_DIR:-/var/log/nas-backup}"
LOCK_FILE="${LOCK_FILE:-/run/lock/nas-backup.lock}"
DRY_RUN="${DRY_RUN:-0}"
mkdir -p "$LOG_DIR"
exec 9>"$LOCK_FILE"
if ! flock -n 9; then
echo "別のバックアップが実行中のため終了します" >&2
exit 0
fi
# NAS がマウントされていないと空ディレクトリをコピーしてしまうので確認する
if ! mountpoint -q "$SRC" && ! mountpoint -q "$(dirname "$SRC")"; then
echo "NAS がマウントされていません: $SRC" >&2
exit 1
fi
args=(
copy "$SRC" "$DEST"
--immutable # 既存オブジェクトと違う場合は上書きせずエラーにする
--size-only # 更新日時の取得(HEAD リクエスト)を省いて料金を抑える
--s3-no-head # アップロード後の HEAD 確認を省く
# 隠しフォルダ(NAS が自動で作るサムネイルなど)は除外し、JPEG・RAW・動画だけを対象にする
--filter "- .*/**"
--filter "+ *.{jpg,jpeg,JPG,JPEG,cr2,CR2,mov,MOV}"
--filter "- *"
--transfers 2
--log-file "$LOG_DIR/backup.log"
--log-level INFO
--stats-one-line --stats 0
)
[ "$DRY_RUN" = "1" ] && args+=(--dry-run)
rclone "${args[@]}"
ポイントは次のとおりです。
| オプション | 理由 |
|---|---|
copy(sync ではない) |
NAS で消した写真を S3 から消さない。保険なので残っていてほしい |
--immutable |
同じ名前で中身が違うファイルは、上書きせずエラーにする。Deep Archive の早期削除課金を避けるのと、ランサムウェアで暗号化されたファイルで上書きしないため |
--size-only + --s3-no-head
|
ファイルごとの HEAD リクエストを省き、毎晩の差分確認を LIST だけで済ませる。2TB・40 万枚でも 1 日数円以下 |
--filter |
上から順に評価される。隠しフォルダを先に除外してから拡張子で絞る |
flock |
初回の長いアップロード中に翌晩のタイマーが重なっても二重に走らない |
mountpoint チェック |
NAS が外れて空のフォルダを見ている状態で実行しない |
systemd timer で毎晩実行
[Unit]
Description=NAS の写真を S3 Deep Archive にバックアップ
Wants=network-online.target
After=network-online.target remote-fs.target
[Service]
Type=oneshot
User=pi
ExecStart=/opt/nas-photo-backup/backup/backup.sh
Nice=10
IOSchedulingClass=idle
[Timer]
OnCalendar=*-*-* 02:00:00
RandomizedDelaySec=10min
Persistent=true
[Install]
WantedBy=timers.target
初回の本番は数時間かかるので、SSH が切れても止まらないよう sudo systemctl start --no-block nas-backup.service で systemd 経由で動かしました。
3. Immich で写真を見る
Immich は Google フォト風のセルフホスト型アプリです。公式の docker-compose.yml から、次の点だけ変えました(クイックスタートの 3-pi-immich.sh はこれを起動します)。
| 変更 | 理由 |
|---|---|
/mnt/nas/photos:/mnt/media/photos:ro を追加 |
NAS の元写真を 外部ライブラリ として読み取り専用で取り込む。Immich で削除しても元写真は消えない |
/mnt/nas/thumbnail:/data/thumbs を追加 |
サムネイルを NAS に置き、ラズパイのディスクを節約する |
機械学習コンテナを profiles: ['ml'] に |
Pi 4 では顔認識・スマート検索が重いので既定では起動しない。後から docker compose --profile ml up -d で戻せる |
IMMICH_CONFIG_FILE で設定ファイルを読む |
サーバー設定を Git で管理する |
DB(PostgreSQL)は ネットワーク共有に置けない ので、ラズパイのローカルに置きます。SD上にDBを置くわけですので、消耗が早くなると思います。今後の展望として一日おきにDBのバックアップをS3かNASにおいてもいいかもしれない。
起動直後に NAS がまだマウントされていないと、写真が「オフライン」扱いになったり、サムネイルがローカルに書かれたりします。そこで Docker 自体を NAS のマウント後に起動するようにしました。
[Unit]
RequiresMountsFor=/mnt/nas/photos /mnt/nas/thumbnail
見るのは 家の LAN 内だけ にしました。外から見られると便利ですが、家族の写真をインターネット側に出す入口を増やしたくないので、ルーターのポートも開けていません。
Immich アプリの「バックアップ」(スマホからの自動アップロード)は使いません。今の構成では写真が NAS ではなくラズパイに入り、S3 にもバックアップされなくなるためです。写真は今まで通り PC から NAS に保存します(スマホの写真は今後の展望で対応したいところです)。
セキュリティで気をつけたこと
構築後に見直したセキュリティのまとめです。
| 対象 | やったこと |
|---|---|
| S3 | 非公開・暗号化・HTTPS のみ・prevent_destroy・バージョニング |
| IAM | ラズパイ用は一覧とアップロードだけ。キーは state にもチャットにも出さない |
| 費用 | 予算超過で書き込みを自動停止、異常課金を毎日通知 |
| 管理者 | MFA 必須、アクセスキーを作らず aws login を使う |
| NAS | ラズパイ専用ユーザー、写真は NAS 側とマウントの両方で読み取り専用、SMB2 以上 |
| ラズパイ | SSH は鍵認証のみ・root ログイン禁止、unattended-upgrades で自動更新 |
| ネットワーク | 家の LAN 内だけで使い、ポート開放・UPnP は使わない |
SSH の強化は、/etc/ssh/sshd_config.d/ に新しくファイルを作ります。このフォルダは 先に読まれたファイルの設定が優先される ので、10- で始まる名前にしておくと、50-cloud-init.conf などに PasswordAuthentication yes があっても勝てます。
sudo tee /etc/ssh/sshd_config.d/10-hardening.conf <<'EOF'
PasswordAuthentication no
KbdInteractiveAuthentication no
PermitRootLogin no
EOF
sudo sshd -t # 書き間違いの確認
sudo sshd -T | grep -E 'passwordauthentication|permitrootlogin' # どちらも no
sudo systemctl restart ssh # 別のターミナルで鍵ログインできることを確かめてから閉じる
ラズパイで ufw を設定しても、Docker の ports: で公開したポート(Immich の 2283)には効きません。Docker が iptables を直接書き換えるためです。Immich を守るのは「ルーターで開けない」ことです。
ハマりどころ
最後になりますが、ここからが本題かもしれません。実際に詰まったところです。クイックスタートのスクリプトには、ここで分かった対策を入れてあります。
1. 試運転したら NAS の自動サムネイルが大量に混ざっていた(.webaxs)
DRY_RUN=1 で試運転したら、ログにこんな行がずらっと並びました。
NOTICE: 2025/1203_誕生日/.webaxs/thumbnail/IMG_0001.JPG.3L.jpg: Skipped copy as --dry-run is set
NOTICE: 2025/1203_誕生日/.webaxs/thumbnail/IMG_0001.JPG.4L.jpg: Skipped copy as --dry-run is set
NOTICE: 2025/1203_誕生日/.webaxs/thumbnail/IMG_0001.JPG.L.jpg: Skipped copy as --dry-run is set
...
Buffalo の WebAccess が、各フォルダの .webaxs/thumbnail/ に 1 枚につき 5 サイズ のサムネイルを自動で作っていました。拡張子は .jpg なので、拡張子のフィルターだけでは素通りします。Deep Archive は一度上げると 180 日は消しても課金されるので、本番前に気づけて助かりました。
拡張子ごとの件数を数えると、何が混ざっているかがすぐ分かります(2-pi-backup.sh は試運転のあとにこれを自動で表示します)。
find /mnt/nas/photos -type f | sed 's/.*\.//' | sort | uniq -c
# 362 appthumb ← NAS が作るサムネイル
# 221 CR2
# 12 DS_Store ← Mac の隠しファイル
# 1815 jpg ← ほぼ全部 .webaxs の中
# 4673 JPG ← 元写真
# 15 MOV
対策は、隠しフォルダ(. で始まるフォルダ)の中を丸ごと除外 するフィルターを先頭に置くことです。
--filter "- .*/**"
--filter "+ *.{jpg,jpeg,JPG,JPEG,cr2,CR2,mov,MOV}"
--filter "- *"
修正後の試運転で grep -c webaxs が 0、対象件数が JPG + CR2 + MOV の合計とぴったり一致することを確認してから本番に進みました。
.webaxs があるということは WebAccess が有効になっているかもしれず、その場合 NAS がインターネットに出ています。古い機種なので WebAccess は切るのがおすすめではありますが、ほかのファイルを閲覧したいので我が家ではそのままオンにしています
2. DRY_RUN=1 backup.sh が本番アップロードになる
最初のスクリプトは、backup.env を読み込んだあとに DRY_RUN を参照していました。そのため、
DRY_RUN=1 ./backup.sh
と打っても、backup.env に書いた DRY_RUN=0 で上書きされて 試運転のつもりが本番 になります。. "$CONF_FILE" で読み込むと、ファイル内の代入がそのまま環境変数を上書きするためです。読み込む前にコマンドラインの値を退避し、読み込み後に戻すようにしました(上のスクリプトの冒頭 4 行)。
3. Terraform が aws login の認証を読めない
新しい AWS CLI の aws login(ブラウザでサインインする方式)でプロファイルを作ったところ、AWS CLI は通るのに Terraform(AWS プロバイダ v5)は次のエラーになりました。
Error: No valid credential sources found
執筆時点のプロバイダ v5 は aws login で作られた認証情報を直接読めないようです。AWS CLI で一時認証情報を環境変数に出し、Terraform にはプロファイルを使わせないようにして回避しました(PowerShell の例。1-aws.ps1 の中でも同じことをしています)。
aws login --region ap-southeast-2 --profile nas-admin
aws configure export-credentials --profile nas-admin --format powershell | Invoke-Expression
terraform plan -var aws_profile= -out=tfplan
terraform apply tfplan
-var aws_profile= を 付け忘れる と、今度はこう怒られます。
Error: A Profile was specified along with the environment variables "AWS_ACCESS_KEY_ID" and "AWS_SECRET_ACCESS_KEY". The Profile is now used instead of the environment variable credentials.
プロバイダの profile に値が入っていると、環境変数よりプロファイルが優先されるためです。毎回付けるのが面倒なら、変数 aws_profile の既定値を空にしておくのが楽です。
4. terraform.tfstate が別のフォルダにあった
バージョニングを追加しようと、別の場所に clone し直したリポジトリで terraform plan したら、bucket_name を聞かれました。terraform.tfvars は Git に入れていないので、clone し直すと消えます。
ここで tfvars だけ作り直して plan していたら危なかったです。state もそのフォルダにない ので、Terraform は「何も作っていない」と判断し、15 リソースを全部新規作成しようとします(バケット名が既に使われているので失敗はしますが、IAM などは重複して作られかねません)。
前回 apply したフォルダを探して、そこで plan し直して解決しました。
dir C:\Users\<user> -Recurse -Filter terraform.tfstate -ErrorAction SilentlyContinue
個人用途なので state はローカルのままにしていますが、「どのフォルダで apply したか」を README などに必ず残す のが教訓です。複数台で触るなら S3 バックエンドに移すべきでした。
さらに、正しいフォルダでも古いブランチのままだったため、最初の plan にはバージョニングが入っていませんでした。plan の結果が想定(今回なら 1 to add)と違ったら、apply せずに原因を確認するのが大事です。
AI用と自分用で作業ディレクトリ分けていたのがよくなかったです。
5. plan に身に覚えのない IAM タグの削除が出る
~ resource "aws_iam_user" "backup" {
~ tags = {
- "AKIAXXXXXXXXXXXXXXXX" = "nas-pi" -> null
}
}
コンソールでアクセスキーを作るときに入力した 「説明」は、IAM ユーザーのタグ(キー=アクセスキー ID)として保存されます。Terraform のコードに書いていないので「消す」と表示されていました。消えるのは説明文だけで、キーや権限には影響しません。気になるなら lifecycle { ignore_changes = [tags] } で無視できます。
同時に出た aws_iam_role_policy の - Statement ... は、依存先(上のユーザー)に変更があるため「apply 時に再計算(known after apply)」と表示されているだけで、中身は変わりませんでした。
6. SSH の config で Host の名前を間違えた
GitHub 用の鍵を別名で作り、~/.ssh/config に書いたのに Permission denied (publickey) になりました。
Host git # ← これが原因
HostName github.com
User git
IdentityFile ~/.ssh/id_ed25519_git
IdentitiesOnly yes
Host は 接続するときに打つ名前 と一致したときだけ使われます。git@github.com に接続するなら Host github.com と書く必要があります(Host git なら git@git で接続することになる)。ssh -vT git@github.com の Offering public key: の行を見ると、どの鍵を送っているかが分かります。
ラズパイ用も同じように書いておくと、ssh nas-pi だけで入れて便利です。鍵が複数あるときは IdentitiesOnly yes を付けないと、順番に試して「試行回数超過」で弾かれることがあります。
Host nas-pi
HostName nas-pi.local
User pi
IdentityFile ~/.ssh/id_ed25519
IdentitiesOnly yes
7. ラズパイでプライベートリポジトリを clone する
設定ファイルをリポジトリで管理しているので、ラズパイで git pull したい。でも自分用のリポジトリはプライベートなので https:// の clone ではパスワードを聞かれて失敗します(公開リポジトリをそのまま使うなら、クイックスタートのとおり https:// で clone できるので不要です)。
ラズパイ専用の Deploy key(読み取り専用) を使いました。そのリポジトリだけ、しかも pull しかできない鍵なので、ラズパイが盗まれても被害が小さく済みます。
ssh-keygen -t ed25519 -f ~/.ssh/nas-photo-backup -N "" -C "raspi deploy key"
cat ~/.ssh/nas-photo-backup.pub # リポジトリの Settings → Deploy keys に登録(Allow write access は付けない)
GIT_SSH_COMMAND="ssh -i ~/.ssh/nas-photo-backup" git clone git@github.com:<user>/<repo>.git /opt/nas-photo-backup
git -C /opt/nas-photo-backup config core.sshCommand "ssh -i ~/.ssh/nas-photo-backup" # 以降の pull も同じ鍵
8. 細かいけど詰まったところ
-
sudo backup.shだとキーが見つからない:rclone.confはpiのホームにあるので、root で動かすと root のホームを探してしまう。piで実行する(systemd でもUser=pi。セットアップのスクリプトも root では動かないようにしています) -
実行しても画面に何も出ない: 出力はログファイルに書く作りなので正常。
tail -f /var/log/nas-backup/backup.logを別の SSH で見る -
「There was nothing to transfer」: NAS の
Photo共有が空だった(写真を移し忘れていた)。マウントはできていたので、ls -laで中身を見るまで気づかなかった -
写真をコピーしている途中に本番を走らせない: 途中までのファイルが S3 に上がると、
--immutableのせいで後から正しいファイルに差し替えられない -
Immich の設定画面が変更できない: 設定ファイル(
IMMICH_CONFIG_FILE)を使うと画面からは変えられなくなる。外部ライブラリの登録は別ページなのでそのまま使える -
Immich のサムネイル作成の進捗: 画面の「ジョブ」か、
find /mnt/nas/thumbnail -name '*-thumbnail.webp' | wc -lで数える。6,500 枚で数時間かかった
まとめ
- 写真 15GB 程度なら、Deep Archive の保管料は 月数円。初回アップロードも数十円
- rclone の
copy --immutableと、読み出し・削除権限のない IAM ユーザーで「追加しかできない」バックアップにした - Budget Actions で、キーが漏れても 予算を超えたら自動で書き込みを止める。バージョニングで上書き攻撃にも備えた
- 本番前の
DRY_RUNは大事。NAS が勝手に作るサムネイル(.webaxs)に気づけた - Terraform は「どこで apply したか(state の場所)」を必ず記録しておく
- セットアップはスクリプト 3 本にまとめたので、設定を書き換えて順に実行すれば同じ構成が作れる
残っている課題は、バックアップ失敗時の通知と、Immich の DB ダンプ(アルバムなど)のバックアップです。そのうち追記します。