How to migrate Unicorn Rails base application to Kubernetes

1/10/2020

I'm trying to optimize a Unicorn base Ruby web application before migrating it from EC2 to Kubernetes.

Challenges: The main issue with Unicorn, is that it's a process based Web server. It can serve a fixed amount of concurrent connections. which means that in case and all connections are occupied, the application will fail to answer Kuebrnetes' Health checks. In addition, it won't be able to serve our users.

Architecture:

  1. Nginx Ingress controller which serve as our reverse proxy / balancer.
  2. Server that runs (k8s: Pod with sidecar):
    1. OpenResty (Nginx + Lua)
    2. Unicorn Ruby web application

Approaches: a naive approach, our (1) Ingress will issue a request to one of the servers (Pods/Service), the (2) OpenResty will accept the connection and will keep pounding (3) Unicorn until it ables to serve the request. As for Health Check request, (1) OpenResty will answer 200-ok immediately if there was a 200OK response during the last X Seconds, otherwise it will forward the request to Unicorn like any other request.

lua_shared_dict responses 1m;

server {
    listen 80;
    server_name  _ ~^(.+)$;
    try_files $uri @app;
    location @app {
         proxy_pass http://unicorn_server;
         -- Update the TTL of a global variable "health_check" on any 200-299 response we had
         log_by_lua_block {
            if ngx.status >= ngx.HTTP_OK and ngx.status < ngx.HTTP_SPECIAL_RESPONSE then
                local responses_dict = ngx.shared.responses
                responses_dict:set("health_check", 1, 60)
            end
         }
    }
    location /health/check.json {
        -- Return 200OK if "health_check" global variable exists, otherwise forward to Unicorn
        content_by_lua_block {
            local responses_dict = ngx.shared.responses
            local health_check = responses_dict:get("health_check")
            if health_check then
                ngx.exit(ngx.HTTP_OK)
            else
                ngx.exec("@app")
            end
        }
    }
}
upstream unicorn_server {
    server 127.0.0.1:8082 max_fails=0 fail_timeout=0;
}

Another approach, is to have our (1) ingress to retry on every 503 error, up to X seconds and Y retries. (2) OpenResty will accept all connections and will pass only N-1 (where N is the max amount of connections Unicorn can handle) to Unicorn, OpenResty will keep retrying to pass the any pending request to Unicorn during the next X seconds, and return 503 if it failed to do so. (3) which will get retried our Ingress (and probably hit another server/pod).

lua_shared_dict try 10m;
server {
    listen 80 default_server deferred;
    server_name  _ broker_server;
    location / {
        proxy_next_upstream http_503 non_idempotent;
        proxy_next_upstream_timeout 1;
        proxy_next_upstream_tries 120;
        rewrite_by_lua_block {
            ngx.req.set_header("X-Request-Id", ngx.var.request_id)
        }
        proxy_pass http://nginx_server;
    }
}
upstream nginx_server {
    server 0.0.0.1;   # just an invalid address as a place holder
    balancer_by_lua_block {
        local balancer = require "ngx.balancer"
        local x_request_id = ngx.req.get_headers()["X-Request-Id"]
        local try_dict = ngx.shared.try
        -- Adding the Key + setting TTL to 10 sec, which is similar to proxy_next_upstream_timeout
        try_dict:safe_add(x_request_id, 0, 10)
        try_dict:incr(x_request_id, 1, 0)
        balancer.set_more_tries(1)
        local ok, err = balancer.set_current_peer("127.0.0.1", 8090)
        if not ok then
            ngx.log(ngx.ERR, "failed to set the current peer: ", err)
            return ngx.exit(500)
        end
    }
    keepalive 10;  # connection pool
}
server {
    listen 8090;
    server_name  _ ~^(.+)$;
    location / {
         content_by_lua_block {
            local x_request_id = ngx.req.get_headers()["X-Request-Id"]
            local try_dict = ngx.shared.try
            -- local try = try_dict:get(ngx.var.x_request_id)
            local try = try_dict:get(x_request_id)
            -- Sleep between each retr
            if try > 1 then
                ngx.sleep( (try - 1 ) * 0.05)
            end
            ngx.exec("@unicorn_server")
         }
         log_by_lua_block {
             local x_request_id = ngx.req.get_headers()["X-Request-Id"]
             if x_request_id and ngx.status == ngx.HTTP_SERVICE_UNAVAILABLE then
                 ngx.shared.try:flush_expired(0)
             else
                 ngx.shared.try:delete(x_request_id)
             end
         }
    }
    location @unicorn_server {
        -- Assuming that we can serve only 5 request at a time
        limit_conn perserver 5;
        limit_conn_status 503;
        proxy_pass http://127.0.0.1:8082;
    }
}

The main issue with this approach, is that I'm not sure it ngx.exec("@unicorn_server") will function in a similar manner to proxy_pass.

Is there any other option that I should consider? Keep in mind that I've already tried to migrate to Unicorn to Puma but failed to do so due to legacy code that piled up over the years.

-- eldad87
kubernetes
ruby
ruby-on-rails-4
unicorn

0 Answers