Ansibleでローカルのdocker imageをデプロイ先に送り込む

# 背景

自前のdockerイメージを使ったアプリなどをansibleでデプロイするシチュエーションです。
少し調べてみるとプライベートレジストリを立ててイメージを配信するような方法がヒットしました。一応社内にdocker registryは立てているので、これを外からアクセスできるようにすればできなくはないのですが、デプロイするためだけに恒久的に外向けにサービス見せる？と考えるといやちょっと待とうか、となってしまいます。もう少し気軽な方法で、できればpush型で済ませたいなと思い、ここでは通常のファイルなどと同様にローカルからホストに直接イメージを送り込む手順を組んでみました。

コマンドラインで書くと

```bash
docker save <image> | ssh <host> docker load
```

のような形でワンライナーで書けるようなことをしたいのですが、ansibleでこれをやろうとするとそれなりのステップが必要になってしまいました。

# 準備

デプロイ元、デプロイ先ともにansibleで使うpythonにdocker(またはdocker-py)モジュールをインストールしておきます。

# 実装

以下のようにhost_varsが定義されているとします。この中で、docker_imagesに記述されるイメージをアプリで使用します。mysqlやnginxはdocker hubからpullすればよいのですが、ここでは全てローカルから送り込む形で統一しています。

```yaml:deploy/host_vars/example.yml
---
work_dir: /tmp
project_name: my_project
docker_images:
  my_module1: local_registry/foo:1.2.3
  my_app1: local_registry/bar:1.0
  db: mysql:5.7
  nginx: nginx:1.20.1
```

タスク記述例

```yaml:deploy/roles/install_docker_images/tasks/main.yml
---
# (1) ホスト側にdocker imageが存在するかどうかテスト
- name: test docker image existences
  docker_image_facts:
    name: "{{ item.value }}"
  with_dict: "{{ docker_images }}"
  register: docker_image_results

# (2) (1)の結果を確認
- name: debug docker_image_results
  debug: var=docker_image_results.results

# (3) 送信リスト初期化
- name: initialize sending_images
  set_fact:
    sending_images: []

# (4) (1)の結果から送信するイメージをリスト化
- name: assemble sending image info
  set_fact:
    sending_images: "{{ sending_images + [{
      'image': item.item.value,
      'image_file': project_name + '-' + item.item.key + '.tar',
    }] }}"
  when: item.images | length == 0
  with_items: "{{ docker_image_results.results }}"

# (5) リストを確認
- name: debug docker image status
  debug: var=sending_images

# (6) まずdocker imageをローカルにファイル保存
- name: save docker image at local
  local_action: docker_image name={{ item.image }} archive_path={{ item.image_file }} timeout=300
  become: false
  with_items: "{{ sending_images }}"

# (7) 保存したimageファイルを送信
- name: send image file
  copy:
    src: "{{ item.image_file }}"
    dest: "{{ work_dir }}"
    owner: "{{ ansible_user }}"
    group: "{{ ansible_user }}"
    mode: "0644"
  with_items: "{{ sending_images }}"

# (8) 送信したファイルをホスト側でロード
- name: load docker image
  docker_image:
    name: "{{ item.image }}"
    state: present
    load_path: "{{ deploy_dir }}/{{ item.image_file }}"
    timeout: 300
  with_items: "{{ sending_images }}"

# (9) 作成したローカルのimageファイルを削除
- name: remove local image file
  local_action: file path={{ item.image_file }} state=absent
  become: false
  with_items: "{{ sending_images }}"

# (10) 送信したホスト側のimageファイルも削除
- name: remove image file
  file:
    path: "{{ work_dir }}/{{ item.image_file }}"
    state: absent
  with_items: "{{ sending_images }}"
```

taskの各項目を以下に説明します。

**(1)(2)**
docker_imagesに記載されたイメージをテストして、結果をdocker_image_resultsという変数に保存します。
docker_image_resultsの結果は以下のような形となります。イメージがホスト側に存在している場合は"images"リストにその内容が反映され、存在していない場合は空となります（この例では local_registry/foo:1.2.3 と local_registry/bar:1.0 がhost側にない状態）。with_itemsでループを回すと、各iterationでのitemの状態も記録されます。

```json
    "docker_image_results.results": [
        {
            ...
            "images": [], 
            ...
            "item": {
                "key": "my_module1", 
                "value": "local_registry/foo:1.2.3"
            }
        }, 
        {
            ...
            "images": [], 
            ...
            "item": {
                "key": "my_app1", 
                "value": "local_registry/bar:1.0"
            }
        }, 
        {
            ...
            "images": [
                {
                    "Architecture": "amd64", 
                    "Author": "", 
                    ...
                }
            ],
            ...
            "item": {
                "key": "nginx", 
                "value": "nginx:1.20.1"
            }
        }, 
        {
            ...
            "images": [
                {
                    "Architecture": "amd64", 
                    "Author": "", 
                    ...
                }
            ], 
            ...
            "item": {
                "key": "db", 
                "value": "mysql:5.7"
            }
        }
    ]
```

**(3)(4)(5)**
上記command結果から送信するイメージをリストにします。image_file名はなんでもよいのですが、docker imageのタグ名には'/'などのファイル名に適さない文字が入っている可能性があるので注意して決定します。

```json
    "sending_images": [
        {
            "image": "local_registry/foo:1.2.3", 
            "image_file": "my_project-my_module1.tar"
        }, 
        {
            "image": "local_registry/bar:1.0", 
            "image_file": "my_project-my_app1.tar"
        }
    ]
```

**(6)**
ローカルのdeployディレクトリの下に以下のイメージファイルをセーブします。
my_project-my_module1.tar
my_project-my_app1.tar

docker imageのサイズは大きくなりがちなので、保存処理がデフォルトのtimeout(60秒)を超えそうな場合は必要な時間に応じてtimeoutを設定しておきます。

**(7)**
イメージファイルをホストに送信します。

**(8)**
送信したファイルをホスト側でロードします。ここでもtimeoutに注意。

**(9)(10)**
ローカル、host共にimageファイルを削除します。

#まとめ

冒頭で書いた通り、やりたいことは比較的単純でかつ普通にありそうなユースケースですが、意外と面倒なことになってしまいました。task記述は汎用的な形にはなったので、一度書いてしまえばポータブルに他のプロジェクトにも応用可能かとは思います。