Work Records

日々の作業記録です。ソフトウェアエンジニアリング全般から、趣味の話まで。

ECSのTaskを同期的に実行するコマンドecs_task_executorを作った


ECSのTaskの実行について

ECSにはServiceとTaskという二つのコンテナの実行方法がある。
Serviceは主にwebサーバーなどに代表されるような常に一定数のコンテナを保つように管理されるもので、Taskの方は一回実行したらそれっきりで終了するバッチ的な利用を想定されている。

APIアクセスによるTaskの実行

awscliやecs-cliによりこのTaskはAPI経由で実行することができる。
awscliであればこんな感じ。

aws ecs run-task --cluster default --task-definition sleep360:1


困ること

API経由でのTaskの実行は非同期で行われる

どういうことかというと、awsコマンドが実行成功した時点で正常終了してしまうということが起きる。
つまり実際にコンテナ上で実行したタスクがしばらくした後に異常終了した場合でもAPIを叩いた側は気づく事が出来ない。

以下は、ecs-cliでの実行例だが、sleep 120を処理しているコマンドでも実行自体は13秒で終わっている事がわかる。

$ time ecs-cli compose --project-name kenjiszk-test --file docker-compose.yml run web "sleep 120"
    INFO[0000] Using ECS task definition                     TaskDefinition="kenjiszk-test:3"
    INFO[0000] Starting container...                         container=e6fece2d-6f6f-44ec-95b9-44f37c7c1205/web
    INFO[0000] Describe ECS container status                 container=e6fece2d-6f6f-44ec-95b9-44f37c7c1205/web desiredStatus=RUNNING lastStatus=PENDING taskDefinition="kenjiszk-test:3"
    INFO[0013] Started container...                          container=e6fece2d-6f6f-44ec-95b9-44f37c7c1205/web desiredStatus=RUNNING lastStatus=RUNNING taskDefinition="kenjiszk-test:3"
    
    real	0m13.105s
    user	0m0.129s
    sys	0m0.098s

で、何が困るか?

連続するタスクがあって、それぞれ直前のタスクが成功した場合のみだけ処理を進めたいような場合、デフォルトの挙動だと失敗しようがしなかろうが成功してしまうのでどんどん先に進んでしまう。
それどころか、タスクの終了も待たないので、タスク自体が重なって実行されてしまうことになる。

具体的な例をあげると、Railsのデプロイ処理の場合

  • db:migrateなどのdbの処理
  • コード(コンテナ)の入れ替え

という流れを経るので、db:migrateが失敗したとしても新しいコードをデプロイしてしまうというやばいことも起きかねない。
そういった処理をする場合には、同期的に実行する事が不可欠になる。


同期的に実行できるようなコマンドを作った

という事で、同期的にコンテナ状のタスクを実行できて、失敗した場合にはしっかり失敗するようなコマンドを作成した。
github.com

READMEを見てもらえるとだいたいどういった処理になっているかわかるが、内部でやっていることは

  • aws APIを叩いてtaskを実行
  • taskの実行結果をpollingして取得する
  • taskのstatusがSTOPPEDになったら終了状況を取得して終了する

といった単純な処理をしている。

run_and_fail.shというしばらくすると失敗するscriptを用意して実行しみた例。
以下のように終了まで待っているのと、異常終了コードをハンドリング出来ている。

$ ecs_task_executor --cluster Sample -t kenjiszk-test:1 -n web -c 'run_and_fail.sh'
Set timeout as 600 sec.
LastStatus=PENDING TimeElapsed=5.024269701s
LastStatus=PENDING TimeElapsed=10.045879005s
LastStatus=PENDING TimeElapsed=15.070078421s
LastStatus=RUNNING TimeElapsed=20.092714283s
LastStatus=RUNNING TimeElapsed=25.109575722s
LastStatus=RUNNING TimeElapsed=30.131358467s
LastStatus=RUNNING TimeElapsed=35.150669821s
LastStatus=RUNNING TimeElapsed=40.16816051s
LastStatus=RUNNING TimeElapsed=45.185257392s
LastStatus=RUNNING TimeElapsed=50.209708875s
{
  ContainerArn: "arn:aws:ecs:ap-northeast-1:000000000:container/df87b9cd-962f-4580-ad8d-f6b97446b9a2",
  ExitCode: 255,
  HealthStatus: "UNKNOWN",
  LastStatus: "STOPPED",
  Name: "web",
  NetworkBindings: [],
  NetworkInterfaces: [],
  TaskArn: "arn:aws:ecs:ap-northeast-1:00000000:task/de79a37c-4c51-4307-826c-f6a3c7d733cb"
}
$ echo $?
255


