30
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Terraformでdotfilesを管理する

Last updated at Posted at 2025-09-09

こちらの記事は「MEDLEY Summer Tech Blog Relay」の13日目の記事です。
https://developer.medley.jp/entry/2025/08/15/20250815/

株式会社メドレーの稲村です。
普段メドレーではSRE業務に従事しており、AWSの運用管理を行っています。

最近Audibleを聞くようになり、ハリー・ポッターシリーズを全て聴きおえ、映画もつい先日最後まで見終えました。
改めて全て通してみると、全ての出来事が繋がり奥深い物語であることが10数年ぶりくらいにわかり、とても感慨深いです(謎のプリンスまでは読んでましたが最後を読んでませんでした)。
映画は時間の都合上か、原作とはかなり異なる点がありますが、うまく再現できていると感じました。
最近表参道にハリー・ポッターのお店が出来たようなので落ち着いたら行ってみようかと考えています(人気がありすぎて5時間待ちだとかなんとか)。

概要

さて、突然ですがみなさんはdotfilesをどのように管理していますでしょうか。
dotfilesといえばローカルPCのホームディレクトリ直下に配置してある .zshrc.vimrcなどのことです。

dotfilesの管理は、プライベートなPCと仕事のPCで同一の設定を反映するのに有用です。
プライベート、または仕事の最中に見つけたアプリケーションや設定を取り込んでおくことで、どちらか一方で困るのを防ぐことができます。

今回はdotfilesをTerraformで管理するようにしたのでそのあたりをまとめていこうと思います。

環境

  • macOS 15.4.1, M2 MacBook Air
  • Terraform 1.13.1

dotfilesの管理方法

dotfilesの管理方法にはいくつかあるかと思います。

1. 手でコピー

これはかなり骨が折れます。毎回cpを叩くのも面倒ですし、ln -sシンボリックリンクを作成するのもsrc, destをよく間違えるし大変です。インフラエンジニアにあるまじきですが、誰かシンボリックリンクのsrc, destを間違えない覚え方を教えて。

2. Shell Script

Shell Scriptは最もよく使われ、エンジニアであれば(体裁さえ気にしなければ)誰でも書けるでしょう。
事実、私も長らくShell Scriptで管理してきました。
しかしながら、セットアップで使っている .sh ファイルの一部はどこかのブログ記事からコピーしてきたものです。
継ぎ足し継ぎ足し秘伝のタレと化したスクリプトは、当時は理解していたとしても今見たら何をやっているか分からない箇所がままあります(思い出すのも面倒です)。

3. Ansible

Ansibleでも数年管理していました。
DSLとymlで書く構成管理は、非常に管理に優れていました。
しかし、Macのバージョンが上がり、Python2系から3系に上がったことでいくつも修正が求められました(AnsibleはPythonで書かれています)。
また、LL系言語は便利で大好きですが、もう少し高速だと嬉しいです。Macのセットアップ時にAnsibleを最初セットアップするのも悩ましいところがあります。結局Shell Scriptに戻りました。

4. Makefile

Makefileは非常に強力なツールですが、Shell Scriptに慣れているとMakefileのif文や変数管理は少し癖を感じます。
タブとスペースが混在すると簡単に動かなくなるのも割とストレスになります(タブとスペースを見分けられるようにエディタで見れば良いけれども)。
そもそもbuildを簡易化するためのツールであり、タスクランナーとして使うことには抵抗を覚えるのです(そう言いながら開発者向けのタスクランナーとしてMakefileをよく用意します)。

5. 管理しない

もう思い切って男らしくその都度最適化していくのも一つでしょう。仕事は仕事、プライベートはプライベート。
しかし、私のポリシーにはマッチしませんでした。仕事で設定した.vimrcの設定がプライベートPCで使えなかったらストレスでしょう。

dotfilesを管理するための要件

これらを経験してdotfilesを管理するには下記要件があると良いと考えました。

  1. ファイルをコピーできる
  2. brew bundleなどのシェルコマンドが実行できる
  3. 特定の関数を指定して実行ができる
  4. 動作が軽量である
  5. できたらDry Run

これらをふまえると正直Shell Scriptあたりが妥当ではあります。
しかし、ある程度抽象化したいのもあり、Shell Scriptで関数を複数書き出すと途端に見通しが悪くなってデバッグがしづらく感じるため、関数を複数書き出したら別のツールにしたほうが良いと考えています。

最近(?)だとGo製のTaskあたりも良いかもしれません。
しかし、新しいツールは運用が腐りやすいという懸念があります。

