GIG

赴くままに技術を。

クラスターオートスケール機能

大分昔に更新して以来すっかり更新を放置していたので、久しぶりに更新してみようと。

前回からすると、お仕事も一新 Azure を触る機会が多くなりました。 いろいろと検証するのですが、書き溜めたメモがなかなか放流できずじまいに。

今回は、Azure Kubernetes Service (AKS) でたまに聞かれることがあるクラスターオートスケール機能について書いてみます。

Azure Kubernetes Service (AKS) でのアプリケーションの需要を満たすようにクラスターを自動的にスケーリング

Kubernetes のオートスケール機能

オートスケールの対象は、PodとNode です。
Pod は、負荷に応じて、Pod 数を増減させる水平オートスケールと、Pod 数ではなく割り当てる CPU / メモリリソースを調整する垂直オートスケール機能があります。
Node の方は、クラスターオートスケール という機能で、負荷状況ではなく、 Pod が割り当てられない (Pending) になった時点で Node を追加する仕組みになります。
現時点で、 AKS でサポートしているのは、水平オートスケール機能とクラスターオートスケール機能になります。

検証

それでは検証してみます。 まずは AKS クラスターを構築します。

$ az group create --name labca --location japaneast
$ az aks create \
  --resource-group labca \
  --name labca \
  --node-count 1 \
  --node-vm-size Standard_DS2_v2 \
  --enable-vmss \
  --enable-cluster-autoscaler \
  --min-count 1 \
  --max-count 3

Standard_DS2_v2 なので、1 ノード当たり 2 vCPU (2000m) 持つことになります (実際、割り当て可能なのは、1900m)。

$ az aks get-credentials -g labca -n labca
$ kubectl get nodes
NAME                                STATUS   ROLES   AGE   VERSION
aks-nodepool1-24609861-vmss000000   Ready    agent   12m   v1.15.10

それでは、以下のようなシナリオで Node が追加されることを確認してみます。

  • 水平オートスケール (HorizontalpodAutoscaler) により、CPU 負荷が 50 % 以上を超えた場合、Pod を最大 5 つまでスケールアウトさせる
  • 対象のPodでは、絶えず負荷がかかるようにしておく

まずは水平オートスケールリソースを作成

$ cat << EOF | kubectl apply -f -
apiVersion: autoscaling/v1
kind: HorizontalPodAutoscaler
metadata:
  name: stress-hpa
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: stress
  minReplicas: 1
  maxReplicas: 5
  targetCPUUtilizationPercentage: 50
EOF

次いでオートスケール対象のPodを作成

$ cat << EOF | kubectl apply -f -
apiVersion: apps/v1
kind: Deployment
metadata:
  name: stress
  namespace: default
spec:
  replicas: 1
  selector:
    matchLabels:
      app: stress
  template:
    metadata:
      labels:
        app: stress
    spec:
      containers:
      - name: stress-test
        image: busybox
        command: ["dd", "if=/dev/zero", "of=/dev/null"]
        resources:
          limits:
            cpu: 500m
          requests:
            cpu: 500m
EOF

Pod の増加を見てみます。 他の管理系のPodがあるため、2つ (cpu : 1000m)分のみしかRunningになりませんでした。

kubectl get pods
NAME                      READY   STATUS    RESTARTS   AGE
stress-864dc999dd-cpbk2   1/1     Running   0          65s
stress-864dc999dd-khfxp   1/1     Running   0          2m
stress-864dc999dd-lnggt   0/1     Pending   0          5s
stress-864dc999dd-ngbq7   0/1     Pending   0          5s

次いでクラスターオートスケールの状態を確認すると、Eventsにスケールアップのイベントが発生していることが分かります(vmss サイズを2に変更した旨)。

$ kubectl describe configmap -n kube-system cluster-autoscaler-status
...(省略)...
Events:
  Type    Reason         Age   From                Message
  ----    ------         ----  ----                -------
  Normal  ScaledUpGroup  80s   cluster-autoscaler  Scale-up: setting group aks-nodepool1-24609861-vmss size to 2
  Normal  ScaledUpGroup  80s   cluster-autoscaler  Scale-up: group aks-nodepool1-24609861-vmss size set to 2

しばらく待つと、Nodeが追加されました。

$ kubectl get nodes
NAME                                STATUS   ROLES   AGE   VERSION
aks-nodepool1-24609861-vmss000000   Ready    agent   25m   v1.15.10
aks-nodepool1-24609861-vmss000001   Ready    agent   35s   v1.15.10

