2018年12月18日 星期二

Docker Compose 初步閱讀與學習記錄

上次在看 Dockerfile 時,有注意到一個工具,Docker Compose。透過這個工具,可以先寫一份檔案,預先定義好多個 service,然後透過單一命令來啟動多個 container 執行你定義的 service,讓他們組成一個你想要的應用服務。直覺這是個 docker run 命令的進階工具,值得看看,因此就花點時間閱讀了官方文件,以下是一些簡單的學習記錄。

什麼是 Docker Compose

Docker Compose 官方說明文件 Overview of Docker Compose 已經有些簡介,以下為官方文件的部分翻譯:

Compose 是一個工具,用來定義與執行多個 container 組成的 Docker Applications。你可以使用 Compose 檔案來組態設定你的應用服務。然後使用單一命令,透過你的組態設定來建立與啟動你的服務。

Compose 適合用來開發、測試、與建立 staging 環境,如同 CI workflows。

使用 Compose 有基本的三個處理步驟:

  1. 使用 Dockerfile 定義你的 app 環境,讓它可以在任何地方都能複製(reproduced)。
  2. 使用 docker-compose.yml 定義你的服務,讓他們可以在獨立環境內一起執行。
  3. 最後,執行 docker-compose up,Compose 將會開始與執行你所有的 app。

docker-compose.yml 檔案看起來像這樣

  version: '2'
  services:
    web:
      build: .
      ports:
      - "5000:5000"
      volumes:
      - .:/code
      - logvolume01:/var/log
      links:
      - redis
    redis:
      image: redis
  volumes:
    logvolume01: {}

docker-compose.yml 也就是組態設定文件,是一種 yaml 格式撰寫的文件,可以上維基看一下 YAML 格式說明,比較需要注意的是他的縮排規定要空白鍵,而不是 tab。

安裝

如果你和我一樣是 Mac 的使用者,在安裝 Docker for Mac 時,就會安裝 docker-compose 工具。

若是其他作業系統使用者,可以到 github - docker/compose 查看最新版本的 docker-compose 與如何下載安裝。

Compose file version 3

目前最新的 Compose file 為版本 3,此文件會分成三大組態設定,分別為 services、networks 以及 volumes:

  • services (top-level)

    services 主要讓你定義你應用服務啟動時,用來執行的 container 相關資訊,比如說 image 是用哪個、是不是要透過 Dockerfile 先進行編譯、要不要覆寫預設 command 或是 entrypoint、環境變數為何、port 導出與對應等等的,這些參數多半在 docker run 指令有相對應的參數。可以參照著看。

    service 雖然提供許多組態設定,但是如果你已經知道使用的 image 或是你自己寫的 Dockerfile 原本有寫了,就不用在這邊在寫一次,比如說像等等範例會用到的 wordpress image,他預設就有定義 entrypoint,這時你就不用在這邊重新定義一次。

    以下簡介一些 services 底下的組態設定:

    • build

      如果你的 service 要使用 Dockerfile 來建立,你可以透過此設定,指定你 Dockerfile build context 在哪,如果你 build 和 image 兩個設定一起使用,則透過 Dockerfile 建立起來的 image 會被命名成你在 image 設定寫的名稱。需要注意的是,要透過 docker stack 指令部署到 swarm 上時,這設定會失效,因為 docker stack 只接受預先建好的 image。

    • deploy

      只有在 Compose file 版本 3 才能使用。此設定只有在使用 docker stack deploy 指令將應用服務部署到 swarm 上才會有作用,在 docker-compose up、docker-compose down 會被忽略。

    • depends_on

      可以定義 service 之間的相依性,比如說官方範例:

      version: '2'
      services:
        web:
          build: .
          depends_on:
            - db
            - redis
        redis:
          image: redis
        db:
          image: postgres

      當下 docker-compose up 指令時,db service 和 redis service 會先啟動,然後在啟動 web service。

    • environment

      定義環境變數,注意,如果值是布林,要用單引號包起來,如 'true'、'false'。否則 YML 解析器會把它變成 True 或是 False。以下為官方範例:

      environment:
        RACK_ENV: development
        SHOW: 'true'
        SESSION_SECRET:
      
      environment:
        - RACK_ENV=development
        - SHOW=true
        - SESSION_SECRET
    • image

      指定 container 要從哪個 image 啟動。以下為官方範例:

      image: redis
      image: ubuntu:14.04
      image: tutum/influxdb
      image: example-registry.com:4000/postgresql
      image: a4bc65fd
    • networks

      container 要加入哪個網路,這邊的項目會參考到最外層 networks 的設定。

    • ports

      讓你指定要導出的 port,可以是 HOST:CONTAINER,或是只指定 CONTAINER,這時會隨機挑一個 HOST Port 來用。注意,port 指定最好都用字串,因為 YAML 解析器在你挑的 port 小於 60 時會出問題。以下為官方範例:

      ports:
       - "3000"
       - "3000-3005"
       - "8000:8000"
       - "9090-9091:8080-8081"
       - "49100:22"
       - "127.0.0.1:8001:8001"
       - "127.0.0.1:5000-5010:5000-5010"
       - "6060:6060/udp"
    • volumes, volume_driver

      設定 container 要使用的 volume,可以是一個路徑或是參考到最外層 volume 的設定,格式為 HOST:CONTAINER,你也可以只寫 CONTAINER,讓 Docker 自動幫你建立一個。參考官方範例:

      volumes:
        # Just specify a path and let the Engine create a volume
        - /var/lib/mysql
      
        # Specify an absolute path mapping
        - /opt/data:/var/lib/mysql
      
        # Path on the host, relative to the Compose file
        - ./cache:/tmp/cache
      
        # User-relative path
        - ~/configs:/etc/configs/:ro
      
        # Named volume
        - datavolume:/var/lib/mysql
  • networks (top-level)

    networks 讓你設定網路。類似 docker network 指令。可以和 servcies 區塊內的 network 搭配使用,例如:

    version: '3'
    
    services:
      t1:
        image: tomcat:8.5.11-jre8
        networks:
          - test-net
      t2:
        image: tomcat:8.5.11-jre8
        networks:
          - test-net
    networks:
      test-net:
  • volumes (top-level)

    volumes 讓你處理資料共享與資料持久(persist)。類似 docker volume 指令。可以和 servcies 區塊內的 volume 搭配使用,比如說像官方範例:

    version: "3"
    
    services:
      db:
        image: db
        volumes:
          - data-volume:/var/lib/db
      backup:
        image: backup-service
        volumes:
          - data-volume:/var/lib/backup/data
    
    volumes:
      data-volume:

特別要注意的地方是,雖然官方說 Compose file 分成三大組態設定,但是其實最上面還有一個 version 設定,這是一定要加的,不然在執行 docker-compose up 時會一直出現無法理解的錯誤。例如:

version: "3"
services:
  foobar:
    image: tomcat:8.5.11-jre8
    networks:
      - foobar-nets
volumes:
  foobar-volume:
networks:
  foobar-nets:

更詳細的 Compose file 組態設定介紹建議可以參考官方網站 Compose file version 3 reference

Sample

推薦官方 WordPress 範例 Quickstart: Compose and WordPress 。雖然他是版本 2 的文件,但是裡面其實已經包含了 docker-compose 的精神,使用單一文件定義服務,之後透過一個指令啟動服務。

首先開啟終端機,在自己本機上建立一個目錄,用來放置 docker-compose.yml 檔案 例如:

# cd /tmp
# mkdir -p docker/compose_wordpress
# cd docker/compose_wordpress
# pwd
/tmp/docker/compose_wordpress 

接著建立 docker-compose.yml

# vi docker-compose.yml

以下為官方網頁上的 docker-compose.yml 檔案內容:

version: '2'

services:
   db:
     image: mysql:5.7
     volumes:
       - db_data:/var/lib/mysql
     restart: always
     environment:
       MYSQL_ROOT_PASSWORD: wordpress
       MYSQL_DATABASE: wordpress
       MYSQL_USER: wordpress
       MYSQL_PASSWORD: wordpress

   wordpress:
     depends_on:
       - db
     image: wordpress:latest
     ports:
       - "8000:80"
     restart: always
     environment:
       WORDPRESS_DB_HOST: db:3306
       WORDPRESS_DB_PASSWORD: wordpress
volumes:
    db_data:

此範例定義從 services 區塊來看,定義了兩個服務,分別為 db 與 wordpress。

