Kubernetesでセッションを維持する

ログインが必要なタイプのWebアプリケーションをKubernetesで動かす際,何も考えずにPodを複数動かしてしまうと,正常にセッション管理ができない場合がある.

例えば,ログインしたという情報をWebアプリケーション側で保持しておく場合を考える.
1. DeploymentでPodのreplicaを3つ作る(Pod A,Pod B,Pod C)
2. クライアントがServiceを経由してPod Aにアクセスする.
3. クライアントはPod Aでログインする
4. ログインを終えたクライアントが,次の画面に遷移するために新しいリクエストを送る
5. リクエストを受け取ったServiceが,Pod A以外のPod(例えばPod B)にリクエストを振り分ける
6. Pod Bではクライアントがログインしたという記録がないので,再度ログイン画面に飛ばされる
7. 同様に,ログイン -> 次のPodに振り分けられるというのが続いてしまう

このような問題を解決するため,Serviceを作成する時に sessionAffinityClientIP に設定する.
ClientIP にすることで,クライアントのIPアドレスを考慮しながらPodへリクエストを流してくれるので,ステートフルなアプリケーションもKubernetes上で実行できる.

文章で説明するのは得意ではないので,実際にテスト.
例として,GrafanaをKubernetesで動かしてみる.
DeploymentとServiceを次のように作成する.

apiVersion: apps/v1
kind: Deployment
metadata:
  name: grafana-deployment
spec:
  replicas: 3
  template:
    metadata:
      labels:
        app: grafana
    spec:
      containers:
        - name: grafana
          image: grafana/grafana
          ports:
            - name: grafana-port
              containerPort: 3000
              protocol: TCP
  selector:
    matchLabels:
      app: grafana
---
apiVersion: v1
kind: Service
metadata:
  name: grafana-service
spec:
  ports:
    - name: grafana
      port: 3000
      protocol: TCP
      targetPort: grafana-port
  selector:
    app: grafana
  type: NodePort
  sessionAffinity: ClientIP

上のマニフェストを用いて作成.

$ kubectl create -f grafana.yml

sessionAffinityClientIP に設定しているので,正常にログインができるはず.

Docker Engine API試用

前に少し気になっていたDocker Engine APiを使ってみたので,それについて.

特に複雑なことはせず,引数に与えたイメージを削除するというプログラムを作ってみた.
ただ単純に削除するだけではつまらないので,何世代分保存しておくか,というのをオプションで指定できるようにした.

コードは mas9612/docker-tools/image-remove に置いてある.
あまりきれいなコードではないのでご注意ください.

Client.ImageList() メソッドでローカルにあるイメージの一覧が取得できるが, filter でイメージ名を指定できなさそうだったので,愚直にfor文で1つ1つ確認している.
アルゴリズムは得意ではないので,良い方法があれば教えてください…

images, err := client.ImageList(ctx, types.ImageListOptions{})
if err != nil {
    log.Fatalf("[ERROR] client.ImageList(): %s\n", err)
}

for _, image := range images {
    for _, repotag := range image.RepoTags {
        repository := strings.Split(repotag, ":")
        if repository[0] == *imageName {
            imageInfos = append(imageInfos, imageInfo{
                ID:      image.ID,
                Created: image.Created,
                Name:    repotag,
            })
        }
    }
}

削除対象のイメージをリスト出来たら,それを作成日時でソートし,指定した世代分は残してそれ以外を Client.ImageRemove() メソッドで削除している.
デフォルトでは,イメージ名にマッチしたもの全てを削除するようになっているのでお気をつけください.

if *generation > len(imageInfos) {
    *generation = len(imageInfos)
}
removeOptions := types.ImageRemoveOptions{
    Force: *force,
}
for _, image := range imageInfos[*generation:] {
    _, err := client.ImageRemove(ctx, image.ID, removeOptions)
    if err != nil {
        log.Fatalf("[ERROR] client.ImageRemove(): %s\n", err)
    }
    fmt.Printf("Image %s was deleted.\n", image.Name)
}

やっている事自体は簡単なので,ドキュメントと見比べて頂ければわかると思います.