今後

自前でコマンドを作って見たが、実は同期的にTaskを実行できるオプションがあるんじゃないかとちょっとヒヤヒヤしている。


ALISのICOスマートコントラクトを読んでALISベータ版にまとめ記事を投稿してみた

ALISとは

公式サイトより。

ALISは信頼性の高い情報・人に素早く出会えるソーシャルメディアプラットフォームです。 信頼できる記事を書いた人、それをいち早く見つけた人が報酬を獲得することで信頼できる情報を蓄積するプラットフォームの実現を目指します。 従来のメディアにありがちな広告のためのコンテンツ、ステルスマーケティング、信頼性の低い情報にうんざりしている人々を解放することがALISの目的です。

alismedia.jp

ブロックチェーンを用いたメディアを作成していて、ICOも成功しているプロジェクトです。
このメディアに記事を書いて高く評価されるとトークンがもらえ、いい記事を誰よりも早く評価してもトークンがもらえるシステムになっています。

何をかく?

ALISのプロジェクトは全てgithubとtrelloで進捗が確認できます。すごい。
ということで、せっかく公開されているコードの一部を読んでみることにしました。
ALISトークンのICOコントラクト部分を読み面白い部分をALISに投稿してみました。
全5回分になります。
ALISのICOスマートコントラクトを読む① | ALIS
ALISのICOスマートコントラクトを読む② | ALIS
ALISのICOスマートコントラクトを読む③ | ALIS
ALISのICOスマートコントラクトを読む④ | ALIS
ALISのICOスマートコントラクトを読む⑤ | ALIS

ALISトークンの実装が気になる方はぜひ記事の中をみてください。
この記事は実際にALISを使ってみた感想を書いてみます。

書いてみた結果

何ALIS獲得できたか

一番気になるのが結果どのくらいのトークンを獲得できたかだと思いますが、2018/05/20の時点で5 記事書いて、全89 いいねをもらい、49.513 ALISを獲得しました。
現在1トークンが$0.186173なので$9.2くらいの価値になります。
1記事200円くらいですね。高いのか安いのかよくわかりません。

どんな記事がいいねを獲得している?

現在、メディアの画面が、新着記事と人気記事のタブしかないので新着記事として出ている期間にどのくらい一気にいいねを獲得できるかがいいねを稼ぐ鍵なのかなあと感じました。
新着期間にたいしていいねを稼げなければ人気記事にも出てこないのでそのまま埋もれていきます。
アイキャッチ画像を工夫していたり、短い記事を量産したりするのもそのあたりの事情なのかなあと思います。

今後

普通のメディアの機能が追加されていく?

お気に入りのブロガーのブックマーク機能とか、過去記事の検索機能とか、普通のメディアにはある機能が実装されていくのではないかと思います。
今は、ブロックチェーンと仮想通貨のテーマしか投稿できないですが、将来は全ジャンル解放になるでしょうからタグ付けなどの機能も出そうですね。

個人的には、daily、weekly、monthlyのランキング機能とか出てくれるといい記事が見つけやすくていいなあと思っています。
トークンによる評価システムはいい記事の見つけやすさが前提で成り立つものだと思いますので。

ウォレット機能が出た時にどうなる?

ベータ版の現在では獲得したALISトークンを移動することができないので特に何も起きていませんが、ウォレットが実装されてトークンを移動できるようになったタイミングが気になります。
その時までにトークンを長く保持している方が得するというインセンティブがしっかりはたらく状況にしておくことが運営側としては重要になります。
white paperによるとトークンを多く持っている方がよりトークンを獲得しやすいといったモデルになっていてそこで担保しているようですが、それをユーザーに実感させる必要はありそうです。
ただ記事を書いてお金を稼ぐというモチベーションの人は貯め続けるというモチベーションはないと思うのでウォレットが実装されたタイミングが非常に注目だと思います。

まとめ

個人的には予想していたよりも1記事あたりに獲得できるALISの量が多かったので継続的に何か記事を書いてみてもいいかなあという感じです。

[asin:B07BGVZLDZ:detail]

