Posted at
DiverseDay 24

Amazon Glacier に afio でランダムアクセス可能なファイル送りつけてバックアップする

More than 3 years have passed since last update.


背景及び解決したい問題

公私ともに、データのバックアップは非常に大切な問題です。

HDD も SSD も USB メモリも BD も、いろんな要因で壊れます。いろんな種類のメディアにバックアップしたとして、自分の生活圏に置いてあるだけだと、災害で物理的に壊れます。

昔どっかで聞いた話だと、毎日、距離の離れた地方の企業同士で、テープを航空便で毎日交換して確実に遠隔地に置いておくみたいな涙ぐましいことをしていて大変だなーと思ったこともありました。(今は知りません)

AWS がある今、Amazon Glacier を使えば、地球の裏遠隔地バックアップが超低コストで行えます。出たばかりの時はレストア破産の危険性などあったようですが、今は、新しく作成されたAWSアカウントのデフォルト設定が "Free Tier Only" になっているので、安心安全に使えます。

安心安全に使えることはわかったんですが、単純にみんな大好きな tar.gz を送りつけちゃうと、巨大なレストアするときに困ります。

どう困るかというと、100GB の tar.gz の中盤にある 100B のファイルとるために、結構なサイズを取ってくる羽目になります。細かく分割すると PUT にかかるコストがかさむので困ります。

なので、ランダムアクセス出来る圧縮アーカイブを送りたいです。


tar.gz の問題点

tar.gz のような tar でアーカイブした後に全体に対して gzip をかける形式を使うと、

圧縮([メタ情報][ファイルコンテンツ] [メタ情報][ファイルコンテンツ] ...)

という形式になってしまいます。

gzip のストリームの途中にアクセスしたい場合、圧縮ブロック単位のアクセスになります。

よって、格納された個々のファイルの頭にくっついているメタ情報の場所を指定して取ってくるのは非常にめんどくさいだろうと予想できます。


afio で勝つる

そこで、

