Skip to content

Instantly share code, notes, and snippets.

@turgayozgur
Last active March 6, 2023 13:38
Show Gist options
  • Save turgayozgur/4d9dbbc39dbe30aef2ac84b44a9e18bb to your computer and use it in GitHub Desktop.
Save turgayozgur/4d9dbbc39dbe30aef2ac84b44a9e18bb to your computer and use it in GitHub Desktop.
ASPNET Core Zero Downtime Deployment to Linux with Nginx
#!/bin/bash
set -euo pipefail
IFS=$'\n\t'
# How to use?
# https://medium.com/@ozgurtrgy/aspnet-core-zero-downtime-deployment-to-linux-with-nginx-b8b230bf1577
# the variables depend on you.
PACKAGE_TAR_NAME="artifacts.tar.gz"
VERSION="1005"
ASPNETCORE_ENV="Production"
APP_NAME="Example.Application.Name"
DLL_NAME="Example.Web.dll"
HEALTHCHECK_PORT=8081 # should be between 8000 and 9000.
DOMAIN_NAME="example.com"
REMOVE_NON_ASCII_COOKIES="true" # true if you are using ASPNET Core version 2.1 or below, otherwise false.
# other variables.
SERVICE_FILE_DIR="/etc/systemd/system"
NGINX_FILE_DIR="/etc/nginx/sites-available"
APP_ROOT_DIR="/var/aspnetcore"
APP_DIR="$APP_ROOT_DIR/$APP_NAME"
HEALTHCHECK_PATH="healthcheck"
APP_URL_LOCAL="http://localhost"
##
# Main routine
##
main() {
local randomFreePort=$(getRandomFreePort)
local deploypath=$(getDeployPath)
local suffix=$(getSuffix)
local appurl="$APP_URL_LOCAL:$randomFreePort";
local healthcheckurl="$appurl/$HEALTHCHECK_PATH"
local servicefile=$(getServiceFilePath ${suffix})
extractPackage $deploypath
replaceSettings $deploypath
configureDataProtection
configurePermissions $deploypath
runService $servicefile $deploypath $appurl $healthcheckurl $suffix
configureNginx $deploypath $appurl $healthcheckurl
checkCorrectVersionIsUp $HEALTHCHECK_PORT $suffix
destroyOldService $servicefile
echo "Successfully finished." > /dev/stdout
}
##
# Get the directory that created recently. It should be created when new package fetched.
##
getLatestDir() {
echo $(echo $(cd "$APP_DIR" && echo $(ls -t | head -1)))
}
##
# Get the current deployment path with version.
##
getDeployPath() {
echo "$APP_DIR/$(getLatestDir)"
}
##
# Get the suffix from current deployment dir name. Suffix is the number that comes after the "-"
##
getSuffix() {
local latestdir=$(getLatestDir)
[[ $latestdir =~ "-" ]] && echo "-${latestdir##*-}" || echo ""
}
##
# Get random port between 5000 and 5300 that free.
##
getRandomFreePort() {
while :; do randomport="`shuf -i 5000-5300 -n 1`"; ss -lpn | grep -q ":$randomport " || break; done
echo $randomport
}
##
# Extract the deployment package.
##
extractPackage() {
local deploypath=$1
(cd $deploypath
sudo tar -xzf $PACKAGE_TAR_NAME --strip-components=1 && sudo rm $PACKAGE_TAR_NAME
)
}
##
# Replace app setting tmpl variables to environment variables.
##
replaceSettings() {
local deploypath=$1
(cd $deploypath
keys=($(grep -o '{{.*}}' appsettings.json.tmpl | sed "s/{{.//g" | sed "s/}}//g"))
echo "Variables are replacing..." > /dev/stdout
#set appsetting.environment.json from environment variables.
for key in "${keys[@]}"
do
value=${!key} # Fetched from environment variables. You can fetch your variables whereever you want.
[[ $value =~ Unrecognized* ]] && (>&2 echo $value; exit 1;)
sudo sed -i -e "s%{{.$key}}%$value%g" appsettings.json.tmpl
done
sudo mv appsettings.json.tmpl appsettings.$ASPNETCORE_ENV.json
)
}
##
# Get the service file path.
##
getServiceFilePath() {
local suffix=${1-""}
local servicefilename="$APP_NAME-$VERSION$suffix.service"
echo "$SERVICE_FILE_DIR/$servicefilename"
}
##
# Configure and run the new service.
##
runService() {
local servicefile=$1
local deploypath=$2
local appurl=$3
local healthcheckurl=$4
local suffix=${5-""}
local servicefilename="${servicefile##*/}"
local service=(
"[Unit]"
"Description=Example .ASPNET Web App running on Ubuntu. app: $APP_NAME version: $VERSION"
""
"[Service]"
"WorkingDirectory=$deploypath"
"ExecStart=/usr/bin/dotnet $deploypath/$DLL_NAME"
"Restart=always"
"RestartSec=30"
"KillMode=mixed" # https://stackoverflow.com/a/40971615
"SyslogIdentifier=$APP_NAME"
"User=www-data"
"LimitNOFILE=640000"
"Environment=ASPNETCORE_ENVIRONMENT=$ASPNETCORE_ENV"
"Environment=ASPNETCORE_URLS=$appurl"
"Environment=DOTNET_PRINT_TELEMETRY_MESSAGE=false"
"Environment=DEPLOYMENT_VERSION=$VERSION$suffix"
""
"[Install]"
"WantedBy=multi-user.target" )
echo "Service is updating..." > /dev/stdout
#configure
[ -f $servicefile ] && sudo rm $servicefile
for line in "${service[@]}"; do
echo $line | sudo tee --append $servicefile > /dev/null
done
#run
echo "Start the service..." > /dev/stdout
sudo systemctl daemon-reload
sudo systemctl restart $servicefilename
sudo systemctl enable $servicefilename 2>&1 # to run on startup.
echo "The service successfully started." > /dev/stdout
#wait for the service is up.
echo "Waiting for the service to up and running..." > /dev/stdout
local end=$((SECONDS+180)) # 3 min to wait service up.
local serviceisup=0
while [ $SECONDS -lt $end ]; do
if [[ "$(curl -s -o /dev/null -w ''%{http_code}'' $healthcheckurl)" != "200" ]]; then
sleep 10; # seconds
else
serviceisup=1
break;
fi
done
[ $serviceisup -ne 1 ] && ( (>&2 echo "Service not responding :("); sudo rm $servicefile; sudo systemctl daemon-reload; exit 1; )
echo "Service is up and running!" > /dev/stdout
}
##
# Configure the nginx config file.
##
configureNginx() {
local deploypath=$1
local appurl=$2
local healthcheckurl=$3
# to removing non-ascii characters from cookie. This is a workaround for kerstel 400 issue that will be fixed in 2.2 relase.
# https://github.com/aspnet/KestrelHttpServer/issues/2884
local removenonasciiblock=("")
local setnonasciicookieline=""
if [ $REMOVE_NON_ASCII_COOKIES == "True" ]; then
local removenonasciiblock=(
' set_by_lua_block $cookie_ascii {'
' local cookie = ngx.var.http_cookie'
" if cookie == nil or cookie == '' then return cookie end"
' local cookie_ascii, n, err = ngx.re.gsub(cookie, "[^\\x00-\\x7F]", "")'
' return cookie_ascii'
' }'
)
local setnonasciicookieline=' proxy_set_header Cookie $cookie_ascii;'
fi
local rootlocation=(
" location / {"
"${removenonasciiblock[@]}"
" root $deploypath;"
" proxy_pass $appurl;"
" proxy_buffering off;" # https://medium.com/@mshanak/soved-dotnet-core-too-many-open-files-in-system-when-using-postgress-with-entity-framework-c6e30eeff6d1
" proxy_read_timeout 7200;"
" proxy_http_version 1.1;"
" proxy_set_header Upgrade \$http_upgrade;"
" proxy_set_header Connection keep-alive;"
" proxy_set_header Host \$host;"
" proxy_cache_bypass \$http_upgrade;"
" proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;"
" proxy_set_header X-Forwarded-Proto \$scheme;"
"$setnonasciicookieline"
" fastcgi_buffers 16 16k;"
" fastcgi_buffer_size 32k;"
" }"
)
local server=(
"server {"
" listen 80;"
" server_name $DOMAIN_NAME;"
"${rootlocation[@]}"
"}" )
local healthcheckserver=(
"server {"
" listen $healthcheckport;"
"${rootlocation[@]}"
"}"
)
local nginxconfigfile="$NGINX_FILE_DIR/$APP_NAME"
echo "nginx configuring..." > /dev/stdout
[ -f $nginxconfigfile ] && sudo rm $nginxconfigfile
# server for all of environments.
for line in "${server[@]}"; do
echo $line | sudo tee --append $nginxconfigfile > /dev/null
done
# healtcheck server to use the app by ip.
if [[ $healthcheckport =~ ^8[0-9]{3}$ ]]; then # healthcheckport should be between 8000 and 9000.
for line in "${healthcheckserver[@]}"; do
echo $line | sudo tee --append $nginxconfigfile > /dev/null
done
fi
sudo ln -sfn $nginxconfigfile /etc/nginx/sites-enabled/
sudo systemctl reload nginx
echo "nginx successfully configured." > /dev/stdout
}
##
# After the request directly to nginx, check the correct version number we see.
##
checkCorrectVersionIsUp() {
local healthcheckport=$1
local suffix=${2-""}
local machineip=$(hostname -I)
local versioncheckurl="http://${machineip}:${healthcheckport}/healthcheck"
local versioncheckurl=$(echo ${versioncheckurl//[[:blank:]]/})
echo "Waiting for the correct version is up to deleting old one... Check url: $versioncheckurl" > /dev/stdout
local end=$((SECONDS+60)) # 1 min to wait service switch.
while [ $SECONDS -lt $end ]; do
if [[ "$(curl -s -o /dev/null -w ''%{http_code}'' $versioncheckurl)" == "200" ]]; then
local deploynumber=$(curl -s "$versioncheckurl" | jq -r '.Version')
if [ "$deploynumber" == "$VERSION$suffix" ]; then
echo "The correct version is up now!" > /dev/stdout
break;
else
sleep 2; # seconds
fi
fi
done
}
##
# Destroy old one.
##
destroyOldService() {
local serviceFile=$1
echo "Stopping and deleting old service..." > /dev/stdout
for oldservicefile in $SERVICE_FILE_DIR/$APP_NAME-*; do
if [[ -f "$oldservicefile" ]] && [[ "$servicefile" != "$oldservicefile" ]]; then
local oldservicefilename="${oldservicefile##*/}"
sudo systemctl stop $oldservicefilename || true
sudo systemctl disable $oldservicefilename 2>&1 || true
sudo rm $oldservicefile
sudo systemctl daemon-reload
echo "$oldservicefile stopped and deleted." > /dev/stdout
fi
done
echo "Old services stopped and deleted." > /dev/stdout
}
##
# Execute main routine.
##
main $@
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment