diveでDockerイメージのファイルを調査する

お仕事にてDocker (Pythonアプリケーション) のイメージサイズの削減に挑戦する機会がありました。その調査で用いた dive について紹介します。

diveとは

diveはイメージやレイヤのコンテンツ (ファイル) を可視化するためのツールで、主にイメージサイズの削減のための調査に用いられます。

github.com

MacであればHomebrewでインストールできます。

$ brew install dive

diveを使った調査

例として python:3.9-slim-buster をdiveで覗いてみます。dive {image_name}:{image_tag}でOKです。

$ dive python:3.9-slim-buster

すると2カラムのコンソール画面が開きます。

Layers (左上) がDockerのレイヤとなっており、選択したレイヤのファイルがCurrent Layer Contents (右) に表示されます。

f:id:ohke:20201017223421p:plain

Layersで2つ目のレイヤを選択します。このレイヤで7MBの増です。

f:id:ohke:20201017224126p:plain

Current Layer Contentsではこのレイヤでのファイルがツリー表示されます。緑文字が増加したファイル・ディレクトリを表しています。ドリルダウンもできるので、不意に増えているファイルなどが調査できます。

f:id:ohke:20201017224554p:plain

Python: pre-commitでコミット前にチェックする

Pythonで安全にコーディングしようとすると、リンタ (ex. flake8) やフォーマッタ (ex. black) 、型チェッカ (ex. mypy) など、コミット前に実行するコマンドが増えていきます。

今回は、コミット時にコマンドを自動的にフックするPythonのツールとしてpre-commitを紹介します。

インストール

pre-commitはpipでインストールできます。

$ python --version
Python 3.8.5

$ pip install pre-commit

$ pre-commit --version
pre-commit 2.7.1

$ pre-commit help
usage: pre-commit [-h] [-V] {autoupdate,clean,hook-impl,gc,init-templatedir,install,install-hooks,migrate-config,run,sample-config,try-repo,uninstall,help} ...
...

設定

次に設定ファイルを追加します。sample-configサブコマンドで生成できます。

  • ファイル名は .pre-commit-config.yaml にする必要があります
$ pre-commit sample-config > .pre-commit-config.yaml

.pre-commit-config.yamlは以下のような内容となってます。
処理を記述したPythonファイルがリモートレポジトリ (repo) にアップロードされており、コミット時には hooks 以下に id で列挙された処理を順に実行する、という設定となります。

repos:
-   repo: https://github.com/pre-commit/pre-commit-hooks
    rev: v2.4.0
    hooks:
    -   id: trailing-whitespace
    -   id: end-of-file-fixer
    -   id: check-yaml
    -   id: check-added-large-files

https://github.com/pre-commit/pre-commit-hooks 以外にも、ツールの製作者がpre-commit用のフックを用意していることもあります。例えばblackを実行する場合は以下のように記述します (blackのREADMEを参照) 。

  • フックはレポジトリ直下の .pre-commit-hooks.yaml に定義されています (idの値もこのファイルで確認できます)
  • 主要なフックのリストは https://pre-commit.com/hooks.html で列挙されています
  • もちろん自分で作ることもできます (参考)
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
...
- repo: https://github.com/psf/black
  rev: 19.10b0
  hooks:
    - id: black
      language_version: python3

フックの実行

サンプルとして、flake8とblackをフックに登録した設定ファイルを用意します。

- repo: https://github.com/psf/black
  rev: 19.10b0
  hooks:
    - id: black
      language_version: python3
repos:
-   repo: https://github.com/pre-commit/pre-commit-hooks
    rev: v2.4.0
    hooks:
    -   id: flake8

pre-commitでフックする場合、設定ファイルを作成後にinstallを実行します。これでスクリプトが生成されていますので、設定ファイルの変更都度実行する必要があります。

$ pre-commit install
pre-commit installed at .git/hooks/pre-commit

$ cat .git/hooks/pre-commit
#!/usr/bin/env python3.8
# File generated by pre-commit: https://pre-commit.com
# ID: 138fd403232d2ddd5efb44317e38bf03

# ... 省略 ...

if sys.platform == 'win32':  # https://bugs.python.org/issue19124
    import subprocess

    if sys.version_info < (3, 7):  # https://bugs.python.org/issue25942
        raise SystemExit(subprocess.Popen(CMD).wait())
    else:
        raise SystemExit(subprocess.call(CMD))
else:
    os.execvp(CMD[0], CMD)

試しに以下のmain.pyファイルをコミットしてみます。

def print_hi(name, age):
    profile = f'{name} ({str(age)})'
    print(f'Hi, {name}')

if __name__ == '__main__':
    print_hi('ohke', 32)

これでコミットしてみますが、エラーとなって失敗します。flake8ではprofileを使っていないことを指摘され、blackではリフォーマットされていることがわかります。

  • コードを変更したファイルのみがチェックの対象となります
$ git commit -m "test"
Flake8...................................................................Failed
- hook id: flake8
- exit code: 1

main.py:2:5: F841 local variable 'profile' is assigned to but never used

black....................................................................Failed
- hook id: black
- files were modified by this hook

reformatted main.py
All done! ✨ 🍰 ✨
1 file reformatted.

コードを修正して改めてコミットすると、無事にflake8とblackにパスして、コミットに成功します。

$ git commit -m "test"
Flake8...................................................................Passed
black....................................................................Passed
[master (root-commit) 9ec7bc8] test
 1 file changed, 7 insertions(+)
 create mode 100644 main.py

