LXDコンテナ内のDockerコンテナ内で動くnginx-proxyを二重NATで公開した(IPv4・IPv6対応)

2023年8月追記・今は無料で使えるCloudflare Tunnelを使うのがオススメ

3年前のこの記事、めちゃくちゃ面倒くさい設定をしていますが、確か2年以上前からCloudflare Tunnelを使ったローカルサーバーの公開が無料でできるようになっているので、多くの場合はそちらを使ったほうが良いと思われます。コンテナでも仮想マシンでもなんでもCloudflare経由で簡単に公開できますし、SSLサーバー証明書もCloudflare任せにできます。自宅サーバーを公開する場合でも、ルーターにポートフォワードを設定するといった面倒な作業は不要になります。インターネットにアクセスできる環境さえあれば公開できるので、おそらくVPN接続している環境や、スターリンクのような衛星回線経由でも大丈夫でしょう。Wordpressでサイトを公開するなら、「Super Page Cache for Cloudflare」を使えば無料プランでもHTMLまでキャッシュさせられるので、たとえ低速で不安定な回線でも、静的コンテンツメインのサイトなら安定して高速にリクエストに応答できます。

また、この記事ではnginx-proxyを使って自動的にLet’s encryptによるSSL証明書の取得やnginxの設定変更などの処理を行っていますが、個人的にはOpenLiteSpeedを用いたols-docker-envに切り替えていっています。

背景など

複数のDockerコンテナをLXDコンテナにまとめておけば、バックアップをとったり、別のサーバーにマイグレーションするのに便利です。詳しくは柴田さんが以下の記事で書かれています。

第459回 LXDを使ってDockerコンテナをマイグレーション | gihyo.jp
LXDとDockerは排他的な存在ではなく、用途にあわせて組み合わせて使うと便利なツールです。そこで今回はLXDで作った仮想環境上でDockerコンテナを動かす方法を紹介します。

LXD 3.7以降ならコンテナのIncremental Copyができるようになっているので、Dockerコンテナ群をLXDコンテナにまとめておけば、日々のバックアップも簡単になります。

しかし、外部からのアクセスをLXDとDockerのプロキシを通してDockerコンテナに転送すると、接続元のIPアドレスがDockerコンテナ内で取得できないという問題があります。コンテナ外にプロキシをたててログをとるというのも、個人で管理するには構成が複雑になりますし、ログが二重になってしまうので気が進みません。

そこで、まずはLXDのプロキシで「Proxy Protocol」を有効にして、Dockerコンテナで動くnginx-proxyでアクセス元のIPv4・IPv6アドレスを取得するように設定しました。メモ書きレベルですが、以下の記事に手順をまとめてあります。

LXC上のDockerコンテナ内のnginxでクライアントIPアドレスが取れなかったのでProxy Protocolを試したらうまくいった。でも結局使わなかった。
柴田さんがUbuntu Weekly RecipeでLXDの記事を何度も書いているのを見て、nginx-proxy環境をLXD上で動かしてみました。nginx-proxyは、他のDockerコンテナを認識して自動でリバースプロキシ設定をして...

この構成で機能的には満足したのですが、LXDプロキシとDockerプロキシという2つのユーザーランドプロキシを経由しているというのが、今一つ気に入りませんでした。そこで試行錯誤した結果、二重NATでDockerコンテナ上のnginxを外部に公開する方法に落ち着きました。

IPv4はともかく、IPv6でNATというのも微妙な感じはしますが、ルーターからIPv6アドレスを複数取得するなどの方法だと、ネットワーク環境に依存することになってしまいます。IPv6アドレスを1つしか取得できないVPSもあるようですし、「ホストサーバーにIPv4・IPv6アドレス1つずつあれば動く」ことを優先しました。そうすれば、大抵の環境にマイグレーションできるはずです。

なお、ホストサーバーとLXDコンテナのOSにはUbuntu 20.04 LTSを使っています。

図解

作業前の、LXD・Dockerプロキシを経由する方式を図にすると、以下のようになります。IPアドレスは例です。