それに伴い、PendingになっていたPodもデプロイされ、5つすべてRunningになります。

$ kubectl get pods
NAME                      READY   STATUS    RESTARTS   AGE
stress-864dc999dd-cpbk2   1/1     Running   0          15m
stress-864dc999dd-ffrtq   1/1     Running   0          10m
stress-864dc999dd-khfxp   1/1     Running   0          16m
stress-864dc999dd-lnggt   1/1     Running   0          14m
stress-864dc999dd-ngbq7   1/1     Running   0          14m

以上にようにアプリケーションの負荷に伴い、インフラ側でも柔軟に対応させることが可能となります。
Nodeの増減に関してより細かい設定を行うことも可能です。

クラスター オートスケーラーの設定の変更

GitLab CI環境を自己証明書を使って構築する

※自己証明書を使うのは検証目的に留めておいて、本番運用では証明書の購入が必要。

2018/5/12追記

  • gitlab omnibusインストールのパラメータ修正(gitlab_shell_ssh_port)
  • ジョブとしてDockerイメージをビルドするため、gitlab-runner registerでdocker.sockのマウントを指定

検証環境

検証した環境は以下。

パッケージ バージョン 備考
Docker for Mac(Edge) 18.05.0-ce-rc1-mac63 Macでk8sを触るためEdgeにしている
GitLab Community Edition 10.7.0-ce.0
GitLab Runner v10.7.0

GitLabをDocker Composeで立てる

フォルダ構成は以下。

.
├── docker-compose.yml
└── volume
    ├── certs
    ├── conf
    ├── data
    └── logs

volume/certs配下に自己証明書を作成して、配備する。 ここで作成した証明書はGitLab Runnerを登録する際に利用する。しかしその時にSAN(Subject Altanative Name)を指定して作成しないと以下のようなエラーが出てしまう。

ERROR: Registering runner... failed                 runner=cTGFEKNu status=couldn't execute POST against https://192.168.1.9/api/v4/runners: Post https://192.168.1.9/api/v4/runners: x509: cannot validate certificate for 192.168.1.9 because it doesn't contain any IP SANs

そのため、GitLab用途で証明書を作成する際には、SANを設定する。Macでは/System/Library/OpenSSL/openssl.cnfにあるので、以下のように秘密鍵と証明書を生成する(ただし、192.168.1.9はホストサーバ(Mac)側に割り当たったIPアドレスで、固定である方が望ましい...)。

$ cd volume/certs/
$ openssl req -newkey rsa:4096 -days 365 -nodes -x509 -subj "/C=JP/ST=CHIBA/L=CHIBA/O=NAN/OU=NAN/CN=192.168.1.9" -extensions v3_ca -config <( cat /System/Library/OpenSSL/openssl.cnf <(printf "[v3_ca]\nsubjectAltName='IP:192.168.1.9'")) -keyout server.pem -out server.crt
Generating a 4096 bit RSA private key
.............................................................++
...............................................................................................++
  • docker-compose.ymlは以下。
    • GITLAB_OMUNIbUS_CONFIG以下には、gitlab.rbで指定可能なパラメータを並べることができる
    • TLSの設定はnginx[]の箇所。
    • rootユーザの初期パスワードはgitlab_rails['initial_root_password']で指定したpasswordである
    • GitLab RunnerからGitLabへの登録を行うトークンもあらかじめgitlab_rails['initial_shared_runners_registration_token']でtokenと指定している
version: "3"
services:
  gitlab:
    image: gitlab/gitlab-ce:10.7.0-ce.0
    container_name: gitlab
    restart: always
    environment:
      GITLAB_OMNIBUS_CONFIG: |
        external_url "https://192.168.1.8"
        gitlab_rails['time_zone'] = "Asia/Tokyo"
        gitlab_rails['initial_root_password'] = "password"
        gitlab_rails['initial_shared_runners_registration_token'] = "token"
        gitlab_rails['gitlab_shell_ssh_port'] = 2222
        unicorn['worker_timeout'] = 60
        unicorn['worker_processes'] = 3
        logging['logrotate_frequency'] = "weekly"
        logging['logrotate_rotate'] = 52
        logging['logrotate_compress'] = "compress"
        logging['logrotate_method'] = "copytruncate"
        nginx['listen_port'] = 443
        nginx['redirect_http_to_https'] = true
        nginx['ssl_certificate'] = "/etc/ssl/certs/gitlab/server.crt"
        nginx['ssl_certificate_key'] = "/etc/ssl/certs/gitlab/server.pem"
        nginx['ssl_protocols'] = "TLSv1.1 TLSv1.2"
        # Add any other gitlab.rb configuration options if desired
    ports:
      - "443:443"
      - "2222:22"
    volumes:
      - ./volume/conf:/etc/gitlab
      - ./volume/certs:/etc/ssl/certs/gitlab
      - ./volume/logs:/var/log/gitlab
      - ./volume/data:/var/opt/gitlab
    logging:
      options:
        max-size: "4M"
        max-file: "14"