db service 指定 image 為 mysql:5.7,然後使用 volume db_data 對應到 container 的目錄 /var/lib/mysql,設定 volume 是因為當 docker-compose down 指令執行時,會將 container 停止並移除,所以資料是會完全消失的,必須把 MySQL 的儲存資料透過 volume 永久保存。然後他設定 restart 為 always。接著他設定 MySQL 預設環境變數,這部分可以參考 Docker Store - MySQL,這邊可以找到 MySQL 官方 image 環境變數的說明。

接著是 wordpress service,他先寫了 depends_on db 這組態設定,意思是 wordpress service 和 db service 有相依性的關係,因此在下 docker-compose up 指令時,會先啟動 db service 的 container,接著才是 wordpress service 的 container。container 使用的 image 為 wordpress:latest image,然後將 port 導到本機的 8000 port,restart 策略為 always,最後在設定環境變數。

docker-compose.yml 檔案準備好之後,在終端機輸入:

# docker-compose up -d
Starting composewordpress_db_1
Starting composewordpress_wordpress_1
Creating network "composewordpress_default" with the default driver
Creating volume "composewordpress_db_data" with default driver
Pulling db (mysql:5.7)...
5.7: Pulling from library/mysql
....
Pulling wordpress (wordpress:latest)...
latest: Pulling from library/wordpress
....
Creating composewordpress_db_1
Creating composewordpress_wordpress_1

會看到首先 docker 會先建立所需的 volume 與一個全新的 network 讓你的應用服務使用,接著會幫你先確認你定義的 service 所需的 image 是否有抓下來了,最後會建立 container 來執行你定義的 service。接著你就可以在你的瀏覽器上輸入網址,連到 WordPress 的管理頁面了:

http://your-docker-host-ip:8000

當你下 docker-compose down 時,你會看到兩個 container 被停止,然後被刪除,建立的 network 也會被刪除。可以用 docker ps -a 驗證一下。

# docker-compose down
Stopping composewordpress_wordpress_1 ... done
Stopping composewordpress_db_1 ... done
Removing composewordpress_wordpress_1 ... done
Removing composewordpress_db_1 ... done
Removing network composewordpress_default

如果你再一次使用 docker-compose up 命令,Docker 又會把所需的 network 以及兩個 container 建立起來並執行,由於 MySQL 有使用 volume 的關係,因此你可以打開 browser 確認一下,資料都還在,不需在重新設定。

# docker-compose up -d
Creating network "composewordpress_default" with the default driver
Creating composewordpress_db_1
Creating composewordpress_wordpress_1

這兩個範例可以改成手動用 docker run 執行,docker-compose up -d 會變成:

# docker network create wordpress
# docker run -d -v db_data:/var/lib/mysql --name sample_mysql --network wordpress --restart always -e MYSQL_ROOT_PASSWORD=wordpress -e MYSQL_DATABASE=wordpress -e MYSQL_USER=wordpress -e MYSQL_PASSWORD=wordpress mysql:5.7
# docker run -d -p 8000:80 --name sample_wordpress --network wordpress --restart always -e WORDPRESS_DB_HOST=sample_mysql:3306 -e WORDPRESS_DB_PASSWORD=wordpress wordpress:latest

要注意要先建立一個 network,讓兩個 container 在同一個 network 下執行,wordpress 才能使用 mysql 的 container name 找到 DB。

而 docker-compose down 會變成:

# docker stop sample_wordpress
# docker stop sample_mysql
# docker rm sample_wordpress
# docker rm sample_mysql
# docker network rm wordpress

小結

從上述自己手動啟動與停止應用服務的多行命令就可以看出 Docker Compose 的強大與方便了。使用 Docker Compose 可以透過組態設定幫你處理好自己下 docker run 時要下的多個參數,而且能將多個 container 組織成一個應用服務,並管理 container 的相依性;另外透過閱讀 docker-compose.yml 檔案,也可以很容易的了解到整個應用服務的組成與架構。是個軟體開發與測試時能好好運用的工具。

ref

Install Docker Compose

Get started with Docker for Mac

github - docker/compose

Docker Compose

Overview of Docker Compose

wikipedia - YAML

YAML 语言教程

Compose file version 3 reference

Quickstart: Compose and WordPress

談談 Docker network-alias

在使用 docker run 指令啟動 container 時,有一個與 network 有關的 flag,為 network-alias,可以為 container 在網路拓墣取一個別名,讓其他 container 透過這個別名找到該 container。一開始看到時,不知道能拿來做什麼,想說我透過 container name 就能直接找到我要的 container 了,為何還要在另外命名?但是最近在弄新的產品需求時,卻剛好有需要用到 network-alias 的應用場景出現,因此想做個記錄。

network-alias 效果

container 在自定義的 network 底下時,可以使用 container name 找到目標對象,若對象有定義 network-alias 時,則也可以使用 network-alias 找到目標。比如說以下範例,建立兩個 container,名為 foo1 與 foo2,將他們加到 foonet 這個自定義的 network 底下,然後 foo2 取 network-alias 為 bar2。使用 docer exec 指令進入到 foo1 的 bash 後,試著執行 ping foo2 與 ping bar2,都是能找到相同的 container。

docker network create --driver bridge foonet
docker run -d --name foo1 --network foonet tomcat:8.5.11-jre8
docker run -d --name foo2 --network foonet --network-alias bar2 tomcat:8.5.11-jre8
docker exec -it foo1 /bin/bash

root@3769d24e6700:/usr/local/tomcat# ping foo2 -c 3
PING foo2 (172.18.0.3): 56 data bytes
64 bytes from 172.18.0.3: icmp_seq=0 ttl=64 time=0.172 ms
64 bytes from 172.18.0.3: icmp_seq=1 ttl=64 time=0.106 ms
64 bytes from 172.18.0.3: icmp_seq=2 ttl=64 time=0.141 ms
--- foo2 ping statistics ---
3 packets transmitted, 3 packets received, 0% packet loss
round-trip min/avg/max/stddev = 0.106/0.140/0.172/0.027 ms

root@3769d24e6700:/usr/local/tomcat# ping bar2 -c 3
PING bar2 (172.18.0.3): 56 data bytes
64 bytes from 172.18.0.3: icmp_seq=0 ttl=64 time=0.093 ms
64 bytes from 172.18.0.3: icmp_seq=1 ttl=64 time=0.097 ms
64 bytes from 172.18.0.3: icmp_seq=2 ttl=64 time=0.099 ms
--- bar2 ping statistics ---
3 packets transmitted, 3 packets received, 0% packet loss
round-trip min/avg/max/stddev = 0.093/0.096/0.099/0.000 ms
root@3769d24e6700:/usr/local/tomcat#

兩個 container 擁有相同 network-alias

接下來是在官方文件看到的,可以多個 container 使用相同的 network-alias。對這真實的運作情況有點好奇,因此建立了模擬狀況來測試。

底下範例建立了三個 container,分別為 foobar1、foobar2 與 foobar3。其中 foobar1 要做為 ping 的發起端,而 foobar2 與 foobar3 為測試對象,他們使用相同的 network-alias,名為 app。

首先依序把三個 container 建立起來,然後進入 foobar1 的 bash,接著開始 ping network-alias 的測試。在測試的時候發現在 ping network-alias 時,不管 ping 多少次,只會找到 foobar2,也就是第一個使用該 network-alias 的 container,當第一個 foobar2 停掉時,才能 ping 到第二個使用該 network-alias 的 container,也就是 foobar3。

docker run -d --name foobar1 --network foonet tomcat:8.5.11-jre8
docker run -d --name foobar2 --network foonet --network-alias app tomcat:8.5.11-jre8
docker run -d --name foobar3 --network foonet --network-alias app tomcat:8.5.11-jre8
docker exec -it foobar1 /bin/bash
root@05c0ad2a04b2:/usr/local/tomcat# ping app
PING app (172.18.0.3): 56 data bytes
64 bytes from 172.18.0.3: icmp_seq=0 ttl=64 time=0.117 ms
64 bytes from 172.18.0.3: icmp_seq=1 ttl=64 time=0.126 ms
64 bytes from 172.18.0.3: icmp_seq=2 ttl=64 time=0.129 ms
root@05c0ad2a04b2:/usr/local/tomcat# exit
exit
mayertekiMacBook-Air:~ mayer$ docker stop foobar2
foobar2
mayertekiMacBook-Air:~ mayer$ docker exec -it foobar1 /bin/bash
root@05c0ad2a04b2:/usr/local/tomcat# ping app
PING app (172.18.0.4): 56 data bytes
64 bytes from 172.18.0.4: icmp_seq=0 ttl=64 time=0.126 ms
64 bytes from 172.18.0.4: icmp_seq=1 ttl=64 time=0.138 ms