slackに飛んでくるアラートの統計を取る

slackに色々なアラートを飛ばしている

slackに色々なアラート通知を飛ばしている人は多いと思います。

例えばDatadogと連携すればこのような感じで。
f:id:kenjiszk:20171220025043p:plain



アラートの統計情報を取りたい

そこでこう言った需要が出て来ます。

  • なんか最近アラート多くないか?半年前と比べてどうなってんだろ。
  • ちょっと無駄なアラートが増えて来たかもしれない。
  • この半年でアラートの件数を半分に減らそう!

アラートの件数の推移を取りたくなってくるわけです。

slackの機能

当初、slackにそう言った機能くらいあるだろ、とたかをくくっていたわけですが、
slackには特定のchannelの特定のbotが発言した回数の統計情報、くらい細かいものは見せてくれませんでした。。。
まあ当たり前か。。。

APIを使って取得してみる

ただし、便利なAPIはたくさん用意してくれているので、サクッとやりたいことは実現できそうです。
api.slack.com

サンプルスクリプト

ということで、サクッとGoでslackのapiを叩いて、mysqlにデータを保存するスクリプトを作りました。
主要部分だけ抜粋。

slackのchannels.historyからデータを取得
/* パラメータのセット、slackのtokenは環境変数から、idはデータを取得したいchannelのchannel id*/
values := url.Values{}
values.Add("token", os.Getenv("SLACK_ACCESS_TOKEN"))
values.Add("channel", id)

resp, err := http.Get("https://slack.com/api/channels.history" + "?" + values.Encode())
if err != nil {
        return err
}
defer resp.Body.Close()

/* レスポンスをよみこんでjsonに変換 */
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
        return err
}
jsonStr := string(body)
jsonBytes := ([]byte)(jsonStr)
rInfo := new(RespInfo)
if err := json.Unmarshal(jsonBytes, rInfo); err != nil {
        return err
}

必要なデータだけ受け取るので、構造体は以下のように定義

type RespInfo struct {
        Ok       bool      `json:"ok"`
        HasMore  bool      `json:"has_more"`
        Messages []Message `json:"messages"`
}

type Message struct {
        BotID string `json:"bot_id"`
        TS    string `json:"ts"`
}
MySQLに突っ込む

rInfo.Messagesに取得して来たメッセージが入っているので必要なものをMySQLに突っ込む
テーブル定義はこのように。

CREATE DATABASE slack_stats;
USE slack_stats;
CREATE TABLE `alerts` (
  `timestamp` varchar(256) DEFAULT NULL,
  `bot_id` varchar(256) DEFAULT NULL,
  `slack_channel` varchar(256) DEFAULT NULL,
  UNIQUE KEY `i1` (`timestamp`,`bot_id`,`slack_channel`)
) ENGINE=InnoDB DEFAULT CHARSET=latin1;

突っ込む側の処理は以下。
apiから取れるslack idはただの文字列なので人が読めるような文字列に変換する処理を入れてあります。

dbStr := fmt.Sprintf("root:%s@tcp(%s:3306)/slack_stats", os.Getenv("MYSQL_PASSWORD"), os.Getenv("MYSQL_HOST"))
db, err := sql.Open("mysql", dbStr)
if err != nil {
        return err
}
defer db.Close()

stmtIns, err := db.Prepare("INSERT IGNORE INTO alerts (timestamp, bot_id, slack_channel) VALUES (?, ?, ?)")
if err != nil {
        return err
}
defer stmtIns.Close()

_, err = stmtIns.Exec(timestampe, bot_id, slack_channel)
if err != nil {
        return err
}
結果

こんな感じで突っ込めました。

mysql> select * from alerts where slack_channel = 'infra' limit 10;
+-------------------+------------------+---------------+
| timestamp         | bot_id           | slack_channel |
+-------------------+------------------+---------------+
| 1513606245.000070 | Datadog          | infra         |
| 1513606374.000311 | Datadog          | infra         |
| 1513607148.000258 | incoming-webhook | infra         |
| 1513607148.000663 | incoming-webhook | infra         |
| 1513607209.000354 | incoming-webhook | infra         |
| 1513607210.000247 | incoming-webhook | infra         |
| 1513607270.000409 | incoming-webhook | infra         |
| 1513607271.000133 | incoming-webhook | infra         |
| 1513607333.000085 | incoming-webhook | infra         |
| 1513607333.000509 | incoming-webhook | infra         |
+-------------------+------------------+---------------+
10 rows in set (0.00 sec)