まとめ

今回はpre-commitについて紹介しました。

pre-commit自体はPythonのツールですが、フックは他の言語でも実装可能ですので、適用範囲は広いツールかと思います。

mypyで静的型チェックを導入する

仕事で既存のコードへmypyの導入を試みる機会がありましたので、使い方とtipsの備忘録としてまとめます。

mypyとは

mypyはPythonで静的型チェックを行うライブラリです。型はtypingで定義します。

インストール

typingと異なり、標準ライブラリではないので、インストールが必要です。

$ python --version
Python 3.7.4

$ pip install mypy

$ mypy --version
mypy 0.782

型チェックの実行

mypyで型チェックできます。以下ではsrcディレクトリ以下の型チェックが走ります。既存のプロジェクトへのmypy導入時には大量のエラー・警告メッセージが表示されるかと思います。

$ mypy src

Tips: 型チェック対象を限定する

コマンドライン引数でファイルやディレクトリ単位の指定が可能です。

$ mypy src/common src/models src/services

これらの型チェック対象をローカル環境やCI環境で共有する場合、以下のように列挙したテキストファイルを作成しておきます。mypy @{作成したファイルパス}とすることでテキストファイルで指定したファイル・ディレクトリのみがチェックされます。

$ cat mypy_files.txt
src/common
src/models
src/services

$ mypy @mypy_files.txt

pipenvの場合、Pipfileに以下のように定義しておくと楽になるかと思います。

[scripts]
mypy = "mypy @mypy_files.txt"

設定

mypyはカレントディレクトリのmypy.ini (あるいはsetup.cfg) で設定します。詳細なパラメータはドキュメントを参照してください。

  • warn_retun_any = Trueとすることで、返す型を明示していないインタフェースに警告させてます (デフォルトはFalse)
[mypy]
python_version = 3.7
warn_return_any = True
warn_unused_configs = True

Tips: スタブが無いパッケージのエラーは無視させる

よく見るエラーの1つがerror: Skipping analyzing '{パッケージ名, 例えばnumpy}': found module but no type hints or library stubsです。これは型情報を持つstubファイル (.pyi) を含まないパッケージをインポートすると起きます。
サードパーティのライブラリだといちいち追加するのも大変なので、無視するようにパッケージ単位でignore_missing_imports = Trueを追加します。

[mypy-numpy]
ignore_missing_imports = True

[mypy-scipy.*]
ignore_missing_imports = True

コードの修正

mypyで指摘された事項を修正していきます。基本的には、正しく型アノテーションを付けることで解消していきます。

返り値がintでアノテーションされてますが、Noneを返すケースもあります。

import re

def atoi(a: str) -> int:
    if re.match(r"[+-]?[0-9]+", a):
        return int(a)
    else:
        return None

mypyでそれが指摘されます。

% mypy src
src/main.py:7: error: Incompatible return value type (got "None", expected "int")
Found 1 error in 1 file (checked 1 source file)

なのでtypingを使って書き換えていきます。

from typing import 

def atoi(a: str) -> Optional[int]:
    # ...

基本はこの繰り返しになりますが、いくつか対処に困るケースもあります。

Tips: Unionでの型エラー

Union型の変数を、いずれかの型であることを前提とした処理を書くことがしばしばあります。例えば以下のコードでは、辞書のキーが "_list" の場合はlist、そうでなければintとして分けています。

from typing import Dict, Union, List

def sum_dict(d: Dict[str, Union[int, List[int]]]) -> int:
    total = 0
    for k, v in d.items():
        if k.endswith("_list"):
            total += sum(v)
        else:
            total += v
    return total

total = sum_dict({
    "ShopA": 100,
    "ShopB_list": [200, 300]
})

print(total)  # 600

問題なく実行もできるのですが、mypyではエラーとなってしまいます。

% mypy src
src/main.py:7: error: Argument 1 to "sum" has incompatible type "Union[int, List[int]]"; expected "Iterable[int]"
src/main.py:9: error: Unsupported operand types for + ("int" and "List[int]")
src/main.py:9: note: Right operand is of type "Union[int, List[int]]"

解決策が2つあります。

1つ目はtyping.castを使って、チェック時に型を教えてあげる方法です。値の型は変わりません。

from typing import Dict, Union, List, cast

def sum_dict(d: Dict[str, Union[int, List[int]]]) -> int:
    total = 0
    for k, v in d.items():
        if k.endswith("_list"):
            total += sum(cast(List[int], v))
        else:
            total += cast(int, v)
    return total

2つ目はisinstanceで明示的に処理分岐させる方法です。こうするとmypyは分岐を解析し、ifの方はlist、elseの方はintで解釈するようになります。

def sum_dict(d: Dict[str, Union[int, List[int]]]) -> int:
    total = 0
    for k, v in d.items():
        if isinstance(v, list):
            total += sum(v)
        else:
            total += v
    return total

個人的には、可能であればisinstanceで実行時の型チェック、それも難しい場合にtyping.castを使うのが安全かなと思います。

Tips: 特定箇所の型チェックエラーを無視したい

どうしても型チェックエラーを無視したいケースもあります。インタフェースを簡単に変えられないケースなどです。

エラーとなっている箇所に# type: ignoreとコメントすることで除外できます。

import re

def atoi(a: str) -> int:
    if re.match(r"[+-]?[0-9]+", a):
        return int(a)
    else:
        return None  # type: ignore

まとめ

今回はmypyの使い方とTipsを紹介しました。