about 5 years ago

本文將簡單示範如何在 Spring Boot 上架設支援 http/2 的網站,以及實際測試 http/2 的 Request and Response Multiplexing,和實作 server push。

若你想更深入了解 http/2 底層及其運作原理,可參考 https://hpbn.co/http2/

環境

  • jdk 1.8 以上
  • gradle 2.3 以上

embedded container

目前 embedded container 僅 jetty 和 undertow 可支援 http/2。
雖然用 undertow 定比較簡單,但是我在實作 server push 時一直無法成功,因此最後還是採取用 jetty。

http/2 設定主要參考 http/2 with jetty,若是有興趣了解如何用 undertow,可參考 http/2 with undertow

如何完整實作整個範例

全部原始碼可在這裡下載

build with gradle

首先需要設定好 build.gradle

buildscript {
    repositories {
        mavenCentral()
    }
    dependencies {
        classpath("org.springframework.boot:spring-boot-gradle-plugin:1.3.5.RELEASE")
    }
}

apply plugin: 'java'
apply plugin: 'eclipse'
apply plugin: 'idea'
apply plugin: 'spring-boot'

jar {
    baseName = 'gs-spring-boot'
    version =  '0.1.0'
}

repositories {
    mavenCentral()
}

sourceCompatibility = 1.8
targetCompatibility = 1.8


dependencies {
    compile("org.springframework.boot:spring-boot-starter-web:1.3.5.RELEASE")
    compile("org.springframework.boot:spring-boot-starter-jetty:1.3.5.RELEASE")
    compile("org.eclipse.jetty.http2:http2-server:9.3.10.v20160621")
    compile("org.eclipse.jetty:jetty-alpn-server:9.3.10.v20160621")
    //compile("org.springframework.boot:spring-boot-starter-undertow:1.3.5.RELEASE")
    compile("org.mortbay.jetty.alpn:alpn-boot:8.1.8.v20160420")

    //因應 buildscript 下列 model 要指定版本
    compile("org.eclipse.jetty:jetty-http:9.3.10.v20160621")
    compile("org.eclipse.jetty:jetty-io:9.3.10.v20160621")
    compile("org.eclipse.jetty:jetty-jndi:9.3.10.v20160621")
    compile("org.eclipse.jetty:jetty-plus:9.3.10.v20160621")
    compile("org.eclipse.jetty:jetty-security:9.3.10.v20160621")
    compile("org.eclipse.jetty:jetty-server:9.3.10.v20160621")
    compile("org.eclipse.jetty:jetty-annotations:9.3.10.v20160621")
    compile("org.eclipse.jetty:jetty-continuation:9.3.10.v20160621")
    compile("org.eclipse.jetty:jetty-servlet:9.3.10.v20160621")
    compile("org.eclipse.jetty:jetty-servlets:9.3.10.v20160621")
    compile("org.eclipse.jetty:jetty-util:9.3.10.v20160621")
    compile("org.eclipse.jetty:jetty-webapp:9.3.10.v20160621")
    compile("org.eclipse.jetty:jetty-xml:9.3.10.v20160621")
    compile("org.eclipse.jetty.websocket:javax-websocket-client-impl:9.3.10.v20160621")
    compile("org.eclipse.jetty.websocket:javax-websocket-server-impl:9.3.10.v20160621")
    compile("org.eclipse.jetty.websocket:websocket-api:9.3.10.v20160621")
    compile("org.eclipse.jetty.websocket:websocket-client:9.3.10.v20160621")
    compile("org.eclipse.jetty.websocket:websocket-common:9.3.10.v20160621")
    compile("org.eclipse.jetty.websocket:websocket-server:9.3.10.v20160621")
    compile("org.eclipse.jetty.websocket:websocket-servlet:9.3.10.v20160621")


    //lib for http2 client
    //compile("org.springframework:spring-web:4.3.0.RELEASE")
    //compile("org.springframework:spring-core:4.3.0.RELEASE")
    //compile("com.squareup.okhttp3:okhttp:3.3.1")
}

task wrapper(type: Wrapper) {
    gradleVersion = '2.3'
}

一個簡單 Spring Boot 網站入口

撰寫一個 Application.class

@SpringBootApplication
public class Application {
    public static void main(String[] args) {
        SpringApplication.run(Application.class);
    }

實作一個可支援 http/2 的 EmbeddedServletContainerCustomizer

撰寫 JettyHttp2Customizer.class

@Component
public class JettyHttp2Customizer implements EmbeddedServletContainerCustomizer {

    private final ServerProperties serverProperties;

    @Autowired
    public JettyHttp2Customizer(ServerProperties serverProperties) {
        this.serverProperties = serverProperties;
    }

