見出し画像

気軽にDev用コンテナを作って壊せるコマンド

はじめに

みなさんこんにちは、ALH開発事業部のREIYAです。
みなさんはdevcontainers-cliをご存知でしょうか?
または、VSCodeの左下の青いとこから入るDev Containersはご存知ですか?

今回はこれらをリスペクトして、どこでも気軽にコンテナを作成し、中に入っていつもの環境設定で作業して、いつでも好きな時に壊せるようにするためのコマンドを作りました。


レポジトリ紹介

こちらのレポジトリに作成しました。
一応日本語でもドキュメントを書いていますので、使いやすいかと思います。


イメージ名について

やっていることはdocker-composeでコンテナ起動してるだけなのですが、今回は複数のコンテナをフォルダごとに別物として作成したかったです。
フォルダAでコンテナを作成し、フォルダBでも作成したとき、別物のコンテナとして同じ構成のものが立ち上がるイメージです。

さて、docker-composeではイメージ名を指定せずにビルドすると、docker-compose.ymlファイルがあるディレクトリ名とサービス名を使ってイメージ名が命名されます。

以下の場合は、「sample-test」というイメージ名になります。

.
└── sample/
    ├── docker-compose.yml
    └── Dockerfile
services:
  test:
    build: .

サービス名はdocker-compose.ymlに固定で書く物なので、フォルダ名のほうを一意にすることで起動するフォルダごとに別のイメージにすることができます。
そのため、docker images結果を照合して一意になるまで疑似乱数を用いて命名することで実現できます。コードでは以下のようになります。

CONTAINERS=$(docker images --format='{{.Repository}}')
S=".devbox-$RANDOM"
while [[ $CONTAINERS =~ $S ]]; do
    S=".devbox-$RANDOM"
done
mkdir $S

複数コンテナ

PC内のいろんな箇所でコンテナを立ち上げまくり、それぞれが独立して動くため、好きに壊せるPCをいっぱい持っていていくらでも素早く作れる状態になります。
今コンテナ内なのかホスト側なのかが分からなくなるため、アスキーアート表示をするなり

私の場合では、ホスト側では「powerlevel10k」コンテナ内では「oh-my-zsh」というように外観を分けています。
以下のようにunameを見て分岐すると良いと思います。

OS_NAME=$(uname -s)
IS_MAC=false
IS_DOCKER=false

if [ "$OS_NAME" = "Darwin" ]; then
  IS_MAC=true
elif [ "$OS_NAME" = "Linux" ]; then
  IS_DOCKER=true
fi

if "$IS_MAC"; then
    source /opt/homebrew/opt/zsh-git-prompt/zshrc.sh
    source /opt/homebrew/share/zsh-autosuggestions/zsh-autosuggestions.zsh
    source /opt/homebrew/share/zsh-syntax-highlighting/zsh-syntax-highlighting.zsh
    source /opt/homebrew/opt/powerlevel10k/share/powerlevel10k/powerlevel10k.zsh-theme
    [[ ! -f ~/.p10k.zsh ]] || source ~/.p10k.zsh
fi

if "$IS_DOCKER"; then
    export ZSH="$HOME/.oh-my-zsh"
    ZSH_THEME="fino"
    plugins=(
      zsh-autosuggestions
      zsh-syntax-highlighting
    )
    source $ZSH/oh-my-zsh.sh
fi

また、今何個コンテナを上げているかなどの管理はOrbStackを見ればわかりますが、lazydockerというTUIコマンドもおすすめです。

起動の速さ

最初の1回はしっかりビルドするため、多くの場合時間がかかります。
レポジトリ通りのDockerfileだと、私の環境では200秒ほどかかりました。

なお、2回目以降、2個目以降ではビルド時にイメージレイヤごとでキャッシュを見るため高速(体感1秒ほど)で起動までが完了します。
Dockerfileやdocker-compose.ymlに変更を入れた場合だと、差分のあるレイヤのみ再ビルドとなります。

カスタム性

そもそもレポジトリ通りの設定では私の環境しか再現できませんが
基本的にDockfileの最後のレイヤのみを修正することを想定しています。

OSイメージについて

OSイメージでマルチアーキ対応しているalpineはglibcではなくmusl-libcを使っているため
以下のようにする必要があります。

FROM alpine
RUN apk update && apk upgrade && apk add bash file curl vim git

