gcloud docker は Docker 18.03 以降をサポートしないらしい。

概要

  • gcloud auth configure-docker してやると、今まで gcloud docker -- image pull してたところを docker image pull でよくなる。

  • CoreOS で Google Cloud SDK を Docker から利用しているとややこしい。

  • gcloud auth print-access-token でトークンを取って docker login するしかなさそう。

詳細

4月くらいには気付いていたんだけど時間がなくて直せてなかったものを直した。

いつものように

gcloud docker -- image pull gcr.io/my-project/my_image:latest

などとしていたところ

WARNING: `gcloud docker` will not be supported for Docker client versions above 18.03. Please use `gcloud auth configure-docker` to configure `docker` to use `gcloud` as a credential helper, then use `docker` as you would for non-GCR registries, e.g. `docker pull gcr.io/project-id/my-image`. Add `--verbosity=error` to silence this warning, e.g. `gcloud docker --verbosity=error -- pull gcr.io/project-id/my-image`. See: https://cloud.google.com/container-registry/docs/support/deprecation-notices#gcloud-docker

というエラーメッセージが出た。エラーメッセージが出るだけでまだ普通に動きはする。

察するに gcloud docker は deprecated となるのだろう。

gcloud auth configure-docker しろと書いてあるので素直にしてやると、

The following settings will be added to your Docker config file
located at [/Users/dnpp/.docker/config.json]:
 {
  "credHelpers": {
    "gcr.io": "gcloud",
    "us.gcr.io": "gcloud",
    "eu.gcr.io": "gcloud",
    "asia.gcr.io": "gcloud",
    "staging-k8s.gcr.io": "gcloud"
  }
}

Do you want to continue (Y/n)?

と出て、 Y してやれば ~/.docker/config.json に上記が追記され、素直に docker コマンドを使えるようになる。

これにて macOS 環境で gcloud docker を使うために無効にせざるを得なかった Docker for Mac の Securely store Docker logins in macOS keychain オプションを有効に出来るようになった。

めでたしめでたし。

しかし話はそんなに簡単ではない。

上記の話は、

  • macOS High Sierra 10.13.4
  • Docker for Mac
  • Google Cloud SDK を curl https://sdk.cloud.google.com | bash で直接インストール

の場合の話である。もうちょい詳細な環境は、

$ docker --version
Docker version 18.05.0-ce-rc1, build 33f00ce

$ which gcloud
/Users/dnpp/google-cloud-sdk/bin/gcloud

$ gcloud --version
Google Cloud SDK 200.0.0
beta 2017.09.15
bq 2.0.33
core 2018.04.30
gcloud
gsutil 4.31

こんな感じ。

手元にある MacBook Pro Retina 13-inch Late 2013 と Mac mini Late 2012 で 2 台とも同じ環境だったが、これは言われた通りにやれば上手く動く。大きな問題はない。

試してはいないが、恐らく gcloud が実体として PATH の通った場所に実行できる形で存在すれば他の環境でも問題はないものと思われる。

本題はここからである。

CoreOS で Google Cloud SDK を Docker から利用しているとややこしい。

Google Cloud SDK は Docker image でも配布されている。

docker image pull google/cloud-sdk:latest

そして CoreOS ではデフォルトでこちらを使うようになっている。

GCE で CoreOS のインスタンスを立ち上げると初期状態で gcloud コマンドが使えて結構驚くのだが、実体は alias のようだ。

alias gcloud="(docker images google/cloud-sdk || docker pull google/cloud-sdk) > /dev/null;docker run -ti --rm --net=host -v $HOME/.config:/root/.config -v /var/run/docker.sock:/var/run/docker.sock google/cloud-sdk gcloud"

実装自体は、昔調べた 2017/9 頃の話だと coreos/ignitioninternal/oem/oem.go にされていた。

2018/5 にこの記事を書くにあたって調べ直したところ、 coreos/coreos-overlay
coreos-base/oem-gce/files/files/google-cloud-sdk.sh の方に移動した様だ。

この間に、

  • -v $HOME/.config:/.config から /root が抜けていたバグ。
  • -v /var/run/docker.sock:/var/run/doker.sock にあった doker という typo
  • run--rm 指定がないので docker ps で見たときにゴミだらけになってた問題。

などの問題が解決されており、着実に進歩していた模様。

2017/9 頃の話なのだが、 gcloud docker -- image pulldoker という typo を直して /var/run/docker.sock をマウントするだけでは動かず、

  • /usr/bin/docker
  • /run/metadata
  • /run/torcx

をマウントするように追記する必要があった。

詳細は Ignition's "gcloud" alias doesn't work with torcx-ified docker #2112 にある。

しかし 2018/5 にこの記事を書くにあたって再び検証してみたところ /var/run/docker.sock のみのマウントで gcloud docker -- image pull が出来るようになっていたので、細かいところで色々な改修が行われているのだなぁという気持ちになった。

どうして動くようになったのか、余裕があれば後で追っておきたい。

本題

  • CoreOS から gcloud を何も考えずに使えるのは alias になっているからであり、コマンドではない。
    • Docker の credential helper として "gcloud" と設定したところで、シェルの alias なので永遠に見付けることができない。
  • alias で設定されている gcloud には -v $HOME/.docker:/root/.docker の指定がない。
    • つまり gcloud auth configure-docker の結果書き出される ~/.docker/config.json がホスト側に永続化されない。

といった問題があり、このままでは全く使えない。

