SIerだけど技術やりたいブログ

6年目のSIerのブログです

L7ロードバランサとL4ロードバランサ

複数のバックエンドサーバがあるときに、リクエストを分散させるための仕組み。

f:id:kimulla:20180407182724j:plain

似たような負荷分散の仕組みとしてDNSラウンドロビンがある。
これはバックエンドサーバのIPアドレスDNSレコードとして登録し、DNSの問い合わせ時にIPアドレスを順繰りに返事することで負荷分散する。DNSの問い合わせ結果はクライアント側でキャッシュされることが多いため、負荷が偏る可能性がある。
またサーバダウン時に、DNS問い合わせ結果がキャッシュされたクライアントには、即座にサーバ切り替えを行うことができない。
また、クライアントごとに同一サーバに割り振るといったパーシステンス機能は実現できない。

上記を実現したいときに、ロードバランサが使われる。

L7ロードバランサ

  • L7レベルでロードバランシングする
  • HTTPの情報を用いた、高度な制御ができる
    • Cookieを利用したスティッキーセッション
    • X-Forwarded-Forヘッダー を利用したクライアントのIPアドレス保持
  • HTTP情報を取得するために、LBでTCPコネクションが終端する
    • クライアントとLB
    • LBとバックエンドサーバ
  • パケットの流れ

f:id:kimulla:20180407181622j:plain

L4ロードバランサ

  • L4以下のレベルでロードバランシングする
  • IPレベルでのセッションパーシステンスができる
    • 接続元IPが同じなら同じバックエンドサーバに送れる
  • IPアドレス変換方式(NAT方式) と MACアドレス変換方式(DSR方式) がある
  • ロードバランサでTCPコネクションは終端しない

IPアドレス変換方式(NAT方式)

  • NATを利用してL4レベルでロードバランシングする
  • NAT変換のために全ての通信がLBを経由する
  • パケットの流れ

f:id:kimulla:20180407182128j:plain

MACアドレス変換方式(DSR方式)

  • 宛先MACアドレスを差し替えてロードバランシングする
    • ロードバランサとバックエンドサーバは同一セグメントに置く必要がある
    • クライアントから見たときの接続先IPアドレスはLBのIPアドレスになる
    • バックエンドサーバから見たときの接続元IPアドレスはクライアントのIPアドレスになる
  • バックエンドサーバの戻りパケットが、ロードバランサを経由せずにクライアントと直接通信する
  • レスポンスの接続元IPアドレスをロードバランサに見せかける必要がある
  • パケットの流れ

f:id:kimulla:20180407235625j:plain

触ってみる

L7ロードバランサ

構成図は以下のとおり。

f:id:kimulla:20180407193008j:plain

今回はLBとして、nginx を利用する。

以下を参考に、nginxの設定をする。
Using nginx as HTTP load balancer

http {
 ...
  upstream myapp1 {
    server 10.133.1.2;
    server 10.133.1.3;
  }
 ...
  server {
    ...
    location / {
      proxy_pass http://myapp1;
    }
  }
  ...
}

次に、バックエンドサーバでhttpdを起動し、クライアントからHTTPリクエストを投げる。

LBのeth0(クライアント側NIC)のパケットをキャプチャする。

# tcpdump port 80 -i eth0 -w eth0.pcap

f:id:kimulla:20180407193805p:plain

LBのeth1(バックエンド側NIC)のパケットをキャプチャする。

# tcpdump port 80 -i eth1 -w eth1.pcap

f:id:kimulla:20180407193808p:plain

上記から、コネクションが2本張られていることがわかる。また、クライアントからLBにHTTPリクエストが送られた後に、LBからバックエンドサーバにTCPコネクションを張っているのがわかる。

クライアントの接続元IPアドレスをバックエンドサーバが知りたい場合は、X-Forwarded-Forヘッダーをnginx側で付与すればよい。

https://www.nginx.com/resources/wiki/start/topics/examples/forwarded/

以下のように、HTTPヘッダーに接続元IPアドレスが付与される。

#] tcpdump -A port 80 -i eth1
...
.*.{.).
GET / HTTP/1.0
X-Forwarded-for: 192.168.11.104
Host: myapp1
Connection: close
Cache-Control: max-age=0
...