# ホストがarm64でもalpineはクロスアーキ
# x86_64のバイナリを実行させるためにglibcを入れる
# apk addはエラーが出るのでコンテナ立ち上げ時は無理、bash_profile等でやる必要がある
WORKDIR /tmp
RUN curl -L -o /etc/apk/keys/sgerrand.rsa.pub https://alpine-pkgs.sgerrand.com/sgerrand.rsa.pub
RUN curl -L -O https://github.com/sgerrand/alpine-pkg-glibc/releases/download/2.34-r0/glibc-2.34-r0.apk
# x86_64 バイナリを実行用 glibc
# エラーが出ます
apk add /tmp/glibc-2.34-r0.apk &> /dev/null

コメントにも記載していますが、glibcをapk addすると何故かエラーがでまして、コンテナビルド中にこれが起きるとエラーになってしまいますので
このように別設定を必要としてしまします。

そもそもx86_64やamd64系のバイナリさえ実行できれば困らないということで、debianの11(bullseye)に追加する形でこのように設定しています。

FROM debian:bullseye-slim
RUN dpkg --add-architecture amd64 \
    && apt update \
    && apt upgrade -y \
    && apt install -y libc6:amd64

なおOSイメージのサイズ比較をされている記事が複数見られましたので、一例を載せておきます。正直大差ないので、無理に軽量化にこだわらなくても良いと感じます。

その他のレイヤについて

本記事の趣旨とズレるので割愛しますが、基本的には自由です。
しかし後述するdotfilesでやるべきだと考えていますので、OSイメージを余程変えたい場合以外ではDockerfileはいじらないでよいと思います。

dotfilesについて

Dockerfileの最後に

RUN /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/serna37/dotfiles/master/install.sh)"

としています。
これは私のdotfilesのインストール処理を読んでいるのですが、内容はこうです。

OS_NAME=$(uname -s)
if [[ $OS_NAME == "Darwin" ]]; then
    /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/serna37/dotfiles/master/install-mac.sh)"
elif [[ $OS_NAME == "Linux" ]]; then
    /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/serna37/dotfiles/master/install-docker.sh)"
fi

Macなら上、Linux(コンテナ)なら下を呼ぶようにしており
Macならこのようにbrewで入れて

repos=(
git vim zoxide eza fzf bat ripgrep
lazygit lazydocker gum yazi
... 割愛
)
brew list
brew cleanup
for v in ${repos[@]}; do
    brew reinstall $v
    wait $!
done

cask_repos=(
wezterm
orbstack
maccy
keycastr
)
for v in ${cask_repos[@]}; do
    brew reinstall --cask $v
    wait $!
done

# dotfilesの適用
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/serna37/dotfiles/master/setup.sh)"

Linux(というかdebian)ならaptを用いるように

repos=(zoxide fzf bat ripgrep python3 python3-venv pip clangd sqlite3)
for v in ${repos[@]}; do
    apt install -y $v
    wait $!
done

# node
apt install -y nodejs npm
npm install n -g
n lts
n latest
apt purge -y nodejs npm

# eza
sudo apt install -y gpg
mkdir -p /etc/apt/keyrings
wget -qO- https://raw.githubusercontent.com/eza-community/eza/main/deb.asc | sudo gpg --dearmor -o /etc/apt/keyrings/gierens.gpg
echo "deb [signed-by=/etc/apt/keyrings/gierens.gpg] http://deb.gierens.de stable main" | sudo tee /etc/apt/sources.list.d/gierens.list
sudo chmod 644 /etc/apt/keyrings/gierens.gpg /etc/apt/sources.list.d/gierens.list
sudo apt update
sudo apt install -y eza

# batcat -> bat
mkdir -p ~/.local/bin
ln -nfs /usr/bin/batcat ~/.local/bin/bat

# gum
sudo mkdir -p /etc/apt/keyrings
curl -fsSL https://repo.charm.sh/apt/gpg.key | sudo gpg --dearmor -o /etc/apt/keyrings/charm.gpg
echo "deb [signed-by=/etc/apt/keyrings/charm.gpg] https://repo.charm.sh/apt/ * *" | sudo tee /etc/apt/sources.list.d/charm.list
sudo apt update && sudo apt install -y gum

# oh-my-zsh
sh -c "$(curl -fsSL https://raw.github.com/ohmyzsh/ohmyzsh/master/tools/install.sh)"
git clone https://github.com/zsh-users/zsh-autosuggestions /root/.oh-my-zsh/custom/plugins/zsh-autosuggestions
git clone https://github.com/zsh-users/zsh-syntax-highlighting /root/.oh-my-zsh/custom/plugins/zsh-syntax-highlighting

# デフォルトのshellをzshに
chsh -s /bin/zsh
export SHELL=/bin/zsh

# dotfilesの適用
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/serna37/dotfiles/master/setup.sh)"