図中の矢印はリクエストの流れです。レスポンスは省略してあります。IPv6はホストサーバー上で動く「lxd userland proxy」までで、そこから先はIPv4で転送されています。

LXDプロキシとDockerプロキシを廃し、NATに置き換えた後が以下の図です。

このように、IPv4パケットとIPv6パケットの両方が、DNATで宛先アドレスを2度書き換えられて、nginx-proxyが動くDockerコンテナまで到達します。

LXD/LXCの設定

以下の記事のプロファイルで作成した、Dockerが動作しているLXDコンテナの設定を変更していきます。

Dockerが動くLXCコンテナのプロファイル
しばらく前にDockerが動くLXCコンテナのプロファイルを書いたので、貼り付けます。Ubuntu 20.04用です。18.04などでも動くと思います。 storage backendはzfsでしか試していません。 cloud-conf...

まず、追加してあったLXDプロキシデバイスを削除しました。「docker-host」は内部でDockerが動作しているLXCコンテナのホスト名です。

lxc config device remove docker-host http
lxc config device remove docker-host https

LXDコンテナに固定IPアドレスを設定しました。アドレスは上の図の例にあわせてあります。実際にはlxdbr0のネットワーク範囲で指定する必要があります。

lxc network attach lxdbr0 docker-host eth0 eth0
lxc config device set docker-host eth0 ipv4.address 10.0.0.2
lxc network set lxdbr0 ipv6.dhcp.stateful true
lxc config device set docker-host eth0 ipv6.address fd00:1::2
lxc restart docker-host

以下のような設定になりました。

$ lxc config device show docker-host 
eth0:
  ipv4.address: 10.0.0.2
  ipv6.address: fd00:1::2
  name: eth0
  nictype: bridged
  parent: lxdbr0
  type: nic

加えて、以下の設定でコンテナに設定するDNSサーバーのIPv6アドレスを指定しておきました。私の環境では、このようにしないとリンクローカルアドレスも追加され、nginx-proxyの起動時にエラーが発生することがあったため、ユニークローカルアドレスだけに限定しています。

$ echo -e "dhcp-option=option6:dns-server,[fd00:1::1]" | lxc network set lxdbr0 raw.dnsmasq -
$ lxc network show lxdbr0 
config:
  ipv4.address: 10.0.0.1/24
  ipv4.nat: "true"
  ipv6.address: fd00:1::1/64
  ipv6.dhcp.stateful: "true"
  ipv6.nat: "true"
  raw.dnsmasq: |
    dhcp-option=option6:dns-server,[fd00:1::1]
description: ""
name: lxdbr0
type: bridge
used_by:
- /1.0/instances/docker-host
managed: true
status: Created
locations:
- none

UFWでDNAT設定

物理ホストの80番ポートと443番ポートへのパケットの宛先アドレスを書き換えて、LXDコンテナ(この例では「docker-host」)に送る設定を行いました。UbuntuなのでUFWの設定ファイルに追記します。

まず、「/etc/ufw/before.rules」の先頭に、以下を追記しました。「enp0s0」は、実際のインタフェース名にあわせて変える必要があります。

*nat
:PREROUTING ACCEPT [0:0]
-F
-A PREROUTING -i enp0s0 -p tcp --dport 80 -j DNAT --to 10.0.0.2:80
-A PREROUTING -i enp0s0 -p tcp --dport 443 -j DNAT --to 10.0.0.2:443
COMMIT

「/etc/ufw/before6.rules」の先頭に以下を追記しました。こちらはIPv6の設定です。

*nat
:PREROUTING ACCEPT [0:0]
-F
-A PREROUTING -i enp0s0 -p tcp --dport 80 -j DNAT --to [fd00:1::2]:80
-A PREROUTING -i enp0s0 -p tcp --dport 443 -j DNAT --to [fd00:1::2]:443
COMMIT

UFWを有効にしました。HTTPとHTTPSに加えてSSHも許可しています。

