UPSIDER Tech Blog

「開発環境をパッケージングする。"ワタシノカンキョウデハウゴクノニ..."を撲滅する - なぜDev containersでなくDevboxなのか」

こんにちは、UPSIDERで請求関連機能の開発を担当しているチームです。

昨今はAI技術の活用が急速に進み、猫も杓子もAI ┗(゚ Д゚ )┛ワッショイ ┗( ゚Д ゚)┛ワッショイ ┗(゚ Д゚ )┛ という世の中で、個人の生産性が高まっていますが、金融領域らしく「複利の力」を意識し、日々の小さな改善を積み重ねていくことが重要だと感じています。

そこで今回は、AIエージェントやチームメンバーが作業しやすい、再現性の高い開発環境をライトウェイトに構築する方法をご紹介します。


開発環境にありがちな悩み

新しいメンバーを迎えた時や、複数プロジェクトを掛け持ちしている時に、こんな経験はありませんか?

>「ワタシノカンキョウデハウゴクノニ…」

多くのエンジニアが一度は経験するこの問題には、いくつかの典型的な要因があります。

  • プロジェクトごとに言語バージョンが異なる
    例:Goは1.20だが別プロジェクトは1.22、Nodeは18と20が混在し切り替えがうまく行っておらず、

  • ツールのインストール方法が人それぞれ
    LinterやFormatterをbrewで入れる人もいれば、go installやnpmを使う人もいて、結果「動かない」問題が発生

  • エディタとシェルのバージョン不一致
    VS Code拡張とshellで利用するバイナリが別々のPATHを参照しており、それぞれのバイナリを呼び出すケース

Dockerでアプリの実行環境は統一できても、開発ツールや依存ライブラリなどの“ズレ”が残ることがあります。

こうした「ちょっとしたズレ」が再現性を壊し、時間を奪う原因になります。

みなさんもご経験があると思いますが、上述したもの以外にも色々なケースで「ちょっとしたズレ」が発生して、時間を食うデバッグや「ワタシノカンキョウデハウゴクノニ…」問題を生みだす原因となっています。

改めてちょっとしたズレが引き起こす結果の違いを実感して、小さなズレが重要だという考え方に切り替えていきたいと思います。

その導入として繊細な化学実験である塩の結晶実験について考えていきたいと思います。

小さなズレが大きな差を生む — 塩の結晶実験から学ぶ

みなさん塩の結晶についてご存じでしょうか? 下記のようなイメージをしてもらうと良いです。 塩の結晶を作るのには塩を溶けきらないほどの量で水に溶かした飽和食塩水を用意し、水分を蒸発させることで塩の結晶を作ることが可能です。

実験手順

  1. 100mlの水に40gの塩を入れて飽和食塩水を作成

  2. 三つのペトリ皿を用意
    ① 蓋をする
    ② 蓋なし
    ③ コップを被せる

  3. 数日間放置する

それでは小さな違いがどんな影響を与えるか少し実験をしてみましょう。

  • まずは100mlの水に40グラムの塩を用意して飽和食塩水を作る準備をします。

  • 沸騰させ飽和食塩水を作ります。

  • こちらの飽和食塩水を利用していきます。

  • 三つのペトリ皿を準備して行きます。左から
    • 容器の蓋を被せたペトリ皿
    • 何も被せないペトリ皿
    • 容器の蓋の代わりにコップを被せたペトリ皿

上記のような条件で数日間おくと塩の結晶が形成されます。それぞれのペトリ皿の中はどうなっているか予想ができるでしょうか? 数日後...

  • 結果がこちらになります
    • カバーを被せた方は綺麗な正方形が一つできています。
    • 被せなかったものは歪な結晶がまばらにできています。
    • グラスを被せた方は被せなかったものに比べて綺麗な状態ですが、複数結晶ができています。

このような結果になる要因として蒸発速度が結晶の形成に影響を与えています。

どうでしょうか?

ペトリ皿や同じ飽和食塩水を利用していたのに蓋のちょっとした違いで塩の結晶の結成に大きな違いがでていることが確認できます。結果をみて高々、蓋、されど蓋という考え方にシフトできたでしょうか?

この実験は下記を参考にさせていただきました。塩の結晶を綺麗に作る方法や不揃いな結晶を作る方法を以下の参考文献から再現してみました。 試してみたい読者や、より理解したい読者は参照ください。