そして、dotfilesレポジトリで管理しているファイルたちと配置する系の処理はOSに関わらず共通なので
以下のようにまとめています。

# dotfilesレポをクローン
mkdir -p ~/git && cd ~/git
git clone https://github.com/serna37/dotfiles
# dotfilesをシンボックスリンク
ln -nfs ~/git/dotfiles/.zshrc ~/.zshrc
ln -nfs ~/git/dotfiles/.p10k.zsh ~/.p10k.zsh
ln -nfs ~/git/dotfiles/.vimrc ~/.vimrc
ln -nfs ~/git/dotfiles/.wezterm.lua ~/.wezterm.lua
# エクスプローラ
mkdir -p ~/.config/yazi/
ln -nfs ~/git/dotfiles/conf/yazi/yazi.toml ~/.config/yazi/yazi.toml
# DB Viewer
mkdir -p ~/.config/gobang/
ln -nfs ~/git/dotfiles/conf/gobang/config.toml ~/.config/gobang/config.toml
# coc用設定をシンボリックリンク
mkdir -p ~/.vim/UltiSnips
ln -nfs ~/git/dotfiles/conf/vim/coc-settings.json ~/.vim/coc-settings.json
# end
exec $SHELL -l

このように、パッケージ管理部分のみをOSで分岐するようにdotfilesのinstallを作っておくことで
ホストPC用でもコンテナ用でも対応できるようになりました。

よって、Dockerコンテナ作成時にdotfiles呼ぶだけで良くなるので、dotfiles最高という感じですね。

軽く仕組みの話

devboxというコマンド(ただの関数ですが...)にしたので、軽く仕組みを説明します。

  1. Dockerエンジンが起動していなければOrbStackを起動します。

APP_LIST=$(osascript -e 'tell application "System Events" to get name of (processes where background only is false)')
if [[ ! $APP_LIST =~ "OrbStack" ]]; then
    open -g /Applications/OrbStack.app
    sleep 5
fi
  1. 以下のファイルを参照します。 (本レポジトリにもありますが、あなたのdotfilesに入れると良いと思います。)

  • Dockerfile

  • docker-compose.yml

  • devbox_gitignore.txt

# このように変数で持って、以降使います。
DEVBOX_DOTFILES_PATH=~/git/dotfiles/conf/devbox
DOCKERFILE=${DEVBOX_DOTFILES_PATH}/Dockerfile
COMPOSE_FILE=${DEVBOX_DOTFILES_PATH}/docker-compose.yml
IGNORE_FILE=${DEVBOX_DOTFILES_PATH}/devbox_gitignore.txt
  1. なければ.devbox-XXXというフォルダを作成し、以下のように構成します。
    XXXの部分は一意になるような数字です。
    未指定でdocker-composeがつけるコンテナ名が{フォルダ名}-{サービス名}なので一意にしています。

.
├── .devbox-1234/
│   ├── shared-register/  : コンテナ起動直前に作成します。
│   │      ├── clip       : コンテナ内のvimでヤンクした値を入れ、ホスト側のクリップボードに渡すためのファイル
│   │      └── tmp        : clipファイルの変更検知のためのもの
│   ├── vol/                : バインドマウントフォルダ。作業でお好きにお使いいただけます。
│   ├── docker-compose.yml  : このymlでコンテナを立ち上げます。
│   └── Dockerfile          : この構成のコンテナにします。
└── .gitignore         : バインドマウントしているvol以外を無視するよう、既存gitignoreに追記します。
if ! ls -d .devbox*/ > /dev/null 2>&1; then
  # なければ作る
  # 冒頭のユニーク命名などをここでやる
fi
  1. Dockerfile, docker-compose.ymlのチェックサムをmd5で確認し、差分があればdotfilesの物で更新します。

# dotfiles側
DOCKERFILE_MD5=$(md5 $DOCKERFILE | cut -d "=" -f 2)
DOCKER_COMPOSE_MD5=$(md5 $COMPOSE_FILE | cut -d "=" -f 2)
S=$(ls -d .devbox*/)
cd $S
# 現在のフォルダ側
CURRENT_DOCKERFILE_MD5=$(md5 Dockerfile | cut -d "=" -f 2)
CURRENT_DOCKER_COMPOSE_MD5=$(md5 docker-compose.yml | cut -d "=" -f 2)
# オプションreか、差分があれば
if [ "$1" = "re"  ] || [ $DOCKERFILE_MD5 != $CURRENT_DOCKERFILE_MD5 ] || [ $DOCKER_COMPOSE_MD5 != $CURRENT_DOCKER_COMPOSE_MD5 ]; then
    cp $DOCKERFILE .
    cp $COMPOSE_FILE .
    # オプション指定の場合はキャッシュを見ない
    if [ "$1" = "re"  ]; then
        docker-compose build --no-cache
    else
        docker-compose build
    fi