[メタ情報]圧縮([ファイルコンテンツ]) [メタ情報]圧縮([ファイルコンテンツ]) ...`

のような形式のアーカイブを作成して送りつけることを考えます。

ぱっと思いついたのは、10年前くらいにちょっと使ったことがある afio でした。

afio は、cpio に gzip 圧縮機能を付けたアーカイバで、cpio をベースにしたシンプルなアーカイバです。


afio の簡単な使い方

# ファイルをアーカイブする

$ find /path | afio -oZ archive.afz

# afio アーカイブを展開する
$ afio -iZ archive.afz

# 中身の一覧を見る
$ afio -tv archive.afz


afio の gzip 圧縮のおもしろい(?)ところ

afio は、格納されるファイル単位で gzip するわけですが、gzip 圧縮がかかると、アーカイブ内部のファイル名に .z のサフフィックスがつきます。

もとからあった *.z と区別付かないじゃんと思ってソースを探求すると、statrdev (通常ファイルだと使われないで、0) に afio で gzip かけた場合は 1 入れておりました。

通常ファイル && rdev == 1

なら、afio が gzip したよーん♪って実装でした。


afio で作ったアーカイブのオフセット一覧を表示する君をつくる

cpio の golang 実装である https://github.com/deoxxa/gocpio を元に、今必要なヘッダ形式のダンプを出せるだけの実装をしてみました。

ヘッダは3形式くらいありますが、今回必要だった形式だけを実装しています。

// Header: Mode Uid Gid Mtime Size Devmajor Devminor Type Name Consumed Offset AllSize

n:&{16877 501 20 181370541 0 0 0 0 %!d(string=work) 81 0 81}
n:&{33188 501 20 181370541 525927 0 0 0 %!d(string=work/file.001.z) 92 81 526019}
n:&{33188 501 20 181370541 525934 0 0 0 %!d(string=work/file.002.z) 92 526100 526026}
...
n:&{33188 501 20 181370541 525926 0 0 0 %!d(string=work/file.041.z) 92 21040778 526018}
...
n:&{33188 501 20 181370541 525925 0 0 0 %!d(string=work/file.100.z) 92 52075777 526017}
n:&{0 0 0 0 999 0 0 0 %!d(string=TRAILER!!!) 87 52601794 1086}
EOF

各エントリのオフセット(Offset)と、ヘッダを含むサイズ(AllSize)がわかります。


実際にやってみる


流れ

一瞬で iikanji に操作できる魔法のコマンドを作るのはあとの楽しみにするとして、泥臭い方法で実際にやってみます。

今回は、上記アーカイブのダンプの work/file.041.z を取ることを目指します。


  • アップロードする

  • アーカイブIdをメモする

  • アーカイブ内の欲しいファイルのオフセットを計算する

  • ファイル取得の Job を発行する

  • 準備が出来たか確認する

  • 準備が出来たらダウンロードする

  • ダウンロードしたファイルから必要な部分を切り出す

  • afio で解凍できることを確認する


アップロードする


コード

package main

import (
"fmt"
"io"
"os"

"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/aws/session"
"github.com/aws/aws-sdk-go/service/glacier"
)

func main() {
svc := glacier.New(session.New(&aws.Config{Region: aws.String("ap-northeast-1")}))

readFile, err := os.Open("work.afz")
if err != nil {
fmt.Println(err.Error())
return
}
defer readFile.Close()

stat, err := readFile.Stat()
if err != nil {
fmt.Println(err.Error())
return
}

body := io.NewSectionReader(readFile, 0, stat.Size())

params := &glacier.UploadArchiveInput{
AccountId: aws.String("your-account-id"), // Required
VaultName: aws.String("your-vault-name"), // Required
ArchiveDescription: aws.String("string"),
Body: body,
}
resp, err := svc.UploadArchive(params)

if err != nil {
// Print the error, cast err to awserr.Error to get the Code and
// Message from an error.
fmt.Println(err.Error())
return
}

// Pretty-print the response data.
fmt.Println(resp)

}


レスポンス例

{

ArchiveId: "あーーーーーーーーーーかいぶId",
Checksum: "f8a6acf9e11fa5809d007ec3c590786935d0bdcf84312f2fcff4837f8ee06b33",
Location: "/your-account-id/vaults/your-vault-name/archives/aaaaaaaaaaaaaaaaa"
}


アーカイブIdをメモする

あーーーーーーーーーーかいぶId


アーカイブ内の欲しいファイルのオフセットを計算する

アーカイブの範囲を指定してダウンロードする場合、受け付けてくれるのは 1MB 単位です。

たとえば、最初の 1MB を取るには、0-1048575 という指定をします。

今回は、オフセットが 21040778 ですので、20MB めから 1MB とればよいことが分かります。

21040778 / (1024*1024) = 20.0660495758057

(21040778+ 526018) / (1024*1024) = 20.567699432373

よって

var ONE_MEG = 1048576

from := 20
to := 21
retrievalByteRange := fmt.Sprintf("%d-%d", ONE_MEG*from, ONE_MEG*to-1)

というようなコードを書いて指定しています。


ファイル取得の Job を発行する


コード

package main

import (
"fmt"

"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/aws/session"
"github.com/aws/aws-sdk-go/service/glacier"
)

var ONE_MEG = 1048576

func main() {
svc := glacier.New(session.New(&aws.Config{Region: aws.String("ap-northeast-1")}))

from := 20
to := 21
retrievalByteRange := fmt.Sprintf("%d-%d", ONE_MEG*from, ONE_MEG*to-1)

params := &glacier.InitiateJobInput{
AccountId: aws.String("your-account-id"), // Required
VaultName: aws.String("your-valut-name"), // Required
JobParameters: &glacier.JobParameters{
ArchiveId: aws.String("glacier にアップロードする君で得た ArchiveId"),
RetrievalByteRange: aws.String(retrievalByteRange),
Type: aws.String("archive-retrieval"),
},
}

resp, err := svc.InitiateJob(params)
if err != nil {
// Print the error, cast err to awserr.Error to get the Code and
// Message from an error.
fmt.Println(err.Error())
return
}

// Pretty-print the response data.
fmt.Println(resp)

}


レスポンス例

{

Action: "ArchiveRetrieval",
ArchiveId: "あーーーーーーーーーーかいぶId",
ArchiveSHA256TreeHash: "f8a6acf9e11fa5809d007ec3c590786935d0bdcf84312f2fcff4837f8ee06b33",
ArchiveSizeInBytes: 52602880,
Completed: false,
CreationDate: "2015-12-24T14:04:36.140Z",
JobId: "じょぶId",
RetrievalByteRange: "20971520-22020095",
StatusCode: "InProgress",
VaultARN: "arn:aws:glacier:ap-northeast-1:your-account-id:vaults/your-valut-name"
}


準備が出来たか確認する

SNS で通知も来るんですが、プログラムからも。

package main

import (
"fmt"

"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/aws/session"
"github.com/aws/aws-sdk-go/service/glacier"
)

func main() {
svc := glacier.New(session.New(&aws.Config{Region: aws.String("ap-northeast-1")}))

params := &glacier.GetJobOutputInput{
AccountId: aws.String("your-account-id"), // Required
VaultName: aws.String("your-valut-name"), // Required
JobId: aws.String("glacier から range download する job を作る君 で得た JobId"),
}

resp, err := svc.DescribeJob(params)
if err != nil {
// Print the error, cast err to awserr.Error to get the Code and
// Message from an error.
fmt.Println(err.Error())
return
}

// Pretty-print the response data.
fmt.Println(resp)

}


レスポンス例

{

Action: "ArchiveRetrieval",
ArchiveId: "あーーーーーーーーーーかいぶId",
ArchiveSHA256TreeHash: "f8a6acf9e11fa5809d007ec3c590786935d0bdcf84312f2fcff4837f8ee06b33",
ArchiveSizeInBytes: 52602880,
Completed: false,
CreationDate: "2015-12-24T14:04:36.140Z",
JobId: "じょぶId",
RetrievalByteRange: "20971520-22020095",
StatusCode: "InProgress",
VaultARN: "arn:aws:glacier:ap-northeast-1:your-account-id:vaults/your-valut-name"
}

{

Action: "ArchiveRetrieval",
ArchiveId: "あーーーーーーーーーーかいぶId",
ArchiveSHA256TreeHash: "f8a6acf9e11fa5809d007ec3c590786935d0bdcf84312f2fcff4837f8ee06b33",
ArchiveSizeInBytes: 52602880,
Completed: true,
CompletionDate: "2015-12-24T18:13:45.894Z",
CreationDate: "2015-12-24T14:04:36.140Z",
JobId: "じょぶId",
RetrievalByteRange: "20971520-22020095",
SHA256TreeHash: "0ed4f1aeaa1940d6d292f0dbb697cc2e61d4e660974b2f147de3c4abe0e729f7",
StatusCode: "Succeeded",
StatusMessage: "Succeeded",
VaultARN: "arn:aws:glacier:ap-northeast-1:your-account-id:vaults/your-valut-name"
}


準備が出来たらダウンロードする


コード

package main

import (
"fmt"
"os"
"io"

"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/aws/session"
"github.com/aws/aws-sdk-go/service/glacier"
)

func main() {
svc := glacier.New(session.New(&aws.Config{Region: aws.String("ap-northeast-1")}))

jobId := "glacier から range download する job を作る君 で得た JobId"
params := &glacier.GetJobOutputInput{
AccountId: aws.String("your-account-id"), // Required
VaultName: aws.String("your-valut-name"), // Required
JobId: aws.String(jobId),
}

resp, err := svc.GetJobOutput(params)
if err != nil {
// Print the error, cast err to awserr.Error to get the Code and
// Message from an error.
fmt.Println(err.Error())
return
}

// Pretty-print the response data.
fmt.Println(resp)

filename := *(resp.Checksum)
writeFile, err := os.Create(filename)
if err != nil {
fmt.Printf(err.Error())
return
}
defer writeFile.Close()

io.Copy(writeFile, resp.Body)
}


レスポンス例

{

AcceptRanges: "bytes",
ArchiveDescription: "string",
Body: buffer(0xc8203d2140),
Checksum: "0ed4f1aeaa1940d6d292f0dbb697cc2e61d4e660974b2f147de3c4abe0e729f7",
ContentType: "application/octet-stream",
Status: 200
}


ダウンロードしたファイルから必要な部分を切り出す

欲しいファイルのアーカイブの頭からのオフセットは、21040778 でした。

なので、途中からダウンロードしてきたファイルでのオフセットは、69258 になります。



21040778 % (1024*1024) = 69258

オフセットと長さを指定して dd で切り出します。

$ dd if=0ed4f1aeaa1940d6d292f0dbb697cc2e61d4e660974b2f147de3c4abe0e729f7 of=041.afz bs=1 skip=69258 count=526018

526018+0 records in
526018+0 records out
526018 bytes transferred in 1.598598 secs (329050 bytes/sec)

hexdump で様子を見てみます。

afio のマジックである 070707 が最初に現れています。また、gzip での最後にあるサイズっぽい 00 00 10 00 が現れています。

これはうまくいってそうですね。

$ hexdump -C 041.afz |head

00000000 30 37 30 37 30 37 30 30 30 30 30 35 31 33 31 33 |0707070000051313|
00000010 37 36 31 30 30 36 34 34 30 30 30 37 36 35 30 30 |7610064400076500|

$ hexdump -C 041.afz |tail -n 3
000806b0 94 7d d9 81 31 7f 98 0e 07 16 49 f2 e3 30 00 00 |.}..1.....I..0..|
000806c0 10 00 |..|
000806c2


afio で解凍できることを確認する

$ afio -iv 041.afz

work/file.041.z -- okay
afio: "041.afz" [offset 513k+706]: Fatal error:
afio: "041.afz": Premature input EOF

ファイルに関しては、work/file.041.z -- okay がでました。

エラーが見えてますが、afio アーカイブの終点を示す TRAILER!!! がないため出ているだけなので無視して大丈夫です。


まとめ


  • glacier を aws の go 言語 sdk で利用して操作しました。

  • glacier でのバックアップにおいて、afio でアーカイブしたファイルを glacier にアップロードし、最低限のファイル転送量で、欲しいファイルを実際に取り出しました。

Happy backup!