$ sudo ufw allow 80/tcp
ルールをアップデートしました
ルールをアップデートしました(v6)
$ sudo ufw allow 443/tcp
ルールをアップデートしました
ルールをアップデートしました(v6)
$ sudo ufw allow OpenSSH
ルールをアップデートしました
ルールをアップデートしました(v6)
$ sudo ufw enable
ファイアウォールはアクティブかつシステムの起動時に有効化されます。

LXDが自動で追加するiptablesのルールも有効にするため、一度以下のコマンドで再起動しました。

sudo systemctl restart snap.lxd.daemon.service

これで、以下のようにDNATが設定されました。コンテナ内部から外部へのIP Masqueradeは、LXDが自動で設定してくれます。

$ sudo iptables -t nat -L -n -v
Chain PREROUTING (policy ACCEPT 0 packets, 0 bytes)
 pkts bytes target     prot opt in     out     source               destination         
    0     0 DNAT       tcp  --  enp0s0 *       0.0.0.0/0            0.0.0.0/0            tcp dpt:80 to:10.0.0.2:80
    0     0 DNAT       tcp  --  enp0s0 *       0.0.0.0/0            0.0.0.0/0            tcp dpt:443 to:10.0.0.2:443

Chain INPUT (policy ACCEPT 0 packets, 0 bytes)
 pkts bytes target     prot opt in     out     source               destination         

Chain OUTPUT (policy ACCEPT 10 packets, 778 bytes)
 pkts bytes target     prot opt in     out     source               destination         

Chain POSTROUTING (policy ACCEPT 9 packets, 738 bytes)
 pkts bytes target     prot opt in     out     source               destination         
    1    40 MASQUERADE  all  --  *      *       10.0.0.0/24       !10.0.0.0/24        /* generated for LXD network lxdbr0 */
$ sudo ip6tables -t nat -L -n -v
Chain PREROUTING (policy ACCEPT 4 packets, 420 bytes)
 pkts bytes target     prot opt in     out     source               destination         
    0     0 DNAT       tcp      enp0s0 *       ::/0                 ::/0                 tcp dpt:80 to:[fd00:1::2]:80
    0     0 DNAT       tcp      enp0s0 *       ::/0                 ::/0                 tcp dpt:443 to:[fd00:1::2]:443

Chain INPUT (policy ACCEPT 4 packets, 420 bytes)
 pkts bytes target     prot opt in     out     source               destination         

Chain OUTPUT (policy ACCEPT 12 packets, 1252 bytes)
 pkts bytes target     prot opt in     out     source               destination         

Chain POSTROUTING (policy ACCEPT 12 packets, 1252 bytes)
 pkts bytes target     prot opt in     out     source               destination         
    0     0 MASQUERADE  all      *      *       fd00:1::/64 !fd00:1::/64  /* generated for LXD network lxdbr0 */

Dockerの設定

LXDコンテナ内でDockerの設定を変更しました。Dockerの初期設定では、コンテナのポートをpublishすると、中継のためにdocker-proxyプロセスが起動します。以下は、nginx-proxyコンテナを起動した場合の例です。

$ ps ax|grep docker-proxy|grep -v grep
    508 ?        Sl     0:00 /usr/bin/docker-proxy -proto tcp -host-ip 0.0.0.0 -host-port 443 -container-ip 172.18.0.2 -container-port 443
    523 ?        Sl     0:00 /usr/bin/docker-proxy -proto tcp -host-ip 0.0.0.0 -host-port 80 -container-ip 172.18.0.2 -container-port 80

docker-proxyではなくDNATによる中継に切り替えるために、「/etc/docker/daemon.json」を以下の内容で作成しました。

{
  "userland-proxy": false,
  "ipv6": true,
  "fixed-cidr-v6": "fd00:3::1/64"
}

「fixed-cidr-v6」を設定しておけば、「docker0」に設定されるIPv6アドレスを固定できます。

以下のコマンドでDockerをリスタートしました。

sudo systemctl restart docker.service

これでIPv4については自動でNATが設定されるのですが、IPv6は設定されません。そこで、IPv6のNAT設定を自動で行うDockerイメージ「ipv6nat」を利用しました。

以下の内容で「~/dock/ipv6nat/docker-compose.yml」を作成し、「docker-compose up -d」を実行しました。