失敗例

結論から言うとこれではダメだったが、とりあえず PATH の通ってる /opt/bin にラッパーシェルスクリプトを置けば良いのではと思ってやってみた。

CoreOS でそのような用途で使っても良いとされているのが /opt 以下で、 /opt/bin にはデフォルトで PATH が通っている。

/opt/bin/gcloud

#!/usr/bin/env bash

docker container run -it --rm --net=host \
-v $HOME/.config:/root/.config \
-v $HOME/.docker:/root/.docker \
-v /var/run/docker.sock:/var/run/docker.sock \
google/cloud-sdk gcloud "$@"

シェルスクリプトを書くときに $@ とか $* とかそれらを "" で囲ったときの違いが怖くて毎回ググってしまうが、ラッパーとして使うには多分これで良いはず。

この状態で gcloud auth configure-docker してやると ~/.docker/config.jsoncredHelpers が追記される。

もっとも gcloud auth configure-docker するだけなら ~/.docker/config.json に書き込み権限があるだけで良いので、

  • -v $HOME/.config:/root/.config
  • -v $HOME/.docker:/root/.docker

とマウントして、

docker container run -it --rm \
-v $HOME/.config:/root/.config \
-v $HOME/.docker:/root/.docker \
google/cloud-sdk gcloud auth configure-docker

これでも良さそうではあった。

とりあえず

  • gcloud ラッパースクリプトが存在している
  • それを "credHelpers" に指定している

という状況で docker image pull をしてみたが、これが失敗する。

$ docker image pull gcr.io/my-project/my_image:latest
Error response from daemon: unauthorized: You don't have the needed permissions to perform this operation, and you may have invalid credentials. To authenticate your request, follow the steps in: https://cloud.google.com/container-registry/docs/advanced-authentication

途中試行錯誤しつつ、

  • gcloud docker と打つたびに変更される ~/.docker/config.json のアクセス権が正しいか確認したり、
  • ラッパースクリプトから環境変数 $HOME が読めるか確認したり、
  • "credHelpers" に直接 sudo docker container run ... などと書いてみたり、
  • そもそも "credHelpers" に指定した gcloud が呼ばれているのか確認したのだが、

そもそも呼ばれてないことが分かった。

マジかよ。僕が勝手に思ってた "credHelpers" の挙動と違うじゃん。

試行錯誤してる中で見付けたのだが、 ~/.docker/config.json への書き込み権限がある状態で gcloud docker と打つと、サブコマンドなど一切打ってなくても ~/.docker/config.json"auths" が勝手に追記され、ついでにアクセス権も root:root600 になり、アクセス権を正しくしてやると、それ以降は docker image pull に成功するということも分かり、とにかく完全につらい気持ちになった。

成功例

エラーメッセージにも書いてあるので、素直に 公式ドキュメント を読みにいくと docker login する方法が書いてあった。

gcloud auth application-default print-access-token

これでトークンが取れると書いてあるが以下のエラーが出る。

ERROR: (gcloud.auth.application-default.print-access-token) The Application Default Credentials are not available. They are available if running in Google Compute Engine. Otherwise, the environment variable GOOGLE_APPLICATION_CREDENTIALS must be defined pointing to a file defining the credentials. See https://developers.google.com/accounts/docs/application-default-credentials for more information.

色々見ていると application-default は別に無くても良さそうなので

gcloud auth print-access-token

とすると取れた。

実際には docker container run --rm -v $HOME/.config:/root/.config google/cloud-sdk という prefix が付いている。

GCLOUD_ACCESS_TOKEN=$(docker container run --rm \
-v $HOME/.config:/root/.config \
google/cloud-sdk \
gcloud auth print-access-token)
$ docker login -u oauth2accesstoken -p "$GCLOUD_ACCESS_TOKEN" 'https://gcr.io'

WARNING! Using --password via the CLI is insecure. Use --password-stdin.
WARNING! Your password will be stored unencrypted in /home/core/.docker/config.json.
Configure a credential helper to remove this warning. See
https://docs.docker.com/engine/reference/commandline/login/#credentials-store

Are you sure you want to proceed? [y/N]

-p でパスワード指定をすると「やめろ!」って言われつつ [y/N] のプロンプトが出てしまう。

言われた通りに --password-stdin してやると二行目の「暗号化されずに保存されるぞ!」という警告は出るが、 [y/N] のプロンプトは出ずにそのままログインが成功する。

echo "$GCLOUD_ACCESS_TOKEN" | docker login -u 'oauth2accesstoken' --password-stdin 'https://gcr.io'

-u 'oauth2accesstoken' は現状では固定の様だ。それ以外の値を指定するとログインに失敗する。

あと gcloud docker と打つだけで生成される ~/.docker/config.json には "email": "not@val.id" とあったので試しに -e 'not@val.id' としてみたら

unknown shorthand flag: 'e' in -e
See 'docker login --help'.

と怒られる。email の時代は終わりです。

まとめるとこうなる。

gcr-login.sh

#!/usr/bin/env bash

GCLOUD_ACCESS_TOKEN=$(docker container run --rm -v $HOME/.config:/root/.config google/cloud-sdk gcloud auth print-access-token)

HOSTS=('https://gcr.io' 'https://asia.gcr.io' 'https://eu.gcr.io' 'https://us.gcr.io' 'https://staging-k8s.gcr.io')

for HOST in ${HOSTS[@]}; do
    echo "$GCLOUD_ACCESS_TOKEN" | docker login -u 'oauth2accesstoken' --password-stdin "$HOST"