そこで ブログドリブンで 普段書き慣れているTerraformで同様のことができないか試してみることにしました。

結論から言えば課題はあれどそこそこ満足しています。

Terraformによる管理

Providerの選定

個人の方ですが下記のようにdotfilesをシンボリックリンクで設定するproviderを公開されている方がいました。
正直私のやり方よりもシンプルで目的通りで良かったものの、あまりメンテナンスされていなそうだったため採用には至りませんでした。

結果、できる限り組み込み関数&HashiCorp社提供のものを採用するに至りました。

ディレクトリ構成

まず前提としてgit管理を行います。
とりあえず、最適解かは不明ですがリポジトリの直下にdotfilesを配置します。
階層化するほどのものでも無いのと、リポジトリの直下=HOME直下のイメージがしやすいからという理由です。
terraformは専用のディレクトリを切っておいたほうがdotfilesと混ざらなくて良いかなと思います。

$ tree
.
├── (dotfileをひたすらリポジトリ直下に配置する)
├── Brewfile
├── Brewfile.lock.json
├── README.md
├── setup.sh(何も設定していない際に一番最初に実行するShell Script。ここでbrewをインストール)
└── terraform
    ├── locals.tf
    ├── main.tf
    ├── terraform.tf
    └── terraform.tfstate

Terraformのセットアップ

ワンバイナリで動くので、バイナリファイルごと一緒にコミットしても良いかなと思いますが、そのあたりは一旦ステイでまずはTerraformがインストールされている想定です。
もし無い場合はbrewでいれましょう(私はtfenvを使っています)。

Terraformを最低限動かすために下記が必要です。
providerhashicorp/localは所見の方もいると思いますが、後述します。

$ cat terraform.tf
terraform {
  backend "local" {
    path = "terraform.tfstate"
  }

  required_providers {
    local = {
      source  = "hashicorp/local"
      version = "~> 2.5.3"
    }
  }
}

普段チームでCloudのインフラをTerraform管理している場合は、backendはクラウドストレージを指定していると思いますがdotfilesはデバイスごとに管理する必要があるため、stateはlocalに保存します。
tfstateのlocal管理はチーム運用には向きませんが、検証等を行う際にはかなり有用です。

間違ってコミットしないように、下記を.gitignoreに追加しておきます。

$ cat .gitignore

.terraform
terraform.tfstate
terraform.tfstate.backup
terraform.tfstate.*.backup

tfenvなどで自動的にバージョン判定できるように .terraform-versionも追加しておきます。
tfenvを使ってなければ無くても大丈夫です。

$ cat .terraform-version
1.13.1

ここまできたら、下記を実行して必要なproviderのダウンロードとterraform.tfstateを生成しておきます。

$ terraform init

brewの管理と実行

私はbrewでインストールするものはBrewfileで管理しています。
そうすると、linuxbrewを使うことでLinux環境でも使えますし、OSで分岐も可能です。
配列も指定できます。

$ cat Brewfile

### Main
tap_list = [
  "fluxcd/tap",
  "sanemat/font",
  "fujiwara/tap",
  "hashicorp/tap"
]

for i in tap_list
  tap i
end

brew_list = [
  "act",
  "actionlint",
  "ansible",

   ~ 8< ~

  "zsh-syntax-highlighting",
  "zx"
]

for i in brew_list
  brew i
end

## Macだけはこんな感じでOSで分岐
if OS.mac?
  cask_list = [
        ここにパッケージ
     ]
  for i in cask_list
    cask i
  end
end

## Mac and Privateなら `.zshrc.local`を作って環境変数で個別に対応している
if ENV['PCENV'] == 'private' and OS.mac?
  cask "rambox" # LinuxもあるけどLinuxでは入れない
end

brew bundleを実行すれば、まとめてインストールしてくれます。
ぶっちゃけbrewはこれだけでOKです。

しかし、dotfilesと別々に管理するのは面倒です。
インストールはterraformでまとめてできるようにします(その他アップデートや削除などはbrewコマンドを実行します)。

resource "terraform_data" "brew_bundle" {
  provisioner "local-exec" {
    command = "brew bundle --file ../Brewfile"
  }
  triggers_replace = {
    filesha256 = fileexists("../Brewfile") ? filesha256("../Brewfile") : "absent"
  }
}

local-execを実行することでシェルコマンドが実行できます。
しかしこれだけですと、一回実行したらその後で差分なしと判定されてしまいます。