    @Override
    public void customize(ConfigurableEmbeddedServletContainer container) {
        JettyEmbeddedServletContainerFactory factory = (JettyEmbeddedServletContainerFactory) container;

        factory.addServerCustomizers(new JettyServerCustomizer() {
            @Override
            public void customize(Server server) {
                if (serverProperties.getSsl() != null && serverProperties.getSsl().isEnabled()) {
                    ServerConnector connector = (ServerConnector) server.getConnectors()[0];
                    int port = connector.getPort();
                    SslContextFactory sslContextFactory = connector
                            .getConnectionFactory(SslConnectionFactory.class).getSslContextFactory();
                    HttpConfiguration httpConfiguration = connector
                            .getConnectionFactory(HttpConnectionFactory.class).getHttpConfiguration();

                    configureSslContextFactory(sslContextFactory);
                    ConnectionFactory[] connectionFactories = createConnectionFactories(sslContextFactory, httpConfiguration);

                    ServerConnector serverConnector = new ServerConnector(server, connectionFactories);
                    serverConnector.setPort(port);
                    // override existing connectors with new ones
                    server.setConnectors(new Connector[]{serverConnector});
                }
            }

            private void configureSslContextFactory(SslContextFactory sslContextFactory) {
                sslContextFactory.setCipherComparator(HTTP2Cipher.COMPARATOR);
                sslContextFactory.setUseCipherSuitesOrder(true);
            }

            private ConnectionFactory[] createConnectionFactories(SslContextFactory sslContextFactory,
                                                                  HttpConfiguration httpConfiguration) {
                SslConnectionFactory sslConnectionFactory = new SslConnectionFactory(sslContextFactory, "alpn");
                ALPNServerConnectionFactory alpnServerConnectionFactory =
                        new ALPNServerConnectionFactory("h2", "h2-17", "h2-16", "h2-15", "h2-14");

                HTTP2ServerConnectionFactory http2ServerConnectionFactory =
                        new HTTP2ServerConnectionFactory(httpConfiguration);

                return new ConnectionFactory[]{sslConnectionFactory, alpnServerConnectionFactory,
                        http2ServerConnectionFactory};
            }
        });
    }
}

設定 SSL

雖然 http/2 沒規定一定要加密協定(例如 SSL),但目前大部分瀏覽器的 http/2 都需要跑在 https 上面。

產生認證

進入專案家目錄底下輸入

Enter keystore password:  
Re-enter new password:
What is your first and last name?
  [Unknown]:  
What is the name of your organizational unit?
  [Unknown]:  
What is the name of your organization?
  [Unknown]:  
What is the name of your City or Locality?
  [Unknown]:  
What is the name of your State or Province?
  [Unknown]:  
What is the two-letter country code for this unit?
  [Unknown]:  
Is CN=Unknown, OU=Unknown, O=Unknown, L=Unknown, ST=Unknown, C=Unknown correct?


會產一個認證檔名為:keystore.p12

設定 Spring Boot

修改 application.properties 如下

server.port = 8443
server.ssl.enable = true
server.ssl.key-store = keystore.p12
server.ssl.key-store-password = mypassword
server.ssl.keyStoreType = PKCS12
server.ssl.keyAlias = tomcat

至此你的 Spring Boot 網站已可以支援 SSL 連線。

修改 jvm 啟動參數

加入下面啟動參數

java -jar -Xbootclasspath/p:${USER_HOME_DIR}/.gradle/caches/modules-2/files-2.1/org.mortbay.jetty.alpn/alpn-boot/8.1.8y.v20160420/a6414838c42ddfa1110ecfac50a9906e330940fc/alpn-boot-8.1.8.v20160420.jar web.jar

這時候啟動你的 web,一個支援 http/2 的網站就完成了。

來驗證吧

先在瀏覽器上安裝 "http 2 and spdy indicator" ( chrome 和 firefox 都有這套件)。
開啟頁面 http://localhost:8443
如果在瀏覽器右上角看到閃電亮起來如下圖,就代表成功啦!


如果在瀏覽器右上角看到閃電亮起來,就代表成功啦!

http/2 的 Request and Response Multiplexing 和 server push 功能測試

http/2 在 TCP/IP 四層中的 Application 層中多塞了一層 Binnary Framing Layer,而這機制也改變了 server 和 client 交換資料的方式:

Request and Response Multiplexing

有別於 http/1.x 中一個 request 就佔據一個 connection,http/2 中一個 connection 可以同時提供給多個 request 和 response 交錯傳輸資料。為了更直觀了解這特色,先寫個簡單的 index.html,開場就先跟 server 發出 20 個影像檔請求:

<!DOCTYPE html>
<html lang="en">
<head>
</head>
<body>
    <img src="1.jpeg">
    <img src="2.jpeg">
    <img src="3.jpeg">
    <img src="4.jpeg">
    <img src="5.jpeg">
    <img src="6.jpeg">
    <img src="7.jpeg">
    <img src="8.jpeg">
    <img src="9.jpeg">
    <img src="10.jpeg">
    <img src="11.jpeg">
    <img src="12.jpeg">
    <img src="13.jpeg">
    <img src="14.jpeg">
    <img src="15.jpeg">
    <img src="16.jpeg">
    <img src="17.jpeg">
    <img src="18.jpeg">
    <img src="19.jpeg">
    <img src="20.jpeg">
</body>
</html>

若是用 http/1.x ,在 chrome 的開發者工具中可以看到 browser 跟 server 請求資源的時間序如下圖:


可以看到整個頁面耗時 884ms,其中 image 的請求不是同時發給 server,而是有先後順序;這是因為 chrome 預設對同一個 web server 同時只能發出六個請求。

而當使用 http/2 以後,變成一次同時發出全部請求:


而耗時降到 450ms 以下。

更棒的是只要你修改設定讓網站支援 http/2,而不用修改到任何程式碼。

測試 server push

也由於 Binary framing Layer,http/2 也打破了以往一個 request 對應一個 response 的模式,可以一個 request對應多個 respnse。這就是 server push,允許由 server 主動 push 。
可用來指定某些資源在頁面讀取前先 push 到 client 端 。
實作上很簡單,下面是一個簡單的 範例,利用 filter 抓取連到 index.html 的請求,透過 jettyRequest push 資源給 client 端。

ServerPushFilter.class

@WebFilter(filterName = "serverPushFilter", urlPatterns = "/index.html")
public class ServerPushFilter implements Filter {
    Logger logger = Logger.getLogger(ServerPushFilter.class);
    public void init(FilterConfig filterConfig) throws ServletException {

    }
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
            throws IOException, ServletException {
        Request jettyRequest = (Request) request;
        if (jettyRequest.isPushSupported()) {
            logger.info("server push");
            for(int i=1;i<20;i++) {
                jettyRequest.getPushBuilder()
                        .path("/" + Integer.toString(i) + ".jpeg")
                        .push();
            }
        } else {
            logger.info("non http2");
        }
        chain.doFilter(request, response);
    }
    public void destroy() {

    }
}

再使用開發者工具看一下這次發出請求的狀況:


可以看到雖然整個耗時約略相等,但在 index.html 載入完以後,每張圖片的載入時間都在 16ms 上下;相對 Request and Response Multiplexing 每個影像檔資源耗時約在 30ms 左右。

http/2 其他特色

其他諸如 Stream Prioritization、Flow Control 等,但目前還查不到如和實作,有機會再分享。

結論

本文簡介了如何讓 Spring Boot 架的網站支援 http/2,而不用透過 nginx 或 apache 這樣的 web server,希望能對大家有幫助。

 
about 5 years ago

最近在研究 Spting Boot 框架和一些 web 開發的基礎安全性問題,花了點時間了解如何實作預防暴力登入的機制。
參考這篇文章 Prevent Brute Force Authentication Attempts with Spring Security

主要邏輯為當一個 ip 嘗試登入失敗多次後,將該 ip 加入 block list 中一段時間,避免攻擊者可以無限次數的重複嘗試登入密碼。

該篇文章將驗證 request ip 寫在 UserDetailsService 中,而官網對該 interface 的定義為 "Core interface which loads user-specific data"。對比另一個 interface "AuthenticationProvider" 的定義 “Indicates a class can process a specific Authentication implementation”,我覺得更適合拿來實作 blocrequestk 的邏輯。
因此修改了部分原始碼內容,下面簡介如實作:

LoginAttemptService

LoginAttemptService 提供一個存取登入失敗次數和對應 ip 列表的服務,利用 guava 的 LoadingCache 存取 block list並設定 timeout,藉此實作 block time out 的機制。

@Service
public class LoginAttemptService {