done

一応環境のメモ

Container Linux by CoreOS alpha (1758.0.0)
Container Linux by CoreOS alpha (1772.0.0)
$ docker --version
Docker version 18.04.0-ce, build 3d479c0

$ docker container run -it --rm google/cloud-sdk gcloud --version
Google Cloud SDK 198.0.0
alpha 2018.04.13
app-engine-go
app-engine-java 1.9.63
app-engine-python 1.9.69
beta 2018.04.13
bigtable
bq 2.0.32
cbt
cloud-datastore-emulator 1.4.1
core 2018.04.13
datalab 20180213
gsutil 4.30
pubsub-emulator 2018.04.13

締め

そもそもの話として gcloud docker コマンドを使うには -- と指定しなくてはいけないのだが、僕にはこれが美しくないと感じてしまう。

引数とオプションを使う側が明確に区別するためのものだが、「先頭に gcloud と付けるだけで docker image pull できますよ!」という触れ込みで触ったのに、一瞬で裏切られたなぁという気持ちは常にあった。

# エラー
$ gcloud docker image pull gcr.io/my-project/my_image:latest
ERROR: (gcloud.docker) unrecognized arguments:
  image
  pull
  gcr.io/my-project/my_image:latest

# -- を付けると通る
$ gcloud docker -- image pull gcr.io/my-project/my_image:latest

あと gcloud docker -- --versiondocker --version で出力が違ったりもする。マジかよって思った。

$ docker container run -it --rm -v $HOME/.config:/root/.config google/cloud-sdk gcloud docker -- --version
Docker version 17.12.0-ce, build c97c6d6

$ docker --version
Docker version 18.04.0-ce, build 3d479c0

中で docker コマンドを別に持ってるのか…。そうか…。

gcloud コマンドはそこそこお行儀が悪いので Docker 内に閉じ込めておきたいという気持ちはかなり強く、 Docker image として配布されているのはとても好印象ではある。

好印象ではあるのだが、これもまたそもそもの問題として google/cloud-sdk の Docker image が 1.65 GB なので、細い回線と細いマシンで docker pull したくないという気持ちも強い。というか Docker image の容量に関してはもうちょっと節約できるやろ、と毎回思っている。

要するに gcloud は邪悪。

5/13 くらいに書き始めたものの、途中で仕事が忙しくなったり、事実関係の調査とか検証を重ねていった結果時間を食われてしまい、出すまで遂に一週間以上掛かってしまった。難しいですね。

今回調べられてない以下の実装について後で調べておきたいなという気持ちだけ残しておきたい。

シェルスクリプトで便利な git の使い方

概要

git コマンドはとにかく便利で、大体なんでも取れる。

SHA-1 Hash を取る。

予想外に色々なやり方がある。

git rev-parse HEAD

git rev-parse --short HEAD # short

git rev-list --max-count=1 HEAD

git show -s --format=%H HEAD

git show -s --format=%h HEAD # short

git show-ref --hash --head HEAD

検証はしていないけど、多分 git rev-parse が速そうな空気感を醸し出している。

Commit Message を取る。

git show -s --format=%B HEAD

例は HEAD だけど勿論 Branch や Hash を直で指定も出来る。

Commit したときの UNIX time を取る。

git show -s --format=%ct HEAD

UNIX time が取れると便利で、例えば「HEAD より過去のコミットが指定された時は無視する」みたいな機能を割と簡単に実装することが出来る。

指定された Hash が指すコミットが存在するのか調べる。

git show の終了コードを見れば良い。

#!/usr/bin/env bash

HASH="0123456789abcdef0123456789abcdef01234567"

git show "$HASH" > /dev/null 2>&1
if [ 0 -ne $? ]; then
    echo 'not found.' 1>&2
    exit 1
fi

バリデーション用途や、既に存在してれば git fetch をスキップするなどの用途などが考えられる。

diff があるかチェックする。

git diff--exit-code を付けると diff コマンドと同じような終了コードで終わってくれるので判定に使うことが出来る。

ついでに --quiet を付ければ > /dev/null 2>&1 する必要もなくなり見通しも良い。

#!/usr/bin/env bash

TARGET="path/to/file_or_dir"
HASH_1="0123456789abcdef0123456789abcdef01234567"
HASH_2="fedcba9876543210fedcba9876543210fedcba98"

git diff --exit-code --quiet "$HASH_1" "$HASH_2" "$TARGET"
if [ 0 -ne $? ]; then
    echo 'diff exists.'
fi

「このファイルが変わってるときだけ再起動する」とか「このディレクトリに変化があるときだけキャッシュを飛ばす」などの用途に使える。

念のため remote の URL をきっちり指定し直す。

拠所無い事情から「ssh を使って欲しくて user も git に指定して port も 22 番を指定するように origin の remote url を直したい」みたいなときがあって、そういうときは git remote set-url でなんとかなる。

git remote set-url origin ssh://git@github.com:22/user/repository.git

などとすれば良い。良いのだけど、しかし、こういう場合は ~/.gitconfig

[url "ssh://git@github.com:22/"]
    insteadOf = git@github.com:
    insteadOf = http://github.com/
    insteadOf = https://github.com/

などと書いたりする方が健全かなとも思う。

SHA-1 っぽい文字列かどうかチェックする。

これは git に関係ないけど、 grep -E^[0-9a-f]{40}$ にヒットするか調べて終了コードを見れば良い。