そこで、triggers_replaceでトリガーを指定するのですが、filesha256を設定しておくことでstatefileにファイルのsha256の値が保存されるため、Brewfileを更新すると差分となり実行することができるようになります。

下記のようにstateを確認すると、ファイルのsha256の値が保存されていることがわかります。

$ terraform state show terraform_data.brew_bundle
# terraform_data.brew_bundle:
resource "terraform_data" "brew_bundle" {
    id               = "cc20bbf5-7e6e-8676-67a2-2e69e900d485"
    triggers_replace = {
        filesha256 = "869f58d70ccd7d03924fc395dc802a937a9e6ddbf0e8ee7a5675a760ff69eca8"
    }
}

差分が発生した場合の表示は以下の通りで、sha256の部分だけが出るため正直わかりにくいです。
ただ、Brewfileを変更した場合、差分が発生してApplyできるため及第点といったところです。

  # terraform_data.brew_bundle must be replaced
-/+ resource "terraform_data" "brew_bundle" {
      ~ id               = "b2f52561-8e74-ed6f-b44a-a2f71ebc71e3" -> (known after apply)
      ~ triggers_replace = {
          ~ filesha256 = "5d851a426bb91ca5b366c8b26fea32ef8e44898908db8ebf0baccf85d2e42e7e" -> "869f58d70ccd7d03924fc395dc802a937a9e6ddbf0e8ee7a5675a760ff69eca8"
        }
    }

Plan: 1 to add, 0 to change, 1 to destroy.

dotfilesのコピー

今までは直接ファイルを変更したら、dotfilesが更新されるようにシンボリックリンクを貼っていました。
しかし、シンボリックリンクだとterraformで管理するのは大変そうなのでファイルを直接コピーするようにしました。

$ cat locals.tf
locals {
  # ファイル一覧を生成
  all_dotfiles = fileset("${abspath(path.module)}/..", ".*")

  # 除外ファイル一覧
  exclude_files = [
    ".DS_Store",
    ".gitignore",
    ".sleep",
    ".wakeup"
  ]

  # 除外ファイルを除外したファイル一覧
  dotfiles_list = [for f in local.all_dotfiles : f if !contains(local.exclude_files, f)]
}

# わかりやすいように
output "deploy_dotfiles_list" {
  value = local.dotfiles_list
}
  1. localsで一つ上の階層のドット始まりのファイル一覧を取得
  2. 除外ファイル一覧を作成
  3. 1のリストから2の除外ファイル一覧のファイルを除外したリストを作成
  • abspath は対象ファイルをフルパスで展開します
  • path.moduleはmoduleのパス、今回はシングルルートモジュール方式なので、現在のディレクトリと考えればOKです(今回においては path.rootでも同じです)

main.tfでは下記のようにして、まとめてコピーするようにしています。
ここで terraform.tfに書いた hashicorp/localプロバイダーで使える local_file リソースを使用します。
組み込み関数や local-execcp も検討したのですが、比較的すっきり書けそうだったのでこちらを採用しました。hashicorp社が提供する公式providerという点も決めての一つでした。

resource "local_file" "copy_dotfiles" {
  for_each        = toset(local.dotfiles_list)
  content         = file("../${each.value}")
  filename        = pathexpand("~/${each.value}")
  file_permission = "0644"
}

pathexpand 関数は ~//Users/hoge/ のように絶対パスに書き換えてくれます(今回初めて知った)。
もし、"~/$(each.value)" のままだと、terraform実行ディレクトリ直下に ~ というディレクトリが出来てそこにファイルが配置されて激キモ状態になります。私はなりました。
間違っても rm -rf ~ などとしないようにしましょう。ホームディレクトリが消え去り、悲惨なことになります。
私は訓練されたインフラエンジニアなので、rm -rfは使わずls -la \~/ で配置されたファイルを確認して、rm \~/.vimrc, rmdir \~としました(エスケープを忘れずに)。

なお、適用は下記のように行います。

$ terraform apply

  # local_file.copy_dotfiles[".commit_template"] will be created
  + resource "local_file" "copy_dotfiles" {
      + content              = <<-EOT
            # ==== Prefix ====
            # [Semantic Commit Message]
            # feat:     機能の追加や変更
            # fix:      不具合の修正
            # refactor: リファクタリングを目的とした修正
            # style:    フォーマットなどのスタイルに関する修正
            # test:     テストコードの追加や修正
            # docs:     ドキュメントの更新
        EOT
      + content_base64sha256 = (known after apply)
      + content_base64sha512 = (known after apply)
      + content_md5          = (known after apply)
      + content_sha1         = (known after apply)
      + content_sha256       = (known after apply)
      + content_sha512       = (known after apply)
      + directory_permission = "0777"
      + file_permission      = "0644"
      + filename             = "/Users/xxxxx/.commit_template"
      + id                   = (known after apply)
    }

