neovim × devcontainer でコンテナに閉じた開発環境を作る

私は普段のエディタとして専らneovimを使用しています。また、趣味開発ではローカルの環境をあまり汚したくないので、devcontainerを使って開発環境を構築することが多いです。今まではローカルで立ち上げたneovimでファイルを編集、必要な時だけコンテナ内に入ってコマンド実行というスタイルで開発してきたのですが、これではちょっと不便なことがあります。コードの実行環境とLSPの実行環境が違うことです。例えばVSCodeでdevcontainerを使用して、コンテナ内で特定の言語の拡張機能を入れた場合、そのLSPはコンテナ内で動きます。しかし、私の方法ではneovimがローカルで働いているため、LSPもローカルで動いています。 このせいで、コンテナ内では問題なく評価されるコードにLSPでエラーの赤線が引かれていたりと、開発体験を損なうことがあります。 そこで、neovimごとdevcontainerにぶち込む方法を考えました。こうすれば先のような心配をする必要はありません。ということで、導入が長くなりましたが、構築手順を書いていきます。

環境

  • Docker Desktop
  • Docker 20.10.7
  • devcontainer cli 0.50.2
  • m1 mac Ventura 13.5
  • Go 1.21.5

コンテナの用意

devcontainer cliをインストールしていない人は公式Githubを参考にインストールしてください。
https://github.com/devcontainers/cli

まずはdevcontainerを作るためのファイルを用意します。テキトーなディレクトリを作成し、その中に .devcontainer/devcontainer.json を作成します。

// For format details, see https://aka.ms/devcontainer.json. For config options, see the README at:
{
    "name": "test-nvim",
    "build": { "dockerfile": "../Dockerfile" }
}

コンテナの名前と、ビルドに使用するDockerfileの場所を指定しているだけのシンプルな設定です。他にも色々な値を設定できます。
参考: https://containers.dev/implementors/json_reference/

次に、ビルドに使用するDockerfileを作成します。

FROM ubuntu:20.04

RUN apt-get update && apt-get install -y \
    build-essential \
    curl \
    git

# 環境変数
ENV TZ=Asia/Tokyo

# latestのneovimのインストール、通常のapt-getだと古いバージョンしか入らない
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
RUN apt-get update && apt-get install -y software-properties-common \
    && add-apt-repository ppa:neovim-ppa/stable \
    && apt-get update \
    && apt-get install neovim -y

# vim-plugのインストール
RUN curl -fLo ~/.local/share/nvim/site/autoload/plug.vim --create-dirs \
    https://raw.githubusercontent.com/junegunn/vim-plug/master/plug.vim

RUN mkdir -p /root/.config/nvim/
COPY ./nvim /root/.config/nvim/

イメージはubuntuを使用していますがここはお好みで。やっていることは大きく分けて以下の2つです。

  • 最新バージョンのneovimのインストール
  • ローカルのnvimディレクトリ以下をコンテナの /root/.config/nvim にコピー

本当は自身のホームディレクトリからnvimをコピーしたかったのですが、Dockerでは基本的にビルドコンテキスト配下のファイルしか参照できません。そのため、作成したディレクトリ直下に nvimディレクトリを置く前提で作っています。

その他、vim-plugのインストールもしていますが、プラグイン管理に他のツールを使っている人は不要です。

次に、neovimファイルをディレクトリに持ってきます。

cp -r ~/.config/nvim ./

はい、ここまでで準備は完了です。早速コンテナをビルドしてみましょう。

コンテナのビルド

コンテナをビルドするには、devcontainer cliのupコマンドを使います。

devcontainer up --workspace-folder .

無事に実行できたら成功です。新しくdevcontainerが作成されていると思います。

docker container ls | grep test-nvim

neovimの実行

devcontainerが作成できたら、コンテナの中のneovimを立ち上げます。まずは開発コンテナの中に入ります。

devcontainer exec --workspace-folder . bash

すると、開発コンテナの中に入ることができます。あとはneovimを起動して、ファイルを作成したり、編集してみたりしましょう。問題なく動くと思います。この設定のままだとマウス操作が無効になるのですが、生粋のvimmerにとってはさほど問題ではないと思います。
それより大きな問題点が一点あります。

ホストマシンとのクリップボード共有

vimmerの中には、vimでヤンクした内容をそのままクリップボードから使えるように連携する設定を入れている方も多いことでしょう。私のinit.luaには以下の設定が入っています。