redashによる可視化

可視化するためのSQL

データを突っ込んでいるだけなので、時間単位のアラート件数を出すように以下のクエリを発行します。

  • timestampの小数点以下を切って
  • JSTにするためにoffsetつけて
  • formatを時間単位にする
mysql> SELECT DATE_FORMAT(FROM_UNIXTIME(CAST(timestamp AS UNSIGNED) + 32400), '%Y-%m-%d %H') AS time, COUNT(*) FROM alerts WHERE slack_channel = 'infra' AND bot_id = 'Datadog' GROUP BY time LIMIT 10;
+---------------+----------+
| time          | COUNT(*) |
+---------------+----------+
| 2017-12-18 23 |        4 |
| 2017-12-19 00 |        3 |
| 2017-12-19 01 |        3 |
| 2017-12-19 02 |        1 |
| 2017-12-19 03 |        1 |
| 2017-12-19 04 |        1 |
| 2017-12-19 05 |        1 |
| 2017-12-19 06 |        4 |
| 2017-12-19 07 |       14 |
| 2017-12-19 08 |       13 |
+---------------+----------+
10 rows in set, 111 warnings (0.00 sec)

クエリがかけたらredashでグラフ化

redashの使い方とかはここでは説明しませんが、MySQLにデータが入ってしまえば何か使い慣れたグラフ化ツールで可視化してあげるといいと思います。
f:id:kenjiszk:20171220043656p:plain
こうやって可視化してみると、朝と夜にアラートが多いということがわかります。



今後

最初に書いたように、以下のような観点でのアラートマネジメントを進めていこうと思っています。

  • なんか最近アラート多くないか?半年前と比べてどうなってんだろ。
  • ちょっと無駄なアラートが増えて来たかもしれない。
  • この半年でアラートの件数を半分に減らそう!

Scriptの実行環境を保証するためにDockerfileを使う

Scriptの実行環境

最近golangを使ってちょっとしたscriptを書く機会が何度かあったが、そこにDockerfileを置いておいてビルド環境や方法を固定する方法がいいなあと思ったので、メモ程度に。
goの場合は実行ファイルはコンパイル後のバイナリで依存がOS(linux or window or mac)くらいしかないと思うが、rubyやらperlやら実行環境にも依存するような場合にはサクッと動作環境をDockerfileに書いておくと簡単で良いなあと感じた。


Dockerfileを一緒に作る

例えば、sampleX.go というのがscript達だとして

┬ sample1.go
├ sample2.go
├ sample3.go
├ libs/
└ configs/

こんな感じで、scriptを作ったとする。
golang wayはよくわからないが結構こんな感じでバラバラとscriptを気軽に量産しまくっている。
(サブコマンドとか使ってmainは一つにしたほうがいいのかもしれないが。)

ここに、Dockerfileをおく

┬ sample1.go
├ sample2.go
├ sample3.go
├ libs/
├ configs/
└ Dockerfile

Dockerfileの中身はこんな感じで。

FROM golang:alpine AS build-env
RUN apk add --update git
ADD . /work
WORKDIR /work
RUN go get "github.com/go-sql-driver/mysql" && \
    GOOS=linux GOARCH=amd64 go build -o sample1 sample1.go && \
    GOOS=linux GOARCH=amd64 go build -o sample2 sample2.go && \
    GOOS=linux GOARCH=amd64 go build -o sample3 sample3.go

FROM alpine
COPY --from=build-env /work/sample1 /usr/local/bin/sample1
COPY --from=build-env /work/sample2 /usr/local/bin/sample2
COPY --from=build-env /work/sample3 /usr/local/bin/sample3

Multi Stage Buildを使っていますが、buildの環境を定義しているのはbuild-envの方。
もし特定のgoのバージョンにしたかったり自前で作ったdocker imageでやりたい場合にはFROMにそれを指定すればいいし、
必要なライブラリはRUNで実行して入れてしまえばok。

実際の手順としては、

  • scriptを修正&テストを行う
  • `Docker build . -f Dockerfile -t xxxx/yyyy` を実行する
  • 適当なレジストリにdocker push xxxx/yyyy
  • 実行したい環境で、`docker run -it --rm xxxx/yyyy /usr/local/bin/sample1`