target指定ももちろん可能です。

$ terraform apply -target='local_file.copy_dotfiles[".gitconfig.template"]'

Terraform used the selected providers to generate the following execution plan. Resource actions are indicated
with the following symbols:
  + create

Terraform will perform the following actions:

  # local_file.copy_dotfiles[".gitconfig.template"] will be created
  + resource "local_file" "copy_dotfiles" {
      + content              = <<-EOT

    ここにファイルの中身が表示される

        EOT
      + content_base64sha256 = (known after apply)
      + content_base64sha512 = (known after apply)
      + content_md5          = (known after apply)
      + content_sha1         = (known after apply)
      + content_sha256       = (known after apply)
      + content_sha512       = (known after apply)
      + directory_permission = "0777"
      + file_permission      = "0644"
      + filename             = "/Users/xxxxx/.gitconfig.template"
      + id                   = (known after apply)
    }

Plan: 1 to add, 0 to change, 0 to destroy.

課題点として、このやり方だと実体であるファイルを更新した場合、このリポジトリのファイルは手動で反映するしか今のところありません。

もしかしたら、local-execでシンボリックリンクを貼るほうが管理しやすいかもしれません。

逆に、リポジトリ側のファイルの変更を行った場合、ファイルの変更値が保存されているため上書きすることが可能です。

  # local_file.copy_dotfiles[".vimrc"] must be replaced
-/+ resource "local_file" "copy_dotfiles" {
      ~ content              = <<-EOT # forces replacement

    ファイルの内容

        EOT
      ~ content_base64sha256 = "GTCKTZELIlqAoXacKeGs09akSK7elUjRN3egSVKJ/QQ=" -> (known after apply)
      ~ content_base64sha512 = "7YwLtFd86SEblHWRd98LYkrZ2VwlLYUU3Hd60GrC+X4YAPpS33MdnooYDhT4QzfVXrrYkTgM+RAORJGK73FAnQ==" -> (known after apply)
      ~ content_md5          = "fafd49907bb52f4ee1d6c76b61168619" -> (known after apply)
      ~ content_sha1         = "a2e2f01ebd76559fe83d638c4af19aa089193646" -> (known after apply)
      ~ content_sha256       = "19308a4d910b225a80a1769c29e1acd3d6a448aede9548d13777a0495289fd04" -> (known after apply)
      ~ content_sha512       = "ed8c0bb4577ce9211b94759177df0b624ad9d95c252d8514dc777ad06ac2f97e1800fa52df731d9e8a180e14f84337d55ebad891380cf9100e44918aef71409d" -> (known after apply)
      ~ id                   = "a2e2f01ebd76559fe83d638c4af19aa089193646" -> (known after apply)
        # (3 unchanged attributes hidden)
    }

Plan: 1 to add, 0 to change, 1 to destroy.

面倒ですが、リポジトリ側を修正して適用するか、実体を修正して差分が無いように修正しようと思います。
このあたりはterraformの運用そのままです。

defaults コマンドの実行

皆さんはMacの設定はどうしてますでしょうか?
システム設定からポチポチ変更している方も多いかと思います。

設定にもよりますが、多くの設定が defaults コマンドで設定の変更が行えるため、私は下記のようなShell Scriptを用意して実行していました。

#!/bin/bash

### Dock
defaults write com.apple.dock orientation -string left # Dockの配置
defaults write com.apple.dock autohide -bool true # 自動的にDockが隠れるように
defaults write com.apple.finder AppleShowAllFiles -bool true # 隠しファイルの表示

## Mouse & TrackPad
defaults write -g com.apple.mouse.scaling -int 2
defaults write -g com.apple.trackpad.scaling -int 5

## Key Repeat
defaults write -g InitialKeyRepeat -int 12
defaults write -g KeyRepeat -int 1

## Screenshot save location
defaults write com.apple.screencapture location -string ~/Downloads # スクリーンショットの保存先

killall Dock
echo "Please Reboot"

これだけなので、Shell Scriptでも良いっちゃ良いのですが、せっかくなのでTerraformで実行できるようにしました。

defaultsコマンドの実行は下記の通りです。

defaults read(or write) (globalなら `-g`) アプリケーション名 型の指定 値