也就是說可以使用 network-alias 建立一個至多個備援的 container,提供服務的 container 掛了後相同 network-alias 的 container 會繼續提供服務,不過實際應用上並不建議這樣使用,若是需要備援服務應該使用如 Docker Swarm 或是 kubernetes 之類的叢集管理工具比較恰當。

應用場景

知道了 network-alias 的效果之後,就會開始思考,這要應用在哪?這可能可以從一個 App Server 與 DB container 的互動來切入,首先我們建立一個 Web 應用程式,要將他放在 tomcat 上,這個 Web 應用程式需要與 DB 互動,因此我們需要一個 MySQL,此時用 Docker 來建立整個環境會如下圖所示:

在 Web 應用程式內,要連到 DB,通常會把他寫成屬性檔,用來描述 DB 的位址以及相關登入資訊,有可能如下:

miniweb.driverClassName = com.mysql.jdbc.Driver
miniweb.url = jdbc:mysql://db1:3306/miniweb?useUnicode=true&characterEncoding=utf-8&autoReconnect=true&useSSL=false
miniweb.username = foo
miniweb.password = bar
miniweb.initialSize = 1
miniweb.maxActive = 50
miniweb.maxIdle = 10
miniweb.maxWait = 180000
miniweb.validationQuery = SELECT CURRENT_TIMESTAMP

在開發與測試階段時,這樣是不會有問題的,但是在程式要上線時,這樣做就會有問題了。通常上線前會先把程式放到 staging 環境先經過測試後,才會上到 production 環境。此時依然可以使用 docker 來為我們建立這兩個環境所需要使用的 container,但是會有個問題,由於 container name 不能重複,因此應用程式內的屬性檔,必須要有兩份才能正常運作,比如說目前環境如下圖:

則你在 ap1 上應用程式的設定檔,DB 的 url 要如下:

miniweb.url = jdbc:mysql://db1:3306/miniweb?useUnicode=true&characterEncoding=utf-8&autoReconnect=true&useSSL=false

而在 ap2 上應用程式的 DB url 要如下:

miniweb.url = jdbc:mysql://db2:3306/miniweb?useUnicode=true&characterEncoding=utf-8&autoReconnect=true&useSSL=false

維護兩份設定檔文件會是一件很麻煩的事情,你可以在打包程式時,手動改這屬性檔,或是使用打包工具搭配一些指令來處理這問題,不管怎樣都需要額外花費一些功夫來處理這狀況。

此時就可以使用 docker network 搭配 network-alias 來處理這問題了,我們使用 docker network 建立兩個獨立的 network 環境,然後各跑各的 Web 應用環境,在 DB containers 上使用 network-alias 把他們都命名為 db,這樣一來,我們的應用程式只要透過 db 這個 network-alias,就能正確的找到相對應的 DB containers。整個環境的概念圖如下:

在這個環境下,屬性檔只要填上 network-alias 即可,如下:

miniweb.url = jdbc:mysql://db:3306/miniweb?useUnicode=true&characterEncoding=utf-8&autoReconnect=true&useSSL=false

Docker Network Bridge

本篇是閱讀 docker network 官方相關文件後一些自己覺得重要的內容而做的筆記,主要是以 bridge 為主,其他詳細的內容可以參考 Docker container networking

network 簡介

network 為 docker 的一項功能,讓你可以建立虛擬網路,將 container 加到網路內,建立起屬於你自己應用程式的網路拓墣。另外 network 也可以橫跨多台主機,讓你實現分散式應用服務。

安裝 Docker 後,預設會有三個 network,分別為 bridge、none 以及 host, 你可以用以下指令查看相關資訊:

# docker network ls
NETWORK ID          NAME                DRIVER              SCOPE
1623cc36aeae        bridge              bridge              local
7039bb756843        host                host                local
564a81a95b49        none                null                local

bridge 是預設的 network,在你使用 docker run 指令啟動 container 時,如果你沒有下 --network 參數指定,則預設都會是 bridge。 你可以透過以下指令來觀察 bridge 的詳細參數:

[root@lzstg ~]# docker network inspect bridge
[
    {
        "Name": "bridge",
        "Id": "1623cc36aeae8538c05780f86517dc07d6dc613c4b9d13ad6f949e33fced8f1e",
        "Created": "2017-03-06T11:35:35.006599348+08:00",
        "Scope": "local",
        "Driver": "bridge",
        "EnableIPv6": false,
        "IPAM": {
            "Driver": "default",
            "Options": null,
            "Config": [
                {
                    "Subnet": "172.17.0.0/16",
                    "Gateway": "172.17.0.1"
                }
            ]
        },
        "Internal": false,
        "Attachable": false,
        "Containers": {
            "5b7be3dc833e3b2a46d9f6510af70e507f0cb068653fca024da7df8d5c86276b": {
                "Name": "miniweb_lzstg",
                "EndpointID": "bd107fcd1fbe5fe269665797b4fbd6728bfd8df06afdf5871ca10d8d426d4888",
                "MacAddress": "02:42:ac:11:00:03",
                "IPv4Address": "172.17.0.3/16",
                "IPv6Address": ""
            },
            "f3aabba6c0a2bc1fac6a262854b0fb1980e5c3329552f63d1fbc61c1eb45ab7d": {
                "Name": "miniweb",
                "EndpointID": "7c8cd504316cd351784a6afee89a4eea48f079e522883322d3f7a253207acf55",
                "MacAddress": "02:42:ac:11:00:02",
                "IPv4Address": "172.17.0.2/16",
                "IPv6Address": ""
            }
        },
        "Options": {
            "com.docker.network.bridge.default_bridge": "true",
            "com.docker.network.bridge.enable_icc": "true",
            "com.docker.network.bridge.enable_ip_masquerade": "true",
            "com.docker.network.bridge.host_binding_ipv4": "0.0.0.0",
            "com.docker.network.bridge.name": "docker0",
            "com.docker.network.driver.mtu": "1500"
        },
        "Labels": {}
    }
]

在 IPAM -> Config 底下可以看見,Subnet 設定為 172.17.0.0/16,而 Gateway 為 172.17.0.1。

在 Containers 設定內,如果你已經有啟動 container 且它的 network 為 bridge,這邊就會有資料,否則會是 {};另外也可以看到這邊啟動了兩個 container,IP 分別為 172.17.0.3 與 172.17.0.2。

其他兩個預設建立的 network 分別為 host 與 none,設為 host 則 container 的網路會與你 docker host 主機的網路設定相同。舉例來說,如果你啟動一個 tomcat container,並且把它的 network 設為 host,當 container 啟動時,你就可以直接在瀏覽器上輸入你的機器 ip,port 為 8080,就能開啟 tomcat 管理頁面了。設定為 none 顧名思義就是 container 不使用任何網路介面卡。

預設的 bridge

上面有提到,在啟動 container 時如果沒有加上 --network 指令來指定網路設定,則都會使用預設的 bridge。在預設的 bridge 底下的 container,是可以透過 ip 互相 ping 對方的。

比如說我先啟動兩個 container,命名為 foo1 與 foo2,然後在使用 docker network inspect 指令觀察他們配到的 ip,如下:

$ docker run --name foo1 -d tomcat:8.5.11-jre8
$ docker run --name foo2 -d tomcat:8.5.11-jre8
$ docker network inspect bridge
[
    {
        "Name": "bridge",
        "Id": "f2a2d7dd6e4ab4edd774ef0d80f8b91f5b80a8f08b0f5162a35c02abf41ef4e1",
        "Created": "2017-03-31T11:30:40.628367278Z",
        "Scope": "local",
        "Driver": "bridge",
        "EnableIPv6": false,
        "IPAM": {
            "Driver": "default",
            "Options": null,
            "Config": [
                {
                    "Subnet": "172.17.0.0/16",
                    "Gateway": "172.17.0.1"
                }
            ]
        },
        "Internal": false,
        "Attachable": false,
        "Containers": {
            "70cdec387fc68128b9968689b9bf883150b180ff37134756a97fec2c881b62a4": {
                "Name": "foo2",
                "EndpointID": "3ed1dcee3d7805fd61cf5436b53d18563314c9abdb617f9d0665b19ff986fa6a",
                "MacAddress": "02:42:ac:11:00:03",
                "IPv4Address": "172.17.0.3/16",
                "IPv6Address": ""
            },
            "c52be8382496c30d735620a601167c1de527c47cbead841e747be2b3bec6843d": {
                "Name": "foo1",
                "EndpointID": "3601fcf1ea3b94d1150925e10238795eab2d7165394024006542516619a105fb",
                "MacAddress": "02:42:ac:11:00:02",
                "IPv4Address": "172.17.0.2/16",
                "IPv6Address": ""
            }
        },
        "Options": {
            "com.docker.network.bridge.default_bridge": "true",
            "com.docker.network.bridge.enable_icc": "true",
            "com.docker.network.bridge.enable_ip_masquerade": "true",
            "com.docker.network.bridge.host_binding_ipv4": "0.0.0.0",
            "com.docker.network.bridge.name": "docker0",
            "com.docker.network.driver.mtu": "1500"
        },
        "Labels": {}
    }
]