GoでMySQLを使う – database/sql package

GoからMySQLを使う方法について調べた.
O/Rマッパーを使う方法も気になったが,まずGo標準パッケージで用意されている機能を使い,SQLを地道に実行していく方法を試した.

ソースコードは以下.

実行

Dockerを使って簡単にローカルにMySQLを準備する.

$ docker run -p 3306:3306 -e MYSQL_ROOT_PASSWORD=mypass -e MYSQL_DATABASE=testdb -e MYSQL_USER=mysql -e MYSQL_PASSWORD=mypass -d --name mysql mysql

DBの準備後,作成したソースコードを実行する.

$ go run mysql_example.go
ID: 1, Name: Tom
ID: 2, Name: Bob
ID: 3, Name: Alice

INSERTしたデータが正しく取得できていそうである.
念のため,MySQLに入って確認してみる.

$ docker exec -it mysql mysql -u root -p
Enter password:

mysql> show databases;
+--------------------+
| Database           |
+--------------------+
| information_schema |
| mysql              |
| performance_schema |
| sys                |
| testdb             |
+--------------------+
5 rows in set (0.01 sec)

mysql> use testdb;
Database changed

mysql> show tables;
+------------------+
| Tables_in_testdb |
+------------------+
| test_tbl         |
+------------------+
1 row in set (0.00 sec)

mysql> select * from test_tbl;
+------+-------+
| id   | name  |
+------+-------+
|    1 | Tom   |
|    2 | Bob   |
|    3 | Alice |
+------+-------+
3 rows in set (0.00 sec)

上記の通り,正常にテーブルの作成とデータの追加が行えていることが確認できた.

解説

準備

Goの database/sql パッケージを使うと,色々なDBを扱うことができる.
しかし, database/sql パッケージとは別に,ここから使いたいDBのdriverを探してインストールしておく必要がある.
今回はMySQLを使いたいので,go-sql-driver/mysqlを利用した.
下記コマンドで go-sql-driver/mysql をインストールする.

$ go get -u github.com/go-sql-driver/mysql

データベースへの接続

データベースへ接続するには, sql.Open() メソッドを使用する.
第1引数に使用したいdriver名,第2引数に接続先を指定する.

db, err := sql.Open("mysql", "mysql:mypass@/testdb")

接続確認を行いたい場合は, sql.Open() の後に DB.Ping() メソッドを呼び出すことでできる.

if err = db.Ping(); err != nil {
    log.Fatalf("db.Ping(): %s\n", err)
}

SQLの実行

SQLの実行は, DB.Exec() 及び DB.Query() メソッドで行うことができる.
CREATE文やINSERT文など,DBからデータが返ってこないものに関しては DB.Exec() メソッドを用い,SELECT文などDBからデータを取得するのが目的であるものに関しては DB.Query() メソッドを用いる.

Exec()

_, err = db.Exec("create table test_tbl (id int, name varchar(32))")
if err != nil {
    log.Fatalf("db.Exec(): %s\n", err)
}

Query()

DBからの結果は sql.Rows に入っている.
Rows.Scan() メソッドで,1レコードの中から値(今回であればSELECT文での取得対象に * をしているため,全てのカラム = idname )を取得することができる.
Rows.Scan() メソッドの引数にはポインタを渡すことに注意する.

1レコード分の処理が終了し,次のレコードに移るためには Rows.Next() メソッドを呼び出す.

var rows *sql.Rows
rows, err = db.Query("select * from test_tbl")
if err != nil {
    log.Fatalf("db.Query(): %s\n", err)
}
defer rows.Close()

for rows.Next() {
    var (
        id   int
        name string
    )
    err = rows.Scan(&id, &name)
    if err != nil {
        log.Fatalf("rows.Scan(): %s\n", err)
    }

    fmt.Printf("ID: %d, Name: %s\n", id, name)
}
if err = rows.Err(); err != nil {
    log.Fatalf("rows.Err(): %s\n", err)
}

Go net package – Goでソケット通信

Goのnetパッケージについて軽く勉強した.
簡単なソケット通信についてまとめる.

ソケット通信