globalなのはマウスやキーボード、トラックパッドなどの挙動が該当します。
globalの指定だけややこしいので、global用のパラメータも追加しておきます。

$ cat locals

  defaults_apps = {
    "com.apple.dock" = {
      # Dockの配置は左
      "app"    = "com.apple.dock"
      "params" = "orientation"
      "type"   = "-string"
      "value"  = "left"
      "global" = false
    },
    # Dockが自動的に隠れるようにする
    "com.apple.dock_2" = {
      "app"    = "com.apple.dock"
      "params" = "autohide"
      "type"   = "-bool"
      "value"  = "true"
      "global" = false
    },
    "com.apple.dock_3" = {
      # ミッションコントロールのアプリケーションウィンドウをグループ化
      "app"    = "com.apple.dock"
      "params" = "expose-group-apps"
      "type"   = "-bool"
      "value"  = "true"
      "global" = false
    },
    "com.apple.finder" = {
      # Finderにパスを表示
      "app"    = "com.apple.finder"
      "params" = "ShowPathbar"
      "type"   = "-bool"
      "value"  = "true"
      "global" = false
    },
    "com.apple.finder_2" = {
      # Finderでも隠しファイルを表示
      "app"    = "com.apple.finder"
      "params" = "AppleShowAllFiles"
      "type"   = "-bool"
      "value"  = "true"
      "global" = false
    },
    "com.apple.finder_3" = {
      # 拡張子変更時にWarningは出さない
      "app"    = "com.apple.finder"
      "params" = "FXEnableExtensionChangeWarning"
      "type"   = "-bool"
      "value"  = "false"
      "global" = false
    },
    "com.apple.finder_4" = {
      # フォルダーを先頭に表示
      "app"    = "com.apple.finder"
      "params" = "_FXSortFoldersFirst"
      "type"   = "-bool"
      "value"  = "true"
      "global" = false
    },
    "com.apple.screencapture" = {
      # スクリーンショットの保存先
      "app"    = "com.apple.screencapture"
      "params" = "location"
      "type"   = "-string"
      "value"  = "~/Downloads"
      "global" = false
    },
   }

tryを使って、globaltrueの場合は-gを付与、falseの場合は付与しないようにします。

resource "terraform_data" "defaults_app" {
  for_each = local.defaults_apps
  provisioner "local-exec" {
    command = "defaults write ${try(each.value.global, true) ? "-g" : ""} ${each.value.app} ${each.value.params} ${each.value.type} ${each.value.value}"
  }
}

resource "terraform_data" "killall" {
  provisioner "local-exec" {
    command = "killall Dock; killall Finder"
  }

  triggers_replace = {
    "killall" = keys(resource.terraform_data.defaults_app)
  }
}

また、defaults_appのkeyを増やしたらkillall Dock, kill Finderが実行されるようにします。

データの取り込み

手動で変更した値との差分の取り込みは検討中です。
下記のように data.externalを使い、defaults readコマンドに加えてtimestampを指定することで毎回現在の値を取得すればtriggerに指定できるかも?と考えました。
しかしながら、毎回差分となるので一旦は採用していません。defaultsを直接実行しなければ、要らないかもしれません。

data "external" "sig" {
  for_each = local.defaults_apps
  program = ["bash", "-lc", <<-EOF
VALUE=$(defaults read ${each.value.app} ${each.value.params} 2>/dev/null || true)
if [ -z "$VALUE" ]; then
  printf "{ \"value\": \"${each.value.value}\" }"
else
  printf '%s' "$VALUE" | jq -Rs "{ \"${each.value.value}\": .}"
fi
EOF
  ]
  query = {
    timestamp = timestamp()
  }
}


ちなみに、下記サイトにはMacで変更できるドメインが多数記載されています。
これを眺めて実行するだけでも、かなり自分用にカスタマイズができるでしょう。
https://macos-defaults.com/

最終的にできあがったコード

最終的に、検証コードも含めて下記のようになりました。

コメントアウト部分を除けばシンプルではあるのでメンテナンスはしやすそうかなと思います。

まとめ

Terraformを使って、dotfilesを管理する手法を考えてみました。

実運用しはじめていますが、直接ファイルを修正した際の取り込みが面倒なこと以外は問題なく運用できています。
取り込みはまた別途考えるか、シンボリックリンクを検討しようと思います。

普段Terraformを使っている方、Terraformに慣れたい方、タスクランナーやdotfilesの管理に困っている方がいたらぜひ試してみてください。

次回は進さんの医療PFマーケティングアセットについてです。

30
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
30
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?