IPアドレス変換方式(NAT方式)

構成図は以下のとおり。

f:id:kimulla:20180407193008j:plain

今回はLBとして、ipvsadm を利用する。
dsas.blog.klab.org

LBでipvsadmの設定をする。

# ipvsadm -C
# ipvsadm -A -t 192.168.11.107:80 -s lc
# ipvsadm -a -t 192.168.11.107:80 -r 10.133.1.2 -m
# ipvsadm -a -t 192.168.11.107:80 -r 10.133.1.3 -m
# ipvsadm -Ln
IP Virtual Server version 1.2.1 (size=4096)
Prot LocalAddress:Port Scheduler Flags
  -> RemoteAddress:Port           Forward Weight ActiveConn InActConn
TCP  192.168.11.107:80 lc
  -> 10.133.1.2:80                Masq    1      0          0
  -> 10.133.1.3:80                Masq    1      0          0

次に、バックエンドサーバでhttpdを起動し、クライアントからHTTPリクエストを投げる。

LBのeth0(クライアント側NIC)のパケットをキャプチャする。

# tcpdump port 80 -i eth0 -w eth0.pcap

f:id:kimulla:20180407200511p:plain

LBのeth1(バックエンド側NIC)のパケットをキャプチャする。

# tcpdump port 80 -i eth1 -w eth1.pcap

f:id:kimulla:20180407200508p:plain

上記から、LBで接続元IPや接続先IPの変換はするものの、TCPコネクションが1本しか張られていないことがわかる。(接続元のエフェメラルポート番号がそのまま接続先に渡されており、また、時刻から、LBはパケットを横流ししている感じになっている)

MACアドレス変換方式(DSR方式)

構成図は以下のとおり。
f:id:kimulla:20180407235653j:plain


今回はLBとして、ipvsadm を利用する。
CentOS 6.5 で LVS (IPVS) の Direct Server Return... | CUBE SUGAR STORAGE

LBでipvsadmの設定をする。

]# iptables -L
]# ipvsadm -A -t 192.168.11.107:80 -s lc
]# ipvsadm -a -t 192.168.11.107:80 -r 192.168.11.108:80 -g
]# ipvsadm -L
IP Virtual Server version 1.2.1 (size=4096)
Prot LocalAddress:Port Scheduler Flags
  -> RemoteAddress:Port           Forward Weight ActiveConn InActConn
TCP  192.168.11.107:http lc
  -> 192.168.11.108:http          Route   1      0          0

次に、バックエンドサーバでhttpdを起動し、クライアントからHTTPリクエストを投げる。

LBのパケットをキャプチャする。

# tcpdump port not 22 -w lb.pcap

戻りパケットがLBを経由しないため、Wiresharkがパケット欠けてると警告を出している。
f:id:kimulla:20180407234612p:plain

バックエンドサーバのパケットをキャプチャする。

# tcpdump port 80 -w backend.pcap

接続先IPアドレスがLBのIPアドレスになっている。また、接続元IPアドレスがクライアントのアドレスになっている。
f:id:kimulla:20180407234617p:plain

上記から、戻りパケットだけはLBを経由せずにクライアントにダイレクトに返っていることがわかる。まさにDirect Server Return…

Docker-Compose の変数定義について

検証したバージョン

$ docker -v
Docker version 17.06.1-ce, build 874a737
$ docker-compose -v
docker-compose version 1.16.1, build 6d1ac21

ファイル内の変数の扱い

環境変数は、docker-compose.yml内で参照できる。

version: "3"
services:
  app:
    image: "alpine:latest"
    command: echo ${MESSAGE}
$ export MESSAGE=hello
$ docker-compose up
Recreating compose_app_1 ... 
Recreating compose_app_1 ... done
Attaching to compose_app_1
app_1  | hello
compose_app_1 exited with code 0

変数の定義方法

.envファイル

docker-compose.yml 内で利用できる環境変数を定義する。
コンテナ内からは利用できない。

$ cat .env 
TAG=3.1
version: "3"
services:
  app:
    image: "alpine:${TAG}"
    command: uname -a

docker-compse.ymlのタグが変わっていることがわかる。