起動する。

$ docker-compose up -d
Starting gitlab ... done

しばらくするとWebブラウザからhttps://192.168.1.9で接続可能になる(起動未完了時は502エラーページが表示される)。 起動完了後は、docker-compose.ymlで指定したように、ユーザIDroot、パスワードpasswordでログインできる。

GitLab Runnerを登録する

フォルダ構成は下記。

.
├── docker-compose.yml
└── volume
    ├── certs
    │   └── 192.168.1.9.crt
    └── config

ここで192.168.1.9.crtとは、GitLab構築時に作成した証明書を(GitLabのアドレス).crtとして格納する(参考情報)。

  • docker-compose.ymlは以下。
    • ジョブを実行するExecuterとしてホストマシン上のDockerコンテナ上で行いたいため、ホストマシン側のDockerエンドポイント(/var/run/docker.sock)を指定している。ただし、MacではこのUNIXソケットの利用をサポートしていない(?)とのこと。
version: '3'
services:
  runner:
    image: gitlab/gitlab-runner:v10.7.0
    container_name: gitlab-runner
    restart: always
    volumes:
      - ./volume/config:/etc/gitlab-runner
      - ./volume/certs:/etc/gitlab-runner/certs
      - /var/run/docker.sock:/var/run/docker.sock
    logging:
      options:
        max-size: "4M"
        max-file: "14"

起動する。

$ docker-compose up -d
Recreating gitlab-runner ... done

ついで、Runnerを登録する。registerで指定可能なオプションはgitlab-runner register --helpで確認できる(分量が非常に多い)。 また、登録されると./volume/config/config.tomlという設定ファイルが出力されるので、これをあらかじめ設定しておき、registerの引数で渡すこともできる。

$ docker exec gitlab-runner gitlab-runner register -n -r token --executor docker --docker-image alpine:latest --url https://192.168.1.8/ --docker-volumes /var/run/docker.sock:/var/run/docker.sock
Running in system-mode.

Registering runner... succeeded                     runner=token
Runner registered successfully. Feel free to start it, but if it's running already the config should be automatically reloaded!

以上でめでたくGitLab検証環境できた。

SwaggerでWeb APIを作る - 非同期実行 (その1)

これまでは同期処理(リクエストを送ると処理が実行され、レスポンスが返答されるまで待つ処理)であったが、処理が長いものなどは非同期で処理を実行しなくてはならない。PythonではCeleryというライブラリで実現できる。 ここではまず環境構築(Flask, Celeryの連携確認)まで行う。

Celeryとは

  • 非同期のタスク/ジョブキュー管理が可能
  • 複数のBrokerをサポート
    • RabbitMQが推奨
  • Result Backendをしているするとタスクのステータスや実行結果を取得可能
  • OpenStackでも採用実績あり

インストール

前提の環境としては、CentOS7を用いる。これをVirtualBox+Vagrantで環境から用意する。

$ vagrant init centos/7
$ cp Vagrantfile{,.bak}
$ vim Vagrantfile
...(省略)...
config.vm.network "forwarded_port", guest: 5672, host: 5672
 config.vm.network "forwarded_port", guest: 15672, host: 15672
...(省略)...
$ vagrant up
$ vagrant ssh
  • rootパスワードはvagrantが初期値となっている
$ su -
Password: 
# rpm -Uvh http://download.fedoraproject.org/pub/epel/7/x86_64/e/epel-release-7-9.noarch.rpm'
  • RabbitMQのインストールおよび自動起動設定を行う
# yum -y install rabbitmq-server
# systemctl enable rabbitmq-server.service
# systemctl list-unit-files -t service | grep rabbit
rabbitmq-server.service                       enabled
  • Pythonのインストール
    • Pythonのバージョンを切り替えやすいため、anyenv(pyenv)で入れておく