    @Autowired
    private HttpServletRequest request;
    private final int MAX_ATTEMPT = 2;
    private final int bolckTimeMins = 1;
    private LoadingCache<String, Integer> blockList;

    public LoginAttemptService() {
        blockList = CacheBuilder.newBuilder().
                expireAfterWrite(bolckTimeMins, TimeUnit.MINUTES).build(new CacheLoader<String, Integer>() {
            public Integer load(String key) {
                return 0;
            }
        });
    }

    public void loginSucceeded(String key) {
        blockList.invalidate(key);
    }

    public void loginFailed(String key) {
        int attempts = 0;
        try {
            attempts = blockList.get(key);
        } catch (ExecutionException e) {
            attempts = 0;
        }
        attempts++;
        blockList.put(key, attempts);
    }

    public boolean isBlocked(String key) {
        try {
            return blockList.get(key) >= MAX_ATTEMPT;
        } catch (ExecutionException e) {
            return false;
        }
    }
}

AuthenticationSuccessEventListener

一個監聽登入成功事件的 listener,每當用戶登入成功便透過 LoginAttemptService 將該 ip 從 block list 中清除。

@Component
public class AuthenticationSuccessEventListener
        implements ApplicationListener<AuthenticationSuccessEvent> {

    @Autowired
    private LoginAttemptService loginAttemptService;

    public void onApplicationEvent(AuthenticationSuccessEvent e) {
        WebAuthenticationDetails auth = (WebAuthenticationDetails)
                e.getAuthentication().getDetails();

        loginAttemptService.loginSucceeded(auth.getRemoteAddress());
    }
}

AuthenticationFailureListener

一個監聽登入失敗事件的 listener,每當用戶登入失敗就透過 LoginAttemptService 將該 ip 放入 block list 中,並記錄失敗次數。

@Component
public class MyAuthenticationFailureHandler extends SimpleUrlAuthenticationFailureHandler {

    @Override
    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response,
                                        AuthenticationException exception) throws IOException {
        response.sendError(403, exception.getMessage());
    }
}

MyUserDetailsService

實作一個 UserDetailsService,透過 Spring Data Repositories 讀取使用者資料。

@Service("MyUserDetailsImpl")
public class MyUserDetailsService implements UserDetailsService {
    @Autowired
    private UserRepository repo;

    public UserDetails loadUserByUsername(String userName) throws UsernameNotFoundException {
        User user;
        try {
            user = repo.getByUsername(userName);
        } catch (Exception e) {
            throw new UsernameNotFoundException("user select fail");
        }
        if(user == null){
            throw new UsernameNotFoundException("no user found");
        } else {
            try {
                List<GrantedAuthority> gas = new ArrayList<GrantedAuthority>();
                gas.add(new SimpleGrantedAuthority("ROLE_USER"));
                return new org.springframework.security.core.userdetails.User(
                        user.getUsername(), user.getPassword(), true, true, true, true, gas);
            } catch (Exception e) {
                throw new UsernameNotFoundException("user role select fail");
            }
        }
    }
}