crystalverse.com

www.youtube.com

先ほどの実験を開発環境になぞらえてみるとハードウェアの差分から始まって、OSやパッケージのバージョン・依存ライブラリ・環境変数・シェルの設定ファイル等々あげればキリがありませんが多くの条件によって結晶になっています。

積み上げられた最終的な結晶が開発環境というふうに見做せるのであれば、メンバーによって蓋を被せていなかったり、被せていたとしても違う蓋を被せていたりと、出来上がった結晶がバラバラになっていて、小さなズレが実験の再現性を壊しているという状態になります。

つまりワタシノカンキョウデハウゴクノニ... というのは再現性が低い状態と考えて差し支えないと考えています。

再現性を出すには実験と同じように違いが生まれない環境を用意していくということが必要になります。その方法として隔離、ないしは密閉することを考えていきたいと思います。

ここまで前置きが長くなりましたが、この記事ではDev containersとDevboxを通して隔離密閉という状態を理解して、より良い開発環境をパッケージングする術をみていきたいと思います。

ではまず初めに環境を隔離していく手段としてDev Containersを軽くおさらいしていきたいと思います。

Dev Containers 手軽な環境統一の切り札、でもその先に...

Dev Containersは、VS Codeなどのエディタと連携し、開発環境をDockerコンテナ内に構築する仕組みです。

プロジェクトごとに独立した環境を提供でき、チーム全体での環境統一が容易になります。

よくあるDev Containersの説明図です。 参照: https://code.visualstudio.com/docs/devcontainers/containers

この図のように、ホストマシンと開発環境が分離され、それぞれのプロジェクトに必要な言語ランタイム、ツール、ライブラリ、ベースイメージがコンテナ内に閉じ込められています。これにより、プロジェクト間でツールのバージョンが競合する心配がなくなります。またチームメンバー間で利用するツールやライブラリなどのバージョンをほぼ固定することができます。

アプリだけではなく開発環境全体もコンテナを利用することで差分が減り再現性を高めることが可能です。

しかしほぼ固定することができますというのはどういうことでしょうか? 例えば以下のようなdevcontainer.jsonファイルを考えてみましょう。

{
  "image": "mcr.microsoft.com/devcontainers/go:1.21",
  "features": {
    "ghcr.io/devcontainers/features/node:1": {
      "version": "18.17.1"
    }
  },
  "postCreateCommand": "go install github.com/golang-migrate/migrate/v4/cmd/migrate@v4.15.2"
}

一見すると問題なさそうですが、ここには時間経過という問題が潜んでいます。

今日、このdevcontainer.jsonで環境構築した人と、6ヶ月前に環境構築した人では実は差分が出るようになっています。

一つはbase imageにハッシュ指定がないため、半年後には違うbase imageで動いている可能性があります。

またfeaturesも実質的な中身はinstall.shになっていて実行されるディストリビューションのパッケージマネージャ(apt-getとかdnfとか) がライブラリーをインストールする仕組みになっていると思います。実行するタイミングが異なれば、参照するパッケージレポジトリの状態も刻々と変化しており、実行時時点でのパッケージリポジトリに依存します。

#!/usr/bin/env bash
set -e
# 変数のデフォルト値
NODE_VERSION="${VERSION:-lts}"
echo "Installing Node.js version: ${NODE_VERSION}"
# 依存パッケージを入れる
apt-get update -y
apt-get install -y --no-install-recommends \
    curl ca-certificates gnupg2 dirmngr xz-utils