version: '3.8'

services:
  driver:
    container_name: ipv6nat
    image: robbertkl/ipv6nat
    restart: always
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
    privileged: true
    network_mode: "host"

「nginx-proxy」コンテナは、Proxy Protocol対応のものが動いていましたが、一度削除して作り直しました。

まず、Dockerのネットワークデバイスを以下のコマンドで作り直しました。

docker network create --ipv6 --driver=bridge \
  --subnet=fd00:2::/64 \
  --opt "com.docker.network.bridge.name"="br-nginx-proxy" \
  nginx-proxy-net

これで、以下のようにネットワークが設定されました。

$ docker network ls
NETWORK ID          NAME                DRIVER              SCOPE
409399cd21c3        bridge              bridge              local
18091209ae37        host                host                local
374c37aecd65        nginx-proxy-net     bridge              local
e727532b76fe        none                null                local
$ ip a show dev br-nginx-proxy
14: br-nginx-proxy: <NO-CARRIER,BROADCAST,MULTICAST,UP> mtu 1500 qdisc noqueue state DOWN group default 
    link/ether 02:42:8d:c6:ff:6c brd ff:ff:ff:ff:ff:ff
    inet 172.18.0.1/16 brd 172.18.255.255 scope global br-nginx-proxy
       valid_lft forever preferred_lft forever
    inet6 fd00:2::1/64 scope global tentative 
       valid_lft forever preferred_lft forever
    inet6 fe80::1/64 scope link tentative 
       valid_lft forever preferred_lft forever

nginx-proxyコンテナは、以下の「~/dock/nginx-proxy/docker-compose.yml」を作成して「docker-compose up -d」で起動しました。

version: '3.8'

services:
  nginx-proxy:
    container_name: nginx-proxy
    image: jwilder/nginx-proxy
    restart: always
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./_data/nginx/conf.d:/etc/nginx/conf.d
      - ./_data/nginx/dhparam:/etc/nginx/dhparam
      - ./_data/nginx/certs:/etc/nginx/certs:ro
      - ./_data/nginx/vhost.d:/etc/nginx/vhost.d
      - ./_data/nginx/html:/usr/share/nginx/html
      - /var/run/docker.sock:/tmp/docker.sock:ro
    environment:
      - ENABLE_IPV6=true

  letsencrypt-companion:
    container_name: letsencrypt-companion
    image: jrcs/letsencrypt-nginx-proxy-companion
    restart: always
    volumes:
      - ./_data/nginx/certs:/etc/nginx/certs
      - ./_data/nginx/vhost.d:/etc/nginx/vhost.d
      - ./_data/nginx/html:/usr/share/nginx/html
      - /var/run/docker.sock:/var/run/docker.sock:ro
    environment:
      - NGINX_PROXY_CONTAINER=nginx-proxy
    depends_on:
      - nginx-proxy

  whoami:
    container_name: whoami
    image: jwilder/whoami
    restart: always
    environment:
      - VIRTUAL_HOST=whoami.local
    depends_on:
      - nginx-proxy

networks:
  default:
    external:
     name: nginx-proxy-net

これで、コンテナが以下のように起動しました。

$ docker ps 
CONTAINER ID        IMAGE                                    COMMAND                  CREATED              STATUS              PORTS                                      NAMES
5181214ae605        jwilder/whoami                           "/app/http"              36 seconds ago       Up 30 seconds       8000/tcp                                   whoami
da97cd17d1d2        jrcs/letsencrypt-nginx-proxy-companion   "/bin/bash /app/entr…"   36 seconds ago       Up 11 seconds                                                  letsencrypt-companion
e63519b08880        jwilder/nginx-proxy                      "/app/docker-entrypo…"   58 seconds ago       Up 36 seconds       0.0.0.0:80->80/tcp, 0.0.0.0:443->443/tcp   nginx-proxy
361f215bff56        robbertkl/ipv6nat                        "/docker-ipv6nat-com…"   About a minute ago   Up About a minute                                              ipv6nat