と言った感じ。

なので基本的にはdockerエンジンだけ実行側に入って入れば全てはちゃんと整った環境で実行される、ということになる。
これで、共通のcronサーバーにあれやこれや死ぬほどinstallしなくてもよくなる!

ちなみにだいたい同じようなscriptだったのでまとめて一個のDockerfileにしているが、
かなり毛色が違って使うライブラリも違うようなものは別 Dockerfileにしたほうが良いと思う。



まとめ

dockerを使い始めると構成管理がすごい楽になるなあと実感する。
必要最低限の範囲に必要最低限のライブラリだけ入れて、それが毎回同じ状況ですぐに立ち上がる、素晴らしい!

クラウドマイニングのススメ (genesis mining編)

クラウドマイングって?

自前でマイニングリグを作る場合

この記事で書きましたが、仮想通貨をマイニングするには最低でこのくらいの初期投資が必要になります。
kenjiszk.hatenablog.com

GPUの枚数によりますが、ある程度の利益を得たいなら50~100万くらいは初期コストが必要です。
また、自作PCの組み立て経験がない場合には多少組み立てに苦労するかもしれませんし、
自宅の電力の契約次第だとブレーカーが落ちるかもしれません。
また、GPUや電源が故障したりした場合にプラスでお金と労力がかかる可能性もあります。
適切な部品を購入して組み立てていないと火事になったり、といったケースもgoogle検索すると出てきます。

クラウドマイニングだと

そのあたりの一切の面倒を見てくれるのがクラウドマイニングです。
ユーザーはハッシュパワーを購入するだけ、後は勝手にマイニング業者がマイニングしてくれます。
当然手数料は取られますが、上記に書いたような手間を考えれば納得です。
また、マイニングリグを作った場合でも多くの場合はマイニングプールに参加するでしょうから
結局、多かれ少なかれ手数料はどこかに引かれることにはなるかと思います。


genesis mining

ということで、1年ほど前から細々と少額を試して見ていましたが、
2017年の夏に仮想通貨の価格が高騰したタイミングで、BTCの無期限マイニングが売りに出ていたので
買い足してみることにしました。


どのくらいの利益があるか?

現在のハッシュパワー

計5.6 TH/s を$787.3で購入しています。

月々の利益

大体、3日に1回くらいのペースで だいたい0.0025BTCくらいがウォレットに送られてきます。
2017/12時点でBTCは200万円くらいなので、大体月に5万円くらいの計算になります。

11月~12月にかけての急激な高騰のおかげで原価が一気に回収できた形です。
実は購入当時の試算では回収に1年くらいはかかる計算だったので、これは嬉しい誤算になりました。

ただし、マイニングのdifficultyの調整が入って来るのでこのレートが常に続くわけではありません。
当然価格が上がってマイナーが増えればdifficultyが上がるので、今がたまたま景気がいい、くらいに考えています。


2017/12時点で買えるハッシュパワー

いつでも好きなハッシュパワーが買えるわけではないようです。
おそらくgenesis mining側でも大量の発注待ちや設備投資を行なっていてその準備が整い次第売り出しが始まる感じです。

現在確認したところ、今買えるのはモネロの2年間契約のみでした。
f:id:kenjiszk:20171217072904p:plain

スクショは一番安いプランの値段をさしてますが、
$830で1000H/sとのこと。
今のdifficultyで計算すると、1年も待たずに回収できるようです。
f:id:kenjiszk:20171217073313p:plain

genesis miningに会員登録だけしておくと、BTCやETHなどの売り出しが始まったタイミングでメールが来ると思うので、
買いたいコインが出るまで会員登録だけしておいて寝かしておくのもありではないかと思います。


リスク

当然リスクはありますが、これは仮想通貨以外の投資でも同じかなあとは感じます。

  • ハッシュパワーを買っているコインの暴落
  • genesis miningが倒産する


割引コード

最後に、私のアフィリエイトコードを貼っておきます。
rK9eYc

購入時にこのコードを使うと3%割引されるそうなので、もしよろしければ。
genesys miningはこちらから => https://www.genesis-mining.com/a/615255

Happy mining!

[asin:B01NBT7Q83:detail]

Dockerfileの置き場所・管理場所を色々考えた

Dockerfileをどこに置いて誰が管理するのが良いのか?という話