-- クリップボードを同期
vim.o.clipboard = "unnamedplus"

この設定を入れておけば、ローカルで開いたneovimとクリップボードの連携はうまくいきます。しかし、先ほどの開発コンテナの中でヤンクしてみてください。その後にホストマシンでペーストしても、ヤンクした内容は出力されません。ホストマシン→コンテナ→neovimと、間にコンテナが噛んでいるため、この設定だけでは不十分です。色々と方法を模索した結果、lemonadeを使った方法が上手くいきました。

lemonadeの導入

lemonadeはTCP経由でコピペを実現できるようにするツールです。
公式リポジトリ: https://github.com/lemonade-command/lemonade

具体的にどのような仕組みなのかは寡聞にして把握していませんが、今回のようにコンテナを使ったり、ssh接続先であっても、TCP接続できればそこからクリップボードの内容を同期できるようです。 lemonadeにはクライアントとサーバーの概念があります。サーバーは接続元、つまり今回はローカルのホストマシン、クライアントは接続先、今回はコンテナです。つまり、ローカルマシンとコンテナの両方にlemonadeをインストールする必要があります。
まずはホストからですが、私はREADMEのインストール手順が動かなかったので、go installコマンドを使いました。

go install github.com/lemonade-command/lemonade@HEAD

インストールが完了したら、ホストマシンでサーバーを起動します。

lemonade server

これで、ポート2489番で接続を待ち構えてくれます。次はコンテナ側の設定です。lemonadeのインストール用にGoをインストールする必要があるため、Dockerfileを以下のように書き換えます。

FROM ubuntu:20.04

RUN apt-get update && apt-get install -y \
    build-essential \
    curl \
    git \
    wget

# 環境変数
ENV TZ=Asia/Tokyo
+ ENV HOME /root
+ ENV PATH $PATH:/usr/local/go/bin
+ ENV GOBIN=/usr/local/go/bin

# nodejsのインストール
RUN curl -fsSL https://deb.nodesource.com/setup_18.x | bash -
RUN apt install -y nodejs

+ # goのインストール
+ RUN curl -OL https://golang.org/dl/go1.20.1.linux-arm64.tar.gz
+ RUN tar -C /usr/local -xvf go1.20.1.linux-arm64.tar.gz
+
+ # lemonadeのインストール
+ RUN go install github.com/lemonade-command/lemonade@HEAD

# latestのneovimのインストール、通常のapt-getだと古いバージョンしか入らない
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
RUN apt-get update && apt-get install -y software-properties-common \
    && add-apt-repository ppa:neovim-ppa/stable \
    && apt-get update \
    && apt-get install neovim -y

# vim-plugのインストール
RUN curl -fLo ~/.local/share/nvim/site/autoload/plug.vim --create-dirs \
    https://raw.githubusercontent.com/junegunn/vim-plug/master/plug.vim

RUN mkdir -p /root/.config/nvim/
COPY ./nvim /root/.config/nvim/

これで準備は完了です。コンテナを再ビルドして立ち上げ直しましょう。

devcontainer up --workspace-folder . --remove-existing-container

コンテナが立ち上がったら、lemonadeからちゃんとコピペができるか確認します。 まずは、コンテナに入って、lemonadeの設定ファイルである ~/.config/lemonade.toml を作成します。ここではDockerのホストマシンと値を共有したいため、host.docker.internal を指定しています。なお、これはDocker Desktop環境でのみしか使用できないため注意が必要です。

host = 'host.docker.internal'

次に、lemonade copy コマンドでコピーします。

lemonade copy hoge

上手く設定できていれば、ホストマシン側のclipboardで hoge が取得できます。

これだけでは不便なので、vimのクリップボードの設定を変更し、ヤンク時にlemonade copyを使うように変更します。

vim.g.clipboard = {
  name = "lemonade2",
  copy = {
    ["+"] = { "lemonade", "copy"},
    ["*"] = { "lemonade", "copy"},
  },
  paste = {
    ["+"] = { "lemonade", "paste"},
    ["*"] = { "lemonade", "paste"},
  },
  cache_enabled = 0,
}

これでvimでヤンクした値もホストマシンと共有できるようになりました。

感想

調べてるうちにtmuxを使う方法とかもよく引っかかったので、そっちも試してみたい... ということでみなさん良いvimライフを〜