なお「whoami」コンテナは動作確認用のバックエンドコンテナです。動作確認が終わったら削除するつもりです。

IPv4のNATは以下のように設定されました。

$ sudo iptables -t nat -L -n -v
Chain PREROUTING (policy ACCEPT 0 packets, 0 bytes)
 pkts bytes target     prot opt in     out     source               destination         
    1    60 DOCKER     all  --  *      *       0.0.0.0/0            0.0.0.0/0            ADDRTYPE match dst-type LOCAL

Chain INPUT (policy ACCEPT 0 packets, 0 bytes)
 pkts bytes target     prot opt in     out     source               destination         

Chain OUTPUT (policy ACCEPT 4 packets, 264 bytes)
 pkts bytes target     prot opt in     out     source               destination         
   80  5821 DOCKER     all  --  *      *       0.0.0.0/0            0.0.0.0/0            ADDRTYPE match dst-type LOCAL

Chain POSTROUTING (policy ACCEPT 4 packets, 264 bytes)
 pkts bytes target     prot opt in     out     source               destination         
    1    40 MASQUERADE  all  --  *      br-nginx-proxy  0.0.0.0/0            0.0.0.0/0            ADDRTYPE match src-type LOCAL
    0     0 MASQUERADE  all  --  *      !br-nginx-proxy  172.18.0.0/16        0.0.0.0/0           
    0     0 MASQUERADE  all  --  *      docker0  0.0.0.0/0            0.0.0.0/0            ADDRTYPE match src-type LOCAL
    0     0 MASQUERADE  all  --  *      !docker0  172.17.0.0/16        0.0.0.0/0           
    0     0 MASQUERADE  tcp  --  *      *       172.18.0.2           172.18.0.2           tcp dpt:443
    0     0 MASQUERADE  tcp  --  *      *       172.18.0.2           172.18.0.2           tcp dpt:80

Chain DOCKER (2 references)
 pkts bytes target     prot opt in     out     source               destination         
    0     0 DNAT       tcp  --  *      *       0.0.0.0/0            0.0.0.0/0            tcp dpt:443 to:172.18.0.2:443
    0     0 DNAT       tcp  --  *      *       0.0.0.0/0            0.0.0.0/0            tcp dpt:80 to:172.18.0.2:80

IPv6のNATは以下のように設定されました。これはipv6natコンテナによって設定されています。

$ sudo ip6tables -t nat -L -n -v
Chain PREROUTING (policy ACCEPT 0 packets, 0 bytes)
 pkts bytes target     prot opt in     out     source               destination         
    0     0 DOCKER     all      *      *       ::/0                 ::/0                 ADDRTYPE match dst-type LOCAL

Chain INPUT (policy ACCEPT 0 packets, 0 bytes)
 pkts bytes target     prot opt in     out     source               destination         

Chain OUTPUT (policy ACCEPT 0 packets, 0 bytes)
 pkts bytes target     prot opt in     out     source               destination         
    0     0 DOCKER     all      *      *       ::/0                 ::/0                 ADDRTYPE match dst-type LOCAL

Chain POSTROUTING (policy ACCEPT 0 packets, 0 bytes)
 pkts bytes target     prot opt in     out     source               destination         
    0     0 MASQUERADE  all      *      br-nginx-proxy  ::/0                 ::/0                 ADDRTYPE match dst-type LOCAL
    0     0 MASQUERADE  all      *      !br-nginx-proxy  fd00:2::/64          ::/0                
    0     0 MASQUERADE  all      *      docker0  ::/0                 ::/0                 ADDRTYPE match dst-type LOCAL
    0     0 MASQUERADE  all      *      !docker0  fd00:3::/64          ::/0                
    0     0 MASQUERADE  tcp      *      *       fd00:2::2            fd00:2::2            tcp dpt:443
    0     0 MASQUERADE  tcp      *      *       fd00:2::2            fd00:2::2            tcp dpt:80

Chain DOCKER (2 references)
 pkts bytes target     prot opt in     out     source               destination         
    0     0 DNAT       tcp      *      *       ::/0                 ::/0                 tcp dpt:443 to:[fd00:2::2]:443
    0     0 DNAT       tcp      *      *       ::/0                 ::/0                 tcp dpt:80 to:[fd00:2::2]:80