ソースコードは以下.

package main

import (
    "fmt"
    "log"
    "net"
)

func main() {
    host := "localhost"
    port := "8000"
    address := net.JoinHostPort(host, port)
    conn, err := net.Dial("tcp", address)
    if err != nil {
        log.Fatalf("net.Dial(): %s\n", err)
    }
    defer conn.Close()

    request := "GET / HTTP/1.1\n\n"
    fmt.Println([]byte(request))
    _, err = conn.Write([]byte(request))
    if err != nil {
        log.Fatalf("Conn.Write(): %s\n", err)
    }

    buffer := make([]byte, 1024)
    var n int
    for {
        n, err = conn.Read(buffer)
        if n == 0 {
            break
    }
    if err != nil {
        log.Fatalf("Conn.Read(): %s\n", err)
    }
    fmt.Print(string(buffer))
    }
}

以下に簡単な解説を.

Goでのソケット通信は, Conn オブジェクトを作成するところから始まる.
まず, net.Dial()Conn オブジェクトを作成する.

conn, err := net.Dial("tcp", address)

第1引数にはネットワークの種類を,第2引数には接続したい先のアドレスを指定する.
今回はサンプルとしてローカルに立てたHTTPサーバへ接続してみるので,ネットワークの種類は TCP を指定しておく.

Conn オブジェクトを作成できたら,後は Conn.Read()Conn.Write() でデータの読み書きができる.

_, err = conn.Write([]byte(request))
...
...
n, err = conn.Read(buffer)

簡単なソケット通信は以上の3つのメソッドを使うことで簡単にできる.

Pythonで簡単にローカルHTTPサーバをたて,作成したプログラムを実行してみる.

$ python -m http.server
$ go run main.go
HTTP/1.0 200 OK
Server: SimpleHTTP/0.6 Python/3.6.2
Date: Sun, 04 Feb 2018 12:47:14 GMT
Content-type: text/html; charset=utf-8
Content-Length: 336

<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN" "http://www.w3.org/TR/html4/strict.dtd">
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<title>Directory listing for /</title>
</head>
<body>
<h1>Directory listing for /</h1>
<hr>
<ul>
<li><a href="main.go">main.go</a></li>
</ul>
<hr>
</body>
</html>

JoinHostPort()/SplitHostPort()

上の例でも使っているが,これらのメソッドを使うことでアドレスとポート番号の結合・分離が簡単にできる.

address := net.JoinHostPort("localhost", "8000")
fmt.Println(address) // localhost:8000

host, port, err := net.SplitHostPort("localhost:3000")
if err != nil {
log.Fatalf("net.SplitHostPort(): %s\n", err)
}
fmt.Printf("Host: %s, Port: %s\n", host, port) // Host: localhost, Port: 3000

HTML等をGitHubへpushした時にWebサーバへ自動でデプロイする

HTMLなどの静的サイトをGitHubで管理していると,それを毎回手動でWebサーバへ反映させるのが面倒になってくる.

これを解決するために,GitHubとTravis CIを使って,GitHubのmasterブランチへpushした時に自動でWebサーバへデプロイする環境を作ったので,手順をまとめておく.

GitHubのリポジトリを準備

まずはHTML等を置いておくためのリポジトリをGitHubに作成しておく.

Travis CIとの連携

次に,Travis CIにGitHubのアカウントでログインする.

ログインしたら,右上のアカウント名の部分をから,「Accounts」へ移動する.すると,自分のGitHubリポジトリの一覧が表示される.
ここから,自動デプロイしたいリポジトリを探し出し,リポジトリ名の左側にある「×」をクリックして「◯」へ変更する.これでTravis CIとの連携設定が完了した.

サーバ側での設定

Webサーバ側で,自動デプロイ用に新しいユーザとグループを作成しておく.例ではCentOS7を使用している.

# useradd app
# passwd app
# groupadd deploy
# usermod -aG deploy app

ユーザとグループを作成し終えた後に,先程作成したユーザに切り替えてSSHのキーペアを作成する.この際, 作成するキーペアにパスフレーズを設定してはいけないことに注意する.ここでパスフレーズを設定してしまうと,Travis CIがWebサーバにデプロイしようとする際にパスフレーズ待ちで停止してしまうため,デプロイが完了しない.