MyAuthenticationProvider

實作一個 AuthenticationProvider ,在驗證帳號密碼之前,會先透過 LoginAttemptService 確認該 request 的 ip 是否被 block 。

@Component
public class MyAuthenticationProvider implements AuthenticationProvider {
    @Autowired
    private MyUserDetailsService myUserDetailsService;
    @Autowired
    private LoginAttemptService loginAttemptService;
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        WebAuthenticationDetails wad = (WebAuthenticationDetails) authentication.getDetails();
        String userIPAddress = wad.getRemoteAddress();
        String username = authentication.getName();
        String password = (String) authentication.getCredentials();
        if(loginAttemptService.isBlocked(userIPAddress)) {
            throw new LockedException("This ip has been blocked");
        }
        UserDetails user = myUserDetailsService.loadUserByUsername(username);
        if(user == null){
            throw new BadCredentialsException("Username not found.");
        }
        if (!Password.encoder.matches(password, user.getPassword())) {
            throw new BadCredentialsException("Wrong password.");
        }

        Collection<? extends GrantedAuthority> authorities = user.getAuthorities();
        return new UsernamePasswordAuthenticationToken(user, password, authorities);
    }

    public boolean supports(Class<?> authentication) {
        return true;
    }
}

SimpleUrlAuthenticationFailureHandler

實作處理驗證失敗的後續行為,此次範例僅簡單拋回 403 異常。

@Component
public class MyAuthenticationFailureHandler extends SimpleUrlAuthenticationFailureHandler {

    @Override
    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response,
                                        AuthenticationException exception) throws IOException {
        response.sendError(403, exception.getMessage());
    }
}

在 WebSecurityConfigurerAdapter 中設定認證時使用自定義的 MyUserDetailsService 、 MyAuthentcationProvider 和 MyAuthenticationFailureHandler

@Configuration
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
    @Autowired
    DataSource dataSource;
    @Autowired
    private UserRepository _userRepo;
    @Autowired
    private MyUserDetailsService myUserDetailsService;
    @Autowired
    private MyAuthenticationProvider myAuthenticationProvider;
    @Autowired
    private MyAuthenticationFailureHandler myAuthenticationFailureHandler;

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                .authorizeRequests()
                .antMatchers("/", "/home", "/signin", "/index").permitAll()
                .anyRequest().authenticated()
                .and()
                .formLogin()
                .loginPage("/login")
                .permitAll()
                .failureHandler(myAuthenticationFailureHandler)
                .and()
                .logout()
                .permitAll()
                .and()
                .csrf().disable();
    }

    @Override
    public UserDetailsService userDetailsServiceBean() throws Exception {
        return myUserDetailsService;
    }

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(myUserDetailsService);
        auth.authenticationProvider(myAuthenticationProvider);
    }
}

總結

大功告成!事實上只要了解如何自定義認證過程,細節部分還需要是使用情境修改,例如:

  • block list 是要存在 memory 裡面還是要存到外部 db
  • block list 的 key 值(本文是以 request ip)

完整的 code 可以參考 github

 
over 5 years ago

這般文章是基於 基於 swarm + consul + nginx 達到 HA 和 dynamic scaling 的架構 的延續架構改造

主要修改的架構為

- 用 interlock 取代原本的 bash 腳本和 confd
- 加入 redis 保存用戶 session,每次新的服務啟動時都能存取到原本的 session

demo 示意

HA

開啟頁面

頁面會顯示使用者名稱,瀏覽次數以及提供服務的 container ip 資訊

此時將後台對應的 vm 關機

頁面會出現 503 error

等兩到三分鐘後,頁面自動恢復服務,且保有上次服務的 session (等待時間取決於 swarm manager 對失敗節點訊息的同步速度)

dynamic scaling

直接在 swarm cluster 上重複啟動多個 container,且不重啟任何服務

$ docker -H master_ip:4000 run -d -P -h goweb.example.url --restart=always genchilu/go-web-example -sessiontype=redis -redisinfo=master_ip:6379 -sessionlifetime=3000

此時在瀏覽器 refresh,會看到 container 的 ip 會有變化(代表每次存取頁面不一定是同一台 vm 提供服務),如下圖
refresh 前

refresh 後

架構

swarm / consul / nginx 在前篇文章有介紹,不贅述

intelock

interlock 會將 swarm cluster 上的container 對應的 ip 和 port 更新到 server 設定檔,目前支援的 plugin 有
- haproxy
- nginx
- stats

redis

常用來存 session 資訊的 key/value DB

monitor script

一個簡單的腳本程式,主要功能為確認 swarm cluster 上有無目標 container ,若無則從 image 跑一個新的 container

基本概念

1. swarm manager 負責指派 nodes 啟動服務,並回報目前集群服務狀態
2. monitor script 負責監控 swarm 上的服務,一旦發現目標 image 沒有在 swarm cluster 啟動的服服中,則從目標 image 啟動新的服務 
3. nginx 用固定的網址訪問 interlock 的 nginx
4. interlock 依據 swarm cluster 中的 container 狀態更新 server 設定檔

實作

簡單的實作 demo 可以參考

quick demo

較詳細的部署流程

  • 本篇文章利用四台安裝好 docker 的 ubuntu 14.04
  • docker daemon 啟動時需加入下列參數 "-H 0.0.0.0:2376 -H unix:///var/run/docker.sock" 以供 manager 獲取資訊
  • master ip 為 192.168.99.102,上面跑 nginx + interlock + consul + swarm manage + redis
  • 三台 node 分別為 192.168.99.109,192.168.99.101,192.168.99.103

swarm / consul 安裝在前篇文章有介紹,不贅述
下面指令都是在 master 上執行

redis

在 ubuntu 上可以直接用 apt-get 安裝

$ apt-get install redis-server

或者可用我打包的 image

$ docker run -d --name redis-server -p 6379:6379 --restart=always genchilu/redis

Dockerfile

example web

$ docker -H 192.168.99.102:4000 run -d -P -h goweb.example.url --restart=always genchilu/go-web-example -sessiontype=redis -redisinfo=192.168.99.102:6379 -sessionlifetime=3000

Dockerfile & source code

這邊要注意兩點
1. interlock 是用 hostname 做為轉址的依據,一定要輸入
2. 以 -P 讓 docker 自動找合法的 port 對應到 container 內的服務,interlock 會根據這個更新設定檔

interlock

直接採用作者自己打包的 image

docker run -d --name interlock --restart=always ehazlett/interlock --swarm-url tcp://192.168.99.102:4000 --plugin nginx start

啟動後可以觀察 interlock 產生的設定檔

$ docker exec -ti interlock cat /etc/nginx/nginx.conf

會有一段

upstream goweb.example.url {
    server 192.168.99.109:32771;
    server 192.168.99.109:32770;
}
server {
    listen 80;     
    listen 8080;
    server_name goweb.example.url;
    location / {
        proxy_pass http://goweb.example.url;
    }
}

當 user 透過 goweb.example.url(即為 hostname)著這個網址訪問 nginx 時
連線都會轉到對應的 container
(若用 ip 訪問 nginx 則會導到 503 頁面)
如果這時候啟動或刪除 container 會更新 upstream goweb.example.url 裡的資訊
而若是啟動 container 時沒有對應的 port 則會出現 "server;;"
因此啟動 container 服務時要注意這兩個地方
(此時有 swarm cluster 中有其他 hostname 的 container 時會有新的 server 設定)

nginx

上面提到要透過 hostname 當網址訪問 interlock 的 nginx
所以前面我多設一台 nginx,作用就是用強迫用 hostname 去訪問 interlock
(這裡是我偷懶...用 dns server 或修改 interlock 的 nginx plugin 會比較好)
作法如下
先修改 nginx 所在的 server 的 /etc/hosts,將 goweb.example.url 對應 interlock 的 ip 192.168.99.102
然後修改 nginx 設定檔如下

worker_processes  1;
events {
    worker_connections  1024;
}
http {
    include       mime.types;
    default_type  application/octet-stream;
    sendfile        on;
    keepalive_timeout  65;
    server {
        listen 80;
        listen 8080;
        server_name localhost;
        location / {
            proxy_pass http://goweb.example.url;
        }
    }
}

以本文範例的話可以用我自己打包好的 nginx image,然後啟動時用 link 指令把 interlock 和 goweb.example.url 對應起來

docker run -d -p 80:80 --link interlock:goweb.example.url genchilu/nginx

Dockerfile

監控腳本

拷貝監控腳本 執行
(執行前確認 bash 有裝 jq 套件,參考上一篇文章)

# 直接輸入 bash checkAlive.sh 顯示參數說明
$ bash checkAlive.sh -a "-sessiontype=redis -redisinfo=192.168.99.102:6379 -sessionlifetime=3000" -i "genchilu/go-web-example" -o "-d -P -h goweb.example.url --restart=always" -s "192.168.99.102:4000" -t 10

監控腳本同樣有包成 docker image

$ docker run -ti --rm genchilu/checkalive -a "-sessiontype=redis -redisinfo=192.168.99.102:6379 -sessionlifetime=3000" -i "genchilu/go-web-example" -o "-d -P -h goweb.example.url --restart=always" -s "192.168.99.102:4000" -t 10

Dockerfile

自此大功告成,可以透過下列指令觀察 container 跑在那一台 server 並關機來測試 HA

$ docker -H 192.168.99.102:4000 ps

或者瘋狂的啟動新服務觀察 interlock 的設定檔更新狀況

個人心得

- redis / swarm manager / interlock 都還是有單點失敗的問題。如果要做到更完善的 HA,還必須要參酌 redis 和 swarm manager 本身的分散式機制,至於 interlock...應該多啟動起台就沒問題了。
- interlock 還有一些小問題,當 VM 關機時並不會觸發 interlock 更新設定檔,如果關機的 VM 上面剛好有跑服務的 container 的話就會出包了...這時候得靠監控腳本在 swarm cluster 啟動服務去觸發 interlock。 
- 搭配監控系統和 alert 機制自動去觸發 dynamic scaling 比較有意義,有空再把這段補上。
 
over 5 years ago

最近想把一些跑在 ubuntu 的服務搬到 btrfs 上,花了點時間實測 btrfs,
紀錄一下測試過程的想法心得和疑問。

為何選 btrfs