$ docker-compose up
Pulling app (alpine:3.1)...
3.1: Pulling from library/alpine
61aa778aed31: Pull complete
Digest: sha256:10de714727daa45047abdfb81c98dbf45e1cad3b590b5043d0da139bfeacebe5
Status: Downloaded newer image for alpine:3.1
Recreating compose_app_1 ... 
Recreating compose_app_1 ... done
Attaching to compose_app_1
app_1  | Linux 33b473091f0e 3.10.0-514.26.2.el7.x86_64 #1 SMP Tue Jul 4 15:04:05 UTC 2017 x86_64 Linux
compose_app_1 exited with code 0

environment

コンテナ内で利用できる環境変数を定義する。
docker-compose.ymlからは利用できない。

version: "3"
services:
  app:
    image: alpine:latest
    environment:
      - SAMPLE_USER=user
    command: env

実行すると、以下のようになる。

$ docker-compose up
Starting compose_app_1 ... 
Starting compose_app_1 ... done
Attaching to compose_app_1
app_1  | PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
app_1  | HOSTNAME=c0334b1fb759
app_1  | SAMPLE_USER=user
app_1  | HOME=/root
compose_app_1 exited with code 0

また、docker-compose.yml内で参照可能な変数は、そのままコンテナ内に引き継げる。

version: "3"
services:
  app:
    image: alpine:latest
    environment:
      - SAMPLE_USER
    command: env

env_file

コンテナ内で利用できる環境変数を、外部ファイルから読み込む。
docker-compose.ymlからは利用できない。

$ cat app.env 
SAMPLE_APP_VERSION=1.0.0
version: "3"
services:
  app:
    image: "alpine:latest"
    env_file:
      - app.env
    command: env
docker-compose up
Recreating compose_app_1 ... 
Recreating compose_app_1 ... done
Attaching to compose_app_1
app_1  | PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
app_1  | HOSTNAME=f18e8de13de7
app_1  | SAMPLE_APP_VERSION=1.0.0
app_1  | HOME=/root
compose_app_1 exited with code 0

docker-compose run -e XXX=YYY

コンテナ内で利用できる環境変数を定義する。
docker-compose.ymlからは利用できない。

version: "3"
services:
  app:
    image: "alpine:latest"
    command: env
$ docker-compose run -e SAMPLE_APP_VERSION=1 app
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
HOSTNAME=ddcc17d12cb3
TERM=xterm
SAMPLE_APP_VERSION=1
HOME=/root

変数の優先順位

コンテナ内の変数の優先順位は、リファレンスの通り。

1. Compose file,
2. Environment file,
3. Dockerfile,
4. Variable is not defined.

docker-compose.yml 内で参照するときの優先順位は 環境変数 > .envファイル の様子。

version: "3"
services:
  app:
    container_name: ${NAME}
    image: "alpine:latest"
    command: env
$ cat .env 
 NAME=hoge
$  export NAME=fuga; docker-compose up
 Starting fuga ... 
 Starting fuga ... done
 Attaching to fuga
 fuga   | PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
 fuga   | HOSTNAME=fe68b57dfc2c
 fuga   | HOME=/root
 fuga exited with code 0

動的な値をどう設定するか?

docker-compose.yml

docker-compose 実行前に、環境変数を設定する。

version: "3"
services:
  app:
    container_name: "app-${ID}"
    image: "alpine:latest"
    command: env
$ export ID=$(date  '+%Y%m%d')
$ docker-compose up
Creating app-20180311 ... 
Creating app-20180311 ... done
Attaching to app-20180311
app-20180311 | PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
app-20180311 | HOSTNAME=10df3a31c3ff
app-20180311 | HOME=/root
app-20180311 exited with code 0

コンテナ内

environmentで環境変数を指定しておき、

version: "3"
services:
  app:
    image: "alpine:latest"
    environment:
      - TIMESTAMP
    command: env

docker-compose 実行前に、環境変数を設定する。

$ export TIMESTAMP=$(date '+%Y%m%d')
$ docker-compose up
Recreating compose_app_1 ... 
Recreating compose_app_1 ... done
Attaching to compose_app_1
app_1  | PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
app_1  | HOSTNAME=4f2282348e06
app_1  | TIMESTAMP=20180311
app_1  | HOME=/root


もしくは、env_fileで読み込むファイルをdocker-compose実行前に作成する。