結論から言うと、各アプリケーションのレポジトリ直下に置いて置けばいいのではないか、と言うのが現状。

そんなん当たり前だろと思った方はそのまま読み飛ばしていただいて、自分の思考の備忘もかねて。
チーム構成とか、開発やデプロイで使っているツールとか、アプリケーションの数でこの辺りの判断は変化しそうだけど、自分がインフラエンジニアの立場として考えていたことをまとめていく。


Dockerを導入する前の構成

Dockerを導入する前は、サーバーの構成管理をansibleで行なっていた。
そのため、すべてのアプリケーションの構成情報は一元管理されて一つのレポジトリでインフラエンジニアが管理する形だった。


Docker導入初期

Dockerを最初に導入した時にはansibleの構成をそのまま引き継ぐことに。
つまり、各Dockerfileを一元管理してインフラエンジニアが責任を持ってメンテナンスするところから始まった。
サーバーを作るのはインフラ側の仕事だし、突然構成管理をDockerfileで開始するからアプリケーション側で持ってね、って言ってもそれはただの無茶振りになる。

各アプリケーションが利用するフレームワークRuby on Rails一つで、一種類のDockerfileで全部カバーできたと言うのも一つの理由。

また、Dockerの初期導入はテスト環境から始めることが多い気がするが、テストやビルドのスクリプト自体も自作して一元管理していたため、Dockerfileもその流れで一元化されたのは割と自然な流れだったと思う。


中央管理により発生する問題

Dockerfileの多様化

当初は一種類でまかなえていたDockerfileもアプリケーションが複雑化する中で多種多様になってくる。
そうなると色々な種類のDockerfileを用意する必要が出てくる。
特に最近はやりのマイクロサービスみたいな構成になってくると結構種類が増える。

インフラ作業が律速になる可能性

上記のような状態になると、XXXと言うミドルウェアを利用したい、とか、xxxという言語で次のアプリを書きたいと言った要望毎に、Dockerfileを更新する作業が発生する。
時間がある時にはいいのだが、障害続きのようなタイミングだと開発サイドを待たせてしまうことになりかねない。

アプリエンジニアのDocker知識がつかない

今後は、Dockerで開発をすることが当たり前になると思うので、組織としてレベルを上げるならこういった懸念も生まれる。


アプリケーションのレポジトリに置くことにする

といった流れで、アプリ側にDockerfileを置くことにした。めでたし。

デメリット

とはいえ、デメリットはそれなりにあると思っている。

各自が自由にできるので、、、

一番の心配はこれ。
今まで管理していたOSやパッケージ周りを手放す不安。

まあでもこれは大丈夫。
開発サイドとよっぽと仲悪くない限り。

Dockerfile編集したらpull requestこっちにもレビュー送ってねーとか、そういったコミュニケーションができていれば全く問題ないと思う。
まあそもそも専売特許でもないので、みんなで管理したらいいと思う。普通に。

重複部分が生まれる

Dockerfileが各所にあるのでどうしても重複する箇所が生まれる。

一斉に編集できない

OSやパッケージ周りで何か問題があった時に、全てのアプリのDockerfileを一斉に修正する手間が大きくなる。

ただし逆に言えば、影響範囲を最小限に抑えて段階リリースできると言うメリットとも言えるので、一長一短。


今後

重複部分が重なる問題に関して、ある程度のレイヤーまでは作られているDockerfileを一つ作ってもいいかなと言う気はしている。
少なくとも、OSと根本的なパッケージ、アプリを動かすユーザーとアプリを置くディレクトリくらいはよっぽどこのことがない限り一緒のはずなので、それは独立して一つ作っても良さそう。
各アプリはそれをFROMで指定してから、あとは自分たちのオリジナルの記述を書いていく形で。


まとめ

という感じで、アプリケーション側にDockerfileを置くことにした。
何より開発側のエンジニアが興味を持ってDockerについて聞いてきてくれたりするのが地味に嬉しかったりする。

[asin:B06Y5V9FK7:detail]

RailsのDockerイメージを小さくしたい

Docker imageを小さくする

Dockerイメージは小さいほど良いです。これは自明です。
プログラムを動かすための必要最低限の実行ファイルがあれば良いのです。

RailsのDockerイメージ

そもそもバイナリだけで動くgoみたいな言語と違って、イメージが大きくなりがちなんですが
それでも多少は頑張りたいと思い試行錯誤してみる。