#!/usr/bin/env bash

HASH="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"

echo "$HASH" | grep -E '^[0-9a-f]{40}$' > /dev/null 2>&1
if [ 0 -ne $? ]; then
    echo 'invalid format.' 1>&2
    exit 1
fi

MD5 なら 32 文字で SHA-256 なら 64 文字になるはず。

締め

GIT_SSH_COMMAND が便利だった話もあり、シェルスクリプトはいくつになっても書く機会がある。

Bot に発言させる Slack のメッセージをお洒落にするのが若干の沼。

概要

git push したらブログが更新されるようにしたのだけど、Slack に投げてた通知が味気ないなと思い、お洒落にしようとしたら沼だった。

Before:

Before

After:

After

詳細

と言っても公式ドキュメントを見るのが一番良い。

知見としては

  • 絵文字で手軽にお洒落になる。

  • "attachments" の中の "fields""short": true で要素を放り込むとお洒落になる。

  • 面倒臭がらずにリンクを張るようにするとお洒落になる。

  • 細かい要素 ("author_name" とか "author_icon" とか) を面倒臭がらずに入れるとお洒落になる。

シェルスクリプト小ネタ

なんか結局シェルスクリプトで色々実装してしまったので微妙な知見が貯まった。

SHA-1 の 40 文字から先頭 7 文字だけ取り出す。

  • cut コマンドを使う。
  • bash の変数展開を使う。

あたりが考えられる。

#!/usr/bin/env bash

REVISION="0123456789abcdef0123456789abcdef01234567"

echo "${REVISION:0:7}" # 0123456

echo "$REVISION" | cut -c 1-7 # 0123456

パイプで繋げて cut コマンドを立ち上げるよりは bash 依存の変数展開の方が速そうではあるけど、経験的に変数展開を駆使すると未来の自分が読めなくなることを知っているのでやめた。

どんな環境でも絶対に JST で日付を出力したい。

TZ を指定しつつ起動してやれば良い。

TZ='JST-09:00' date '+%Y/%-m/%-d %-H:%M:%S %Z' # 2018/5/10 1:49:00 JST

alpine 等の環境で tzdata が入ってないような場合でも 'JST-09:00' と指定すれば意図した通りの挙動になる。

重たいコマンドを実行した場合の経過秒数を出したいけど別に精度は要らない。

date コマンドで UNIX time を保持しておけば引き算で出せる。

#!/usr/bin/env bash

BEGIN_UNIX_TIME=$(date '+%s')
function elapsed_sec() {
    CURRENT=$(date '+%s')
    ELAPSED=$(( $CURRENT - $BEGIN_UNIX_TIME ))
    echo "$ELAPSED"
}

sleep 10
echo "$(elapsed_sec) sec" # 10 sec
sleep 5
echo "$(elapsed_sec) sec" # 15 sec

time コマンドでも実行時間は測れるけど、別にそういう正確な情報が欲しい訳じゃなくて、ステップ毎に「何秒経過しました」みたいなのを雑に出すにはこういうのもあるかなと。あと、そもそも bash 組み込みの time は単純に扱い辛い。

しかしこの辺まで来ると段々気が狂ってきて「expr コマンドより $(( )) の二重括弧の方が速いっしょ」みたいな気持ちで実装しがちになる。別にループ内で使う訳でもないから誤差の範囲になるのだけど。

締め

最初の段階で jq コマンドが便利すぎた結果、それに合わせる形で curl で API を叩いて遊ぶみたいなことしてしまって、うっかり最後までシェルスクリプトで実装してしまったみたいな話でした。

cloud-builderscurl が追加されてた。

詳細

タイトルの通りなんだけど Google Cloud Container Builder の公式イメージのリポジトリ github.com/GoogleCloudPlatform/cloud-builders を眺めてたら curl が追加されてた。

プルリクは これ。2018年3月終わりくらいに切られてて、4月終わりにマージされてた模様。

気持ち

今までは wget しかなかったけど curl も使えるようになって選択肢が増えて心が豊かになった。

GIT_SSH_COMMAND が便利。

概要

  • 環境変数を指定して起動してやると git コマンドの挙動を制御することが出来る。

  • ssh が絡む部分だと GIT_SSHGIT_SSH_COMMANDGIT_SSH_VARIANT などがある模様。

  • ~/.ssh/configgitconfig を弄ることが難しい場合や、環境に依存しないシェルスクリプトを書く場合などに役に立ちそう。

詳細

ドキュメントここ を読めば良い。

ssh が絡む部分だと git fetchgit pullgit clonegit push あたりだろうが、僕は git fetch で上手く動いてることしか確認していない。

GIT_SSH_COMMAND が使えるのは 2.3 以降

Git 2.3 has been released を見るに 2.3 以降でないと使えないらしい。

使えない場合は GIT_SSH の方が昔からあるので、それを使ったラッパースクリプトを書くやり方があるようだ。

使い所

-i で秘密鍵を指定したり -Fssh_config を指定したり -o で好きなようにオプションを投げるような感じの使われ方が想定されているのだと思われる。

GIT_SSH_COMMAND="\
ssh \
-i /path/to/secret_access_key \
-F /path/to/ssh_config \
-o UserKnownHostsFile=/dev/null \
-o StrictHostKeyChecking=no" \
git fetch origin

締め

gitssh も毎日使ってるのに知らない便利機能が毎日出てきて凄い。