可以在 Containers 的設定內看到,foo1 配到的 ip 為 172.17.0.2,而 foo2 配到的 ip 為 172.17.0.3,接著我們使用 docker exec 指令進入到 foo1 的 bash,然後看看能不能 ping 到 foo2(172.17.0.3)

$ docker exec -it foo1 /bin/bash
root@c52be8382496:/usr/local/tomcat# ping 172.17.0.3 -c 4
PING 172.17.0.3 (172.17.0.3): 56 data bytes
64 bytes from 172.17.0.3: icmp_seq=0 ttl=64 time=0.171 ms
64 bytes from 172.17.0.3: icmp_seq=1 ttl=64 time=0.094 ms
64 bytes from 172.17.0.3: icmp_seq=2 ttl=64 time=0.094 ms
64 bytes from 172.17.0.3: icmp_seq=3 ttl=64 time=0.115 ms
--- 172.17.0.3 ping statistics ---
4 packets transmitted, 4 packets received, 0% packet loss
round-trip min/avg/max/stddev = 0.094/0.118/0.171/0.032 ms

透過 ip ping 的結果是能找到對方的,但是若是透過 container name 則無法找到對方,如下:

root@c52be8382496:/usr/local/tomcat# ping foo2
ping: unknown host

要能透過 container name 直接找到其他 container,有兩種方式,一種是 docker 比較早期的方法,在 docker run 時加上 --link,讓 container 連接在一起,此方法可以參考官網的這篇文件 Legacy container links,官方有說明 --link 最終可能會被棄用,有興趣的可以閱讀此篇文章,這邊不多做說明。另一種方式就是使用自己定義的 network 來達成。

自定義的 bridge network

我們可以使用 docker network create 指令來建立一個新的網路拓墣。

docker network create foonet

接著在使用 docker run 啟動 container 時,使用 --network flag 將 container 指定到此網路拓墣:

docker run -d --network foonet --name foobar1 tomcat:8.5.11-jre8
docker run -d --network foonet --name foobar2 tomcat:8.5.11-jre8

我們可以透過 docker network inspect 指令觀察目前網路狀態,可以在印出來的 Containers 設定內找到兩個加入到此網路拓墣的 container 網路資訊。

docker network inspect foonet
[
    {
        "Name": "foonet",
        "Id": "c15a5f50792fe854f7722d266ca4cc7e57ccf9ac3dfe479dd711959de55deb0d",
        "Created": "2017-03-25T02:37:24.164070946Z",
        "Scope": "local",
        "Driver": "bridge",
        "EnableIPv6": false,
        "IPAM": {
            "Driver": "default",
            "Options": {},
            "Config": [
                {
                    "Subnet": "172.18.0.0/16",
                    "Gateway": "172.18.0.1"
                }
            ]
        },
        "Internal": false,
        "Attachable": false,
        "Containers": {
            "8c5956967f3e9c0701a84aa868f08053a8f03d691c509c37c462f045dedaa827": {
                "Name": "foobar2",
                "EndpointID": "576f9ee3f923af8838d4ce126de16b0c576dcb2aa814f642a482b1b54dd6f01d",
                "MacAddress": "02:42:ac:12:00:03",
                "IPv4Address": "172.18.0.3/16",
                "IPv6Address": ""
            },
            "b219692bba64311507c23bf4ee69ff33f415ebfcffe3d412b28fa7a44b942356": {
                "Name": "foobar1",
                "EndpointID": "37138005c073250f90f94cad523fbab656155a68f9a238f7a529d116a1bf0833",
                "MacAddress": "02:42:ac:12:00:02",
                "IPv4Address": "172.18.0.2/16",
                "IPv6Address": ""
            }
        },
        "Options": {},
        "Labels": {}
    }
]

最後我們進入到其中一個 container 內,然後使用 container name 來 ping 對方,看看找不找的到對方。可以從結果看出,若是在自己定義的網路拓墣內,是可以直接用 container name 來找到對方機器的。

docker exec -it foobar1 /bin/bash
root@b219692bba64:/usr/local/tomcat# ping foobar2
PING foobar2 (172.18.0.3): 56 data bytes
64 bytes from 172.18.0.3: icmp_seq=0 ttl=64 time=0.151 ms
64 bytes from 172.18.0.3: icmp_seq=1 ttl=64 time=0.141 ms
64 bytes from 172.18.0.3: icmp_seq=2 ttl=64 time=0.285 ms

參考

Docker container networking

Work with network commands

Docker Volume 初步閱讀與學習紀錄

Docker Volume 是個讓 Docker Container 保存與共用資料的機制。原本 Docker Container 的資料只會存在該 container 內,並不會與外部或是其他 container 共享,如果在 container 運作時新增了一些資料,而在 container 移除時又沒有將該 container commit 成 image,則當 container 被刪除時,資料就會遺失。可以參考官方文件 Understand images, containers, and storage drivers 了解 image 與 container 的運作方式。

要避免資料遺失,就可以使用 Docker Volume。你可以建立一個 volume,將它指定到 container 內的某個目錄,這樣該目錄的資料就可以永續保存著,不會因為 container 移除而被移除。你也可以為 volume 命名,這樣其他 container 就能透過這個名稱來使用相同的 volume,另外 volume 也可以與本機的目錄上連結,這樣你就可以讓你本機上的目錄和 container 上的目錄共享資料。

建立 volume

你可以使用許多方式建立 volume,比如說在建立 container 時使用 -v flag 一併建立:

docker run -d -v /var/lib/mysql mysql:5.7.17

你也可以在使用 -v 時一併指定 volume 名稱,讓資料保存下來,當下次要用別的 container 時,指定相同 volume 名稱就能使用相同資料:

docker run -d -v db_data:/var/lib/mysql mysql:5.7.17

或是先使用 docker volume 指令建立好 volume,在 run 時指定你建立的 volume 名稱即可使用:

docker volume create db_data2
docker run -d -v db_data2:/var/lib/mysql mysql:5.7.17

你也可以使用 volume 來實現本機目錄與 container 目錄資料共享:

docker run -d -v /Users/mayer/Documents/dockerspace/mysqldata:/var/lib/mysql mysql:5.7.17

volume 掛載測試

官方有說明,如果你是建立一個 volume 然後掛載到 container 的目錄,則原本 container 的目錄內所有的資料會複製到 volume 內。但是看文件沒有說明如果掛載的目錄有 a 檔案,而 volume 內也一樣有 a 檔案,會有什麼狀況發生,因此就自己想了個例子測試。

首先建立一個全新的 tomcat container,命名為 foo1,然後使用 -v 指令掛載一個名為 foovolume 的 VOLUME 到 tomcat container 的 /usr/local/tomcat 目錄,接著進入到該 tomcat 的 /bin/bash:

$ docker run -d --name foo1 -v foovolume:/usr/local/tomcat/conf tomcat:8.5.11-jre8
$ docker exec -it foo1 /bin/bash

使用 ls 觀察 server.xml 檔案修改時間,接著在使用 echo 指令在 server.xml 底下加上一些註解,用 tail 指令觀察該檔案是否已經加入資料。然後退出 bash 並停止 foo1 container,讓他不影響接下來的測試。

root@a6e3c1a143f5:/usr/local/tomcat# ls -al conf/server.xml
-rw-------  1 root root 7511 Jan 10 21:05 server.xml
root@a6e3c1a143f5:/usr/local/tomcat# echo '<!-- foobar -->' >> conf/server.xml
root@a6e3c1a143f5:/usr/local/tomcat# tail -n 3 conf/server.xml
  </Service>
</Server>
<!-- foobar -->
root@a6e3c1a143f5:/usr/local/tomcat# ls -al conf/server.xml
-rw------- 1 root root 7527 Mar 16 09:10 conf/server.xml
root@a6e3c1a143f5:/usr/local/tomcat# exit

$ docker stop foo1

接著建立另一個全新的 tomcat container,命名為 foo2,一樣使用 -v 指令掛載剛剛建立好的 foovolume VOLUME 到 /usr/local/tomcat 目錄,接著進入到該 tomcat 的 /bin/bash:

$ docker run -d --name foo2 -v foovolume:/usr/local/tomcat/conf tomcat:8.5.11-jre8
$ docker exec -it foo2 /bin/bash