現状のDockerイメージ

Railsに関係する部分だけが削減対象かというとそうでもなく、
Dockerを導入し始めたばかりですのでイメージの最小化まで手がつけられていなかったのが現実で、基本的なところから色々と試して見ます。

今使っているDockerfileは恥ずかしながら、ansibleで実行しているコマンドをそのまま置き換えただけのものなので、インストーラなど実行ファイル以外のものがたくさん含まれています。

ubuntuのbase imageに

  • apt-getで大量のパッケージを入れ
  • rbenvをinstallし
  • rubyの特定バージョンをinstallして
  • bundle installする

という感じ。

一部省略していますが、Dockerfileは以下のようになっています。
/appというディレクトリの下に、sample(仮名)というソースを配置しています。

FROM ubuntu:trusty
RUN apt-get update && \
    apt-get -y install git curl make openssl zlib1g-dev libssl-dev libreadline-dev sysv-rc-conf build-essential mysql-client libmysqlclient-dev && \
    mkdir -p /app && \
    git clone https://github.com/sstephenson/rbenv.git ~/.rbenv && \
    git clone https://github.com/sstephenson/ruby-build.git ~/.rbenv/plugins/ruby-build && \
    echo 'export PATH="~/.rbenv/bin:$PATH"' >> ~/.bashrc && \
    echo 'eval "$(rbenv init -)"' >> ~/.bashrc && \
    CONFIGURE_OPTS='--disable-install-rdoc' /root/.rbenv/bin/rbenv install 2.3.1 && \
    /root/.rbenv/bin/rbenv global 2.3.1
ADD . /app/sample
RUN /root/.rbenv/shims/gem install bundle && \
    cd /app/sample && /root/.rbenv/shims/bundle install

なんとイメージサイズは892MBもありました。

rbenvのinstallとrubyのinstallが無駄ではないか?

そもそも真面目にDockerfile内でinstallしていることに疑問を持ち(インストール用に余計にパッケージが必要)、dockerhubからrubyのbaseimageを使ってみることにしました。
rubyが既に入っているので、それを入れるために必要だったのもを全て排除して見た結果のDockerfileがこちら。

FROM ruby:2.3.1
RUN  mkdir -p /app
ADD . /app/sample
RUN cd /app/sample && /usr/local/bin/bundle install

なんとサイズが1.05GBGBに!なぜ増える!

ruby-alpineに変えてみる

よくよくみると、rubyのimage sizeが730MBもあることに気づきます。
結局rubyをinstallするために色々とパッケージを入れている模様。
ということで、127MBしかないruby-alpineを使ってみることに。
以下がDockerfile。alpineなので多少必要なパッケージを追加で入れています。

FROM ruby:2.3.1-alpine
RUN apk add --update\
    build-base \
    git \
    openssh \
    mysql-dev \
    linux-headers \
    && \
    mkdir -p /app
RUN  mkdir -p /app
ADD . /app/sample
RUN cd /app/sample && /usr/local/bin/bundle install

そしてイメージサイズは、857MB、、、あれ、、、、

結局何がサイズを大きくしてんの??

ruby:2.3.1-alpineのサイズ : 127MB
apk add した後 545MB
sample app ソースを追加した後 563MB
bundle install後 857MB

apk add と bundle installが大きい。まあ予想通り。

Multi-Stage Buildsを使おう

本題はここから。

Multi-Stage Buildsは、簡単にいうと、ビルド用のイメージを作った後に必要なファイルだけコピーして新しいデプロイ用などのイメージを作成できる機能です。

注) 諸々必要なものをinstallすると、ubuntuとalpineに大差がないと判明したので以後は何かと都合の良いubuntuで話を進めることに。

まず試したのは、今までのbuildイメージを先に作ってbundle installまでした後に、アプリのソース(/app以下)とruby(/root以下)のライブラリだけをコピーして来るパターン。

注2) Dockerfileを簡単にするために/rootにrbenv入れてますが本来は別ユーザー作ってそっちに入れた方がいいと思います。

