安全にサーバオペレーションするための、バックアップするだけの簡易コマンドをGoで作った

この記事は Go3 Advent Calendar15日目の記事です。

サーバオペレーション用に作成したツールについて語ります。[^1]

# 作った経緯
AWS上でサーバ構築のオペレーションをやっていたときに
設定を変更する前に設定ファイルのバックアップをとりわすれることが度々あって、
それを解消したいと思ったからです。

最近でしたらInfrastructure as Codeを推進する動きもあるので
手動でのオペレーション自体を減らしていったほうがいい、とも思います。
が、プライベートでの作業かつサーバ台数も大したことないので、
Ansibleを入れたり、Dockerで全部コンテナ化するまでやるのは流石に(学習コストも含めて)高コストという感覚があります。

なので、Ansibleとかほどでないけれど、設定を更新する前にバックアップを勝手に保存してくれるようなCLIがあれば楽になるんでないかなぁとか考えました。
サーバに配置するものなので、Goで書いてバイナリを生成すれば、簡単に導入できそうだなぁと思って、自分用に作成しました。

# 作ったもの
sop(safety operation)というツールです。
https://github.com/jiro4989/sop

READMEに記載のとおりgo getでも取得できますし、
Releaseにもあげてるので普通にDLできます。

# 何ができるのか
使い方はREADMEに書いてることがすべてですが、一応記載。

sopは以下のサブコマンドを持ちます。意図的にLinuxのコマンド名と同じにしてますが、
backupとeditは独自です。
- edit {editor} {targetFile}
- cp
- rm
- backup

## 使用例

### cp

以下のようにコマンドを実行する。

```bash
sop cp src.txt dst.txt
sop cp src.txt dst.txt
sop cp src.txt dst.txt
```

実行結果の確認

```
ls -la *.txt*
-rw-rw-r-- 1 jiro jiro 5 12月  8 15:13 dst.txt
-rw-rw-r-- 1 jiro jiro 5 12月  8 15:13 dst.txt.2018-12-08_151322
-rw-rw-r-- 1 jiro jiro 5 12月  8 15:13 dst.txt.2018-12-08_151339
-rw-rw-r-- 1 jiro jiro 5 12月  8 15:13 src.txt
```

見ての通り、cpによって上書きされたdst.txtのバックアップが保存されます。
(現時点では)日付の書式は`%Y-%m-%d_%H%M%S`で固定です。

### ユーザ、グループ、権限指定の例

権限や所有者を指定しつつコピー。
Linuxコマンドでいう`install`コマンドの簡易版。
installコマンド知らない人以外と多そうなイメージがあります(偏見)。

```bash
echo 12345 > dst.txt
sudo chown syslog:root dst.txt
echo 1234567890 > src.txt
sudo sop cp src.txt dst.txt -o root -g syslog -m 0740
```

結果確認

```
% ls -la 
合計 20
drwxrwxr-x  2 jiro   jiro   4096 12月 16 02:01 .
drwxrwxrwt 18 root   root   4096 12月 16 02:00 ..
-rwxr-----  1 root   syslog   11 12月 16 02:01 dst.txt
-rw-rw-r--  1 syslog root      6 12月 16 02:01 dst.txt.2018-12-16_020114
-rw-rw-r--  1 jiro   jiro     11 12月 16 02:01 src.txt
```

こんな感じです。cpの際にパーミッションと所有者を指定したり、
上書き対象の権限を維持したままバックアップを取ったりできます。
その場合は必要な権限が必要なので、だいたいsudoも一緒につけることになると思います(後述)。

ちなみに`cp -r`とかはできません:sunglasses:

## edit
cpでは元になるファイルで上書きしていましたが、こちらはそのファイルをエディタで編集して更新します。
内部的には`exec.Command`してるだけです。
上書きのアプローチが違うだけでできることはcpと全く同じです。

`sop edit vim dst.txt`みたいにつかいます。

## backup
対象のファイルを更新しません。同じ階層にバックアップだけ保存します。

## rm
消すだけです。でもバックアップします。
需要なさそうだなぁとか思いつつも一応用意しました。

# 作成を通して得られた知見
できることはたったこれだけですが、作成を通して気づいたこととかハマったことについて。