然後使用 ls 與 tail 指令觀察 server.xml 檔案修改日期與檔案的內容,可以發現到與剛剛 foo1 修改後的檔案日期與內容是一致的。

root@a564777a0059:/usr/local/tomcat# ls -al conf/server.xml
-rw------- 1 root root 7527 Mar 16 09:10 conf/server.xml
root@a564777a0059:/usr/local/tomcat# tail -n 3 conf/server.xml
  </Service>
</Server>
<!-- foobar -->
root@a564777a0059:/usr/local/tomcat# exit

$ docker stop foo2

從這個測試可以觀察出,如果你將一個剛建立的 volume 掛載到某個 container 的目錄,若該目錄裡面已經有資料了,則 Docker 會將資料複製到這個剛建立的 volume,然後把這個 volume 覆蓋在你指定的目錄上,原本目錄的東西將會變成不可視。若你掛載的是一個經過初始化的 volume,則他會直接把 volume 內的資料覆蓋在你指定的目錄。

可以用另一個簡單的測試來驗證上面的說法是否正確。新建立一個 volume,掛載到 tomcat 的 /usr/local/tomcat/conf,然後在將這個 volume 掛載到另一個 tomcat 的 /usr/local/tomcat/webapps,就可以看到,原本 webapps 底下的 ROOT 等等的 Web App 都變成了設定檔了,也就是說,初次建立 volume 時,Docker 把 設定檔複製到 volume 上,volume 再次被掛載時,Docker 把 volume 蓋到 webapps 目錄之上。

$ docker run -d --name foo3 -v foovolume2:/usr/local/tomcat/conf tomcat:8.5.11-jre8
$ docker stop foo3
$ docker run -d --name foo4 -v foovolume2:/usr/local/tomcat/webapps tomcat:8.5.11-jre8
$ docker exec -it foo4 /bin/bash
root@028b5095b04a:/usr/local/tomcat# ls -al webapps/
total 236
drwxr-x---  3 root root    4096 Mar 16 09:50 .
drwxr-sr-x 14 root staff   4096 Mar 16 09:51 ..
drwxr-x---  3 root root    4096 Mar 16 09:50 Catalina
-rw-------  1 root root   12895 Jan 10 21:05 catalina.policy
-rw-------  1 root root    7202 Jan 10 21:05 catalina.properties
-rw-------  1 root root    1338 Jan 10 21:05 context.xml
-rw-------  1 root root    1149 Jan 10 21:05 jaspic-providers.xml
-rw-------  1 root root    2358 Jan 10 21:05 jaspic-providers.xsd
-rw-------  1 root root    3622 Jan 10 21:05 logging.properties
-rw-------  1 root root    7511 Jan 10 21:05 server.xml
-rw-------  1 root root    2164 Jan 10 21:05 tomcat-users.xml
-rw-------  1 root root    2633 Jan 10 21:05 tomcat-users.xsd
-rw-------  1 root root  168133 Jan 10 21:05 web.xml

$ docker stop foo4

volume 與本機目錄掛載測試:

接下來是一些 volume 與本機目錄的掛載測試,主要是要看確認資料是否真的共享,與掛載本機目錄之後的 container 其目錄變化,測試將會使用到 tomcat container,還會用到自己打包的 war 檔。

首先在測試前先準備好 war 檔,放在本機目錄,

$ ls /Users/mayer/Documents/dockerspace/volumetest/
miniweb.war

接著啟動一個沒有掛載 volume 的 tomcat container,然後觀察其 webapps 目錄,可以看到 webapps 底下會有一些預設安裝 tomcat 時的應用程式:

$ docker run -d  --name foo tomcat:8.5.11-jre8
$ docker exec -it foo /bin/bash

$ root@635985e5b693:/usr/local/tomcat# ls webapps
ROOT  docs  examples  host-manager  manager

接著使用 -v flag 啟動一個有掛載的 container,將本機目錄掛載到 webapps 目錄:

$ docker run -d -v
/Users/mayer/Documents/dockerspace/volumetest:/usr/local/tomcat/webapps --name bar tomcat:8.5.11-jre8

然後觀察 tomcat webapps 目錄,可以跟上面沒掛載的對照看,發現原本的 ROOT 等等目錄都不見了,只有你本機目錄上的 war 檔與 tomcat 自動幫你解開的目錄。也就是說原本 webapps 的目錄被隱藏了,現在只會顯示 volume 掛載目錄的內容。

$ docker exec -it bar /bin/bash
root@3eca0381a364:/usr/local/tomcat# ls webapps
miniweb  miniweb.war

然後接著測試是否真的目錄共享,先在 container 內新增一個 bar.txt,然後退出 container bash,觀察本機目錄,可以看到本機目錄上也新增了一個 bar.txt 檔案:

root@3eca0381a364:/usr/local/tomcat# touch webapps/bar.txt
root@3eca0381a364:/usr/local/tomcat# ls webapps
bar.txt  miniweb  miniweb.war
root@3eca0381a364:/usr/local/tomcat# exit
$ ls /Users/mayer/Documents/dockerspace/volumetest
bar.txt     miniweb     miniweb.war

接著反向測試,看看在本機目錄新增 foo.txt,再回到 container 內觀察 webapps 目錄是否也會新增一個 foo.txt。從結果可以看出,container 內也新增 foo.txt 檔案了:

$ touch /Users/mayer/Documents/dockerspace/volumetest/foo.txt
$ docker exec -it bar /bin/bash
root@3eca0381a364:/usr/local/tomcat# ls webapps
bar.txt  foo.txt  miniweb  miniweb.war

透過上面的幾項簡單測試,可以看出當 volume 掛載本機目錄時,container 的目錄與本機的目錄資料會完全共用。

需要注意這個測試和上一個測試的不同之處,若是透過 volume 把本機目錄掛載到 container 的目錄,則 container 目錄裡面原本的內容會被完全隱藏,只是出現你本機目錄的檔案;如果是單純的建立 volume 然後掛載到 container 的目錄,則對於初次建立的 volume,Docker 會將 container 目錄裡面原本的內容全部都複製到 volume 上。

使用其他 container 當成 data volume

官方有提到另一個用法,就是在啟動 container 時使用 volumes-from 指令,來分享 container 之間的資料,對於此用法我個人感受不深,覺得很不直覺,因此這邊只會紀錄一下官方的範例。

首先先建立一個有名稱的 container。該 container 不執行任何的應用,他只重新利用 training/postgres image,使所有容器都使用共同的層,節省磁盤空間。

$ docker create -v /dbdata --name dbstore training/postgres /bin/true

接著你就可以在其他 container 上使用 --volumes-form 來掛載 /dbdata volumes。

$ docker run -d --volumes-from dbstore --name db1 training/postgres
$ docker run -d --volumes-from dbstore --name db2 training/postgres

這範例,如果 training/postgres images 原本 /dbdata 目錄內有資料,則會被隱藏,只有從 dbstore container 掛載過來的資料可以被看見。

另外也可以用在備份上,可以透過以下方式進行備份:

$ docker run --rm --volumes-from dbstore -v $(pwd):/backup ubuntu tar cvf /backup/backup.tar /dbdata

然後將備份還原到其他 container:

$ docker run -v /dbdata --name dbstore2 ubuntu /bin/bash
$ docker run --rm --volumes-from dbstore2 -v $(pwd):/backup ubuntu bash -c "cd /dbdata && tar xvf /backup/backup.tar --strip 1"

移除 volumes

使用 volumes 時要注意到,docker 並不會自動幫你移除你沒用到的 volumes。例如你建立 foo container 並掛載一個 foovolume,當你的 foo container 移除時,foovolume 會繼續存在。

在 docker run 時,可以加上 --rm 指令,讓 container 移除時一併把匿名 volume 移除,比如說以下指令:

$ docker run --rm -v /foo -v awesome:/bar busybox top

此命令會建立一個匿名的 /foo volume。當 container 被移除時,Docker Engine 會移除 /foo volume,而不會移除 awesome volume。

你可以用以下指令查看看哪些 volumes 目前沒被任何 container 關聯,然後在下指令移除他們:

docker volume ls -qf dangling=true
docker volume rm $(docker volume ls -qf dangling=true)

若你也像我一樣,目前正在測試階段,則可以直接使用以下指令,清除沒被用到的 volumes:

docker volume prune

參考

Manage data in containers

Docker volume 簡單用法

深入理解Docker Volume(一)

2017年2月15日 星期三

Dockerfile 初步閱讀與學習紀錄