動作確認

この状態でLXDコンテナ(この例ではホスト名「docker-host」)で以下のコマンドを実行し、Dockerコンテナ(コンテナ名:nginx-proxy)へのDNATが機能していることが確認できました。

$ curl --dump-head - -H "Host: whoami.local" http://127.0.0.1
HTTP/1.1 200 OK
Server: nginx/1.17.6
Date: Sat, 29 Aug 2020 15:50:23 GMT
Content-Type: text/plain; charset=utf-8
Content-Length: 17
Connection: keep-alive

I'm 279c0b5629a4

また、他のPCで以下のコマンドを実行し、HTTPの二重DNATが機能していることを確認しました。IPアドレスは上の図にあわせた例です。

$ curl --dump-header - -H "Host: whoami.local" http://203.0.113.1
HTTP/1.1 200 OK
Server: nginx/1.17.6
Date: Sat, 29 Aug 2020 15:55:53 GMT
Content-Type: text/plain; charset=utf-8
Content-Length: 17
Connection: keep-alive

I'm 279c0b5629a4

IPv6アドレスでも、以下のようにうまくいきました。

$ curl --dump-header - -H "Host: whoami.local" http://[2001:db8::1]
HTTP/1.1 200 OK
Server: nginx/1.17.6
Date: Sat, 29 Aug 2020 16:01:12 GMT
Content-Type: text/plain; charset=utf-8
Content-Length: 17
Connection: keep-alive

I'm 279c0b5629a4

HTTPSについては、自己署名証明書しか設定していないので、nginx-proxyがレスポンスコード500を返してきますが、二重DNATが動作していることは確認できます。

$ curl --dump-header - --insecure -H "Host: whoami.local" https://203.0.113.1
HTTP/2 500 
server: nginx/1.17.6
date: Sat, 29 Aug 2020 16:05:33 GMT
content-type: text/html
content-length: 177

<html>
<head><title>500 Internal Server Error</title></head>
<body>
<center><h1>500 Internal Server Error</h1></center>
<hr><center>nginx/1.17.6</center>
</body>
</html>
$ curl --dump-header - --insecure -H "Host: whoami.local" https://[2001:db8::1]
HTTP/2 500 
server: nginx/1.17.6
date: Sat, 29 Aug 2020 16:07:11 GMT
content-type: text/html
content-length: 177

<html>
<head><title>500 Internal Server Error</title></head>
<body>
<center><h1>500 Internal Server Error</h1></center>
<hr><center>nginx/1.17.6</center>
</body>
</html>

LXDコンテナで「docker logs nginx-proxy」を実行して、以下のようにアクセス元のアドレスが記録されていることが確認できました。

nginx.1    | whoami.local 203.0.113.2 - - [29/Aug/2020:15:55:53 +0000] "GET / HTTP/1.1" 200 17 "-" "curl/7.58.0"
nginx.1    | whoami.local 2001:db8::2 - - [29/Aug/2020:16:01:12 +0000] "GET / HTTP/1.1" 200 17 "-" "curl/7.58.0"
nginx.1    | whoami.local 203.0.113.2 - - [29/Aug/2020:16:05:33 +0000] "GET / HTTP/2.0" 500 177 "-" "curl/7.58.0"
nginx.1    | whoami.local 2001:db8::2 - - [29/Aug/2020:16:07:11 +0000] "GET / HTTP/2.0" 500 177 "-" "curl/7.58.0"

これで、「Dockerコンテナのログにアクセス元のIPアドレスを記録する」「ユーザーランドプロキシを使わない」「サーバーのネットワーク環境に極力依存しない」「Dockerコンテナ群をLXDコンテナに入れてバックアップやマイグレーションを簡単にする」といった目標が達成できました。

あとは、DNSの設定さえしておけば、Webアプリのコンテナを追加するだけで、自動でHTTPSかつIPv4もしくはIPv6で外部からアクセス可能になります。

コメント

タイトルとURLをコピーしました