FROM ubuntu:trusty as build-env
RUN apt-get update && \
    apt-get -y install git curl make openssl zlib1g-dev libssl-dev libreadline-dev sysv-rc-conf build-essential mysql-client libmysqlclient-dev && \
    mkdir -p /app && \
    git clone https://github.com/sstephenson/rbenv.git ~/.rbenv && \
    git clone https://github.com/sstephenson/ruby-build.git ~/.rbenv/plugins/ruby-build && \
    echo 'export PATH="~/.rbenv/bin:$PATH"' >> ~/.bashrc && \
    echo 'eval "$(rbenv init -)"' >> ~/.bashrc && \
    CONFIGURE_OPTS='--disable-install-rdoc' /root/.rbenv/bin/rbenv install 2.3.1 && \
    /root/.rbenv/bin/rbenv global 2.3.1
ADD . /app/sample
RUN echo 'gem: --no-rdoc --no-ri' > /root/.gemrc && \
    /root/.rbenv/shims/gem install bundle && \
    cd /app/sample && /root/.rbenv/shims/bundle install

FROM ubuntu:trusty
RUN apt-get update && \
    apt-get -y install mysql-client
COPY --from=build-env /root /root
COPY --from=build-env /app /app

mysql-clientだけ必要だったのでそれは追加。
これでイメージサイズが727MBに!
元々が892MBだったので165Mも削減!

こっそり.gemrc追加しましたが、これは1MBくらいしか削減効果ありませんでした。

OSを変える

Dockerfileを眺めていてふと気づく。
ubuntuが古い。
試しに、ubuntu:zestyをdocker pullしてみると、trustyよりもサイズが小さい!
昔誰かに、ubuntuを使っているならとりあえずdebianに変えて見たらdockerイメージが小さくなるよって言われたのを思い出しましたが、
debianのイメージサイズよりもubuntu:zestyの方が小さいようです。

ということで書き換え。

FROM ubuntu:zesty as build-env
RUN apt-get update && \
    apt-get -y install git curl make openssl zlib1g-dev libssl-dev libreadline-dev build-essential mysql-client libmysqlclient-dev && \
    mkdir -p /app && \
    git clone https://github.com/sstephenson/rbenv.git ~/.rbenv && \
    git clone https://github.com/sstephenson/ruby-build.git ~/.rbenv/plugins/ruby-build && \
    echo 'export PATH="~/.rbenv/bin:$PATH"' >> ~/.bashrc && \
    echo 'eval "$(rbenv init -)"' >> ~/.bashrc && \
    CONFIGURE_OPTS='--disable-install-rdoc' /root/.rbenv/bin/rbenv install 2.3.1 && \
    /root/.rbenv/bin/rbenv global 2.3.1
ADD . /app/sample
RUN echo 'gem: --no-rdoc --no-ri' > /root/.gemrc && \
    /root/.rbenv/shims/gem install bundle && \
    cd /app/sample && /root/.rbenv/shims/bundle install

FROM ubuntu:zesty
RUN apt-get update && \
    apt-get -y install tzdata openssl libmysqlclient-dev
COPY --from=build-env /root /root
COPY --from=build-env /app /app

多少必要なパッケージが変わったのでapt-getの部分を少し修正。
イメージサイズは614MBに!
OSを変えるだけでこんなに変わるとは、、、

いらなそうなファイルがないか探す

あんまりなさそうだったが、この辺は要らないので Dockerfileの最後にこれを追加。
bundle install後にできるgemsの中身はもっとせめて消せそうなものがありそうだけど、攻めすぎて壊しそうなので一旦断念。
c extensionを含むgemが割とサイズが大きそうで、実行ファイル以外消したらもうちょと小さくなりそうだけど、ROI低そうな感じがした。

RUN rm -fr /var/lib/apt/lists/*.lz4 && \
        rm -fr ~/.rbenv/versions/2.3.1/lib/ruby/gems/2.3.0/cache/*

これを消してもそこまで小さくならず、613MBに。

とても大事なことに最後に気づく、、、

配布用のイメージなので、bundle installは --without development test をつけてあげないとダメでした。。。
ということで最終的には、547MBになりました。
gemの削減が一番効果あるな、これは、、、

892MB => 547MB なので 345MBの削減になりました!

まとめ

  • Multi-Stage Buildsは使った方がいい
  • alpineとかじゃなくてもOS変えるだけでイメージ小さくなることがある
  • 使わないgemとかバンバン消していきましょう!

ちなみに

出来上がったイメージが問題なく動作するかどうかは、rspecが成功するかどうかで見ています。
実際にミドルウェアレベルの変更を行なったものをリリースする場合には、QAなど手厚くすることをお勧めします。