git push したらブログが更新されるようにした話。

概要

こんなややこしいことしなくてもいいなとも思うけど、こういうのが趣味なのでやっているという話。

詳細

更新の作業が面倒だと記事を全然書かなくなるであろうことは最初から分かりきっていたので後でなんとかしようと思ってたのだけど、遂になんとかした。

このサイトのリポジトリは bitbucket の無料プライベート枠で管理しており、 Webhook からのアクセスによって自分自身を更新できるような機能を実装すれば git push するだけで更新出来るようになるのでその様にした。

sinatra で bitbucket からの webhook を受ける。

特に言うことはない。任意に設定した URL に POST で JSON が飛んでくるので、

post '/webhook' do
  payload = JSON.parse(request.body.read)
  # payload から必要な値を使ったり適当な処理をして、適当な status code と body を返す。
end

とすれば良い。仕様は Manage webhooksEvent Payloads を見れば良い。

ただこの sinatra アプリケーション自体は Docker 内に閉じ込めているので、ホスト側に良い感じに通知して、通知を受けたホスト側が良い感じになんとかするような実装をする必要があった。

通知パターンの設計の勘所だけど、登場人物がお互いの事を完全に知っている必要があると容易に破綻してしまうので、そこだけは注意する必要がある。

それに bitbucket から飛んできた Webhook のリクエストにはさっさとレスポンスを返しておきたい。更新の成否を bitbucket 側に返す必要も今回の用途だと無い。

シェルスクリプトで何とかする。

悩んだ結果、Webhook からのアクセスが来たら Docker Volume でマウントしたホスト側にファイルを作るだけ作ったらさっさとレスポンスを返しつつ、別に tail -F で受けておいてその中でゆっくり処理するみたいな感じにした。

tail -F /path/to/file | while read -r LINE; do
    # $LINE を使ってなんか処理
    echo $LINE
done

こんな感じで tail -F にパイプで while read に食わせてやればコマンドに流せるようだ。シェルスクリプトの世界は奥が深い…。

実際に使うには、各所のエラーハンドリングとか、重複で起動しないようにしたりとか、大量の Webhook が来ても大丈夫なようにしたりとか、シェルスクリプト自身が変更されないように、あるいは変更されても大丈夫なようにしたりとか、成否は Slack に通知したいよねとか、まぁ考えることが結構あるので実装はやや複雑にはなる。

シェル芸をやりだすとキリがないし未来の自分が読めなくなるのであまりトリッキーな書き方はしないようにしているが、基礎的かつ嵌るポイントみたいなのはちゃんと押さえておきたい。

上記の while read -r を付けた話で言うと、例えば以下のワンライナーを実行した場合、

sleep 10 | echo 'print'

これは 10 秒経った後に print と表示される訳ではない。即座に print と表示され、10 秒後に操作が返ってくるという挙動を示す。

複数のコマンドをパイプで繋げたとき、プロセスはそれぞれ同時に実行されており、ただ単に入出力が繋がっているだけ、という理解を個人的にはしている。

tail -F からの出力が来る度にそれを利用する処理を実行したい、といった場合は間に while read を挟んでやるといったテクニックが定石なようだ。

しかしこの手法では行毎にプロセスが立ち上がるはずなので、大量のデータが流れてくる状況で出来るだけ短時間で処理する必要がある用途には向かないと思われる。まぁ把握だけしてれば良いだろう。

締め

今回は docker image の更新の必要がないように予め作っていた部分を利用して作ったため、docker image の更新が伴う更新は実現出来てない。

ただ docker image の build には Google Cloud Container Builder を使っているのでなんとでもなるというか、その内ちゃんと作ります。

Docker に Gemfile.lock を含む Ruby アプリケーションを良い感じに乗せる。

概要

  • docker image build で毎回 bundle install が走ると遅くなりがちなので、なるだけキャッシュを効かせたい。

  • よく変更されるアプリケーション部分の COPY よりも先に Gemfile と Gemfile.lock だけ COPY しておいて RUN bundle install するような Dockerfile にしておく手法が良さそうだと思う。

  • 先に Gemfile.lock を生成しておく必要があるので、bundle install を走らせて Gemfile.lock を取り出すスクリプトを書いた。

  • alpine ベースのイメージを使うと軽い。罠も無くは無いけど。

  • あと細かい知見とかを書いた。

詳細

/path/to/your/great/app が以下のような構成になってるとして

$ tree /path/to/your/great/app
/path/to/your/great/app
├── Dockerfile
├── Gemfile
├── Gemfile.lock
├── app
│   └── app.rb
└── docker-entrypoint.sh

Dockerfile をこう書いたとする。

FROM ruby:latest

ENV APP_ROOT /path/to/your/great/app

RUN mkdir -p $APP_ROOT
WORKDIR $APP_ROOT

COPY ./ .

RUN bundle install --path vendor/bundle

ENTRYPOINT ["./docker-entrypoint.sh"]

COPY ./ . の後に RUN bundle install があるので、Gemfile を弄ってなくてもアプリをちょっと変更する度に docker image build のキャッシュが無効になってしまい、毎回 bundle install が走ってしまう。

毎回 native extension の build が走ったりするのは個人的には許容出来ない。時間も掛かるし、エコでもない。

そうすると大体以下の様に先に Gemfile と Gemfile.lock を COPY して、先に RUN bundle install するような Dockerfile を書くようになると思う。

FROM ruby:latest

ENV APP_ROOT /path/to/your/great/app