`cp -p`でコピーする際にファイルの権限を維持したままコピーできます。
backupを保存する実装にcp -pをイメージするようにしていました。
Goで実装を始めたわけですが、Goでファイルコピーをするにはいくつか問題がありました。

1. そもそもCopyFile的なユーティリティ関数がない
2. Chownする際に所有者を指定するにはuid, gidを指定する必要がある
3. Chownには強い権限が必要である
4. os.FileInfoのUid/Gidを使用しようとするとWindows向けビルドができない

## そもそもCopyFile的なユーティリティ関数がない
はい、Goにはファイルコピーのユーティリティ関数がないんです。ioutilにならありそうだと思ってましたが、ないんです。
なので、以下のような感じの関数を自前で定義して実装することになりました:frowning2:

```go
func cp(srcFile, dstFile string, uid, gid int, m ...os.FileMode) error {
	b, err := ioutil.ReadFile(srcFile)
	if err != nil {
		log.Println(err)
		return err
	}

	dst, err := os.Create(dstFile)
	if err != nil {
		log.Println(err)
		return err
	}
	defer dst.Close()

	if _, err := dst.Write(b); err != nil {
		log.Println(err)
		return err
	}

	// rootユーザは 0
	// UID/GIDにマイナス値は使わない
	if 0 <= uid && 0 <= gid {
		if err := dst.Chown(uid, gid); err != nil {
			log.Println(err)
			// chownはできなくてもしかたないのでreturnしない
		}
	}

	if 1 <= len(m) {
		if err := dst.Chmod(m[0]); err != nil {
			log.Println(err)
			// chmodはできなくてもしかたないのでreturnしない
		}
	}

	return nil
}
```

...はい、色々実装が汚いです:sweat_smile:

UID/GIDを指定しない場合のために-1を指定した場合はchownしないとかいう分岐がありますが、ざっくり調べたところ、値の範囲的にも使わない仕様のはずなので、まぁいいか、と。
[Wikipedia - ユーザー識別子](https://ja.wikipedia.org/wiki/%E3%83%A6%E3%83%BC%E3%82%B6%E3%83%BC%E8%AD%98%E5%88%A5%E5%AD%90)

他にも**// chownはできなくてもしかたないのでreturnしない**とかいうコメントがありますが、こちらは後述します。

## Chownする際に所有者を指定するにはuid, gidを指定する必要がある
Goにはユーザ名、グループ名を指定してchownできる関数はなかったと思います。
IDで指定します。Linuxのコマンドのchownコマンドだと名前で指定できるのになー:confused:

でもこのインタフェースをそのままこのCLIのインタフェースにしたくなかったです。
だって、以下の２つでしたら、前述のほうが絶対わかりやすいですので。

```bash
sudo sop cp src.txt dst.txt -o root -g root -m 0644
sudo sop cp src.txt dst.txt -o 0 -g 0 -m 0644
```

***0ユーザって誰だよ！！！***ってなりますから...:innocent:

なので、コマンドのオプション引数にはユーザ名を受け付けて、
内部でUID/GIDに変換して関数に渡すようにする必要がありました。
なんかいい感じのないかな〜と思ってたら、ありました。
[os/user](https://golang.org/pkg/os/user/)パッケージです。

```go
func LookupGroup(name string) (*Group, error)
func Lookup(username string) (*User, error)
```

ほー いいじゃないか
こういうのでいいんだよこういうので

使ってるとこ↓

```go
		u, err := user.Lookup(owner)
		if err != nil {
			log.Println(err)
			return err
		}

		g, err := user.LookupGroup(group)
		if err != nil {
			log.Println(err)
			return err
		}

		uid, err = strconv.Atoi(u.Uid)
		if err != nil {
			log.Println(err)
			return err
		}

		gid, err = strconv.Atoi(g.Gid)
		if err != nil {
			log.Println(err)
			return err
		}
```

Chownの定義とUser, Group構造体

```go
func (f *File) Chown(uid, gid int) error

type Group struct {
        Gid  string // group ID
        Name string // group name
}

type User struct {
        Uid string
        Gid string
        Name string
        HomeDir string
}
```

uid/gidの型が違う:innocent: 
まぁ、こうして実装しました。

## Chownには強い権限が必要である
実装してから動作確認してたときにrootユーザ指定でChownしようとしたらerrorが返りました。権限不足だそうで。

あれー、cp -pでrootの権限とか維持したままコピーできてなかったっけかなぁ〜〜〜とか思って、cp -pのほうも試してみました。

```bash
sudo touch src.txt
cp -p src.txt src2.txt
ls -la
```

結果

```
合計 8
drwxrwxr-x  2 jiro jiro 4096 12月 16 02:47 .
drwxrwxrwt 18 root root 4096 12月 16 02:46 ..
-rw-r--r--  1 root root    0 12月 16 02:47 src.txt
-rw-r--r--  1 jiro jiro    0 12月 16 02:47 src2.txt
```

```
                 `..............   .T;(1<<++<1++1+
            ..JdWVVZOIz1lz+Jz?<?Oo,  1~?+<;<<?+?++
 .`      .JWWUC1=zzwOO&&+OUwzwOzti-?(.(J-.v+>+<<;<
     `.JSZXwZZ?jwvTwzCO&+?<+--...--_.?i.?-.?<<<><<
    .dWUXVz;<1CiJC+z=~.JzCd,OZVCuvQ2G(7z{  ?1.?+++
   .HZOI<_/</(v1Jv~.ZC(Z!(MT.XXv3>UNJOo.1    `_!_,
 .gW0v> /<~(viJ?.(v<+<~.MP``1jz1: (dMm-(,}
.XWI<`.~?(v~J^.-jl?<.dHBVn.!.l<>.11yZTMKX_
X0O!`_,~(^.C_-r~dQHHHkwwO?> .zz._zdWkXw+z} .
OZ!  ~.>.J!.C?`.Z3JWkXWWkk>~ OO (dKY!!?Td`` `    `
v` ~-J!.I-J>J .Z+dC```   ?U+ (O}(f~.    (
!  1Z.z!.%.j`..rd% `  `    d. 1zJ. `  v!.      ` .
  .0zzlJ!.(!`_.0d{     ?:` ko~ Cdn~.    ,   ``.JQR
 .ZzwwS:.<{ _ `?vh.`.`    (r`.  1wS+..+vr  `.ZwAVU
.v1jd6O>_( `-  . <U&.-..(0C-.`  (wo?7? .1 .jyz0XXw
+!!.GIzz.+     _ `OOVOvz1<!` .  ~_J!..  (.zOxVOOOw
! J!(/?c(( _ !   .I<<>z<++Jn-2i(2i++(++,(+Jzz+v+vz
 Jv--1o,<+ _` .,OvI1zzkrwwXXOl,OO,C<(-n<j<Iz1Ozz1z
(!`l?ww[<J`!.+GdOv11-((J_~~_(-__``(2  j+d+z+<+z111
t  (._vT.( (JdZ1?!?!   .    .`     ~  .IX;(<<+><1+
< _.t ?v z (Z3>    _   .    .      _  .III_(1<<1(<
+-..(-(1.(..>::    ~`...&JggQm&&-...  (:$<l(>j++(+
.-__+< <z+ (( .. ..dWHMMMHHMMHHHHHMUWe3_D!?C_i<<<z
 l_(2 :.+<~<z! JdHkHHMHMH#MMNMHHB8XXWQM_6.  ?i<(+i
`j(z! _ <+_<R(wWgHMMgggMHMMMMWXQQHNMNMM_}.i `??i<+
 ,sw{_. ~(.`HKHHHHHHHHHMMMHMMHHHHHMMMMM>d.Iz-  _7&
  Hv`~.. (; HMHHHkHkHHHMMM@@HWWWWkHMMMMr}`1<+O,  (
  dv `._`(b XHHHHHbB9UUZTOZOOZUWHHHMM@Mt:/i I+I1.
 .5~  +{ (W,,HMWH91=1z1zz1lzlzldkVMMHMM$~ i 1+1<Ji
.v>. (<: .Uk HXVXz1+<<<><11<<<<1?XZWMHM1   _(,.I<+
Zl_<+<!:  wwl(RXXs!+?<<<<<(z<><<<JXn(MMj;  . .<(+z
+1vz<~ :  jwX>4IzZo.1<><<<>>?+<:(_wX{.M(O.      ?1
zO1+!  :  (kwwn4} ,1-_<1=1+<<+?;<<JNX ,-?z.`  `
zI1<   ~  .d?zOw<. `?yz-.._~<-.-(jdNR.((z+<z-.
O1z _  (  `?} ?1Z+<.:` ?TGage&dXWVY! .}yOv<++11z77
lOv !. (-   1.  <OX,?.    .     .   `.j0Iv<+1=1+++
z1! ._`(l   .1.  (OZi ._??:-......._!+1<+v1?+1zIC+
1z. +> (Z,  `(1.`..1OO.,1tIz&v+zz1OXw>,  .<1zOz<>+
~<111+..ww    (z.~  ?zwn-_!<?><<<<?!`.D.(+1+<!~`_
  -~<<<<+1n   `(?-`  .1OzzOCl11+1vOIwOwzz?`
```

はい、cp -pで権限が足りないと自分が所有者としてコピーされるみたいです。
その場合も別にエラーではないらしく、`echo $?`したところ0が返りました。

cpの-pのmanを読んでみたのですが、そういう挙動については記載がなく...。
まぁchownに権限が必要で、多分cp -pも内部的には(多分)cpしてchownしてるんだろうなぁとか考えると、この挙動は至極当然な気がします。
ということで、その挙動にならってGoのChown呼び出しのエラーも***握りつぶす***ようにしました:sweat_smile:
(一応ログだけは出力するようにしましたが)

## os.FileInfoのUid/Gidを使用しようとするとWindows向けビルドができない
ファイルのバックアップをする際に、そのファイルの所有者を取得して
ファイルをcpしてchownする必要がありました。
その際、os.FileInfoからUid/Gidを取得するには、以下のようにアクセスする必要がありました。

```go
	fi, err = os.Stat(srcFile)
	if err != nil {
		return nil
	}

	var (
		sys = fi.Sys()
		uid = sys.(*syscall.Stat_t).Uid
		gid = sys.(*syscall.Stat_t).Gid
		m   = fi.Mode()
	)
```

fi.Sys()はinterface型なので、キャストする必要がありましたが、
それには*syscall.Stat_tを使う必要がありました。この型を使うと、Windowsでビルドできません。まぁシステムコールですからね...。

以下の例でも確認。

```bash
% cat main.go 
package main

import (
	"fmt"
	"os"
	"syscall"
)

func main() {
	var (
		fi      os.FileInfo
		err     error
		srcFile = "src.txt"
	)

	fi, err = os.Stat(srcFile)
	if err != nil {
		panic(err)
	}

	var (
		sys = fi.Sys()
		uid = sys.(*syscall.Stat_t).Uid
		gid = sys.(*syscall.Stat_t).Gid
		m   = fi.Mode()
	)

	fmt.Println(sys, uid, gid, m)
}

% go build main.go

% GOOS=windows go build main.go
# command-line-arguments
./main.go:23:15: undefined: syscall.Stat_t
./main.go:24:15: undefined: syscall.Stat_t

```

当初の予定としては、作ったものはいろんな環境で動かせるようにしたかったので
これはなんとしても回避したい問題でした。
ということで、Windows向けのソースを用意して、ビルド時に参照するソースを切り替えることにしました。

結果的には下記のアプローチで、この問題を解消しました。

- Windows以外の場合は*.goというファイル名のソースにする。
- Windows用の場合は*_windows.goというファイル名のソースにする。
- *.goのソースの先頭に`// +build !windows`というタグを埋め込む

これで、`go build`時に`-tags="windows"`というタグ情報を渡すことで、
Windowsビルド時は*_windows.goのソースを参照するようになり、コンパイルが通るようにしました。

コード内にタグ情報を埋め込んで色々できるGoならではの芸当ですね:thumbsup:
[このソース](https://github.com/jiro4989/sop/blob/master/file/file.go)で使ってます。

ちなみに、このプロジェクトのMakefileでは[gox](https://github.com/mitchellh/gox)というクロスコンパイル用のGo製CLIを活用させてもらってます。
そちらでもビルド時にgo buildのオプションが指定できるので、以下のようにクロスコンパイルしています。

```Makefile
# VERSION := v$(shell gobump show -r)
VERSION := v1.0.0
DIST_DIR := dist/$(VERSION)
LDFLAGS := -ldflags="-s -w \
	-extldflags \"-static\""
XBUILD_TARGETS := \
	-os="linux darwin" \
	-arch="386 amd64" 
XBUILD_TARGETS_FOR_WINDOWS := \
	-tags="windows" \
	-os="windows" \
	-arch="386 amd64" 

xbuild: $(SRCS) bootstrap ## クロスコンパイル
	gox $(LDFLAGS) $(XBUILD_TARGETS) --output "$(DIST_DIR)/{{.Dir}}_{{.OS}}_{{.Arch}}/{{.Dir}}"
	gox $(LDFLAGS) $(XBUILD_TARGETS_FOR_WINDOWS) --output "$(DIST_DIR)/{{.Dir}}_{{.OS}}_{{.Arch}}/{{.Dir}}"
```

※[gobump](https://github.com/motemen/gobump)はGo製のバージョニングCLI

# 今後の課題
ということで、いろんな問題に直面して、そして解消して目的を達成しました。
今後の課題は[issues](https://github.com/jiro4989/sop/issues)にメモしてるのですが、以下の通りです。

- 日付の指定にLinuxのdateコマンドと同じ、strftime書式を採用する
- 変更対象と同じディレクトリ以外にもバックアップ先を指定できるようにする
- editコマンドで変更がなかった場合(≒差分がない)はバックアップしないようにする
- 設定ファイルで設定を変更できるようにする

## 日付の指定にLinuxのdateコマンドと同じ、strftime書式を採用する
Goではかなり特殊なDate書式指定をします。具体的には以下の通り。

```go
	now := time.Now().Format("2006-01-02_150405")
```

Goに触ったことのない方だと**"2006-01-02_150405"！？！？**ってなること間違いないです。
Goを使う上では別によくあることなので気にしないのですが、
Goへの理解に明るくない方も使う可能性を考えると、この書式はよろしくないなぁと考えてます。
なので、Linuxのdateコマンドと同じく、strftime書式を採用しようと考えています。
具体的には以下の通り。

```bash
date +%Y-%m-%d_%H%M%S
```

サーバオペレーションをする人が使うことを考えると
サーバオペレーションで頻繁に目にする書式のが扱いやすいでしょうし。

## 変更対象と同じディレクトリ以外にもバックアップ先を指定できるようにする
実際にプライベートのサーバで設定をしていて`/etc/cron.d/foobar`の設定を編集していたときに気づきました。読み込むファイルを拡張子とかで制限していない場合に、バックアップファイルも読み込まれてしまうな...と。

`include conf.d/*.conf`みたいな感じで、.confで終わることが指定されているケースならいいのですが、そうでない場合はこの設定ファイルも読み込まれてしまう。
これは非常にマズイです。
なので、設定ファイルと同じディレクトリではなく、別の退避用のディレクトリにコピーできるようにもする必要があるな、と考えています。

イメージとしては`/etc/cron.d/foobar`を`/var/backup/etc/cron.d/foobar.20180101`みたいに、/からのパス構造を維持したままバックアップするようにしたいです。

## editコマンドで変更がなかった場合(≒差分がない)はバックアップしないようにする
はい、やりたいです。Ansibleとかはやってますしね。

## 設定ファイルで設定を変更できるようにする
前述の設定とかを変更できるようにしたいです。
現状ハードコーディングしてしまってるので...。

# まとめ
このCLI作成を通して以下の知見を得ました。

- os, os/userへの理解が深まった
- 環境ごとにソースをわけざるを得ない場合のクロスコンパイルの方法を学んだ
- linuxのuid, gid, chown, chmodの理解が深まった
- 設定ファイルの読み込まれ方など、考慮すべきことは多岐にわたることを学んだ

丁寧なオペレーションにミスはつきものなので、ミスはツールで予防したいです:helmet_with_cross:
まぁ、`rm -rf /`とかかました日には復旧不可能ですけどね...:sob:

[^1]: 「シェルスクリプトでよくね？」と思ったあなた、その指摘は正しい