$ sudo yum -y install git zlib-devel bzip2 bzip2-devel readline-devel sqlite sqlite-devel openssl-devel xz xz-devel
$ git clone https://github.com/riywo/anyenv ~/.anyenv
$ echo 'export PATH="$HOME/.anyenv/bin:$PATH"' >> ~/.bash_profile 
$ echo 'eval "$(anyenv init -)"' >> ~/.bash_profile 
$ exec $SHELL -l
$ anyenv install pyenv
$ exec $SHELL -l
$ pyenv install 3.5.4
$ pyenv versions
* system (set by /home/vagrant/.anyenv/envs/pyenv/version)
  3.5.4
$ pyenv global 3.5.4
$ pyenv versions
  system
* 3.5.4 (set by /home/vagrant/.anyenv/envs/pyenv/version)
  • Celery, flaskのインストール
$ pip install celery flask
  • RabbitMQの設定
  • ユーザ, バーチャルホスト, ユーザタグ, 権限を設定
  • 権限は、設定、読み書き全ての権限を付与
# rabbitmqctl add_user vagrant vagrant
Creating user "vagrant" ...
...done.
# rabbitmqctl add_vhost vagrant
Creating vhost "vagrant" ...
...done.
# rabbitmqctl set_user_tags vagrant vagrant_tag
Setting tags for user "vagrant" to [vagrant_tag] ...
...done.
# rabbitmqctl set_permissions -p vagrant vagrant ".*" ".*" ".*"
Setting permissions for user "vagrant" in vhost "vagrant" ...
...done.

FlaskからCeleryを使う

以下の順番で公式チュートリアルを読んでから手を動かし始めた

  1. First Steps with Celery
  2. Celery Based Background Tasks

ほとんどチュートリアル通りの内容。

celery_flask.py

config.py

app.py

次にこれを実行するが、その前にCeleryワーカーを起動していないくてはならない。celery workerコマンドを使うとフォアグラウンドで実行するので、celery multiコマンドを使って、バックグラウンドで実行する

$ sudo mkdir -p /var/run/celery
$ sudo mkdir -p /var/log/celery
$ sudo chown -R vagrant. /var/run/celery/
$ sudo chown -R vagrant. /var/log/celery/
$ celery multi start w1 -A app -l info --pidfile=/var/run/celery/%n.pid --logfile=/var/log/celery/%n%I.log

最後にターミナルをもう1つ開いてタスクが投げ込まれるかを見てみる。

  • ターミナル1
$ python
Python 3.5.4 (default, Aug 24 2017, 12:02:53)
[GCC 4.8.5 20150623 (Red Hat 4.8.5-11)] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> from app import add_together
>>> add_together.delay(1,3)
<AsyncResult: 0ee3b370-561c-4e9f-a978-58635b00df89>
  • ターミナル2
$ tailf /var/log/celery/w1-1.log
...(省略)...
[2017-09-16 02:24:23,203: INFO/ForkPoolWorker-1] Task app.add_together[74dabaad-aa7e-4dee-aab1-a505deaeb76f] succeeded in 0.0009727960004966008s: 4

押して開く

半年ぐらいずっと同じような指摘をいただいている。

「会になっていない」
「口割りまで降りていない」

だったり、矢が下に行ったり。

弓道で本当に一連の動作が大事だなと思えるのは、こういった原因が直前の動作の引き分けにあるのではなく、遡ると取り懸けに原因があったり、足踏みにあったり、奥が深い。

まだ何が原因かは突き止めていないが、先日の練習でイメージが湧いたのは、「妻手を肩にのせるように」というアドバイスだった。 意識すると妻手の肩が入っておらず、それが先の指摘に続いていた。そうすると今度は小さく引いているような気がしてくる(右肩がクルッと回っているような感覚)。これでは次はおそらく大きく引くことという指摘をもらうだろう。

そうなると今の時代は便利で、Youtubeで人の行射を眺めたり、解説を読んだり。最後にたどり着いたのは「押して開くこと」。しばらくはこの押して開くを無意識にできるよう、なんどもなんども反復したい。

SwaggerでWeb APIを作る - DBと連携する

前の記事で書いたWeb APIを今度はDBと連携させる。前回までは辞書オブジェクトにデータを保存していたので、アプリケーションを再起動させるとPOSTしたデータが失われることになる。