fi
  1. イメージをビルドします。差分があればコンテナをレイヤのキャッシュを見てビルドします。
    devbox reとreオプションをつけることで、キャッシュを見ずに一からビルドします。

docker-compose up -d

またこのとき、以下の設定がないとコンテナがしまっちゃいます。

services:
  sandbox:
    build: .
    volumes:
      - type: bind
        source: ./shared-register
        target: /shared-register
      - type: bind
        source: ./vol
        target: /work
    # コンテナを起動させ続ける
    # https://qiita.com/messhii222/items/01ae86ebedd576355fab
    tty: true
    stdin_open: true
  1. shared-registerフォルダの部分を構築し、コンテナ内のvimでのヤンクとホストのclipboardを
    非同期プロセスで繋ぎます。このプロセスはコンテナを抜けた際に終了されます。

\rm -rf shared-register && mkdir shared-register && cd shared-register
echo $(pbpaste) > clip
echo $(ls -l clip) > tmp
cd ..
watch_shared_clipboard() {
    while [ 1 ]; do
        # フォルダが消えたらループ終了
        if [ ! -d shared-register ]; then
            return
        fi
        LATEST=$(cat shared-register/tmp)
        CURRENT=$(cd shared-register && ls -l clip)
        if [[ "$LATEST" != "$CURRENT" ]]; then
            pbcopy < shared-register/clip
            echo $CURRENT > shared-register/tmp
        fi
        sleep 1
    done
}
watch_shared_clipboard & # バックグラウンドで実行
  1. コンテナを起動し、zshでログインします。

docker-compose exec -it -w /work sandbox zsh
  1. コンテナをexitすると、shared-registerやプロセスを消します。

なお、今ホストなのかコンテナなのか分からなくなりやすいので、zshrcに記載してロード時にこいう感じのロゴをechoすると良いです

logo_darwin() {
    echo -e "\e[34m"
    echo "                ..J.               "
    echo "               dMMF                "
    echo "          ....,M#:...              "
    echo "        .MMMMMMMMNN#MN             "
    echo "       .MMMMMMMMMNNM@              "
    echo "       JMMMMMMMMNNNM[              "
    echo "       (MMMMMMMMNNN#N,             "
    echo "        MMMMMMNNNNN##M]            "
    echo "         WMMMMMNMNNN##             "
    echo "          (YMMY'WMMY=              "
    echo "  _____                         _ ";
    echo " (____ \                       (_) ";
    echo "  _   \ \   ____   ____  _ _ _  _  ____ ";
    echo " | |   | | / _  | / ___)| | | || ||  _ \ ";
    echo " | |__/ / ( ( | || |    | | | || || | | | ";
    echo " |_____/   \_||_||_|     \____||_||_| |_| ";
    echo -e "\e[m"
}

# debianロゴ
logo_debian() {
    echo -e "\e[34m"
    echo "                  ..gMNgga             "
    echo "           .(MMMM9''''MMMN,            "
    echo "          .MM#^          TMMN,         "
    echo "         JMD              ,M#5         "
    echo "       .JM$      .Y=  ?!   JM[         "
    echo "        MF      .%      .  .MF         "
    echo "        M%      d,     ..  .#_         "
    echo "        M[      ,N.  ~'   .@'          "
    echo "        Mb      .?dN....J@^            "
    echo "        .NN,        ?!                 "
    echo "         (NN                           "
    echo "          ,HN.                         "
    echo "            ?Hm.                       "
    echo "               79a.                    "
    echo "  _____           _      _ ";
    echo " (____ \         | |    (_) ";
    echo "  _   \ \   ____ | | _   _   ____  ____ ";
    echo " | |   | | / _  )| || \ | | / _  ||  _ \ ";
    echo " | |__/ / ( (/ / | |_) )| |( ( | || | | | ";
    echo " |_____/   \____)|____/ |_| \_||_||_| |_| ";
    echo -e "\e[m"
}

さいごに

自由度の観点から、このdevboxコマンドはdevcontainersなどのようではなく、あくまでzshrcに生やしておくのが良いかと考えているので、真面目にパッケージコマンド化はしてません。

dotfilesを気軽に導入できるTipsを用いて、あなたも気軽に壊せる作業場としてのコンテナを活用してみてはいかがでしょうか?