最近花了點時間閱讀 Docker 官網的上 Dockerfile reference 這篇教學文件。閱讀後在加上一些自己實作測試,稍微有點心得,因此就想說再寫篇文章當作學習筆記吧。

Dockerfile 簡介

在 docker 要自己建置 image,你可以使用比較麻煩點的方法,就是自己先把自己想要的 base image 從 docker hub 拉下來,然後用 container 執行它,接著在 container 內執行你想做的命令,然後在 commit container 讓他變成 image,這方式就會變成是一直在重複用 container run,然後 commit 成 image 的動作。

docker 提供了另一種方式來建置 image,也就是本篇要提到的 Dockerfile。docker 可以透過 Dockerfile 讀指令的方式自動建立 image,也就是說你可以把你要 build image 的步驟透過 Dockerfile 的指令一步一步的寫出來,這樣就能使用 docker build 指令來幫你自動建置 image。至於中間過程需要用 container 執行你的命令、再將 container commit 回 image 這些瑣碎的細節,你可以不需要太在意他。

Context:

在開始寫 Dockerfile 之前,要先知道 build context 的概念,context 可以是本地端的目錄或是某個 URL(Git repository),context 不論是本地端目錄或是 URL,其底下的所有子目錄或是檔案都是屬於 context 內。

當你在 command line 下 docker build 指令要透過 Dockerfile 來 build image 時,docker 並不會在 command line 直接幫你 build,而是要透過 docker daemon 來執行。它會把 build context 傳送給 docker daemon,之後 docker daemon 在幫你 build。

所以 build 的一切過程,是在 docker daemon 上執行的,你在 build 過程中如果要複製檔案到 image 內,檔案也必須要先放在 context 內,這樣 docker daemon 才能在 build 過程中找到檔案。

另外你也可以把 build image 相關的說明文件放在 context 目錄內,然後在搭配 .dockerignore 檔案讓說明文件在 docker build 時不會被傳送到 docker daemon 中。

這邊的 context 有點像是在寫 project 時,一個 project 會放在某個目錄底下,該目錄可以視為一個 context,然後該 project 要執行所需的原始碼、lib、以及相關的說明文件都會放在此目錄底下的。

Dockerfile command:

Dockerfile command 說多不多,以下列出並加上簡短說明。

  • FROM:base image 設定,你在 build image 時,一定需要指定一個 image 來源。
  • RUN:build 時使用。會執行你的命令,然後 commit 結果,被 commit 的結果會被 Dockerfile 下一步驟所使用。
  • CMD:run container 時要執行的命令,要注意這個跟 RUN 的區別,他是指當你使用 docker run 時,container 建立起來的時候要跑什麼樣的命令。另外一個 Dockerfile 內要有一個 CMD 或是等等會提到的 ENTRYPOINT。
  • LABEL:標籤,會是以 key=value 的形式來定義。你可以用 LABEL 記錄一些 image 資訊,如版號、作者等等的訊息。官方建議用一個 LABEL 指令來定義多組值會比較有效率。
  • EXPOSE:告訴 container 在執行時需要監聽哪個 port。注意,EXPOSE 並不是直接開放 port 給外界訪問,要讓外界訪問,你要在下 docker run 指令時加上 -p flag 才能讓外界訪問。
  • ENV:環境變數設定。
  • ADD:將檔案加到 images 內,檔案來源可以是 URL 或是 context 內的檔案。
  • COPY:將檔案加到 images 內,檔案來源是 context 內的檔案,注意這個跟 ADD 的區別,上網查有人是說這個語意清楚,行為單純,就是複製檔案的概念,建議用這個。
  • ENTRYPOINT:run container 時要執行的命令,可以和 CMD 混搭用,此時 CMD 會被視為初始參數來使用。
  • VOLUME:掛載目錄用。
  • USER:可以指令使用者為何。在此指令過後的 Dockerfile 指令,都會用這個使用者來執行。
  • WORKDIR:工作目錄設定,在執行此指令後,在 Dockerfile 內後續的 RUN、CMD、ENTRYPOINT、COPY
  • ARG:buid 時可以設定參數,讓 docker build 指令可以下參數來處理。
  • ONBUILD:提供一個 trigger 機制,當別人要使用你 build 好的 image 當成 base 時,如果你當初在 build 時有寫 ONBUILD 指令,則別人在用這個 image 當成 base image 時,當跑完他 Dockerfile 的 FROM 指令之後會先跑你寫的 ONBUILD,接著才會開始跑它寫的其他指令。
  • STOPSIGNAL:要不要在結束時收到系統發的信號。
  • HEALTHCHECK:提供一個檢查的機制,你可以透過 HEALTHCHECK 來讓 docker 知道什麼樣的狀況對你的 container 來說才算是正常的。
  • SHELL:改變預設的 shell。Dockerfile command 會有 shell 格式的命令,預設是用 /bin/sh -c 來執行命令,你可以透過 SHELL 強制修改掉。

一個簡單的 Dockerfile 範例

接下來示範怎麼寫一個簡單的 Dockerfile:

  1. 首先先在本機上決定你的 context 要在哪,比如說我用以下路徑的 foobar 目錄,foobar 底下的所有檔案都會視為 context。

    /Users/mayer/Documents/dockerspace/foobar
  2. 由於我是使用 Mac 系統,所以我要用 docker 啟動時一併開啟的終端機來 build,先將終端機切換到該目錄:

    cd /Users/mayer/Documents/dockerspace/foobar
  3. 之後,透過 vi 新增 Dockerfile

    vi Dockerfile
  4. 然後開始撰寫 Dockerfile,這邊 FROM image 我們使用 alpine linux,這是個容量只有 3.9 MB 左右的超輕量 Linux,然後預設他並不會執行任何的命令,所以我們會讓他預設起來就執行 top 指令,Dockerfile 內容如下:

    FROM alpine:3.5
    CMD ["top"]
  5. 之後使用 :wq 離開 vi,這樣 Dockerfile 檔案就準備好了。

  6. 接著要使用此 Dockerfile 來 build 我們自己的 image,我們可以使用 docker build 指令來處理,以下是 build 的資訊:

    $ docker build -t mayer/foo:1.0.0 .
    Sending build context to Docker daemon 2.048 kB
    Step 1 : FROM alpine:3.5
    3.5: Pulling from library/alpine
    0a8490d0dfd3: Pull complete 
    Digest: sha256:dfbd4a3a8ebca874ebd2474f044a0b33600d4523d03b0df76e5c5986cb02d7e8
    Status: Downloaded newer image for alpine:3.5
     ---> 88e169ea8f46
    Step 2 : CMD top
     ---> Running in 5f41dcb5519c
     ---> 7265d7f11cd1
    Removing intermediate container 5f41dcb5519c
    Successfully built 7265d7f11cd1

    上述的 docker build 指令有用上 -t flag,用來指定 image 名稱與 tag,另外後面的 . 代表 context 的路徑為當下目錄。

    下完指令後的第一個訊息,可以看到 docker 先把 build context 傳送給 Docker daemon,然後才開始 build 步驟,首先 docker daemon 在執行 FROM 指令時發現到本地端沒有 alpine:3.5 這個 image,所以他先幫你 pull 下來,然後結束第一步驟。接著執行第二步驟 CMD top,指定這個 image 在透過 container 啟動時預設要執行的命令為 top,這樣就完成了 build image 步驟。

  7. 可以透過 docker images 查看是否 build 成功:

    $ docker images
    REPOSITORY          TAG                 IMAGE ID            CREATED             SIZE
    mayer/foo           1.0.0               7265d7f11cd1        8 minutes ago       3.984 MB
  8. 接著使用 container 來執行這個 image,這邊用 -d flag 讓他在背景執行:

    docker run -d mayer/foo:1.0.0
  9. 你可以透過 docker ps 來查詢 container 運行狀態:

    $ docker ps
    CONTAINER ID        IMAGE               COMMAND             CREATED             STATUS              PORTS               NAMES
    8332ee2f38a6        mayer/foo:1.0.0     "top"               30 seconds ago      Up 29 seconds                           nauseous_albattani      
  10. 另外除了 docker ps 指令之外,你也可以 Kilematic 工具來直接觀看 container 運作的內容,看畫面在跑感受會比較深一點。

透過以上步驟,我們已經完成了透過 Dockerfile 來 build image,然後使用 container 來執行 image 的流程了。

接著下面要講記錄一些讓人有些困惑的指令。

ENTRYPOINT 與 CMD

假設有個 Dockerfile 長這樣:

FROM centos:7.2.1511
ENTRYPOINT ["top", "-b"]
CMD ["-c"]

我們透過此 Dockerfile 來 build images:

docker build -t mayer/foo .