# NodeSource のリポジトリを追加してインストール
curl -fsSL https://deb.nodesource.com/setup_${NODE_VERSION}.x | bash -
apt-get install -y nodejs
# キャッシュ削除でイメージをスリム化
apt-get clean -y
rm -rf /var/lib/apt/lists/*
# 確認用出力
node -v
npm -v

上記のケースはnodeのマイナーバージョンがコンテナをビルドするタイミングで決まるので、ズレが生じる可能性があります。

回避策(ハッシュ固定の例)

ベースイメージに関してはimageのハッシュまで指定することで防ぐことは可能かもしれませんが、featuresのような問題に関しては意識しないと回避が難しいです。

意識してビルドされた公式のバイナリを取得するなど細心の注意を払ったfeatureが必要になるでしょう。

e.g) ハッシュを意識したNode.jsを利用するinstall.sh

#!/usr/bin/env bash
set -e

# 環境変数 VERSION で指定(例: 18.17.1)、なければ LTS をデフォルトに
NODE_VERSION="${VERSION:-18.17.1}"

echo "Installing Node.js v${NODE_VERSION}..."

# 依存パッケージ
apt-get update -y
apt-get install -y --no-install-recommends curl ca-certificates xz-utils gnupg dirmngr
rm -rf /var/lib/apt/lists/*

# アーキテクチャ判定
arch="$(dpkg --print-architecture)"
case "$arch" in
    amd64) nodearch="x64" ;;
    arm64) nodearch="arm64" ;;
    armhf) nodearch="armv7l" ;;
    *) echo "Unsupported architecture: $arch"; exit 1 ;;
esac

# Node.js バイナリ取得と検証
cd /tmp
curl -fsSLO "https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-${nodearch}.tar.xz"
curl -fsSLO "https://nodejs.org/dist/v${NODE_VERSION}/SHASUMS256.txt"

grep " node-v${NODE_VERSION}-linux-${nodearch}.tar.xz\$" SHASUMS256.txt | sha256sum -c -

# 展開して配置
tar -xJf "node-v${NODE_VERSION}-linux-${nodearch}.tar.xz" -C /usr/local --strip-components=1 --no-same-owner
ln -sf /usr/local/bin/node /usr/local/bin/nodejs

# 掃除
rm -f "node-v${NODE_VERSION}-linux-${nodearch}.tar.xz" SHASUMS256.txt

# 確認
echo "Node.js installed:"
node -v
npm -v

Node.jsのバージョンを固定して、取得したNode.jsのチェックサムが一致するか確認をとっています。 

また実際のdevcontainer.jsonでは自前のDockerfileからイメージを構築することも多く、featuresで起きている問題と同様、内部で依存しているパッケージマネージャーの影響を受けてしまうと思います。

# Go 1.21 ベース
FROM mcr.microsoft.com/devcontainers/go:1.21

# Node.js バージョンをビルド引数で指定可能(デフォルト 18.x 系)
ARG NODE_VERSION=18

USER root

# 依存パッケージのインストール
RUN apt-get update && \
    apt-get install -y --no-install-recommends \
        curl ca-certificates gnupg && \
    rm -rf /var/lib/apt/lists/*

# NodeSource のセットアップスクリプトを利用して Node.js をインストール
RUN curl -fsSL https://deb.nodesource.com/setup_${NODE_VERSION}.x | bash - && \
    apt-get install -y nodejs && \
    apt-get clean -y && \
    rm -rf /var/lib/apt/lists/*

# 動作確認
RUN node -v && npm -v

# vscode ユーザーに戻す
USER vscode
{
  "name": "Go + Node Dev Containers",
  "build": {
    "dockerfile": "Dockerfile",
    "args": {
      "NODE_VERSION": "18"
    }
  }
}

ここまで見てきてDev Containersは隔離された環境ではありますが、微かながら外部からの影響を受ける隙間があるように見えてきたでしょうか? 

再現性を高めるために隔離を利用してきましたが、次なる課題が見えてきました。

どうしたら隔離以上の再現性を得られるでしょうか?

Dev Containersの抱えていた課題に対してDevbox… もう少し厳密に言うとDevboxのベースに利用されている純粋関数型パッケージマネージャであるNixが持つ密閉性を利用することで再現性を高めたいと思います。

Devboxと再現性

Devboxではdevbox.jsonにpackagesを指定しインストールすることができます。

{
  "$schema": "https://raw.githubusercontent.com/jetify-com/devbox/0.14.2/.schema/devbox.schema.json",
  "packages": {
    "git": {
      "version":   "2.50.1",
      "platforms": ["aarch64-darwin"],
    },
    "hello":  "latest",
  },
  "shell": {
    "init_hook": ["echo 'Welcome to devbox!' > /dev/null"],
    "scripts": {
      "hello": ["echo $GREETING"],
    },
  },
  "env_from": ".env",
}

これに対して、 Devboxではロックファイルを生成します。 (devbox.lock)

  "lockfile_version": "1",
  "packages": {
    "git@2.50.1": {
      "last_modified": "2025-07-28T17:09:23Z",
      "resolved": "github:NixOS/nixpkgs/648f70160c03151bc2121d179291337ad6bc564b#git",
      "source": "devbox-search",
      "version": "2.50.1",
      "systems": {
        "aarch64-darwin": {
          "outputs": [
            {
              "name": "out",
              "path": "/nix/store/jn9byxgdjndngf0d2by0djg8gcdll7xc-git-2.50.1",
              "default": true
            },
            {
              "name": "doc",
              "path": "/nix/store/j8djmq64ckbah7bl6jv1y6arrjr0shmv-git-2.50.1-doc"
            }
          ],
          "store_path": "/nix/store/jn9byxgdjndngf0d2by0djg8gcdll7xc-git-2.50.1"
        },
        ....another arch..., 
      }
    },
    "hello@latest": {
      "last_modified": "2025-10-07T08:41:47Z",
      "resolved": "github:NixOS/nixpkgs/bce5fe2bb998488d8e7e7856315f90496723793c#hello",
      "source": "devbox-search",
      "version": "2.12.2",
      "systems": {
        "aarch64-darwin": {
          "outputs": [
            {
              "name": "out",
              "path": "/nix/store/8ha1dhmx807czjczmwy078s4r9s254il-hello-2.12.2",
              "default": true
            }
          ],
          "store_path": "/nix/store/8ha1dhmx807czjczmwy078s4r9s254il-hello-2.12.2"
        },
      ...another arch....,
 }
    }
    "github:NixOS/nixpkgs/nixpkgs-unstable": {
      "last_modified": "2025-10-20T13:06:07Z",
      "resolved": "github:NixOS/nixpkgs/cb82756ecc37fa623f8cf3e88854f9bf7f64af93?lastModified=1760965567&narHash=sha256-0JDOal5P7xzzAibvD0yTE3ptyvoVOAL0rcELmDdtSKg%3D"
    }
  }
}

これにより、Devboxでは誰がいつ何時でもサンドボックス開発環境を立ち上げてもロックファイルに記述されたパッケージを利用することで再現性が高まります。

そして注目してもらうと内容はstore_pathとして/nix/storeが指定されています。これが意味するところとしてDevboxでインストールをしたpackagesはNixでビルドされたものだけが利用できるようになっています。

そしてこのNixのパッケージだけで構成された環境がさらなる再現性を高めてくれます。

Nixと再現性

Nixは先ほども記述した通り純粋関数型パッケージマネージャになっています。そしてパッケージの配布方法はdebian(apt)やFedora(dnf)などで配布されるような形と大きく異なっています。 apt/dnfは完成されたバイナリを配布します。 一方でNixは基本的には詳細なレシピを配布するようになっています。

UPSIDER請求書チーム特製カレーの例で考えてみましょう。 ざっくりイメージでNixに記されたカレーのレシピは、キッチンや調理器具、必要な具材(産地や品種)、手順まで事細かに書かれていてレシピに従うと誰でも同じカレーが作れるようになっています。

特製カレーは必ず 三口コンロで、鍋はアルミ鍋で、ジャガイモはメイクイーンではなく男爵芋で、香辛料はUPSIDER商店から取り寄せたクミン、ターメリック、コリアンダーを使うことみたいなイメージが下記のレシピになります。 

Nixではこれをderivationと呼びます。

{
      "/nix/store/hspi07f7273dw16dlrsx172ixjiv9w9s-hello-2.12.2.drv": {
        "args": [
          "-e",
          "/nix/store/l622p70vy8k5sh7y5wizi5f2mic6ynpg-source-stdenv.sh",
    "/nix/store/shkw4qm9qcw5sc5n1k5jznc83ny02r39-default-builder.sh"
        ],
        "builder": "/nix/store/xhsnsmvh1ka5mxszd1psxq36vaanb8jy-bash-5.3p3/bin/bash",
        "env": {
          "NIX_LDFLAGS": "-liconv",
          "NIX_MAIN_PROGRAM": "hello",
          "__darwinAllowLocalNetworking": "",
          "__impureHostDeps": "/bin/sh /usr/lib/libSystem.B.dylib /usr/lib/system/libunc.dylib
    /dev/zero /dev/random /dev/urandom /bin/sh",
          "__propagatedImpureHostDeps": "",
          "__propagatedSandboxProfile": "",
          "__sandboxProfile": "",
          "__structuredAttrs": "",
          "buildInputs": "",
          "builder": "/nix/store/xhsnsmvh1ka5mxszd1psxq36vaanb8jy-bash-5.3p3/bin/bash",
          "cmakeFlags": "",
          "configureFlags": "",
          "depsBuildBuild": "",
          "depsBuildBuildPropagated": "",
          "depsBuildTarget": "",
          "depsBuildTargetPropagated": "",
          "depsHostHost": "",
          "depsHostHostPropagated": "",
          "depsTargetTarget": "",
          "depsTargetTargetPropagated": "",
          "doCheck": "1",
          "doInstallCheck": "1",
          "mesonFlags": "",
          "name": "hello-2.12.2",
          "nativeBuildInputs": "/nix/store/dmf496b4hcp6dsn08ds4xs2avfjfldh4-version-check-hook",
          "out": "/nix/store/8ha1dhmx807czjczmwy078s4r9s254il-hello-2.12.2",
          "outputs": "out",
          "patches": "",
          "pname": "hello",
          "postInstallCheck": "stat \"${!outputBin}/bin/hello\"\n",
          "propagatedBuildInputs": "",
          "propagatedNativeBuildInputs": "",
          "src": "/nix/store/dw402azxjrgrzrk6j0p66wkqrab5mwgw-hello-2.12.2.tar.gz",
          "stdenv": "/nix/store/qi9g4qs1cx0yalsivaa7j5jj6xz5n3af-stdenv-darwin",
          "strictDeps": "",
          "system": "aarch64-darwin",
          "version": "2.12.2"
        },
        "inputDrvs": {
          "/nix/store/fsj2cdrhhppm4m45d49r68aca7nvk2gi-stdenv-darwin.drv": {
            "dynamicOutputs": {},
            "outputs": [
    "out"
            ]
          },
          "/nix/store/hk999f0kgipgalvp2ndw3680r01jf826-hello-2.12.2.tar.gz.drv": {
            "dynamicOutputs": {},
            "outputs": [
    "out"
            ]
          },
          "/nix/store/lcl4r36136xj0yc9y6bwz0k3p86jmsc4-version-check-hook.drv": {
            "dynamicOutputs": {},
            "outputs": [
    "out"
            ]
          },
          "/nix/store/zs6m8jln5a5fgzax9iizyk0gc2vy5r00-bash-5.3p3.drv": {
            "dynamicOutputs": {},
            "outputs": [
    "out"
            ]
          }
        },
        "inputSrcs": [
          "/nix/store/l622p70vy8k5sh7y5wizi5f2mic6ynpg-source-stdenv.sh",
    "/nix/store/shkw4qm9qcw5sc5n1k5jznc83ny02r39-default-builder.sh"
        ],
        "name": "hello-2.12.2",
        "outputs": {
          "out": {
            "path": "/nix/store/8ha1dhmx807czjczmwy078s4r9s254il-hello-2.12.2"
          }
        },
        "system": "aarch64-darwin"
      }
    }

Helloコマンドのderivationの例になります。 

キッチン(builder)には
"/nix/store/xhsnsmvh1ka5mxszd1psxq36vaanb8jy-bash-5.3p3/bin/bash"を使い、素材(src)は
"/nix/store/dw402azxjrgrzrk6j0p66wkqrab5mwgw-hello-2.12.2.tar.gz" を利用して香辛料(coreutils)には
"/nix/store/qi9g4qs1cx0yalsivaa7j5jj6xz5n3af-stdenv-darwin"を利用しましょうみたいな手順になっています。この通りに作られた特製カレーは唯一無二のカレー(/nix/store/8ha1dhmx807czjczmwy078s4r9s254il-hello-2.12.2))になります。

見て感じ取れるようにbashやcoreutilsさえも、同様にderivationがあり再現性が担保されています。

入力が同じであれば出力が一意に決まるため、Nixは純粋関数型パッケージマネージャと呼ばれています。そして他のパッケージマネージャとは異なり、レシピを配るので誰のどの環境でも同じようにビルドされます。

Nixでは誰がどの環境でビルドしても一意に決まるため一度ビルドしたものをキャッシュしておいてaptやdnfと同じように配布することができますが、本質的にはderivationを配布していることにあります。

Nixの話を続けたいところですがカレーの話でお腹いっぱいだと思うので、Nixの密閉性についての深掘り(リモートからsrcをとってくる時のfixed-outpt deriviationsの話とか)は別の機会に譲りたいと思います。

要するにNixを利用する事で再現性の高いパッケージを利用することが可能になるということが理解できたでしょうか?

再現性 ✖️ パフォーマンス

ここまで一貫して開発環境の再現性について議論してきましたが、Devbox(Nix)の導入によって環境間の差異が大幅に削減されるという点はご理解いただけたかと思います。しかし、Nixのような複雑なツールを導入するほどの環境差異に悩まされた経験がない方、あるいはDev Containersが現実的な解決策だと考えている方もいらっしゃるかもしれません。

たとえDev Containersで十分だと感じる方であっても、開発環境の動作が重いという問題に直面し、どうにかして快適にしたいと考えている方は少なくないのではないでしょうか?

特にプロジェクトの規模が大きくなるにつれてビルド時間が長くなり、環境に変更を入れる際にデバッグが必要になった暁には開発効率が著しく低下することは珍しくありません。Dockerfileでマウントしてる内容もホストとVM間でのファイルシステムの違いによるオーバーヘッドを意識せずに書いてしまうと途端にビルドに時間がかかってしまいます。そんな問題を回避するワークアラウンドを書いた記事も見かけることでしょう。

またmacOSなどはApple Containerization frameworkなどが発表されて今後どう変わっていくかわかりませんが現状ではホストOSのリソースをVMに割いておく必要がありホスト自体に負荷がかかり開発以外の操作に影響が出ることもままあると思います。

その点Devboxは環境変数やPATHに/nix/storeをプリペンドするなどをdevbox.jsonで設定した内容を読み込んだshellを立ち上げるだけの軽量なサンドボックス環境をホストOS上に用意してくれるので立ち上げも軽量です。

追加パッケージなど環境の更新があってもすぐさま反映することが可能です。

再掲

{
  "$schema": "https://raw.githubusercontent.com/jetify-com/devbox/0.14.2/.schema/devbox.schema.json",
  "packages": {
    "git": {
      "version":   "2.50.1",
      "platforms": ["aarch64-darwin"],
    },
    "hello":  "latest",
  },
  "shell": {
    "init_hook": ["echo 'Welcome to devbox!' > /dev/null"],
    "scripts": {
      "hello": ["echo $GREETING"],
    },
  },
  "env_from": ".env",
}

そしてDevboxでは改めて見るとNixのことを良くわからない開発者でもJsonで宣言的に書くだけて取り組みやすくNixをベースとしている事で容易に再現性の高いパッケージをロックして、ズレが極端に少ない開発環境を構築することができます。

まとめ

この記事では、開発環境における「小さなズレ」が再現性を壊すこと、そしてそれを防ぐための手段としてDev ContainersとDevboxを比較しました。

Dev Containersは便利で広く使われていますが、環境の変化に対して完全ではありません。

一方、DevboxはNixを基盤にした密閉性の高い環境構築により、極めて高い再現性を実現します。 再現性を意識した開発環境の整備は、チーム全体の生産性を底上げする重要な投資です。

ぜひ一度、自分の開発環境を見直してみてください。

最後に

UPSIDERでは、こうした技術的な基盤づくりや改善に取り組むエンジニアを募集しています。

請求チームでは Temporal を活用したワークフロー開発や、モブプログラミング によるチーム開発も実践しています。

UPSIDERの金融事業を支える柱として信用という考え方があります。与信は予測に基づいて信用を付与して、請求・債権管理というのは実績を管理することになります。予実をしっかりと管理することで適切な与信リスクを判断し金融事業の土台を築いています。

「請求なんて地味」と思われるかもしれませんが、実の部分を担う重要なチームです。

興味をお持ちの方は、ぜひカジュアルにお話ししましょう!

参考文献

We Are Hiring !!

UPSIDERでは現在積極採用をしています。
ぜひお気軽にご応募ください。 herp.careers

herp.careers

UPSIDER Engineering Deckはこちら📣

speakerdeck.com