こちらの記事は「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を管理するには下記要件があると良いと考えました。
- ファイルをコピーできる
- brew bundleなどのシェルコマンドが実行できる
- 特定の関数を指定して実行ができる
- 動作が軽量である
- できたら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を最低限動かすために下記が必要です。
provider
のhashicorp/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
}
-
locals
で一つ上の階層のドット始まりのファイル一覧を取得 - 除外ファイル一覧を作成
- 1のリストから2の除外ファイル一覧のファイルを除外したリストを作成
-
abspath
は対象ファイルをフルパスで展開します -
path.module
はmoduleのパス、今回はシングルルートモジュール方式なので、現在のディレクトリと考えればOKです(今回においてはpath.root
でも同じです)
main.tfでは下記のようにして、まとめてコピーするようにしています。
ここで terraform.tf
に書いた hashicorp/local
プロバイダーで使える local_file
リソースを使用します。
組み込み関数や local-exec
でcp
も検討したのですが、比較的すっきり書けそうだったのでこちらを採用しました。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
を使って、global
がtrue
の場合は-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マーケティングアセットについてです。