RUN mkdir -p $APP_ROOT
WORKDIR $APP_ROOT

COPY ./Gemfile      Gemfile
COPY ./Gemfile.lock Gemfile.lock
COPY ./.bundle      .bundle

RUN bundle install --path vendor/bundle

COPY ./ .

ENTRYPOINT ["./docker-entrypoint.sh"]

Gemfile.lock を先に生成するには。

  1. 使い捨てのコンテナを作る。
  2. ホストから Gemfile をコンテナに docker cp する。
  3. 使い捨てコンテナの中で bundle installdocker exec する。
  4. コンテナ内に生成された Gemfile.lock を docker cp でホストに回収して終わり。

という内容のシェルスクリプトを書いて使っている。

本当は引数を取るように書いた方がいいんだろうけど面倒で毎回中を書き換えて使ってる。後で何とかします。

今気付いたけど .bundle ディレクトリ取り出してなかった。後で何とかします。

#!/usr/bin/env bash

RUBY_IMAGE_TAG="2.5.0"
GEMFILE_DIR="/path/to/your/great/app"

if [ ! -s "$GEMFILE_DIR"/Gemfile ]; then
    echo "$GEMFILE_DIR""/Gemfile not found." 1>&2
    exit 1
fi

NAME="gemfile_lock_generator_$(date '+%Y%m%d_%H%M%S')"
W_DIR="/opt/app/gemfile_lock_generator"

docker container create -it --name "$NAME" --rm ruby:"$RUBY_IMAGE_TAG"
docker container start "$NAME"
docker container exec -t "$NAME" mkdir -p "$W_DIR"
docker container cp "$GEMFILE_DIR"/Gemfile "$NAME":"$W_DIR"/Gemfile
docker container exec -t --workdir "$W_DIR" "$NAME" bundle install --path vendor/bundle
docker container cp "$NAME":"$W_DIR"/Gemfile.lock "$GEMFILE_DIR"/Gemfile.lock
docker container stop "$NAME"

いつの頃からか Docker にサブコマンドが導入されて本当に分かりやすくなった。

ターミナルでコマンドを直打ちするときは手癖で docker ps -a とか docker images とか docker rmi とか打ってしまうけど、シェルスクリプトにまとめるときなどは意識的にサブコマンドを書くようにしている。

bundle platform 問題の話。

Gemfile.lock を生成したマシンと実行するマシンの bundle platform が違っていると bundle install 時のなんらかの native extension ビルドでコケる事例がある。

# コンテナ内に入って実行
$ bundle platform
Your platform is: x86_64-linux
# macOS 上で直接実行
$ bundle platform
Your platform is: x86_64-darwin16

ホストの macOS で生成された Gemfile.lock を安易に持っていくと死ぬ場合があるので大人しく同じ環境で生成している。

Mac 上で開発してた Rails アプリを本番サーバーに持っていこうとしたらなんか上手くいかないみたいなやつの Docker 版みたいなことが起こる訳だ。

本来 Docker 自体は x86_64-linux に限定されたものではなく、armv7l な Raspberry Pi などでも動くには動くのだが、狭義の Docker というと暗黙の了解で x86_64-linux が前提であることが多い。

そのため Docker Hub に置いてある Raspberry Pi 向けのイメージには rpi- などの prefix を付けるような文化になっていて涙ぐましい気持ちになったりもする。

ベースイメージのタグでメジャーバージョン指定しておく。

実際に使うには FROM で引いてくるイメージのタグは詳細に指定した方が良いと思われる。latest だとうっかりメジャーバージョンが上がってしまう。

指定に有効なタグ一覧は公式 https://hub.docker.com/_/ruby/ を見ましょう。

基本的にはバージョンアップには追従する姿勢だけど、CI で Docker イメージビルドを自動化してるような環境だと稀に事故が起こる。

alpine ベースにすると軽い。

Docker あるあるとして alpine ベースのイメージがあればそちらを使うことによって容量をかなり節約出来る。

$ docker images
REPOSITORY  TAG              IMAGE ID      CREATED     SIZE
ruby        2.5.0-alpine3.7  42abb49f8ada  7 days ago  60.7MB
ruby        2.5.0            e8d112ff7588  7 days ago  869MB

通常だとベースイメージで 869 MB もあるところが 60.7 MB になる訳なので効果は大きい。

しかしまぁ alpine ベースにすると色々なものがなくて不便なこともある。

例えば tzdata などが入っていないため、 ENV['TZ'] = 'Asia/Tokyo' の様な指定が出来なくなる。この場合は tzdata を自前で入れるか、諦めて ENV['TZ'] = 'GMT-9' などと記述すれば良い。

個人的にはタイムゾーン周りは政治と歴史の都合で完全な闇だと思っているので 'GMT-9' みたいな指定の方が事故らないのではと思う。最近も Qiita で タイムゾーン呪いの書 などがバズっていたけど、ここまでまとまってる情報を前にただただ圧巻という他なかったです。すごい。

他にも bashmakegcc が入っていない。これらを入れておかないと native extension のビルドが必要な場合の bundle install でコケる。

gcc libc-dev musl-dev g++ あたりは必要だったり不要だったりアンインストールしてはいけない場合があるので、最小構成で作ってみてエラーが出たら必要なのを足すみたいな感じにしてる。

という訳で最近雛形として使ってる Dockerfile はこんな感じです。

FROM ruby:2.5.0-alpine3.7