雖然 google 一些資料後(憑感覺)發現 zfs 相較之下似乎比較少災情,但考量到幾個點以後還是決定使用 btrfs:
- 官網宣稱有對 SSD 做最佳化,剛好想搬的服務就是跑在 SSD 上。
- 號稱 linux 下一代檔案系統。
- 在處於有 proxy 的網路環境下,ubuntu 裝 btrfs 比較方便。

目的

一開始會想用 btrfs 主要想透過 block 層級的壓縮達到加速 io 的效果
因此下面介紹會侷限在壓縮功能的效能比較,主要會針對三種檔案系統做比較:

  • ext4
  • btrfs
  • btrfs with lzo compression

其他有趣的特點諸如內建的 raid,snapshot,COW(copy on wtite) 等網上已經有很多文章分享了,
本文就不贅述。

測試過程

測試環境

在 macbook air 上裝 virtual box 虛擬 ubuntu 機器來實測,
VM 規格為 1 core 的 CPU, 768m 的記憶體,額外掛載一顆 8g 的 disk 測試。

測試工具

我用 iozone3
因為想要觀察 io 過程中的 cpu 使用狀況,所以找了這個能同時紀錄 cpu 使用狀況的工具。
測試指令為:

iozone -a -R -s 1536m -r 4096k -o -i 0 -i 1 -i 2 -+u -f 測試檔案路徑

參數說明
- -a 自動測試模式
- -R 結果輸出成 ecel 格式
- -s 測試檔案大小 (設為 vm 記憶體兩倍避免 os 的快取誤事)
- -r 每次傳送的 record 大小,依我的理解應該是指檔案系統的 block (設為 4096k 是因為 ext4 和 btrfs 預設的大小即為 4096k )
- -i 測試的類型,0: 循序寫,1: 循序讀,2: 隨機讀寫
- -+u 紀錄 cpu 使用狀況
- -f 指定測試的檔案路徑

測試結果

io 效能


分兩部分來看:
1. ext4 vs btrfs:無論是讀還是寫都表現比 ext4 佳,尤其在讀檔部分。讀檔不能理解,寫檔或許跟 COW 有關?
2. 有加入 lzo 壓縮的 btrfs vs btrfs:在寫檔部分有加入 lzo 壓縮選項的 btrfs 效能大增,但讀取部分則略減,似乎解壓縮花的時間比壓縮多?

cpu 使用率


一樣分兩部分看:

  1. ext4 vs btrfs:寫入時 CPU 使用率是 btrfs 較高,讀取時則差異不大,莫非 COW 比較吃 CPU?
  2. 有加入 lzo 壓縮的 btrfs vs btrfs:出乎意料的寫入時有壓縮的 btrfs CPU 使用率竟然較低?個人推測或許 iozone 測試 io 時產生的暫存檔在用 lzo 的壓縮率極高,導致 os 不用花多少力氣寫檔。

不管如何,更快的寫入速度以及更低的 CPU 使用率一直讓我直覺上不能接受。
因此我開始猜測,會不會 btrfs 對 SSD 有做優化的關係呢?
畢竟 btrfs 官網說 mount 時加入 SSD 選項會對寫入做一些優化,避免頻繁寫入導致 SSD 效能衰退。

btrfs on HDD

為了確認是不是因為在 mount btrfs 加入 ssd 選項所以產生出乎意料的結果,我找了一台普通硬碟的機器,用 KVM 開 ubuntu 測試。

io

cpu


其實這比較符合我一開的預測,畢竟 lzo 宣稱是快速解壓縮的演算法,利用 lzo 壓縮主要提升的應該會是讀取速度才對(而且 cpu 使用率也相對較高),但是在寫入部分依舊是高 io 效能和低 CPU 使用率,只是相較於在 SSD 下就沒那麼誇張。
雖然依舊難以解釋,但至少是勉強可以接受的範圍就是了...

沒有結論的結論

上面的實驗數據僅供參考,畢竟環境變數太多了。主要是想紀錄測試 io 過程的一些想法心得和疑問,希望有朝一日能豁然開朗。

題外話,一開始的目的是想要透過檔案壓縮達到加速 solr 讀索引的時間,但測到一半發現 lzo 對 solr 底層的索引檔案壓縮率不到 5% ....orz

 
over 5 years ago

本文章介紹的架構可以做什麼?

- 當機器無預警關機時服務會自動在另一台機器部署新的服務
- 當服務負載過大時可以動態在其他機器啟動服務並做負載均衡

架構

swarm

docker 原生的 cluster 方案,透過 swarm manager 管理整個 docker cluster 集群

consul

一個 key/value 儲存服務,常被應用分布式環境下讓所有服務共享訊息

nginx

常見的網頁伺服器

confd

一個設定檔管理工具,可以透過查詢 etcd,consul 等服務並動態從樣板產生新的設定檔

monitor script

一些簡單的腳本程式,主要功能有

  • 定期向 swarm manager 確認集群中的 container ,並更新當下所有服務的位置同步到 consul 上
  • 透過 swarm manager 去跑一個新的 container ,並將新啟動的服務位置資訊同步到 consul 上
  • 透過 swarm manager 去刪除某個節點上的 container ,並更新資訊到 consul 上

基本概念