接著執行兩個 container:

docker run -d mayer/foo
docker run -d mayer/foo -H

使用 docker ps -a 查詢:

CONTAINER ID        IMAGE               COMMAND             CREATED             STATUS              PORTS               NAMES
83b6433d168b        mayer/foo           "top -b -H"         20 minutes ago      Up 4 minutes                            modest_hodgkin
20ba3187d8e3        mayer/foo           "top -b -c"         21 minutes ago      Up 9 seconds                            angry_bardeen

可以看到 Docker 會執行 ENTRYPOINT,若有 CMD,他會將它當成預設參數,你可以在 run 時改變它。

另外可以下指令查 images 相關資訊:

docker inspect mayer/foo:latest

他會印出此 image 預設的 Entrypoint 與 Cmd 為何:

    ...
    "Cmd": [
        "-c"
    ],
    "Image": "sha256:97aa54a9a5ebfeff5a2b1992a1e0cb0721162fc15061da2c4192205ba93f8554",
    "Volumes": null,
    "WorkingDir": "",
    "Entrypoint": [
        "top",
        "-b"
    ],
    ...

WORKDIR:

image 的 workdir 可以用 docker inspect 來觀察,比如說 tomcat:8.5.11-jre8 的:

$ docker inspect tomcat:8.5.11-jre8

...
"WorkingDir": "/usr/local/tomcat",
...

可以看到此 image,他把 WorkingDir 設定在 /usr/local/tomcat。然後我們想從這個 image 加上自己的 webapp,我們該如何做?

首先,在本機上建立一個目錄,把這個目錄視為 context,這邊先用以下路徑的 sample 目錄作為 context:

/Users/mayer/Documents/dockerspace/sample

然後用終端機切換到那個目錄內:

cd /Users/mayer/Documents/dockerspace/sample

然後我們要將 Web Application 放到這目錄,

mv /somepath/sample.war ./

接著使用 vi 來編輯檔案

vi Dockerfile

Dockerfile,內容如下:

FROM tomcat:8.5.11-jre8
COPY sample.war webapps/

可以注意到,這邊 COPY 指令的 dest,是直接寫 webapps/,這邊能這樣寫,是因為他有設定了 WORKDIR 的緣故,其實真正的 dest 會是:

/usr/local/tomcat/webapps

所以上面的 Dockerfile 是說,我要 build 一個 images,來源是 tomcat:8.5.11-jre8,然後幫我把檔案 sample.war 複製到 /usr/local/tomcat/webapps 目錄底下,這樣一來我就可以直接使用 docker build 來建立一個裡面包含我 webapp 的 tomcat image 了:

docker build -t mayer/sample .

注意 docker build 最後那個 . 代表 context 為當下目錄,不能省略。

CMD 與 EXPOSE

接著要如何執行呢?在執行之前,讓我們再看一下 tomcat:8.5.11-jre8 的詳細資訊

$ docker inspect tomcat:8.5.11-jre8

...
"ExposedPorts": {
            "8080/tcp": {}
        },
...
"Cmd": [
    "catalina.sh",
    "run"
],
...
"Entrypoint": null,
...

這邊要注意的是這三個資訊,一個是預設執行的 container 會監聽 8080,一個是他的初始 Cmd 就是讓 tomcat 啟動,然後他沒有寫 Entrypoint,所以可以不用擔心會影響到 Cmd,也就是說如果我們要使用我們 build 的 image 的話,我們只需要使用 docker run 指令來啟動 container 並執行,如下:

docker run -d -p 8080:8080 mayer/foo

注意 port 要透過 -p flag 處理,冒號前面的 8080 代表本地端機器的 port,冒號後面的 8080 代表 container 的 port。

更多實際應用範例

如果你需要更多範例,請參考官網 Docker Documentation - Dockerization examples 這個網頁,裡面包含像是 MongoDB、PostgreSQL 之類的應用使用 Dockerfile 來 build image 的過程。

Reference:

Docker Documentation - Dockerfile reference

Stackoverflow - What is the difference between the COPY and ADD commands in a Dockerfile?

Stackoverflow - What is the difference between CMD and ENTRYPOINT in a Dockerfile?

segmentfault - Dockerfile里指定执行命令用ENTRYPOING和用CMD有何不同?

2017年2月4日 星期六

Spring MVC with Maven

最近工作上遇到需要快速建立 web service project 來讓同仁測試的需求,想起了好久之前看過但是一直沒試的 Spring MVC。詢問了朋友的意見與上網查了相關資料之後,發覺用這個來建還真的蠻快的,而且處理 JSON 也很方便,通通幫你整合好了,是個很好的快速解決方案,因此嘗試了一下,然後在搭配 Maven 處理 jar 檔問題,摸索加建立基礎架構大概只花兩天而已吧,整個就是迅速。以下是記錄建立整個 web service project 的過程。

Maven

Apache Maven Project 官網 抓 maven,設 path,然後 maven repository 設定位置如下,可用預設不用改:

/Users/mayer/Develop/apache-maven-3.3.9/conf/settings.xml
Default: ${user.home}/.m2/repository

Spring 開發工具:

  1. 先抓 eclipse 4.6.2 j2ee
  2. 在抓 spring 開發工具 Spring Tool Suite。到 https://spring.io/tools/sts/all 抓 Update Site Archives,版本要是 4.6.2
  3. 開啟 eclipse,到 Help -> Install New Software,然後點 Add,在選 Archive,指到你剛剛下載的 spring 開發工具。然後選 Core Spring IDE、Extensions Spring IDE、Integrations Spring IDE、Resources Spring IDE,裝好之後會重啟,這樣 IDE 就完成了。

建立 Maven Project

  1. 建立 Maven Project

    1. 右鍵 New -> Maven Project,選 maven-archetype-webapp 1.0,Group Id 填 tw.com.maxkit,Artifact Id 填 ivrsample,package 會長這樣 tw.com.maxkit.ivrsample,
    2. 預設 index.jsp 會出錯,把 Server Runtime 加進 project 去就好。
    3. 修改 pom 檔案,加上會用到的 spring mvc dependency

      <dependency>
          <groupId>org.springframework</groupId>
          <artifactId>spring-webmvc</artifactId>
          <version>4.3.5.RELEASE</version>
      </dependency>
    4. 修改 pom 在 build 內加上 plugins,確保下次執行 Maven Update Project 時 eclipse 設定不會跑掉:

        <build>
          <finalName>ivrsample</finalName>
          <plugins>
              <plugin>
                  <groupId>org.apache.maven.plugins</groupId>
                  <artifactId>maven-compiler-plugin</artifactId>
                  <version>3.1</version>
                  <configuration>
                      <source>1.8</source>
                      <target>1.8</target>
                  </configuration>
              </plugin>
          </plugins>
        </build>
  2. Spring MVC 相關設定

    1. 在 web.xml 加上:

      <servlet>
          <servlet-name>dispatcher</servlet-name>
          <servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class>
      </servlet>
      <servlet-mapping>
          <servlet-name>dispatcher</servlet-name>
          <url-pattern>/</url-pattern>
      </servlet-mapping>
    2. 增加 dispatcher-servlet.xml,把它放到路徑 ivrsample/WebContent/WEB-INF 底下。專案點右鍵 -> New -> Spring Bean Configuration File,名稱為 dispatcher-servlet.xml,xsd 選 mvc、p、context。然後加上這兩行:

      <context:component-scan base-package="tw.com.maxkit.ivrsample.controller"/>
      <mvc:annotation-driven />
    3. 這樣就可以開始寫 web service,到 tw.com.maxkit.ivrsample.controller 底下建立 FooController.java,範例如下:

      @Controller
      @RequestMapping("/foo")
      public class FooController {
      
          @ResponseBody
          @PostMapping("/test")
          public String hello(){ 
              System.out.println("into test");
              return "hello";
          }
      
          @ResponseBody
          @PostMapping(path = "/testjson", consumes = "application/json")
          public TestBean json(@RequestBody TestBean testBean) {
              System.out.println("testBean = " + testBean);
              System.out.println(testBean.getId());
              System.out.println(testBean.getName());
              System.out.println(testBean.getDate());
      
              TestBean testBean2 = new TestBean();
              testBean2.setId(2);
              testBean2.setName("james");
              testBean2.setDate(new Date());
              return testBean2;
          }
      }

      這個 class 的存取路徑會是:

      http://localhost:8080/ivrsample/foo

      有開放兩個 api:

      http://localhost:8080/ivrsample/foo/test
      http://localhost:8080/ivrsample/foo/testjson

      json 處理參考接下來的說明。

Spring MVC with Json

需要 jackson lib,在 pom 加上:

<dependency>
    <groupId>org.codehaus.jackson</groupId>
    <artifactId>jackson-mapper-asl</artifactId>
    <version>1.9.13</version>
</dependency>
<dependency>
    <groupId>com.fasterxml.jackson.core</groupId>
    <artifactId>jackson-databind</artifactId>
    <version>2.5.3</version>
</dependency>

然後你的 controller method 直接回傳物件就好,沒有其他設定了,如下:

@ResponseBody
@PostMapping(path = "/testjson", consumes = "application/json")
public TestBean json(@RequestBody TestBean testBean) {
    System.out.println("testBean = " + testBean);
    System.out.println(testBean.getId());
    System.out.println(testBean.getName());
    System.out.println(testBean.getDate());

    TestBean testBean2 = new TestBean();
    testBean2.setId(2);
    testBean2.setName("james");
    testBean2.setDate(new Date());
    return testBean2;
}

遇到日期時,他會回傳 timestamp,若要日期轉字串則要加上:

public class TestBean {
    private int id;
    private String name;
    @JsonFormat(pattern="yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
    private Date date;
    // ... get and set method
}

日期相關處理參考:http://wiki.fasterxml.com/JacksonFAQDateHandling

不讓 null 回傳,在 bean 上面加上:

import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonInclude.Include;

@JsonInclude(Include.NON_NULL)
public class ApiOut { ... }

or

import com.fasterxml.jackson.databind.annotation.JsonSerialize;

@JsonSerialize(include=JsonSerialize.Inclusion.NON_NULL)
public class ApiOut { ... }

null 回傳處理參考:http://stackoverflow.com/questions/11757487/how-to-tell-jackson-to-ignore-a-field-during-serialization-if-its-value-is-null

取 ServletContext

在你的 Spring 控管 object 內寫上這個,就會自動注入了

protected ServletContext context;

@Autowired
public void setContext(ServletContext context) {
    this.context = context;
}

另一種方式,連 get、set 都省了,上網查是用 JAVA Reflection 做到的,不過細節沒去看就是了:

@Autowired
protected ServletContext context;

2014年5月13日 星期二

Android Service之IntentService

前言:

對於Android的Service,一直以來都是一知半解的狀況,因此前陣子有小段空擋時,花點時間在Android Develop Guide看看Service的說明,意外看到了一個玩意,IntentService,對它有點好奇,有Service就好了為何還會有它?因此對這個類別研究並測試了一下。

淺談Service

Android Service其實有點複雜,要談的話可能需要多點篇幅,因此這邊稍微講一下它的大概就好。
在我的認知裡,其實就把它想成一個沒有畫面的Activity就好了,因為它沒有畫面,所以它也不會因為User切換Activity而被中斷掉,因此是一個很適合做背景工作的App Component,如放音樂、下載檔案等工作,畢竟你不可能讓User只能在某個特定頁面才能正常下載,而且還跟User說,不准動手機,等我抓完你才可以動這類的話吧。
而Android提供了方便的框架,讓開發者去使用自己寫的Service,也就是透過context.startService(Intent service),就可以對Service送出request,讓它做某些工作。
只是因為要讓Service能完成更全面的工作,因此Android官方在設計此Components時將此它弄的很彈性,相對的複雜度也提高。在看官方API時可能會對於context.bindService()和context.startService()差別在哪為何要用有所困惑,又或者對為什麼service.onStartCommand()的回傳值要傳特定的參數回去,每個參數有何意義,在什麼狀況下我的參數會發揮什麼作用諸如此類的。
所以在看IntentService時,只需知道幾件和Service有關的事情:
  1. Service並不會另外開一條process出來執行你的code,除非你另外指定,否則它會和application執行在同一條process內,所以你如果在這邊執行大量運算的工作,還是會出現ANR的訊息。
  2. Service並不是另開一條新的thread來執行你的工作,它還是在main thread底下,所以不能直接透過他執行網路存取的工作。

Why IntentService

由於上面提到的Service特性以及它的複雜度的關係,因此官方提供了一個簡單使用的Service來給開發者使用,也就是今天要提到的IntentService,IntentService跟Service的差別在於,系統會給IntentService獨立的一條worker thread,讓它不會和activity共用thread,然後它預設會有個queue的機制,會保證一次只有一個request會被執行而已,也就是說不管你呼叫幾次context.startService(intent),在同一時間內只有單一個request會在service.onHandleIntent(intent)執行,而當沒有request在queue內時,該IntentService會自動銷毀,讓你不需要管理它的命週期。這邊整理了IntentService的一些特點:
  1. Easy to use,一樣透過context.startService(Intent service)就可以送出request。
  2. 不需要管Service的生命週期,只要把主要工作的code寫在onHandleIntent(Intent intent)就好了,code執行完畢如果沒有其他request則它會自己銷毀。
  3. 保證一次只有一個request會被處理,其餘的request會被block住。
  4. 由於他是另外開一條worker thread的關係,因此request被block住也不會影響main thread,簡單來說就是不會出現ANR的訊息。
  5. 也由於是另一條thread的關係,因此你可以直接在onHandleIntent()內寫網路相關的程式。

測試範例:

先在resource裡面定一個Button:
<Button 
    android:id="@+id/btn_is"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:text="intent service go"/>
接著在Activity內寫他的事件,只寫了這段code,發送request出去:
btn_is = (Button) findViewById(R.id.btn_is);
btn_is.setOnClickListener(new OnClickListener() {
    @Override
    public void onClick(View view) {
        Intent intent = new Intent();
        intent.setClass(MainActivity.this, 
                    IntentServiceImpl.class);
        startService(intent);
    }
});
而主要IntentService code,在onHandleIntent()裡面會故意將Thread sleep 2秒,用以觀察request bolcking的狀況,這邊需要注意的是,要覆寫建構子回傳一個IntentServiceImpl Service的名稱給worker thread,如下::
public class IntentServiceImpl extends IntentService {
    public static final String TAG = "mayer";
    private int i = 0;

    public IntentServiceImpl() {
        super("IntentServiceImpl");
    }

    @Override
    public void onCreate() {
        super.onCreate();
        Log.d(TAG, "onCreate()");
    }

    @Override
    protected void onHandleIntent(Intent intent) {
        Log.d(TAG, "onHandleIntent(), start work.");
        Log.d(TAG, "print start, init i = " + i);
        for(; i< 5; i++) {
            Log.d(TAG, "i = " + i);
        }
        try {
            TimeUnit.SECONDS.sleep(2l);
        } catch (InterruptedException e) {
            Log.e(TAG, "Error:", e);
        }
        Log.d(TAG, "onHandleIntent(), end work.");
    }

    @Override
    public void onDestroy() {
        super.onDestroy();
        Log.d(TAG, "onDestroy()");
    }
}
最後,跟一般Service一樣,需要在AndroidManifest.xml加上:
<service android:name="com.test.servicetest.IntentServiceImpl" />

範例結果:

測試時對按鈕連點三下,會輸出以下結果:
01-14 11:02:43.746: onCreate()
01-14 11:02:43.761: onHandleIntent(), start work.
01-14 11:02:43.761: print start, init i = 0
01-14 11:02:43.761: i = 0
01-14 11:02:43.761: i = 1
01-14 11:02:43.761: i = 2
01-14 11:02:43.761: i = 3
01-14 11:02:43.765: i = 4
01-14 11:02:45.765: onHandleIntent(), end work.
01-14 11:02:45.769: onHandleIntent(), start work.
01-14 11:02:45.773: print start, init i = 5
01-14 11:02:47.773: onHandleIntent(), end work.
01-14 11:02:47.777: onHandleIntent(), start work.
01-14 11:02:47.781: print start, init i = 5
01-14 11:02:49.785: onHandleIntent(), end work.
01-14 11:02:49.793: onDestroy()
由於點了按鈕三次,因此是送出了三個context.startService()的request出去。可以看到測試的Intent Service只會onCreate()一次,也就代表只會有一個Intent Service的實例被建立,接著他執行onHandleIntent()裡面的程式,由於會自己幫你block住其他的request,因此可以確保一次只有一個request會在這邊被執行,第一個request執行完之後第二個在進來執行,以此類推。當第三個request執行完時,由於沒有request了,因此這個Intent Service會自己呼叫onDestroy(),將自己銷毀。

結論:

所以這能幹麻呢?舉個例子,譬如你的Client要做圖片下載,而你又想限制一次只從Server上抓取一張圖片,一張抓完之後在抓下一張,這時你就可以用IntentService來做,當要抓圖片時把intent包一包,就直接呼叫startService(intent),然後就可以達成你的需求了!