ENV APP_ROOT /path/to/your/great/app

RUN mkdir -p $APP_ROOT
WORKDIR $APP_ROOT

COPY ./Gemfile      Gemfile
COPY ./Gemfile.lock Gemfile.lock
COPY ./.bundle      .bundle

RUN apk add --no-cache --virtual .bundledeps musl-dev make gcc libc-dev && \
    apk add --no-cache g++ bash && \
    bundle install --path vendor/bundle && \
    apk del .bundledeps && \
    rm -rf /var/cache

COPY ./ .

ENTRYPOINT ["./docker-entrypoint.sh"]

締め

Docker の世界観にも色々ややこしさはあるものの、各種 LL を管理するために入れた なんちゃらenv 系は壊れるときによくシステムを巻き込むことを考えると、環境を Docker に閉じ込められる方が幸せだと感じている。

特に LL の環境構築のベストプラクティスを理解することは昔は難しかったと思うのだが、今は Docker Hub にある公式の Dockerfile を見れば良くなった。ありがたいことです。

Docker for Mac は結局 macOS の中で仮想マシンを動かしてるし、ホストとゲスト間のファイル共有が死ぬほど遅い問題のせいでボリューム機能が死ぬほど遅くて辛い話とかもあって、それなら virtualbox + vagrant で良いじゃんみたいな気持ちも無くは無いんだけど、まぁ時と場合によって両方使ってます。

他にも /(?:機械|強化|深層)*学習/ 系のタスクを解くためにマシンパワーを限界まで使いたいみたいな時は、仮想化レイヤやコンテナレイヤを挟むことによるオーバーヘッドが無視できない場合もあって、そういう時は大人しくホストの環境を汚しましょう。

sass でネストの深いリストを良い感じにスタイリングする。

先に概要

  • sass では @function が書けるが、再帰で呼び出すと比較的早い段階で SystemStackError - stack level too deep と怒られる。

  • プログラミング言語だと思って見ると sass での変数の扱いと配列の扱いが難しい。

  • redcarpet の問題なのか、リストを 6 段階以上ネストさせると出力が消える。正しい仕様と挙動が分からないので、ちゃんと検証したら issue を立てたい。

詳細

Markdown では以下の様に書くと

- list1
- list2

次の様に出力される。

  • list1
  • list2

簡単にリストが書けて大変便利でよく使う訳だけど、これはネストさせることが出来る。

- list1
    - list2
- list3
    - list4
        - list5
    - list6
        - list7
            - list8
                - list9

するとこんな感じになる。

  • list1
    • list2
  • list3
    • list4
      • list5
    • list6
      • list7
        • list8
          • list9

いくらでも階層を深くさせられるので大変便利。

ここに良い感じな css をあてたいと考えてしまった。

具体的には

  • 1 段階目は
  • 2 段階目は
  • 3 段階目は
  • 以下ループ

リストには順序付きのものもある。

  • 1 段階目は 1.
  • 2 段階目は i.
  • 3 段階目は a.
  • 以下ループ

となるように list-style-type を調整したい。

要するに <ul><ol> 要素に対して、それぞれの親要素の <ul><ol> を数え上げて 3 で剰余を求めて 0, 1, 2 で場合分けして style を指定出来れば良い。

そんな便利な指定方法はない。(ないよね?)

いっちょやったろうと思って sass@function などを駆使して実装してみた。

実装

sass でプログラミングをするのは初めてなので、とりあえず適当な Int を放り込むと剰余から該当する <ul> 用の list-style-type が返ってくる君を実装してみた。

// $i % 3 == 1 -> disc
// $i % 3 == 2 -> circle
// $i % 3 == 0 -> square
@function ul_style_for($i) {
    $ul_styles: (square, disc, circle); // scss の Index は 1 始まりなので、配列の方をずらすと上手く行く。
    $index: ($i % length($ul_styles) + 1);
    @return nth($ul_styles, $index);
}

この段階で割と後悔が強い。まず配列が難しい。

$list: "1.1" "1.2" "1.3", "2.1" "2.2" "2.3", "3.1" "3.2" "3.3";

こう書くと二次元配列として扱えるらしい。なるほど。

というか sass の世界では配列と変数の区別があるのか疑わしい。見た感じ無さそうだなと思う。配列というか単にデリミタとして空白とカンマを使っているだけのようにも見える。調べてはいない。

そして for のループカウンタや配列の index は 1 から始まる。確かに css を書くような局面では 1 から始まった方が都合が良い場合が多いのかもしれない。

数値の扱いも難しい。よく考えたら sass では 100px * 2 などが実現されているのだから難しいに決まっているのであった。

しれっと出てきたが、配列から要素を取り出すには nth() を使うことになっている。

めげずに <ol> 用のものも実装する。

// $i % 3 == 1 -> decimal
// $i % 3 == 2 -> lower-roman
// $i % 3 == 0 -> lower-alpha
@function ol_style_for($i) {
    $ol_styles: (lower-alpha, decimal, lower-roman);
    $index: ($i % length($ol_styles) + 1);
    @return nth($ol_styles, $index);
}

とりあえず上記で動いてることは地味な print デバッグなどを駆使してなんとか確認出来た。なぜこんなことを始めてしまったのか。

損切りのタイミングをミスったため最後までやっていく。

最終的には愚直に書かれた css をコンパイルして出力することになるため、無限を無限のまま扱うことは出来ない。無限のネストに対する指定は諦め、現実的な深さのネストに対する style の指定をすることになる。