1. swarm manager 負責指派 nodes 啟動服務,並回報目前集群服務狀態
2. consul 記錄所有服務對應到的 node ip 以及 port
3. monitor script 定期查詢 swarm manager 並把服務資訊更新至 consul
4. confd 定期確認 consul 上的變數值,並透過樣板產生新的 nginx 設定檔後呼叫 nginx reload 設定檔

實作

簡單的實作 demo 可以參考

quick demo

較詳細的部署流程

  • 本篇文章利用四台安裝好 docker 的 ubuntu 14.04
  • docker daemon 啟動時需加入下列參數 "-H 0.0.0.0:2375 -H unix:///var/run/docker.sock" 以供 manager 獲取資訊
  • master ip 為 192.168.99.104,上面跑 nginx + confd + consul + swarm manage
  • 三台 node 分別為 docker01(192.168.99.105),docker02(192.168.99.106),docker03(192.168.99.107)

在開始前 master 須先安裝 jq,一個 bash 解析 json 的套件,方便後續作業

$ apt-get install jq

consul

docker hub 上有包好的的 consul image 可以直接用(建議如果是正式服務的話還是參考官網教學
在 master 啟動 consul 服務

$ docker run -d -p 8400:8400 -p 8500:8500 -p 8600:53/udp -h node1 --name consul progrium/consul -server -bootstrap

測試 consul

$ curl -X PUT -d 'bar' http://192.168.99.104:8500/v1/kv/foo
$ culr -s http://192.168.99.104:8500/v1/kv/foo | jq .
[
  {
    "Value": "YmFy",
    "Flags": 0,
    "Key": "foo",
    "LockIndex": 0,
    "ModifyIndex": 12289,
    "CreateIndex": 12286
  }
]

可以看到 foo 這個 key 已經有儲存上傳的值 (用 base64 加密)

swarm

swarm manager 可以透過下列方式得知集群中所有 node 的位置資訊

- token
必須要連上 internet 才能使用,若處於 proxy 環境還要克服 proxy 障礙,相當麻煩...
- etcd
跟 consul 一樣是個 key/value 儲存服務,架設的過程一直遇到奇怪的雷...後來放棄
- consul
本文用的 key/value 儲存服務
- zookeeper
還沒實驗
- 將集群資訊寫在檔案中或直接在 manager 啟動時指定 nodes ip
好處是 nodes 不用裝 swarm ,壞處是 cluster 不能動態加節點,比較適合小型集群

由於 docker 官方有將 swarm 包成 image,當然義無反顧直接用
在 nodes 上啟動 swarm agent

$ ssh 192.168.99.105
$ docker run -d --name swarm --restart=always swarm join --advertise=192.168.99.105:2375 consul://192.168.99.104:8500/v1/kv/swarm

$ ssh 192.168.99.106
$ docker run -d --name swarm --restart=always swarm join --advertise=192.168.99.106:2375 consul://192.168.99.104:8500/v1/kv/swarm

$ ssh 192.168.99.107
$ docker run -d --name swarm --restart=always swarm join --advertise=192.168.99.107:2375 consul://192.168.99.104:8500/v1/kv/swarm

在 master 上啟動 swarm manager

$ docker run -d -p 2376:2375 --name swarm swarm manage consul://192.168.99.104:8500/v1/kv/swarm

利用 info 指令測試 swarm manager 能正確獲取三個 nodes 的資訊

$ docker -H 192.168.99.104:2376 info
Containers: 3
Images: 6
Role: primary
Strategy: spread
Filters: health, port, dependency, affinity, constraint
Nodes: 3
 docker01: 192.168.99.105:2375
  └ Containers: 1
  └ Reserved CPUs: 0 / 1
  └ Reserved Memory: 0 B / 778.3 MiB
  └ Labels: executiondriver=native-0.2, kernelversion=3.19.0-28-generic, operatingsystem=Ubuntu 14.04.3 LTS, storagedriver=aufs
 docker02: 192.168.99.106:2375
  └ Containers: 1
  └ Reserved CPUs: 0 / 1
  └ Reserved Memory: 0 B / 778.3 MiB
  └ Labels: executiondriver=native-0.2, kernelversion=3.19.0-28-generic, operatingsystem=Ubuntu 14.04.3 LTS, storagedriver=aufs
 docker03: 192.168.99.107:2375
  └ Containers: 1
  └ Reserved CPUs: 0 / 1
  └ Reserved Memory: 0 B / 778.3 MiB
  └ Labels: executiondriver=native-0.2, kernelversion=3.19.0-28-generic, operatingsystem=Ubuntu 14.04.3 LTS, storagedriver=aufs
CPUs: 3
Total Memory: 2.28 GiB
Name: ebb77eea736a

nginx & confd

在 master 上安裝 nginx

$ apt-get install nginx

在 master 下載 confd 並加入 PATH

$ wget https://github.com/kelseyhightower/confd/releases/download/v0.10.0/confd-0.10.0-linux-amd64 -O confd
$ chmod +x confd
$ mv confd /usr/local/bin/

編輯 confd 設定檔

$ vi /etc/confd/conf.d/nginx.toml 
# 加入以下內容

[template]
# template 名稱

src = "nginx.tmpl"
#動態產生的設定檔置放位置

dest = "/etc/nginx/sites-enabled/default"
# 將會監控 http://192.168.99.104:8500/v1/kv/helloweb 值的變化

keys = [ "/helloweb" ]

# 使用者和權限

owner = "root"
mode = "0644"

# 每當 consul 上監控的 key/value 有變化,動態產生設定檔後執行下列指令

reload_cmd = "/usr/sbin/service nginx reload"

編輯 nginx 設定檔的 template

$ vi /etc/confd/templates/nginx.tmpl
# 加入以下內容

upstream helloweb {
        #helloweb 的 value 會被帶入  {{.}}

        {{range getvs "/helloweb/*"}}
                server {{.}};
        {{end}}
}

server {
        listen 83 default_server;
        listen [::]:83 default_server ipv6only=on;

        root /usr/share/nginx/html;
        index index.html index.htm;

        server_name localhost;

        location / {
                proxy_pass        http://helloweb;
                proxy_redirect    off;
                try_files $uri $uri/ =404;
        }

}

啟動 confd 每 5 秒去向 consul 確認監控的值有無變化

$ confd -interval 5 -backend consul -node 192.168.99.104:8500

此時觀察 /etc/nginx/site-enable/default 可看到 upstream helloweb 為空

upstream helloweb {

}

測試 High availability 和動態發佈服務

在任一台機器下載 demo 用的 monitor script

$ git clone https://github.com/genchilu/haWithSwarm.git
$ cd haWithSwarm/haScripts/

動態發佈服務

先測試動態發佈一個服務

# 輸入 bash scaleup.sh -h 輸出參數說明

$ bash scaleup.sh -s "192.168.99.104:2376" -c "http://192.168.99.104:8500" -p "helloweb" -o "-d -p 3000:3000 --restart=always --entrypoint=/usr/bin/node" -i "genchilu/helloweb" -a "/opt/helloweb/app.js"

這時候在 swarm manager 查詢,會看到 demo 的服務在 192.168.99.106:3000 啟動

$ docker -H 192.168.99.104:2376 ps
CONTAINER ID        IMAGE               COMMAND                  CREATED              STATUS              PORTS                           NAMES
98b1168f2e19        genchilu/helloweb   "/usr/bin/node /opt/h"   About a minute ago   Up About a minute   192.168.99.106:3000->3000/tcp   docker02/distracted_ptolemy

去看 /etc/nginx/site-enable/default,upstream helloweb 指向 192.168.99.106:3000

upstream helloweb {

                server 192.168.99.106:3000;

}

curl master的 83 port 會回傳一個 express 的 example 內容

$ curl http://192.168.99.104:83
<!DOCTYPE html><html><head><title>Express</title><link rel="stylesheet" href="/stylesheets/style.css"></head><body><h1>Express</h1><p>Welcome to Express</p></body></html>

每次執行 scaleup.sh 會動態在 nodes 中選一台機器部署服務,scaledown.sh 則相反
可搭配系統 loading 動態做新增服務做 load balance 或減少服務釋放資源

High availability

執行 script

# 輸入 checkEvery10s.sh  -h 輸出參數說明

$ bash checkEvery10s.sh -a "/opt/helloweb/app.js" -c "http://192.168.99.104:8500" -i "genchilu/helloweb" -o "-d -p 3000:3000 --restart=always --entrypoint=/usr/bin/node" -p "helloweb" -s "192.168.99.104:2376"

連線到 192.168.99.106 後執行關機

$ ssh 192.168.99.106
$ poweroff

此時 curl master 的 83 port 會發現 502 錯誤

$ curl http://192.168.99.104:83                                                                  <html>
<head><title>502 Bad Gateway</title></head>
<body bgcolor="white">
<center><h1>502 Bad Gateway</h1></center>
<hr><center>nginx/1.4.6 (Ubuntu)</center>
</body>
</html>

等約一分鐘後,查詢 swarm manager,發現服務自動在 192.168.99.105 啟動

$ docker -H 192.168.99.104:2376 ps
CONTAINER ID        IMAGE               COMMAND                  CREATED                  STATUS                  PORTS                           NAMES
40ff012aad58        genchilu/helloweb   "/usr/bin/node /opt/h"   Less than a second ago   Up Less than a second   192.168.99.105:3000->3000/tcp   docker01/high_shockley

觀看 /etc/nginx/site-enable/default 可看到 upstream helloweb 被指向 192.168.99.105:3000

upstream helloweb {

                server 192.168.99.105:3000;

}

curl master的 83 port 發現服務依舊正常運作

$ curl http://192.168.99.104:83
<!DOCTYPE html><html><head><title>Express</title><link rel="stylesheet" href="/stylesheets/style.css"></head><body><h1>Express</h1><p>Welcome to Express</p></body></html>

重新將 192.168.99.106 開機,待 swarm agent 啟動後會發現 nginx 上的設定檔會變成這樣

upstream helloweb {

                server 192.168.99.105:3000;

                server 192.168.99.106:3000;

}

此時服務流量會被分散到 105,106 兩台機器上
執行 scaledown.sh 可以動態卸除一個服務

 
over 5 years ago

Hi, This a demo post of Logdown.

Logdown use Markdown as main syntax, you can find more example by reading this document on Wikipedia

Logdown also support drag & drop image uploading. The picture syntax is like this:

Bloging with code snippet:

inline code

Plain Code

puts "Hello World!"

Code with Language

puts "Hello World!"

Code with Title

hello_world.rb
puts "Hello World!"

MathJax Example

Mathjax

Inline Mathjax

The answser is .

Table Example

Tables Are Cool
col 1 Hello $1600
col 2 Hello $12
col 3 Hello $1