DBとしてSQLiteを使ってみる。 業務では専らPostgreSQLなんだけど、開発時や組み込みで使われることがある(らしい)。

まずはdb.pyを作り、下記のような接続処理を書く。

petshop/db.py

create_engineの引数では、DBの接続パスと、文字列をunicode文字列として扱うようにcovert_unicode=Trueを指定する。

次にモデルを定義する。モデルで定義した属性は、そのままDBの属性となる(O/Rマッピング機能)。 Petモデルを以下のように定義する。

petshop/model.py

as_dict()は、検索系のAPIで辞書データを返す必要があるため、そのようなメソッドを用意している。 モデルまで作成したので、モデルからDBを初期化する。

$ python -c "from db import init_db; init_db()"
$ cat petstore.db 
nn??tablepetspetsCREATE TABLE pets (
    id INTEGER NOT NULL, 
    name VARCHAR(100), 
    tag VARCHAR(20), 
    created DATETIME, 
    PRIMARY KEY (id)

コントローラー部分は、DB連携のためPetモデルの処理とdb_sessionの操作を追加している。

petstore/default_controller.py

一覧取得find_petsは、複数のタグによる検索が可能となっているので、リストでtagsが取得される。それを条件に展開するので、Pet.tag == tagをリストの要素分作って、sqlalchemy._or()の要素に展開するように実装している。 またlimitによる件数の制限は、取得したものについて行うようにしている(本当は検索するときに指定すべき)。

or_filters = or_(*[Pet.tag == tag for tag in tags])
pets = Pet.query.filter(or_filters).all()

SwaggerでWeb APIを作る - APIを実装する

この記事はFujitsu Advent Calendarの20日目です。

Swaggerとは?

前のポストではSwaggerでWeb APIを設計し、ドキュメント化、モックサーバの起動について書いた。 改めてSwaggerについて。

  • REST API設計とそのツール群
    • 仕様書(YAML形式)を書くことでそれから機能を作れたり、ドキュメント化して公開するなど仕様の齟齬をなくす
    • メンテされなくなったExcelで書いた仕様書といったものを防げそう
  • REST APIの標準化団体Open API Initiativeが仕様として採用した

Flaskサーバを実装

使うAPI仕様書は、出来合いのPetstore(Simple)のものを使う(Swagger Editorで[File] > [Open Example...]でpetstore_simple.yamlを選択)。その仕様に沿ったサーバ機能の雛形は、同様にSwagger Editorの[Generate Server]から好みのフレームワークを選択することで取得できる。

ディレクトリ構造は以下のようなかたち。 swagger.yamlAPI仕様書。ロジックはdefault_controller.pyにつくる。

.
├── LICENSE
├── README.md
├── __init__.py
├── app.py
├── controllers
│   ├── __init__.py
│   └── default_controller.py
└── swagger
    └── swagger.yaml

Swaggerに準拠したFlaskのフレームワークとしては、connexionがある。 hjacobs/connexion-exampleに習い、今回はデータを辞書として持ち回るように実装する。

まずはPythonのライブラリを2つほどインストールする。

$ pip install Flask connexion

次にアプリケーションの起点であるapp.pyを見てみる。
API仕様書のswagger.yamlapp.add_apiで読み込み、8080ポートでサーバを起動することが書いてあるのみ。

#!/usr/bin/env python3

import connexion
        
if __name__ == '__main__':
    app = connexion.App(__name__, specification_dir='./swagger/')
    app.add_api('swagger.yaml')
    app.run(port=8080)

default_controller.pyが処理を書くメインとなっている。 このパスとメソッド名をドットでつないだものがoperationIdで指定しているものである。 connexionは、辞書で返却するとjsonで返してくれる(jsonifyなどでjson化しなくて良い)。

import logging
from connexion import NoContent

logger = logging.getLogger(__name__)

PETS = {}


def add_pet(pet):
    id = pet['id']
    # idがすでに使用されているか
    exists = id in PETS
    if exists:
        logger.info('If exists, update %s', id)
        PETS[id].update(pet)
    else:
        logger.info('If not exists, create %s', id)
        PETS[id] = pet
    return PETS[id], 200


def delete_pet(id):
    if id in PETS:
        logger.info('Deleting pet %s', id)
        del(PETS[id])
        return NoContent, 204
    else:
        return NoContent, 404
      
      
def find_pet_by_id(id):
    pet = PETS.get(id)
    return pet or (NoContent, 404)


def find_pets(tags=None, limit=None):
    return [pet for pet in PETS.values()
            if not tags or pet['tag'] in tags][:limit]

実装したREST APIを使ってみる

  • まずはデータのPost。
    • レスポンスとして投入したデータを返却する。
$ cat pet.json 
{"id":1,"name":"foo","tag":"dog"}
$ cat pet2.json 
{"id":2,"name":"bar", "tag":"cat"}
$ curl -i -X POST --header "Content-Type: application/json" --header "Accept: application/json" http://localhost:8080/api/pets -d @pet.json
HTTP/1.0 200 OK
Content-Type: application/json
Content-Length: 46
Server: Werkzeug/0.11.11 Python/3.5.2
Date: Mon, 19 Dec 2016 14:02:06 GMT

{
  "tag": "dog",
  "name": "foo",
  "id": 1
}
$ curl -i -X POST --header "Content-Type: application/json" --header "Accept: application/json" ttp://localhost:8080/api/pets -d @pet2.json
HTTP/1.0 200 OK
Content-Type: application/json
Content-Length: 46
Server: Werkzeug/0.11.11 Python/3.5.2
Date: Mon, 19 Dec 2016 14:02:11 GMT

{
  "tag": "cat",
  "name": "bar",
  "id": 2
}
  • 一覧を取得
$ curl -i http://localhost:8080/api/pets
HTTP/1.0 200 OK
Content-Type: application/json
Content-Length: 118
Server: Werkzeug/0.11.11 Python/3.5.2
Date: Mon, 19 Dec 2016 14:32:26 GMT

[
  {
    "name": "foo",
    "id": 1,
    "tag": "dog"
  },
  {
    "name": "bar",
    "id": 2,
    "tag": "cat"
  }
]
  • 一覧を取得(取得件数を1件(limit=1)を指定)
$ curl -i http://localhost:8080/api/pets?limit=1
HTTP/1.0 200 OK
Content-Type: application/json
Content-Length: 60
Server: Werkzeug/0.11.11 Python/3.5.2
Date: Mon, 19 Dec 2016 14:33:26 GMT

[
  {
    "name": "foo",
    "id": 1,
    "tag": "dog"
  }
]
  • 一覧を取得(tagsを指定)
    • dogのみ取得
$ curl -i http://localhost:8080/api/pets?tags="dog"
HTTP/1.0 200 OK
Content-Type: application/json
Content-Length: 60
Server: Werkzeug/0.11.11 Python/3.5.2
Date: Mon, 19 Dec 2016 14:35:20 GMT

[
  {
    "name": "foo",
    "id": 1,
    "tag": "dog"
  }
]
  • catまたはdogのtagのものを取得
$ curl -i http://localhost:8080/api/pets?tags="dog,cat"
HTTP/1.0 200 OK
Content-Type: application/json
Content-Length: 118
Server: Werkzeug/0.11.11 Python/3.5.2
Date: Mon, 19 Dec 2016 14:35:36 GMT

[
  {
    "name": "foo",
    "id": 1,
    "tag": "dog"
  },
  {
    "name": "bar",
    "id": 2,
    "tag": "cat"
  }
]
  • 削除してから再び取得
    • 削除して204となる
    • id=1を取得して404となる
$ curl -i -X DELETE http://localhost:8080/api/pets/1
HTTP/1.0 204 NO CONTENT
Content-Type: text/html; charset=utf-8
Content-Length: 0
Server: Werkzeug/0.11.11 Python/3.5.2
Date: Mon, 19 Dec 2016 14:37:23 GMT
$ curl -i http://localhost:8080/api/pets/1
HTTP/1.0 404 NOT FOUND
Content-Type: text/html; charset=utf-8
Content-Length: 0
Server: Werkzeug/0.11.11 Python/3.5.2
Date: Mon, 19 Dec 2016 14:37:43 GMT

その他

レスポンスはpretty printされて返却される

github.com

add_apiのオプションとして渡せたらいいなという議論がある。今はflask_compressを使う。

x-swagger-router-controller:をオペレーションの上に書くとエラー

x-swagger-router-controllerとしては、controllerのモジュールのパス(上記の例だとcontrollers.default_controllerを記載して、oeprationIdはメソッドのみを記載するといった使い方ができそうだけど、URL直下に書くと以下のようなエラーが起きる。

Traceback (most recent call last):
  File "app.py", line 8, in <module>
    app.add_api('swagger.yaml')
  File "/Users/hermesian/.anyenv/envs/pyenv/versions/dev352/lib/python3.5/site-packages/connexion/app.py", line 146, in add_api
    debug=self.debug)
  File "/Users/hermesian/.anyenv/envs/pyenv/versions/dev352/lib/python3.5/site-packages/connexion/api.py", line 102, in __init__
    validate_spec(spec)
  File "/Users/hermesian/.anyenv/envs/pyenv/versions/dev352/lib/python3.5/site-packages/swagger_spec_validator/validator20.py", line 88, in validate_spec
    validate_apis(apis, bound_deref)
  File "/Users/hermesian/.anyenv/envs/pyenv/versions/dev352/lib/python3.5/site-packages/swagger_spec_validator/validator20.py", line 151, in validate_apis
    oper_params = deref(oper_body.get('parameters', []))
AttributeError: 'str' object has no attribute 'get'

ドキュメントではオペレーション直下に書いているけど、それならoperationIdだけで良さそう。

API仕様書のresponseを実装側で上書きしてしまう

API仕様書ではリターンコードが204だが、アプリケーション側で200を返すように書くと、後者で返される。レスポンスデータについても同様。 これ結構悩ましい問題。レスポンスのバリデーションが必要そう。

SwaggerでWeb APIを作る - Web APIの設計

Web APIの設計

Web APIの設計でExcelを使って定義書を作成していたが、仕様の変更等によって気がついたら設計書とシステムに乖離しているなんてことがあった。そのようなことがないようにWeb APIの定義情報を常に正とするようなアプリにしたい。

そこでSwaggerは、いくつかの企業によるコンソーシアムによって、Web APIの標準化を行うための規約とそのツール群を使う。

Swagger

Swaggerのサイトに行くとSwagger-editorやSwagger-UIなどツールがいくつかあるけど、swagger-nodeとbootprint-openapiで一通りできる。

GitHub - swagger-api/swagger-node: Swagger module for node.js

$ npm install -g swagger

GitHub - bootprint/bootprint-openapi: Bootprint-module to render OpenAPI specifications

$ npm install -g bootprint
$ npm install -g bootprint-openapi
$ npm -g install html-inline

手順としては、以下。

Swagger-editorでWeb API定義ファイルを作成する

はじめにプロジェクトのディレクトリを作る。 その際にNodeJSのwebフレームワークを選択させられる。これは後で作るモックサーバをカスタマイズするときに改造していくものである(Flaskが対応していたら便利だけど)。 expressを選択する。

$ swagger project create example

次に生成されたディレクトリに降りて、エディタを起動する。

$ cd example/
$ swagger project edit

Webブラウザが起動し、エディタが表示される。 GUIに対応できない環境(x windowを起動していないサーバなど)で利用するときは、-s | --silentを付ける。

今回はSwagger Editorのpetstores_simple.yamlを使う。

ドキュメント化する

bootprint-openapiでSwaggerファイルをHTML化する。

$ pwd
example
$ bootprint openapi api/swagger/swagger.yaml target

するとtargetディレクトリ配下にhtml, css, javascriptが書き出され、適当なwebサーバに配置することで閲覧できる。 さらにcss, javascriptをHTMLにまとめたいときは下記のようにし、dist.htmlを公開すれば良い。

$ html-inline target/index.html > target/dist.html

モックサーバを立てる

モックサーバはサーバ機能をまだ実装していない段階で、クライアント側の仕様検討、実装を待ちにすることなく行う目的で建てる。

$ pwd
example
$ swagger project start -m

クライアント側(同一端末だが)でAPIを叩く。 (2016.12.12 修正: 下記で/petsを叩いていたのだけど、そもそもbathPath: /apiなので、 そのようなAPIはないのは当たり前だった。

$ curl -i http://localhost:10010/api/pets
HTTP/1.1 404 Not Found
X-Powered-By: Express
X-Content-Type-Options: nosniff
Content-Type: text/html; charset=utf-8
Content-Length: 21
Date: Mon, 12 Dec 2016 04:50:26 GMT
Connection: keep-alive

Cannot GET /api/pets

おや? 同じ事象がRunning in mock modeチュートリアル例でも起きる。

調べてみると、下記のissueに遭遇。x-swagger-router-controllerを指定しないとダメとのこと。 これを付けるのは、pathかoperationの階層で定義すれば良い。これとoperationIdAPIから呼ばれるメソッドを指定する。

issueでも言われているけど、モックなのにこれを指定しないといけないのは使い勝手が悪い気が...。 Flaskでswaggerに沿ったサーバ機能を作ることができるconnexionでは、むしろoperationレベルでのみでxx-swagger-router-controllerを設定できる。これだとoperationIdのみでもう良くないかと思えてきた。なのでモック使うとき以外は特に使用しないことにする。

github.com

pathに含まれるoperationで全て同じとする。

..(省略)..

  /pets:
    x-swagger-router-controller: pets

..(省略)..

  /pets/{id}:
    x-swagger-router-controller: pets

..(省略)..

改めてクライアント側で叩く。 そのまま使うと決められた値(numberなら1, stringなら"Sample text")が返ってくる。 レスポンスをカスタマイズしたい場合は、Implementing mock mode controllersを参照。

$ curl -X GET -i http://localhost:10010/api/pets
HTTP/1.1 200 OK
X-Powered-By: Express
Access-Control-Allow-Origin: *
Content-Type: application/json
Date: Sat, 24 Sep 2016 13:08:50 GMT
Connection: keep-alive
Content-Length: 51

[{"id":1,"name":"Sample text","tag":"Sample text"}]
$ cat pet.json 
{"id":1,"name":"foo","tag":"dog"}
$ curl -X POST --header "Content-Type: application/json" --header "Accept: application/json" -i http://localhost:10010/api/pets -d @pet.json
HTTP/1.1 200 OK
X-Powered-By: Express
Access-Control-Allow-Origin: *
Content-Type: application/json
Date: Sat, 24 Sep 2016 13:29:25 GMT
Connection: keep-alive
Content-Length: 49

{"id":1,"name":"Sample text","tag":"Sample text"}
$ curl -X GET -i http://localhost:10010/api/pets/1
HTTP/1.1 200 OK
X-Powered-By: Express
Access-Control-Allow-Origin: *
Content-Type: application/json
Date: Sat, 24 Sep 2016 13:36:02 GMT
Connection: keep-alive
Content-Length: 49

{"id":1,"name":"Sample text","tag":"Sample text"}
$ curl -X DELETE -i http://localhost:10010/api/pets/1
HTTP/1.1 500 Internal Server Error
X-Powered-By: Express
Access-Control-Allow-Origin: *
Content-Type: application/json
Date: Sat, 24 Sep 2016 13:37:25 GMT
Connection: keep-alive
Content-Length: 196

{"message":"Response validation failed: void does not allow a value","code":"INVALID_TYPE","failedValidation":true,"path":["paths","/pets/{id}","delete","responses","204"],"originalResponse":"{}"}

最後だけこけた。どうやらレスポンスが空は許可されていないみたい。 対象療法だけど、レスポンスを追加した。

    delete:
      description: deletes a single pet based on the ID supplied
      operationId: deletePet
      parameters:
        - name: id
          in: path
          description: ID of pet to delete
          required: true
          type: integer
          format: int64
      responses:
        '200':
          description: pet deleted
          schema:
            $ref: '#/definitions/pet'
        default:
          description: unexpected error
          schema:
            $ref: '#/definitions/errorModel'

どうにか。

$ curl -X DELETE -i http://localhost:10010/api/pets/1
HTTP/1.1 200 OK
X-Powered-By: Express
Access-Control-Allow-Origin: *
Content-Type: application/json
Date: Sat, 24 Sep 2016 13:51:11 GMT
Connection: keep-alive
Content-Length: 49

{"id":1,"name":"Sample text","tag":"Sample text"}

サーバ機能の雛形を生成する

最後にAPI仕様書からサーバ機能の雛形を作る

swagger-nodeの方ではないSwagger-Editorを用いると、サーバ機能のダウンロードができる(swagger-codegenでも良い)。 インストールしても良いが、Dockerイメージがあるので、コンテナにて行う。

$ docker pull swaggerapi/swagger-editor
$ docker run -p 80:8080 swaggerapi/swagger-editor

あとはhttp://(YOUR_CONTAINER_URI)にアクセスし、API仕様書の内容をコピー&ペーストして、[Generate Server]からお好みのフレームワークの雛形をダウンロードする。これからFlaskでサーバ機能を作るので、Flaskを選択する。