Mac(M1 CPU)で、互換性のあるpyenv+pipenvの環境を作る

SNSピリカやタカノメのサービスでは、バックエンドで度々Pythonを使用しています。その中でいくつかPythonで書かれたコードがあり、プロジェクトに応じ別のPythonバージョンで実装しています。

しかしながら、Mac(M1 CPU)からarm64ベースのアーキテクチャになったこともあり、一部Pythonバージョンや一部ライブラリがインストールできないことが多々あります。

そこで、複数のPythonバージョンで仮想環境を切り分けられるよう、pyenv + pipenvの環境を整えました。

<!-- more -->

**(2024.01 追記)**
もしWebフロントエンドなど、複数言語のバージョン管理が必要であれば、pyenvのかわりに[mise(旧rtx)](https://mise.jdx.dev/)(ないし分岐元の[asdf](https://asdf-vm.com/))や[anyenv](https://github.com/anyenv/anyenv)を利用しても良いかもしれません。特にasdf(ないしrtx)は様々な言語をプラグインでサポートしています([参考](https://github.com/asdf-vm/asdf-plugins))。

**(2022.03.21 追記)**
MacOS Monterey(12.3)からApple Clangが13.1.6になり、過去のPythonバージョンに適合しないようになっています。そのためpyenvにおいて、x86_64・arm64環境の両方で以下バージョンがインストールできません(後述のエラーが出ます)。

- 3.8系: 3.8.12以下
- 3.9系: 3.9.7以下
- 3.10系: 3.10.0
- (3.7系: 3.7.12以下)

その場合、より新しいバージョンをインストールするか、旧バージョンにパッチが出るまでお待ち下さい[(参考ページ: pyenv issue#2143)](https://github.com/pyenv/pyenv/issues/2143#issuecomment-1072032647)。

**ビルド時のエラー内容**
`configure: error: internal configure error for the platform triplet, please file a bug report`

## モチベーション
- Pythonを使う限りは、なるべくarm64環境で動かした方がパフォーマンスが良い傾向にあります
- 互換性のため、Python 3.8未満の古いバージョンも使えることが必要です
  - 追記: 2023/6/27に3.7系のサポートが終了しましたため、早期に3.8以上(できれば3.9以上へのアップデートが推奨されます)。

## 前提知識
- Python 3.8未満は、公式にM1(arm64)をサポートしていません ([python bug tracker メッセージ382939](https://bugs.python.org/msg382939)より)。セキュリティアップデートのみが提供されるバージョンのため、今後もサポートされません[^1]
- x86_64環境でも、pyenvで3.7.8未満をビルドするとインストールに失敗します。当該バージョンを入れたい場合、後述の対策を入れる必要があります
- pyenv(および各種Python), pipenvともにHomebrewをインストールしたアーキテクチャに合わせる必要があります(合わせない場合、Pythonのビルドや依存ライブラリのインストールに失敗します)
- pyprojなど、arm64環境でインストールできないライブラリがあります

## 基本方針
- Python 3.8未満、もしくはarm環境で動かないライブラリを含む場合: x86_64(Rosetta 2)環境で動かします
- Python 3.8以上: arm64環境で動かします

## 構築した環境例
### 検証環境
- MacBook Pro (M1, 2020)
- macOS Monterey バージョン12.1

### ディレクトリ構成
本稿では、以下の構成で環境構築を行うものとします。

- arm64環境で、Python 3.9.7, 3.9.9, 3.10.0を使用
- x86_64環境で、Python 3.7.6, 3.7.12, 3.9.7を使用

これにより、インストールされるHomebrew、pyenv、pipenvおよび仮想環境の関係は以下の通りとなります。

#### arm64環境
**/opt/homebrew/bin: arm64向けHomebrew**

- /opt/homebrew/bin/pyenv: arm64向けpyenvバイナリ

**~/.pyenv_arm64/: バージョンごとのPython置き場 (>= 3.8.0)**

- ~/.pyenv_arm64/versions/3.9.9/: Python 3.9.9
  - ~/.pyenv_arm64/versions/3.9.9/bin/pipenv: Python 3.9.9環境下のPipenv
- ~/.pyenv_arm64/shims/pipenv: pipenvのエントリポイント(現在選択しているPythonバージョンに合わせて、pipenvを選択する)

#### x86_64環境

**/usr/local/bin: x86_64向けHomebrew**

- /usr/local/bin/pyenv: x86_64向けpyenvバイナリ

**~/.pyenv_x86/: バージョンごとのPython置き場(< 3.8.0)**

- ~/.pyenv_x86/versions/3.7.6/: Python 3.7.6
  - ~/.pyenv_x86/versions/3.7.6/bin/pipenv: Python 3.7.6環境下のPipenv
- ~/.pyenv_x86/versions/3.7.12/: Python 3.7.12
  - ~/.pyenv_x86/versions/3.7.12/bin/pipenv: Python 3.7.12環境下のPipenv
- ~/.pyenv_x86/shims/pipenv: pipenvのエントリポイント(現在選択しているPythonバージョンに合わせて、pipenvを選択する)

![最終的なインストール構成図](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1967276/785b3831-f7c6-e008-3670-acd66f04d147.png)

## 構築フロー
以下のフローに従って環境構築をすすめます[^2]。
1. (任意) arm64, x86_64環境をコマンドで切り替えられるようにする
2. x86_64版、arm64版両方でHomebrewをインストール、環境構築する
3. x86_64版、arm64版両方でpyenvをインストール、環境構築する
4. 各pyenvの各Pythonバージョンをインストール
5. 各pyenvの各Pythonバージョンでpipenvをインストール※

※ Homebrewを使って、`$ brew install pipenv`でグローバルにpipenvをインストールすることもできます。こちらであれば、1回pipenvをインストールすれば5.のステップが不要になります。本紙では、pyenvで切り分けたバージョン毎に環境を独立させたいため、個別にpipenvを入れるようにしています。

### 1. (任意) arm64, x86_64環境をコマンドで切り替えられるようにする

以下、~/.zshrcに追記します。これにより、`x86`を実行するとx86_64環境、`arm`を実行するとarm64環境でシェルが動作するようになります。

```bash
alias x86='arch -x86_64 zsh'
alias arm='arch -arm64e zsh'
```

### 2. x86_64版、arm64版両方でHomebrewをインストール、環境構築

arm64版を以下のスクリプトでインストールします。

```bash
$ uname -m   # arm64環境で動いていることを確認する
arm64

$ /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
```

arm64版をインストールできていれば、`which brew`でopt/homebrew/bin/brewがHomebrewの実行パスとして表示されます。

```bash
$ which brew
opt/homebrew/bin/brew
```

x86_64版を以下のスクリプトでインストールします。

```bash
$ x86
$ uname -m   # x86_64環境で動かせていることを確認する
x86_64

$ /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
```

x86_64版をインストールできていれば、`which brew`で/usr/local/bin/brewがHomebrewの実行パスとして表示されます。

```bash
$ which brew
/usr/local/bin/brew
```

最後に、~/.zshrcで以下を追記し、brewのパスがPATHに入るようにします。

```bash
if [ "$(uname -m)" = "arm64" ]; then
  eval "$(/opt/homebrew/bin/brew shellenv)"
  export PATH="/opt/homebrew/bin:$PATH"
else
  eval "$(/usr/local/bin/brew shellenv)"
fi
```

※`x86`や`arm`を実行したときにPATHがアップデートされるよう、~/.zshrcに記載しています。ログインシェルにのみ適用させるのであれば~/.zprofileに追記すべきですが、そうすると`x86`や`arm`実行後にbrewの実行パスが切り替わりません。また、`x86`,`arm`実行の度に上記pathがどんどん追記されますが、実用上問題ありません。

この時点で、下図の通りx86_64版, arm64版のHomebrew両方が実行可能になります。

![Homebrewインストール後の構成図](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1967276/d9790022-16d4-95d7-8772-8f2a025c32da.png)


### 3. x86_64版、arm64版両方でpyenvをインストール、環境構築

arm版のHomebrewでpyenvをインストールします。

```bash
$ uname -m　　 #　もし結果がx86_64であれば、`arm`を実行後に再度実行する
arm64

$ brew install pyenv
```

x86_64版のHomebrewでpyenvをインストールします。

```bash
$ uname -m　　 #　もし結果がarm64であれば、`x86`を実行後に再度実行する
x86_64

$ brew install pyenv
```

最後に、~/.zshrcで以下を追記し、pyenvの初期化と実行パス追加が実行されるようにします。arm64版のpyenvは~/.pyenv_arm64、x86_64版のpyenvは~/.pyenv_x86下に各Pythonバージョンがインストールされるようにします。

なお、`eval "$(pyenv init -)"`では実行パス追加が行われないので、`eval $(pyenv init --path)`も合わせて実行する必要があります[^3]。

```bash
if [ "$(uname -m)" = "arm64" ]; then
  export PYENV_ROOT="$HOME/.pyenv_arm64"
  export PATH="$HOME/.pyenv_arm64/bin:$PATH"
else
  export PYENV_ROOT="$HOME/.pyenv_x86"
  export PATH="$HOME/.pyenv_x86/bin:$PATH"
fi
eval "$(pyenv init -)"
eval "$(pyenv init --path)"
```

![pyenvインストール後の構成図](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1967276/49d60e78-92fb-68fe-fe08-ea32aec1f150.png)


### 4. 各pyenvの各Pythonバージョンをインストール
Python 3.8以上はarm64版のpyenvでインストールします。また、Python 3.7以下もしくはx86_64環境のPythonが必要な場合はx86_64版のpyenvでインストールします。

**Python 3.9.9をインストールする場合**

```bash
$ arm
$ where pyenv    # pyenv () {} の次に、/opt/homebrew/bin/pyenvが入っているはず

pyenv () {
...
}
/opt/homebrew/bin/pyenv
...

$ pyenv install 3.9.9
```

**Python 3.7.12をインストールする場合**

```bash
$ x86
$ where pyenv    # pyenv () {} の次に、/usr/local/bin/pyenvが入っているはず

pyenv () {
...
}
/usr/local/bin/pyenv
...

$ pyenv install 3.7.12
```

上記のプロセスに従って、残りのPythonバージョンについてもインストールしておきます。

なお、Python 3.7.8未満もしくは3.8.4未満バージョンはそのままではインストールできません。これらよりも新しいバージョンをインストールするか、インストール時にPythonビルドに必要な依存ライブラリのパス指定やパッチ適用を行う必要があります。

**4.1. Python 3.7.6**

x86_64環境でも、単純に`pyenv install 3.7.6`とするとビルドに失敗します。

```bash
BUILD FAILED 
...
./Modules/posixmodule.c:9197:15: error: implicit declaration of function 'sendfile' is invalid in C99 [-Werror,-Wimplicit-function-declaration]
        ret = sendfile(in, out, offset, &sbytes, &sf, flags);
              ^
1 error generated.
make: *** [Modules/posixmodule.o] Error 1
make: *** Waiting for unfinished jobs....
1 warning generated.
```

以下の通りパラメータを加えた上でPython 3.7.6をビルドするとインストールできます。
- Big Sur以降向けのパッチを当てる
- pythonビルド時にopenssl, bzip2, readline, zlibのライブラリパス, インクルードパスを指定

```bash
CFLAGS="-I$(brew --prefix openssl)/include -I$(brew --prefix bzip2)/include -I$(brew --prefix readline)/include -I$(xcrun --show-sdk-path)/usr/include" LDFLAGS="-L$(brew --prefix openssl)/lib -L$(brew --prefix readline)/lib -L$(brew --prefix zlib)/lib -L$(brew --prefix bzip2)/lib" pyenv install --patch 3.7.6 < <(curl -sSL https://github.com/python/cpython/commit/8ea6353.patch\?full_index\=1)
```

**Python 3.8.3の場合**

こちらも同様に、posixmodule.c周りのビルドでエラーが発生します。パッチおよびインクルードパス等を指定してビルドしてください。

```bash
BUILD FAILED 
...
./Modules/posixmodule.c:9197:15: error: implicit declaration of function 'sendfile' is invalid in C99 [-Werror,-Wimplicit-function-declaration]
        ret = sendfile(in, out, offset, &sbytes, &sf, flags);
              ^
1 error generated.
make: *** [Modules/posixmodule.o] Error 1
make: *** Waiting for unfinished jobs....
1 warning generated.
```

```bash
CFLAGS="-I$(brew --prefix openssl)/include -I$(brew --prefix bzip2)/include -I$(brew --prefix readline)/include -I$(xcrun --show-sdk-path)/usr/include" LDFLAGS="-L$(brew --prefix openssl)/lib -L$(brew --prefix readline)/lib -L$(brew --prefix zlib)/lib -L$(brew --prefix bzip2)/lib" pyenv install --patch 3.8.3 < <(curl -sSL https://github.com/python/cpython/commit/8ea6353.patch\?full_index\=1)
```

ひとしきりPythonバージョンのインストールができると、下図の状態になります。

![各Pythonバージョンインストール後の構成図](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1967276/05b5d6c4-acd0-c0dd-c40e-ef1d4dd50557.png)

### 5. 各pyenvの各Pythonバージョンでpipenvをインストール

pyenvで適宜Pythonバージョンを切り替えつつ、各Pythonバージョンごとにpipenvをインストールします。pipenvはpythonによる1ライブラリなので、Pythonバージョンごとにpip installする必要があります。

**Python 3.7.12での例**

```bash
$ x86
$ pyenv global 3.7.12
$ pyenv versions   # 今、どのpythonバージョンを使っているか確認
  system
* 3.7.12 (set by /Users/ユーザ名/.pyenv_x86/version)
  3.7.6

$ pip install pipenv  # python 3.7.12環境にpipenvがインストールされる
...

$ pipenv --version
pipenv, version 2021.11.23
```

**Python 3.9.9での例**

```bash
$ arm
$ pyenv global 3.9.9
$ pyenv versions   # 今、どのpythonバージョンを使っているか確認
  system
* 3.9.9 (set by /Users/ユーザ名/.pyenv_arm64/version)

$ pip install pipenv  # python 3.9.9環境にpipenvがインストールされる
...

$ pipenv --version
pipenv, version 2021.11.23
```

なお、pyenvで選択したPython環境にpipenvが入っていない場合は以下のエラーとなります。

```bash
$ arm
$ pyenv global 3.10.1
$ pipenv --version
pyenv: version `3.10.1' is not installed (set by PYENV_VERSION environment variable)
```

あとは同様にして、各Pythonバージョンごとにpipenvをインストールします。完了すると、下図の状態になります。

![pipenvインストール後の構成図](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1967276/d5a1416d-b3a4-753b-99a2-cb8c951e8d64.png)

### 6. Pythonによるプロジェクトごとに仮想環境を作る
使いたいPythonのバージョンを指定しつつ、仮想環境を使用します。下記の様に、使用したいPythonを明示的に指定する必要があります。pipenvでは複数マイナーバージョンの環境があるとき、最新のものを使おうとするためです(`pipenv global`や`pipenv shell`で明示的にマイナーバージョンを指定しても同様)。

```bash
pipenv install --python 3.9.7
```

なお、.python-versionが存在していれば、下記の様に指定することもできます。

```zsh
pipenv install --python $(cat .python-version)
```

以上になります、ご覧いただきありがとうございます！

[^1]: Python 3.8もセキュリティアップデートのみ提供される段階に入っているものの、arm64環境で動作するようサポートされています
[^2]: [M1MacのRosettaとARM環境にpyenv + pipenvの環境構築を行う](https://qiita.com/tamachan210/items/b253ced93425d7cc0f1f)による環境構築を参考にしました
[^3]: `pyenv init -`のみを実行してみると、"WARNING: \`pyenv init -\` no longer sets PATH."と警告されるようになっています