version: "3"
services:
  app:
    image: "alpine:latest"
    env_file:
      - app.env
    command: env
$ cat > app.env  << FIN
TIMESTAMP=$(date '+%Y%m%d')
FIN
$ docker-compose up
Starting compose_app_1 ... 
Starting compose_app_1 ... done
Attaching to compose_app_1
app_1  | PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
app_1  | HOSTNAME=4f2282348e06
app_1  | TIMESTAMP=20180311
app_1  | HOME=/root
compose_app_1 exited with code 0

これを利用してコンテナ内にgitのコミットハッシュやブランチ名を入れておけば、アプリのリビジョンがわかるようになる。

AWS Lambda と LINE Notify で毎朝のゴミ出しを促す

唐突ですが、我が家の課題に『ゴミ出しを忘れる』というのがあります。たまに、家に帰って扉開けたらやばいニオイがして、激しく後悔します。ということで、LINEへ毎朝のゴミ出しの通知をしてみました。

という建前の AWS Lambda と LINE Notify 触ってみた系の記事です。
結論を先に言うと、LINEに定期的に通知するだけのボットが完成します。

f:id:kimulla:20180312211458p:plain

AWS Lambda とは

AWSが提供するマネージドサービスの一つ。
AWS Lambda (サーバーレスでコードを実行・自動管理) | AWS

コードをアップロードさえすれば、インフラを管理することなくコードを実行できるのが利点。AWS Lambda上の様々なイベントを契機にできる。

  • S3のアップロード
  • CloudWatch Logsに書き込まれた特定ログ
  • Dynamo DBのデータ更新
  • CloudWatch Eventsによる定期イベント(cron的な)

LINE Notify とは

LINEのチャンネルにメッセージを通知できる連携サービス。

notify-bot.line.me

実装

こんなイメージで連携させる。

f:id:kimulla:20180312211311j:plain

LINE Notify でパーソナルアクセストークンを発行する

今回はパーソナルアクセストークンを利用する。
トークンを付けてリクエストすることで、チャンネルに任意のメッセージを送れる。

qiita.com

AWS Lambda で実行するコードを作成する

おおまかに以下の手順。

  • mavenで依存関係を追加する
  • ハンドラを実装する
  • fat jarを作る

AWS Toolkit for Eclipse』を入れると、コード作成が簡素化できるらしい。
が、今回はお試しなので、すべて自前で作る。

mavenで依存関係を追加する

awsのcoreライブラリを追加する。

    <dependency>
      <groupId>com.amazonaws</groupId>
      <artifactId>aws-lambda-java-core</artifactId>
      <version>1.2.0</version>
    </dependency>
ハンドラを実装する

以下を参考に、Javaのコードを書く。

Lambda 関数ハンドラー (Java) - AWS Lambda


package garbage.notification;

import ...

public class App implements RequestHandler<Integer, String> {
  private static final String ACCESS_TOKEN = "SECRET_ACCESS_TOKEN";
  private static final String API = "https://notify-api.line.me/api/notify";

  @Override
  public String handleRequest(Integer count, Context context) {
    Calendar cal = Calendar.getInstance(TimeZone.getTimeZone("Asia/Tokyo"));

    switch (cal.get(Calendar.DAY_OF_WEEK)) {
      case Calendar.SUNDAY:
        break;
      case Calendar.MONDAY:
        post("今日は生ごみの日");
        break;
      case Calendar.TUESDAY:
        post("今日は燃やさないゴミの日(第2,第4の週なら)");
        break;
      case Calendar.WEDNESDAY:
        post("今日は生ごみの日");
        break;
      case Calendar.THURSDAY:
        break;
      case Calendar.FRIDAY:
        post("今日は生ごみの日");
        break;
      case Calendar.SATURDAY:
        post("今日はプラスチックゴミの日");
        break;
    }
    return "success";
  }

RequestHandler<T1, T2> は AWS Lambdaのハンドラのインタフェース。

  • T1は 入力の型
    • AWSの各イベントに特化したクラスがある。例えばS3のバケット名やアップロードされたオブジェクト名を取得できるS3Eventクラスが存在する
    • aws-lambda-java-events の依存関係が必要
  • T2は 出力の型

T2 handleRequest(T1, Context) はハンドラメソッド。