そうするとネスト数を指定できるように書いた方が都合が良いので下記の様になった。

// $i: 1 -> ""
// $i: 2 -> ul, ol
// $i: 3 -> ul ul, ul ol, ol ul, ol ol
// $i: 4 -> ul ul ul, ul ol ul, ul ol ul, ul ol ol, ol ul ul, ol ol ul, ol ol ul, ol ol ol
// $i: 5 -> (略)
$list_tags: (ul, ol);
@function xl_prefixes_for($i) {
    @if ($i == 1) {
        @return ""; // ここは空配列じゃなく空文字を返す。
    }
    @else if ($i == 2) {
        @return $list_tags;
    }
    @else {
        $p: (); // ここは空文字ではなく空配列にする。
        @each $prefix in xl_prefixes_for($i - 1) {
            @each $list_tag in $list_tags {
                $p: append($p, #{$list_tag $prefix});
            }
        }
        @return $p;
    }
}

これを実際に使って 6 段目までのネストに対応する css を出力するには以下の様に書けばいい。

@for $i from 1 through 6 {
    @each $prefix in xl_prefixes_for($i) {
        #{$prefix} ul {
            list-style-type: ul_style_for($i);
        }
        #{$prefix} ol {
            list-style-type: ol_style_for($i);
        }
    }
}

@function xl_prefixes_for($i) で再帰が使われている。

現状は 6 にしてあるが 9 くらいまでは行ける。自分の環境ではこのコードで 10 にすると sass コンパイル時に SystemStackError - stack level too deep の例外を吐いて死ぬ。

このコードは O(2^n) な訳で、そらそうだなという気持ちになった。

どうせ nginx 側で gzip されるし、圧縮率の良さそうな出力だなぁ…という感想です。

締め

一応このページの css に使われているものの、こんなことに頑張ってもしょうがなかったなという気持ちになった。

なおデバッグ中に Markdown のネスト表記をガンガン深くしていったところ、6 段目あたりで redcarpet からの出力が消えることが確認されたのだが、環境依存なのか、仕様なのかは調べていないので分からない。余力があれば調べてなんとかしたい。

文章を書いておける場所を作った。

ブログのようなものを作った。ここです。

アウトプットしていかないとダメなのでは…と急に意識が高まったので、雑に Markdown を書くだけで色々と良い感じなるような Web アプリケーションをしたためた次第です。

作るにあたって考えたこと

お金を掛けないようにしたい。

運用に必要なお金は固定費になるし、そういうのは節約すべきかなと。

幸い Google Cloud Platform の Always Free 枠で f1-micro を使っておけば本当にタダなので、それで出来る範囲のものを逆算して作っておけば良さそうだと思い至った。

転送量とか Cloud DNS 部分とかドメイン管理はお金かかるけど、ドメイン周りは必要経費だし、個人サイトの転送量などは微々たるものだろう。

インタラクティブにしたくない。

ブラウザはドキュメントビューワであってアプリケーションの実行環境では無い派なので、なるだけ 1 リクエストで描画に必要なものが全て返ってくるというシンプルさを突き詰めていきたい。

というか GCP の f1-micro はマジで死ぬほど弱いインスタンスなので、せいぜい静的ファイルを返すくらいしか出来ないんだよね。極力リクエスト数を抑えたいというのが大きい。

しかし考えてみればブログなんて https で html を返すだけだし、最低限 nginx が ssl の終端とキャッシュをこなしてくれれば良くて、裏の Web アプリケーションが重くても最初の 1 回だけで済む訳で、これは用途としては丁度良いのではという感じになった。

技術的なこと

なんやかんや便利だし書き慣れているのでアプリケーション部分は sinatra で書いている。

Markdown のレンダリングには redcarpet だし、シンタックスハイライトには rouge だし、テンプレート部分は slim + sass と、そんな変な作りではないのではと思ってる。

しかし sass で再帰を実装したら辛くなった話とかもあって、ここに書いた。

そうやって組んだ Web アプリケーションを Docker 化して CoreOS 上で動かしてる。

主に bundler のせいなのだけど、 Ruby で書いたアプリケーションを綺麗に Docker に乗せるのは若干めんどくさい。バッドノウハウもそこそこ貯まりつつある気はする。そういうことも書いておきたいと思ったので、一応ここに残した。

どちらかと言うと CoreOS を単体の f1-micro インスタンスで使ってる方が微妙ではあって、アプリケーションを Docker 化してるなら GCP にも AWS にもマネージドなコンテナ管理サービスあるじゃんという気持ちはあり、完全に仰る通りなのだが、それらは極限まで金を使わないような貧乏臭い使い方をすることが出来ない。仕方無くこうなってる。

おかげで systemd とか cloud-config 周りで地獄は見られるんだけど、勉強にもなって良いかなとも思う。

しかし一番時間が掛かったのは css を弄るところ。仕様が確定したデザインを粛々と実装に落とし込む作業自体はそこまで大変ではない。面倒臭い場合はあるけど。パソコンからでもスマートフォンからでも綺麗に見えそうなレイアウトを考える方が大変だった。世の中のデザイナさん達は凄い。いつもありがとうございます。

締め

redcarpet は Markdown から HTML を出力することをレンダリングと言っていて、sass は css を出力することをコンパイルと言っている。

この辺の言語感覚は面白いとも思うし難しいなとも思う。違和感の無い表現をしていきたい。

Recents