# su - app
$ ssh-keygen
$ mv ~/.ssh/id_rsa.pub ~/.ssh/authorized_keys

SSHキーペアの作成が完了したら,自動デプロイしたいリポジトリをサーバ側にcloneする.clone先はDocumentRootなど,実際に本番で配置する場所にする.

$ git clone <repository url> /path/to/document/root

リポジトリのclone後,Travis側からのpushを許可するために以下の設定を行う.

$ git config --local receive.denyCurrentBranch updateInstead

上記の設定まで完了したら,cloneしたリポジトリのディレクトリ内に移動し,SSH秘密鍵の暗号化を行う.暗号化にはTravis CLIを使用するため,必要に応じてインストールをしておく.

$ cd /path/to/repository
$ touch .travis.yml     # .travis.ymlを作成しておく
$ gem install travis -v 1.8.8 --no-rdoc --no-ri
$ travis login --org    # GitHubのアカウントでログイン
$ travis encrypt-file /path/to/private/key --add

秘密鍵の暗号化が完了すると, <秘密鍵の名前>.enc というファイルが生成されるので,存在を確認しておく.
暗号化した秘密鍵は,リポジトリ内に .travis ディレクトリを作成してその中に入れておくと良い.

$ mkdir .travis
$ mv id_rsa.enc .travis

ここまでの作業が終了したら,再度Travis CIの画面に戻る.先程GitHubリポジトリとTravis CIの連携を行った画面で,リポジトリ名の左にある歯車マークをクリックして設定画面へ移る.

設定画面へ移動したら,Environment Variablesの部分までスクロールし, encrypted_ から始まる環境変数が2つ設定されていることを確認する.もしこれらの変数が存在していない場合は,Travis CLIでログインが正しく出来ていないか,先程の秘密鍵暗号化の手順が上手くいっていない可能性があるので再度やり直す.

自動デプロイ用に次の3つの環境変数を追加する.追加の際,「Display value in build log」はOFFにしておく.これがONになっていると,ビルドログ中に設定した環境変数の値が表示されてしまう.

  • IP : デプロイ先WebサーバのIPアドレス
  • PORT : デプロイ先Webサーバへpushする際のポート.SSHを使う場合は22を指定する.
  • DEPLOY_DIR : Webサーバ内でのリポジトリ配置先を指定(例: /var/www/html ).

ここまで完了すればTravis CI側での設定は以上となる.

最後に, .travis.yml とデプロイ用に使用するシェルスクリプトを用意してリポジトリ内に配置する.
下記に .travis.yml と デプロイ用シェルスクリプト( deploy.sh )の例を掲載しておく.
.travis.yml 内の $encrypted_xxxxxx_key$encrypted_xxxxxx_iv に関しては,各自Travis CIのEnvironment Variablesに設定されていたものに変更する.

.travis.yml

addons:
    ssh_known_hosts: $IP

before_install:
- openssl aes-256-cbc -K $encrypted_xxxxxx_key -iv $encrypted_xxxxxx_iv
    -in .travis/id_rsa.enc -out .travis/id_rsa -d

script: ""

after_success:
    - ssh-keyscan -t rsa $IP >> ~/.ssh/known_hosts
    - bash .travis/deploy.sh

deploy.sh

#!/bin/bash

eval "$(ssh-agent -s)"
chmod 600 .travis/id_rsa
ssh-add .travis/id_rsa

git config --global push.default matching
git remote add deploy ssh://git@$IP:$PORT$DEPLOY_DIR
git push deploy master

.travis.yml はリポジトリの最上位階層, deploy.sh.travis ディレクトリ内に配置した.
最終的なディレクトリ構造は次のようになる.

.
├── index.html
├── .travis.yml
└── .travis
     ├── deploy.sh
     └── id_rsa.enc

ここまで完了したら,後はリポジトリにpushするたびに自動でTravis CIが動作し,サーバへ自動で変更を反映してくれるはず.