  • 引数の Context には AWSの実行環境に関する情報が格納される
    • requestIDなど


今回は時刻による定期実行イベントのため、リクエストもレスポンスも適当なプリミティブ型を指定しておく。

次に、HTTPのPOST部分を作成する。
特に変わったところはないので説明は省略する。

  public static void post(String msg) {
    RestTemplate restTemplate = new RestTemplate(new SimpleClientHttpRequestFactory());

    try {
      MultiValueMap<String, String> params = new LinkedMultiValueMap<String, String>();
      params.add("message", msg);

      HttpHeaders headers = new HttpHeaders();
      headers.setContentType(new MediaType(MediaType.APPLICATION_FORM_URLENCODED, Charset.forName("utf-8")));
      headers.add("Authorization", "Bearer " + ACCESS_TOKEN);

      RequestEntity req = new RequestEntity(params, headers, HttpMethod.POST, new URI(API));
      restTemplate.exchange(req, String.class);
    } catch (URISyntaxException e) {
      e.printStackTrace();
    }
  }

}
fat jarを作る

以下を参考に、mavenを利用してfat jarを作る。
IDE なしで Maven を使用した .jar デプロイパッケージの作成 (Java) - AWS Lambda

AWSの公式では maven-shade-plugin が例に挙げられているけど maven-assembly-plugin でも動いた。classファイルがすべて含まれた fat jar 形式であることが重要なんでしょう。たぶん。

  <build>
    <plugins>
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-shade-plugin</artifactId>
        <version>2.3</version>
        <configuration>
          <createDependencyReducedPom>false</createDependencyReducedPom>
        </configuration>
        <executions>
          <execution>
            <phase>package</phase>
            <goals>
              <goal>shade</goal>
            </goals>
          </execution>
        </executions>
      </plugin>
    </plugins>
  </build>

https://aws.amazon.com/jp/lambda/pricing/

定期実行するイベントを作成する

CloudWatchサービスに移動し、時刻起動のイベントを作成する。
cron式は UTC 時間のため、 JST(日本時間) から -9 時間したものを指定する。

f:id:kimulla:20180312221344p:plain

AWS Lambda の設定をする

関数を作成する。

関数コードを設定する
  • 実行環境にjava8を指定する。
  • 作成した fat jar をアップロードする
  • ハンドラを指定する(インタフェースを継承したクラスのFQDNを指定する)
  • タイムアウト時間を延ばす

f:id:kimulla:20180312221456p:plain

CloudWatchEvents を指定する
  • 作成した定期実行イベントを指定する

f:id:kimulla:20180312221540p:plain

テストする

『テストイベントの設定』をクリックする。

f:id:kimulla:20180312221618p:plain

今回はリクエストの型をIntegerとしたため、数値を指定する。
実際にはアプリケーションコードで引数を使ってないので、意味はない。

f:id:kimulla:20180312214623p:plain

テストを実行すると、結果が表示される。

f:id:kimulla:20180312214733p:plain

また、LINEにも通知されている。

f:id:kimulla:20180312215516p:plain

CloudWatch Eventsの入力値を設定する

CloudWatchで作成したイベントの編集にうつり、ターゲット > 入力の設定 で入力値を設定する。

f:id:kimulla:20180313063717p:plain

お金の話

無料枠内で使える時間は、利用するメモリに依存するらしく、128MBであれば 3,200,000s が目安になる。ということは 3,200,000s / 15s = 213333 回は実行できるので、当分はお金面の心配はなさそう。

料金体系の詳細はリファレンスを参照してください。
料金 - AWS Lambda(サーバーレスでコードを実行)|AWS

感想

  • こんな簡単なコードでも、ビルド~アップロードが手間
  • メモリ使用量と実行時間に比例してお金がかかるようなので、Javaが向いてるのかはちょっと疑問
  • タイムアウト時間の上限が数分程度なので、バッチ処理をキックするような用途はたぶん間違い
  • (簡易的な自動化のためと割り切れば)Pythonのほうが AWS Lamdba のコンソールでコードを書ける分、ラクそう
  • 動き始めてからは実行環境を気にしなくていい点は素晴らしい
  • LINEに通知した位でゴミ出しができれば苦労はしない
  • ゴミ出しという行為そのものを無くしたい